diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 7249bd13..b92a1988 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -52,7 +52,7 @@ jobs: prefix = "rtichoke/_vendor/rtichoke_viz/" required = { f"{prefix}VENDORED_FROM", - f"{prefix}rtichoke-viz-0.19.0.tar.gz", + f"{prefix}rtichoke-viz-0.20.0.tar.gz", f"{prefix}rtichoke-viz.js", f"{prefix}rtichoke-viz.css", f"{prefix}rtichoke-viz.schema.json", @@ -60,6 +60,7 @@ jobs: f"{prefix}rtichoke-viz-report.schema.json", } assert required <= names + assert f"{prefix}rtichoke-viz-0.19.0.tar.gz" not in names assert f"{prefix}rtichoke-viz-0.14.0.tar.gz" not in names PY diff --git a/.github/workflows/quarto-acceptance.yml b/.github/workflows/quarto-acceptance.yml index 21d8ab25..2ef7904a 100644 --- a/.github/workflows/quarto-acceptance.yml +++ b/.github/workflows/quarto-acceptance.yml @@ -13,6 +13,7 @@ on: - "src/rtichoke/_vendor/rtichoke_viz/**" - "tests/test_quarto_summary_report_browser.py" - "tests/test_summary_report_browser.py" + - "tests/test_summary_report_times_browser.py" - "tests/test_decision_curve_browser_acceptance.py" - ".github/workflows/quarto-acceptance.yml" pull_request: @@ -27,6 +28,7 @@ on: - "src/rtichoke/_vendor/rtichoke_viz/**" - "tests/test_quarto_summary_report_browser.py" - "tests/test_summary_report_browser.py" + - "tests/test_summary_report_times_browser.py" - "tests/test_decision_curve_browser_acceptance.py" - ".github/workflows/quarto-acceptance.yml" workflow_dispatch: @@ -61,3 +63,5 @@ jobs: uv run --with playwright pytest tests/test_quarto_summary_report_browser.py tests/test_decision_curve_browser_acceptance.py + tests/test_summary_report_browser.py + tests/test_summary_report_times_browser.py diff --git a/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM b/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM index e2e6d181..f2b79588 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM +++ b/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM @@ -1,5 +1,5 @@ repository=https://github.com/uriahf/rtichoke_viz -release=v0.19.0 -source_commit=26f22617bb34664ad65d6591ce7b20ecc080e739 -archive=rtichoke-viz-0.19.0.tar.gz -sha256=bdeb1dd3041f700341730904492c7ab271bcdb9fa3637c7a9fd0a21f183bec70 +release=v0.20.0 +source_commit=15e9712686650e23d3c79ddea99892bc5836f5e4 +archive=rtichoke-viz-0.20.0.tar.gz +sha256=9a89356ffa5e72de3526f343960ad21bbc57475a3515748d846cbd447dee17d1 diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.19.0.tar.gz b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.19.0.tar.gz deleted file mode 100644 index 8f13c770..00000000 Binary files a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.19.0.tar.gz and /dev/null differ diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.20.0.tar.gz b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.20.0.tar.gz new file mode 100644 index 00000000..ee84f132 Binary files /dev/null and b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.20.0.tar.gz differ diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.css b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.css index e1c102e4..4f36e276 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.css +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.css @@ -132,6 +132,271 @@ margin-left: 0.5rem; } +/* Performance Table Scoped Styling */ +.rtichoke-performance-table { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-size: 14px; + line-height: 1.4; + color: #1f2937; + margin: 0; + padding: 0; +} + +.rtichoke-performance-table__title { + font-size: 1.1rem; + font-weight: 600; + color: #111827; + margin-bottom: 0.5rem; +} + +.rtichoke-performance-table__scroll { + overflow-x: auto; + max-width: 100%; + -webkit-overflow-scrolling: touch; +} + +.rtichoke-performance-table__table { + width: 100%; + border-collapse: collapse; + text-align: left; + border-spacing: 0; +} + +.rtichoke-performance-table__header { + border-bottom: 2px solid #e5e7eb; +} + +.rtichoke-performance-table__header th { + padding: 0.5rem 0.75rem; + font-weight: 600; + font-size: 13px; + color: #374151; + background-color: #f9fafb; + border-bottom: 1px solid #e5e7eb; + white-space: nowrap; +} + +.rtichoke-performance-table__spanner { + text-align: center !important; + border-bottom: 1px solid #d1d5db !important; +} + +.rtichoke-performance-table__spanner--empty { + background-color: transparent !important; + border-bottom: none !important; +} + +.rtichoke-performance-table__table tbody td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #f3f4f6; + white-space: nowrap; + position: relative; +} + +.rtichoke-performance-table__table tbody tr:hover { + background-color: #f9fafb; +} + +.rtichoke-performance-table__model, +.rtichoke-performance-table__population, +.rtichoke-performance-table__evaluation, +.rtichoke-performance-table__identity { + font-weight: 500; + color: #111827; +} + +.rtichoke-performance-table__op, +.rtichoke-performance-table__horizon, +.rtichoke-performance-table__context { + color: #4b5563; +} + +.rtichoke-performance-table__metric { + font-variant-numeric: tabular-nums; + font-weight: 500; + text-align: right; + min-width: 70px; +} + +.rtichoke-performance-table__bar { + position: absolute; + top: 4px; + bottom: 4px; + left: 4px; + right: 4px; + pointer-events: none; + overflow: hidden; + border-radius: 2px; +} + +.rtichoke-performance-table__bar-fill { + position: absolute; + top: 0; + bottom: 0; + border-radius: 2px; + opacity: 0.25; + transition: width 0.2s ease; +} + +.rtichoke-performance-table__bar-fill--positive { + background-color: #2e7559; +} + +.rtichoke-performance-table__bar-fill--negative { + background-color: #dc2626; +} + +.rtichoke-performance-table__bar-fill--neutral { + background-color: #6b7280; +} + +/* Disclosure column & toggle button */ +.rtichoke-performance-table__toggle-header { + width: 28px; + min-width: 28px; + padding: 0.5rem 0.25rem !important; +} + +.rtichoke-performance-table__toggle-cell { + width: 28px; + min-width: 28px; + padding: 0.25rem !important; + text-align: center; +} + +.rtichoke-performance-table__toggle-btn { + background: transparent; + border: none; + cursor: pointer; + font-size: 12px; + color: #4b5563; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.rtichoke-performance-table__toggle-btn:hover { + background-color: #e5e7eb; + color: #111827; +} + +.rtichoke-performance-table__toggle-btn:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 1px; +} + +/* Detail Row & Confusion Matrix Container */ +.rtichoke-performance-table__detail-row[hidden] { + display: none !important; +} + +.rtichoke-performance-table__detail-cell { + background-color: #f8fafc; + padding: 0.75rem 1rem !important; + border-bottom: 1px solid #e2e8f0 !important; +} + +.rtichoke-performance-table__confusion-container { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-width: 480px; + margin: 0.25rem 0; +} + +.rtichoke-performance-table__confusion-title { + font-size: 13px; + font-weight: 600; + color: #1e293b; +} + +.rtichoke-performance-table__confusion-caption { + font-size: 11px; + color: #64748b; + margin-bottom: 0.25rem; + font-style: italic; +} + +/* Confusion Matrix Inner Table */ +.rtichoke-performance-table__confusion-table { + border-collapse: collapse; + width: 100%; + font-size: 12px; + background-color: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 4px; + overflow: hidden; +} + +.rtichoke-performance-table__confusion-header-empty { + border: none !important; + background-color: transparent !important; +} + +.rtichoke-performance-table__confusion-spanner { + text-align: center !important; + background-color: #f1f5f9 !important; + border-bottom: 1px solid #cbd5e1 !important; + font-weight: 600; + color: #334155; + padding: 0.25rem 0.5rem !important; +} + +.rtichoke-performance-table__confusion-table th { + padding: 0.35rem 0.5rem; + font-size: 11px; + font-weight: 600; + color: #475569; + background-color: #f8fafc; + border: 1px solid #e2e8f0; + text-align: center; +} + +.rtichoke-performance-table__confusion-cell { + padding: 0.35rem 0.5rem; + text-align: center; + border: 1px solid #e2e8f0; + font-variant-numeric: tabular-nums; + line-height: 1.25; +} + +.rtichoke-performance-table__confusion-val { + display: block; + font-weight: 600; + color: #0f172a; +} + +.rtichoke-performance-table__confusion-pct { + display: block; + font-size: 10.5px; + color: #64748b; +} + +/* Subtle Favorable / Unfavorable / Total Color Treatments */ +.rtichoke-performance-table__confusion-cell--favorable { + background-color: #f0fdf4; + border-color: #dcfce7; +} + +.rtichoke-performance-table__confusion-cell--favorable .rtichoke-performance-table__confusion-val { + color: #166534; +} + +.rtichoke-performance-table__confusion-cell--unfavorable { + background-color: #fef2f2; + border-color: #fee2e2; +} + +.rtichoke-performance-table__confusion-cell--unfavorable .rtichoke-performance-table__confusion-val { + color: #991b1b; +} + +.rtichoke-performance-table__confusion-cell--total { + background-color: #f8fafc; + border-color: #e2e8f0; +} + .rtichoke-report { display: flex; flex-direction: column; diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js index 61ff47cc..ccb7d16e 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js @@ -20288,38 +20288,119 @@ function renderInterventionsAvoidedChart(spec, options, selectedOperatingPointVa // src/render/performance-table.ts var MISSING = "\u2014"; +var PRIMARY_METRIC_ORDER = [ + { id: "sensitivity", defaultLabel: "Sensitivity" }, + { id: "specificity", defaultLabel: "Specificity" }, + { id: "ppv", defaultLabel: "PPV" }, + { id: "npv", defaultLabel: "NPV" }, + { id: "lift", defaultLabel: "Lift" }, + { id: "net_benefit", defaultLabel: "Net Benefit" } +]; function cell(document2, text2, className) { const element = document2.createElement("td"); element.textContent = text2; if (className) element.className = className; return element; } -function header(document2, text2) { +function header(document2, text2, options) { const element = document2.createElement("th"); - element.scope = "col"; + element.scope = options?.scope ?? "col"; + if (options?.colSpan) element.colSpan = options.colSpan; + if (options?.rowSpan) element.rowSpan = options.rowSpan; element.textContent = text2; + if (options?.className) element.className = options.className; + if (options?.ariaLabel) element.setAttribute("aria-label", options.ariaLabel); return element; } -function formatNumber2(value) { - return new Intl.NumberFormat("en-US", { maximumSignificantDigits: 6 }).format(value); -} -function formatMetric(value) { - if (!value || value.estimate === null) return MISSING; - const estimate = formatNumber2(value.estimate); - if (value.lower === void 0 && value.upper === void 0) return estimate; - const lower2 = value.lower === void 0 || value.lower === null ? MISSING : formatNumber2(value.lower); - const upper = value.upper === void 0 || value.upper === null ? MISSING : formatNumber2(value.upper); - return `${estimate} [${lower2}, ${upper}]`; -} -function formatOperatingPoint(type2, value) { - return type2 === "ppcr" ? `PPCR ${formatNumber2(value)}` : `Threshold ${formatNumber2(value)}`; -} -function formatContext(context) { - if (!context) return MISSING; - const parts = []; - if (context.censoringHeuristic) parts.push(`censoring: ${context.censoringHeuristic}`); - if (context.competingEventHeuristic) parts.push(`competing event: ${context.competingEventHeuristic}`); - return parts.length ? parts.join("; ") : MISSING; +function extractConfusionCounts(rowValues) { + const map5 = new Map(rowValues.map((v) => [v.metricId, v.estimate])); + const tp = map5.get("true_positives"); + const tn = map5.get("true_negatives"); + const fp = map5.get("false_positives"); + const fn = map5.get("false_negatives"); + if (tp === void 0 || tp === null || isNaN(tp) || tn === void 0 || tn === null || isNaN(tn) || fp === void 0 || fp === null || isNaN(fp) || fn === void 0 || fn === null || isNaN(fn)) { + return null; + } + return { tp, tn, fp, fn }; +} +function renderConfusionCell(document2, val, n, className) { + const td = document2.createElement("td"); + td.className = className; + const valSpan = document2.createElement("span"); + valSpan.className = "rtichoke-performance-table__confusion-val"; + valSpan.textContent = formatCount(val); + const pctSpan = document2.createElement("span"); + pctSpan.className = "rtichoke-performance-table__confusion-pct"; + if (n > 0 && !isNaN(val) && !isNaN(n)) { + const pct = val / n * 100; + pctSpan.textContent = `${pct.toFixed(2)}%`; + } else { + pctSpan.textContent = MISSING; + } + td.append(valSpan, pctSpan); + return td; +} +var tableInstanceCounter = 0; +function humanizeContextValue(val) { + return val.split("_").map((w, i) => i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w).join(" "); +} +function formatCount(val) { + if (Number.isInteger(val)) return val.toString(); + return new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(val); +} +function format2Decimals(val) { + return val.toFixed(2); +} +function determineThresholdPrecision(values2) { + if (values2.length <= 1) return 2; + let decimals = 2; + while (decimals <= 6) { + const formatted = values2.map((v) => v.toFixed(decimals)); + const uniqueFormatted = new Set(formatted); + if (uniqueFormatted.size === new Set(values2).size) { + return decimals; + } + decimals++; + } + return decimals; +} +function formatMetricValue(value, formatter) { + if (!value || value.estimate === null || value.estimate === void 0 || isNaN(value.estimate)) return MISSING; + const est = formatter(value.estimate); + if (value.lower === void 0 && value.upper === void 0) return est; + const lower2 = value.lower === void 0 || value.lower === null || isNaN(value.lower) ? MISSING : formatter(value.lower); + const upper = value.upper === void 0 || value.upper === null || isNaN(value.upper) ? MISSING : formatter(value.upper); + return `${est} [${lower2}, ${upper}]`; +} +function appendInCellBar(td, document2, percent, isDiverging = false, isNegative = false, barStyle = "positive") { + if (isNaN(percent) || !isFinite(percent)) return; + const bar = document2.createElement("div"); + bar.className = "rtichoke-performance-table__bar"; + const fill = document2.createElement("div"); + fill.className = "rtichoke-performance-table__bar-fill"; + if (isDiverging) { + const width = Math.min(Math.max(percent, 0), 50); + fill.style.width = `${width}%`; + if (isNegative) { + fill.classList.add("rtichoke-performance-table__bar-fill--negative"); + fill.style.right = "50%"; + fill.style.left = "auto"; + } else { + fill.classList.add("rtichoke-performance-table__bar-fill--positive"); + fill.style.left = "50%"; + fill.style.right = "auto"; + } + } else { + const width = Math.min(Math.max(percent, 0), 100); + fill.style.width = `${width}%`; + if (barStyle === "neutral") { + fill.classList.add("rtichoke-performance-table__bar-fill--neutral"); + } else { + fill.classList.add("rtichoke-performance-table__bar-fill--positive"); + } + } + bar.append(fill); + td.append(bar); } function renderPerformanceTable(spec, document2 = globalThis.document) { assertPerformanceTableReferentialIntegrity(spec); @@ -20331,38 +20412,327 @@ function renderPerformanceTable(spec, document2 = globalThis.document) { title.textContent = spec.title; root2.append(title); } + const scrollWrapper = document2.createElement("div"); + scrollWrapper.className = "rtichoke-performance-table__scroll"; const table = document2.createElement("table"); table.className = "rtichoke-performance-table__table"; + const evaluationsMap = new Map(spec.evaluations.map((ev) => [ev.id, ev])); + const distinctModels = new Set(spec.evaluations.map((e) => e.model).filter(Boolean)); + const distinctPopulations = new Set(spec.evaluations.map((e) => e.population).filter(Boolean)); + const showModel = distinctModels.size > 1; + const showPopulation = distinctPopulations.size > 1; + const renderModelCol = showModel; + const renderPopulationCol = showPopulation || !showModel && distinctModels.size === 0 && distinctPopulations.size > 1; + const showEvaluationLabel = spec.evaluations.some((e) => { + if (!e.label) return false; + if (showModel && e.label !== e.model) return true; + if (showPopulation && e.label !== e.population) return true; + if (!showModel && !showPopulation && e.label !== e.model && e.label !== e.population) return true; + return false; + }); + const hasHorizon = spec.rows.some((r) => r.horizon !== void 0); + const hasCensoring = spec.rows.some((r) => r.context?.censoringHeuristic !== void 0); + const hasCompetingEvent = spec.rows.some((r) => r.context?.competingEventHeuristic !== void 0); + const operatingTypes = new Set(spec.rows.map((r) => r.operatingPoint.type)); + const isPureThreshold = operatingTypes.size === 1 && operatingTypes.has("probability_threshold"); + const isPurePpcr = operatingTypes.size === 1 && operatingTypes.has("ppcr"); + const specMetricIds = new Set(spec.metrics.map((m) => m.id)); + const hasPredictedPositivesMetric = specMetricIds.has("predicted_positives"); + const hasPpcrMetric = specMetricIds.has("ppcr"); + const canConstructComposite = hasPredictedPositivesMetric && (hasPpcrMetric || isPurePpcr); + const renderThresholdCol = isPureThreshold || !isPurePpcr && operatingTypes.has("probability_threshold"); + const renderCompositePredPosCol = canConstructComposite; + const renderPpcrFallbackCol = isPurePpcr && !canConstructComposite; + const renderGenericOpCol = !isPureThreshold && !isPurePpcr && !canConstructComposite; + const thresholdValues = spec.rows.filter((r) => r.operatingPoint.type === "probability_threshold").map((r) => r.operatingPoint.value); + const thresholdPrecision = determineThresholdPrecision(thresholdValues); + const primaryMetricSpecs = PRIMARY_METRIC_ORDER.map((p) => { + const match = spec.metrics.find((m) => m.id === p.id); + return match ? { id: match.id, label: match.label || p.defaultLabel } : null; + }).filter((m) => m !== null); + const seenLabels = /* @__PURE__ */ new Set(); + const activePrimaryMetrics = primaryMetricSpecs.filter((m) => { + if (seenLabels.has(m.label)) return false; + seenLabels.add(m.label); + return true; + }); + let maxLift = 0; + let maxAbsNB = 0; + for (const row of spec.rows) { + for (const val of row.values) { + if (val.metricId === "lift" && val.estimate !== null && val.estimate !== void 0 && isFinite(val.estimate)) { + if (val.estimate > maxLift) maxLift = val.estimate; + } + if (val.metricId === "net_benefit" && val.estimate !== null && val.estimate !== void 0 && isFinite(val.estimate)) { + const absVal = Math.abs(val.estimate); + if (absVal > maxAbsNB) maxAbsNB = absVal; + } + } + } + const currentTableInstance = ++tableInstanceCounter; const head = document2.createElement("thead"); - const headRow = document2.createElement("tr"); - for (const label of ["Model", "Population", "Evaluation", "Operating point", "Horizon", "Context"]) { - headRow.append(header(document2, label)); + head.className = "rtichoke-performance-table__header"; + const topRow = document2.createElement("tr"); + const bottomRow = document2.createElement("tr"); + let nonMetricColCount = 1; + bottomRow.append(header(document2, "", { ariaLabel: "Row details", className: "rtichoke-performance-table__toggle-header" })); + if (renderModelCol) { + nonMetricColCount++; + bottomRow.append(header(document2, "Model")); + } + if (renderPopulationCol) { + nonMetricColCount++; + bottomRow.append(header(document2, "Population")); + } + if (showEvaluationLabel) { + nonMetricColCount++; + bottomRow.append(header(document2, "Evaluation")); + } + if (renderThresholdCol) { + nonMetricColCount++; + bottomRow.append(header(document2, "Probability Threshold")); + } + if (renderCompositePredPosCol) { + nonMetricColCount++; + bottomRow.append(header(document2, "Predicted Positives")); + } + if (renderPpcrFallbackCol) { + nonMetricColCount++; + bottomRow.append(header(document2, "PPCR")); + } + if (renderGenericOpCol) { + nonMetricColCount++; + bottomRow.append(header(document2, "Operating Point")); + } + if (hasHorizon) { + nonMetricColCount++; + bottomRow.append(header(document2, "Time Horizon")); + } + if (hasCensoring) { + nonMetricColCount++; + bottomRow.append(header(document2, "Censoring")); + } + if (hasCompetingEvent) { + nonMetricColCount++; + bottomRow.append(header(document2, "Competing Event")); + } + if (activePrimaryMetrics.length > 0) { + const emptySpanner = header(document2, "", { colSpan: nonMetricColCount, className: "rtichoke-performance-table__spanner--empty" }); + topRow.append(emptySpanner); + } + if (activePrimaryMetrics.length > 0) { + const metricsSpanner = header(document2, "Performance Metrics", { + colSpan: activePrimaryMetrics.length, + className: "rtichoke-performance-table__spanner" + }); + topRow.append(metricsSpanner); + for (const metric of activePrimaryMetrics) { + bottomRow.append(header(document2, metric.label, { className: "rtichoke-performance-table__metric-header" })); + } } - for (const metric of spec.metrics) headRow.append(header(document2, metric.label)); - head.append(headRow); + head.append(topRow); + head.append(bottomRow); table.append(head); - const evaluations = new Map(spec.evaluations.map((evaluation) => [evaluation.id, evaluation])); + const totalColumns = nonMetricColCount + activePrimaryMetrics.length; const body = document2.createElement("tbody"); - for (const row of spec.rows) { - const evaluation = evaluations.get(row.evaluationId); + for (let rowIndex = 0; rowIndex < spec.rows.length; rowIndex++) { + const row = spec.rows[rowIndex]; + const evaluation = evaluationsMap.get(row.evaluationId); const tr = document2.createElement("tr"); tr.dataset.evaluationId = row.evaluationId; - tr.append(cell(document2, evaluation.model ?? MISSING, "rtichoke-performance-table__model")); - tr.append(cell(document2, evaluation.population, "rtichoke-performance-table__population")); - tr.append(cell(document2, evaluation.label ?? evaluation.id, "rtichoke-performance-table__evaluation")); - tr.append(cell(document2, formatOperatingPoint(row.operatingPoint.type, row.operatingPoint.value))); - tr.append(cell(document2, row.horizon === void 0 ? MISSING : formatNumber2(row.horizon))); - tr.append(cell(document2, formatContext(row.context))); - const values2 = new Map(row.values.map((value) => [value.metricId, value])); - for (const metric of spec.metrics) { - const td = cell(document2, formatMetric(values2.get(metric.id)), "rtichoke-performance-table__metric"); + const confusion = extractConfusionCounts(row.values); + const detailId = `rtichoke-confusion-detail-${currentTableInstance}-${rowIndex}`; + const toggleTd = document2.createElement("td"); + toggleTd.className = "rtichoke-performance-table__toggle-cell"; + let detailTr = null; + if (confusion) { + const toggleBtn = document2.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.className = "rtichoke-performance-table__toggle-btn"; + toggleBtn.setAttribute("aria-expanded", "false"); + toggleBtn.setAttribute("aria-controls", detailId); + toggleBtn.setAttribute("aria-label", "Show confusion matrix detail"); + toggleBtn.textContent = "\u25B8"; + toggleTd.append(toggleBtn); + detailTr = document2.createElement("tr"); + detailTr.className = "rtichoke-performance-table__detail-row"; + detailTr.hidden = true; + const detailTd = document2.createElement("td"); + detailTd.colSpan = totalColumns; + detailTd.className = "rtichoke-performance-table__detail-cell"; + const detailContainer = document2.createElement("div"); + detailContainer.id = detailId; + detailContainer.className = "rtichoke-performance-table__confusion-container"; + if (row.evaluationId) { + detailContainer.setAttribute("data-evaluation-id", row.evaluationId); + } + detailContainer.setAttribute("data-operating-point-type", row.operatingPoint.type); + detailContainer.setAttribute("data-operating-point-value", row.operatingPoint.value.toString()); + const isHorizon = row.horizon !== void 0; + const titleDiv = document2.createElement("div"); + titleDiv.className = "rtichoke-performance-table__confusion-title"; + titleDiv.textContent = isHorizon ? "Estimated Confusion Matrix" : "Confusion Matrix"; + detailContainer.append(titleDiv); + if (isHorizon) { + const captionDiv = document2.createElement("div"); + captionDiv.className = "rtichoke-performance-table__confusion-caption"; + captionDiv.textContent = "Estimated classification quantities at the displayed time horizon."; + detailContainer.append(captionDiv); + } + const matrixTable = document2.createElement("table"); + matrixTable.className = "rtichoke-performance-table__confusion-table"; + const mHead = document2.createElement("thead"); + const mTopRow = document2.createElement("tr"); + mTopRow.append( + header(document2, "", { className: "rtichoke-performance-table__confusion-header-empty" }), + header(document2, "Predicted", { colSpan: 3, className: "rtichoke-performance-table__confusion-spanner" }) + ); + const mSubRow = document2.createElement("tr"); + mSubRow.append( + header(document2, "", { className: "rtichoke-performance-table__confusion-header-empty" }), + header(document2, "Positive"), + header(document2, "Negative"), + header(document2, "Total") + ); + mHead.append(mTopRow, mSubRow); + matrixTable.append(mHead); + const mBody = document2.createElement("tbody"); + const n = confusion.tp + confusion.tn + confusion.fp + confusion.fn; + const actualPosTotal = confusion.tp + confusion.fn; + const actualNegTotal = confusion.fp + confusion.tn; + const predPosTotal = confusion.tp + confusion.fp; + const predNegTotal = confusion.tn + confusion.fn; + const rPos = document2.createElement("tr"); + rPos.append(header(document2, "Actual Positive", { scope: "row" })); + rPos.append(renderConfusionCell(document2, confusion.tp, n, "rtichoke-performance-table__confusion-cell rtichoke-performance-table__confusion-cell--favorable")); + rPos.append(renderConfusionCell(document2, confusion.fn, n, "rtichoke-performance-table__confusion-cell rtichoke-performance-table__confusion-cell--unfavorable")); + rPos.append(renderConfusionCell(document2, actualPosTotal, n, "rtichoke-performance-table__confusion-cell rtichoke-performance-table__confusion-cell--total")); + mBody.append(rPos); + const rNeg = document2.createElement("tr"); + rNeg.append(header(document2, "Actual Negative", { scope: "row" })); + rNeg.append(renderConfusionCell(document2, confusion.fp, n, "rtichoke-performance-table__confusion-cell rtichoke-performance-table__confusion-cell--unfavorable")); + rNeg.append(renderConfusionCell(document2, confusion.tn, n, "rtichoke-performance-table__confusion-cell rtichoke-performance-table__confusion-cell--favorable")); + rNeg.append(renderConfusionCell(document2, actualNegTotal, n, "rtichoke-performance-table__confusion-cell rtichoke-performance-table__confusion-cell--total")); + mBody.append(rNeg); + const rTot = document2.createElement("tr"); + rTot.append(header(document2, "Total", { scope: "row" })); + rTot.append(renderConfusionCell(document2, predPosTotal, n, "rtichoke-performance-table__confusion-cell rtichoke-performance-table__confusion-cell--total")); + rTot.append(renderConfusionCell(document2, predNegTotal, n, "rtichoke-performance-table__confusion-cell rtichoke-performance-table__confusion-cell--total")); + const grandTotalTd = document2.createElement("td"); + grandTotalTd.className = "rtichoke-performance-table__confusion-cell rtichoke-performance-table__confusion-cell--total"; + const grandValSpan = document2.createElement("span"); + grandValSpan.className = "rtichoke-performance-table__confusion-val"; + grandValSpan.textContent = formatCount(n); + const grandPctSpan = document2.createElement("span"); + grandPctSpan.className = "rtichoke-performance-table__confusion-pct"; + grandPctSpan.textContent = n > 0 ? "100.00%" : MISSING; + grandTotalTd.append(grandValSpan, grandPctSpan); + rTot.append(grandTotalTd); + mBody.append(rTot); + matrixTable.append(mBody); + detailContainer.append(matrixTable); + detailTd.append(detailContainer); + detailTr.append(detailTd); + toggleBtn.addEventListener("click", () => { + if (!detailTr) return; + const isExpanded = toggleBtn.getAttribute("aria-expanded") === "true"; + toggleBtn.setAttribute("aria-expanded", isExpanded ? "false" : "true"); + toggleBtn.setAttribute("aria-label", isExpanded ? "Show confusion matrix detail" : "Hide confusion matrix detail"); + toggleBtn.textContent = isExpanded ? "\u25B8" : "\u25BE"; + detailTr.hidden = isExpanded; + }); + } + tr.append(toggleTd); + if (renderModelCol) { + tr.append(cell(document2, evaluation?.model ?? MISSING, "rtichoke-performance-table__model")); + } + if (renderPopulationCol) { + tr.append(cell(document2, evaluation?.population ?? MISSING, "rtichoke-performance-table__population")); + } + if (showEvaluationLabel) { + tr.append(cell(document2, evaluation?.label ?? evaluation?.id ?? MISSING, "rtichoke-performance-table__evaluation")); + } + const valueMap = new Map(row.values.map((v) => [v.metricId, v])); + if (renderThresholdCol) { + const thVal = row.operatingPoint.type === "probability_threshold" ? row.operatingPoint.value : void 0; + const formattedTh = thVal !== void 0 ? thVal.toFixed(thresholdPrecision) : MISSING; + tr.append(cell(document2, formattedTh, "rtichoke-performance-table__op")); + } + if (renderCompositePredPosCol) { + const predPosVal = valueMap.get("predicted_positives")?.estimate; + const ppcrVal = valueMap.get("ppcr")?.estimate ?? (row.operatingPoint.type === "ppcr" ? row.operatingPoint.value : void 0); + let text2 = MISSING; + let ppcrPercent = NaN; + if (predPosVal !== void 0 && predPosVal !== null && ppcrVal !== void 0 && ppcrVal !== null) { + const formattedCount = formatCount(predPosVal); + const formattedPct = (ppcrVal * 100).toFixed(2); + text2 = `${formattedCount} (${formattedPct}%)`; + ppcrPercent = ppcrVal * 100; + } else if (ppcrVal !== void 0 && ppcrVal !== null) { + const formattedPct = (ppcrVal * 100).toFixed(2); + text2 = `${formattedPct}%`; + ppcrPercent = ppcrVal * 100; + } + const td = cell(document2, text2, "rtichoke-performance-table__op"); + if (!isNaN(ppcrPercent)) { + appendInCellBar(td, document2, ppcrPercent, false, false, "neutral"); + } + tr.append(td); + } + if (renderPpcrFallbackCol) { + const ppcrVal = row.operatingPoint.type === "ppcr" ? row.operatingPoint.value : valueMap.get("ppcr")?.estimate; + const text2 = ppcrVal !== void 0 && ppcrVal !== null ? `PPCR ${ppcrVal.toFixed(2)}` : MISSING; + const td = cell(document2, text2, "rtichoke-performance-table__op"); + if (ppcrVal !== void 0 && ppcrVal !== null) { + appendInCellBar(td, document2, ppcrVal * 100, false, false, "neutral"); + } + tr.append(td); + } + if (renderGenericOpCol) { + const opText = row.operatingPoint.type === "ppcr" ? `PPCR ${row.operatingPoint.value.toFixed(2)}` : `Threshold ${row.operatingPoint.value.toFixed(thresholdPrecision)}`; + tr.append(cell(document2, opText, "rtichoke-performance-table__op")); + } + if (hasHorizon) { + tr.append(cell(document2, row.horizon !== void 0 ? formatCount(row.horizon) : MISSING, "rtichoke-performance-table__horizon")); + } + if (hasCensoring) { + const c4 = row.context?.censoringHeuristic; + tr.append(cell(document2, c4 ? humanizeContextValue(c4) : MISSING, "rtichoke-performance-table__context")); + } + if (hasCompetingEvent) { + const ce = row.context?.competingEventHeuristic; + tr.append(cell(document2, ce ? humanizeContextValue(ce) : MISSING, "rtichoke-performance-table__context")); + } + for (const metric of activePrimaryMetrics) { + const val = valueMap.get(metric.id); + const formattedText = formatMetricValue(val, format2Decimals); + const td = cell(document2, formattedText, "rtichoke-performance-table__metric"); td.dataset.metricId = metric.id; + if (val && val.estimate !== null && val.estimate !== void 0 && isFinite(val.estimate)) { + const est = val.estimate; + if (["sensitivity", "specificity", "ppv", "npv"].includes(metric.id)) { + appendInCellBar(td, document2, est * 100); + } else if (metric.id === "lift") { + const pct = maxLift > 0 ? est / maxLift * 100 : 0; + appendInCellBar(td, document2, pct); + } else if (metric.id === "net_benefit") { + if (maxAbsNB > 0) { + const isNeg = est < 0; + const barWidth = Math.abs(est) / maxAbsNB * 50; + appendInCellBar(td, document2, barWidth, true, isNeg); + } + } + } tr.append(td); } body.append(tr); + if (detailTr) { + body.append(detailTr); + } } table.append(body); - root2.append(table); + scrollWrapper.append(table); + root2.append(scrollWrapper); return root2; } diff --git a/tests/test_quarto_summary_report_browser.py b/tests/test_quarto_summary_report_browser.py index daa80e23..6afcdac4 100644 --- a/tests/test_quarto_summary_report_browser.py +++ b/tests/test_quarto_summary_report_browser.py @@ -140,7 +140,11 @@ def test_quarto_single_browser_summary_report(tmp_path): tbl_text = frame.locator( ".rtichoke-performance-table__table" ).first.inner_text() - assert "Model" in tbl_text or "True Positives" in tbl_text + assert ( + "Model" in tbl_text + or "Probability Threshold" in tbl_text + or "True Positives" in tbl_text + ) assert frame.locator("svg").count() >= 2 assert len(errors) == 0, f"Console errors found: {errors}" browser.close() @@ -235,7 +239,11 @@ def test_quarto_two_browser_summary_reports(tmp_path): tbl_text = frame.locator( ".rtichoke-performance-table__table" ).first.inner_text() - assert "Model" in tbl_text or "True Positives" in tbl_text + assert ( + "Model" in tbl_text + or "Probability Threshold" in tbl_text + or "True Positives" in tbl_text + ) assert frame.locator("svg").count() >= 2 assert len(errors) == 0, f"Console errors found: {errors}" diff --git a/tests/test_rtichoke_viz_vendor.py b/tests/test_rtichoke_viz_vendor.py index 2c327ae0..57bf1257 100644 --- a/tests/test_rtichoke_viz_vendor.py +++ b/tests/test_rtichoke_viz_vendor.py @@ -3,19 +3,19 @@ from pathlib import Path _VENDOR = Path(__file__).parents[1] / "src" / "rtichoke" / "_vendor" / "rtichoke_viz" -_RELEASE_DIR = "rtichoke-viz-0.19.0" -_SHA256 = "bdeb1dd3041f700341730904492c7ab271bcdb9fa3637c7a9fd0a21f183bec70" -_SOURCE_COMMIT = "26f22617bb34664ad65d6591ce7b20ecc080e739" +_RELEASE_DIR = "rtichoke-viz-0.20.0" +_SHA256 = "9a89356ffa5e72de3526f343960ad21bbc57475a3515748d846cbd447dee17d1" +_SOURCE_COMMIT = "15e9712686650e23d3c79ddea99892bc5836f5e4" -def test_vendored_rtichoke_viz_v0190_provenance_archive_and_schemas(): +def test_vendored_rtichoke_viz_v0200_provenance_archive_and_schemas(): provenance = (_VENDOR / "VENDORED_FROM").read_text() - assert "release=v0.19.0" in provenance + assert "release=v0.20.0" in provenance assert f"source_commit={_SOURCE_COMMIT}" in provenance - assert "archive=rtichoke-viz-0.19.0.tar.gz" in provenance + assert "archive=rtichoke-viz-0.20.0.tar.gz" in provenance assert f"sha256={_SHA256}" in provenance - archive = _VENDOR / "rtichoke-viz-0.19.0.tar.gz" + archive = _VENDOR / "rtichoke-viz-0.20.0.tar.gz" assert hashlib.sha256(archive.read_bytes()).hexdigest() == _SHA256 with tarfile.open(archive, "r:gz") as release: assert set(release.getnames()) == { @@ -30,7 +30,7 @@ def test_vendored_rtichoke_viz_v0190_provenance_archive_and_schemas(): manifest = release.extractfile(f"{_RELEASE_DIR}/MANIFEST") assert manifest is not None assert manifest.read().decode() == ( - f"version=0.19.0\ncommit={_SOURCE_COMMIT}\n" + f"version=0.20.0\ncommit={_SOURCE_COMMIT}\n" ) for filename in ( "rtichoke-viz.css", @@ -43,6 +43,7 @@ def test_vendored_rtichoke_viz_v0190_provenance_archive_and_schemas(): assert packaged is not None assert (_VENDOR / filename).read_bytes() == packaged.read() + assert not (_VENDOR / "rtichoke-viz-0.19.0.tar.gz").exists() assert not (_VENDOR / "rtichoke-viz-0.14.0.tar.gz").exists() assert (_VENDOR / "rtichoke-viz.js").stat().st_size > 0 assert (_VENDOR / "rtichoke-viz.css").stat().st_size > 0 @@ -58,7 +59,7 @@ def test_vendored_rtichoke_viz_v0190_provenance_archive_and_schemas(): assert '"summary_metrics"' in report_schema -def test_v0190_bundle_keeps_existing_exports_and_time_dependent_surfaces(): +def test_v0200_bundle_keeps_existing_exports_and_time_dependent_surfaces(): bundle = (_VENDOR / "rtichoke-viz.js").read_text(encoding="utf-8") for export_name in ( "renderRoc", diff --git a/tests/test_summary_report_browser.py b/tests/test_summary_report_browser.py index 12c69c2f..1586f0af 100644 --- a/tests/test_summary_report_browser.py +++ b/tests/test_summary_report_browser.py @@ -11,6 +11,13 @@ import numpy as np import pytest +from rtichoke._performance_table_spec import ( + _performance_table_spec_from_performance_data, +) +from rtichoke._report_browser import RtichokeBrowserReport +from rtichoke._report_spec import _build_report_spec_v11 +from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.processing.evaluation_semantics import _build_evaluation_metadata from rtichoke.summary_report import summary_report as summary_report_module from rtichoke.summary_report.summary_report import create_summary_report @@ -262,3 +269,90 @@ def test_browser_summary_report_rejects_unknown_renderer(): assert str(exc) == "renderer must be either 'r' or 'browser'" else: raise AssertionError("unknown renderer should fail") + + +def test_static_performance_table_confusion_matrix_disclosure(tmp_path): + try: + from playwright.sync_api import sync_playwright # type: ignore[import-untyped] + except ImportError: + pytest.skip("playwright is not available") + + probs, reals = _inputs() + output_thresh = tmp_path / "report_disclosure.html" + create_summary_report(probs, reals, renderer="browser", output_file=output_thresh) + + output_ppcr = tmp_path / "ppcr_table_disclosure.html" + perf_ppcr = prepare_performance_data(probs, reals, stratified_by=("ppcr",)) + metadata = _build_evaluation_metadata(probs, reals, times=None) + ppcr_spec = _performance_table_spec_from_performance_data(perf_ppcr, metadata) + ppcr_report_spec = _build_report_spec_v11( + [ + { + "id": "performance-table", + "title": "Performance Table", + "components": [ + { + "id": "ppcr-table", + "spec": ppcr_spec, + } + ], + } + ] + ) + RtichokeBrowserReport(ppcr_report_spec).write_html(output_ppcr) + + with _serve(tmp_path) as base_url, sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + errors: list[str] = [] + page.on("console", lambda msg: print("CONSOLE:", msg.type, msg.text)) + page.on("pageerror", lambda err: print("PAGE ERROR:", err)) + + # 1. Static probability threshold performance table disclosure + page.goto(f"{base_url}/{output_thresh.name}") + page.wait_for_selector(".rtichoke-performance-table__table") + + toggle_btn = page.locator( + "button[aria-label='Show confusion matrix detail']" + ).first + toggle_btn.wait_for() + toggle_btn.click() + + container = page.locator( + ".rtichoke-performance-table__confusion-container" + ).first + container.wait_for() + title_el = container.locator(".rtichoke-performance-table__confusion-title") + assert title_el.inner_text() == "Confusion Matrix" + assert ( + container.get_attribute("data-operating-point-type") + == "probability_threshold" + ) + assert container.get_attribute("data-operating-point-value") is not None + assert ( + page.locator(".rtichoke-performance-table__confusion-caption").count() == 0 + ) + + # 2. Static PPCR performance table disclosure + page.goto(f"{base_url}/{output_ppcr.name}") + page.wait_for_selector(".rtichoke-performance-table__table") + + ppcr_toggle_btn = page.locator( + "button[aria-label='Show confusion matrix detail']" + ).first + ppcr_toggle_btn.wait_for() + ppcr_toggle_btn.click() + + ppcr_container = page.locator( + ".rtichoke-performance-table__confusion-container" + ).first + ppcr_container.wait_for() + ppcr_title_el = ppcr_container.locator( + ".rtichoke-performance-table__confusion-title" + ) + assert ppcr_title_el.inner_text() == "Confusion Matrix" + assert ppcr_container.get_attribute("data-operating-point-type") == "ppcr" + assert ppcr_container.get_attribute("data-operating-point-value") is not None + + assert len(errors) == 0 + browser.close() diff --git a/tests/test_summary_report_times_browser.py b/tests/test_summary_report_times_browser.py index 89bb33bf..bae530e8 100644 --- a/tests/test_summary_report_times_browser.py +++ b/tests/test_summary_report_times_browser.py @@ -450,3 +450,102 @@ def test_time_lift_excludes_only_non_finite_values_retains_boundary_points(tmp_p assert "path" in ref_types horizontal_ref = next(r for r in refs if r["type"] == "horizontal") assert horizontal_ref["value"] == 1.0 + + +def test_time_performance_table_estimated_confusion_matrix_disclosure(tmp_path): + try: + from playwright.sync_api import sync_playwright # type: ignore[import-untyped] + except ImportError: + pytest.skip("playwright is not available") + + probs, reals, times, horizons = _inputs() + output = tmp_path / "time_report_disclosure.html" + create_summary_report_times( + probs, reals, times, fixed_time_horizons=horizons, output_file=output + ) + + with _serve(tmp_path) as base_url, sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + errors: list[str] = [] + page.on("pageerror", lambda err: errors.append(str(err))) + + page.goto(f"{base_url}/{output.name}") + page.wait_for_selector(".rtichoke-performance-table__table") + + toggle_btn = page.locator( + "button[aria-label='Show confusion matrix detail']" + ).first + toggle_btn.wait_for() + toggle_btn.click() + + container = page.locator( + ".rtichoke-performance-table__confusion-container" + ).first + container.wait_for() + title_el = container.locator(".rtichoke-performance-table__confusion-title") + assert title_el.inner_text() == "Estimated Confusion Matrix" + assert ( + container.get_attribute("data-operating-point-type") + == "probability_threshold" + ) + assert container.get_attribute("data-operating-point-value") is not None + + caption_el = container.locator(".rtichoke-performance-table__confusion-caption") + assert ( + "Estimated classification quantities at the displayed time horizon." + in caption_el.inner_text() + ) + assert len(errors) == 0 + browser.close() + + +def test_performance_table_missing_confusion_values_suppresses_disclosure(tmp_path): + try: + from playwright.sync_api import sync_playwright # type: ignore[import-untyped] + except ImportError: + pytest.skip("playwright is not available") + + probs, reals, times, horizons = _inputs() + output = tmp_path / "incomplete_table_report.html" + create_summary_report_times( + probs, reals, times, fixed_time_horizons=horizons, output_file=output + ) + + # Modify the embedded spec to remove true_positives from all data rows in performance-table section + html = output.read_text(encoding="utf-8") + report = _embedded_report(html) + perf_section = next(s for s in report["sections"] if s["id"] == "performance-table") + for group in perf_section["items"]: + for comp in group.get("components", [group]): + for row in comp["spec"]["rows"]: + row["values"] = [ + v for v in row["values"] if v["metricId"] != "true_positives" + ] + + # Re-embed modified spec into HTML + start_tag = '" + start_idx = html.index(start_tag) + len(start_tag) + end_idx = html.index(end_tag, start_idx) + + modified_html = html[:start_idx] + json.dumps(report) + html[end_idx:] + modified_file = tmp_path / "modified_report.html" + modified_file.write_text(modified_html, encoding="utf-8") + + with _serve(tmp_path) as base_url, sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + errors: list[str] = [] + page.on("pageerror", lambda err: errors.append(str(err))) + + page.goto(f"{base_url}/{modified_file.name}") + page.wait_for_selector(".rtichoke-performance-table__table") + + # Disclosure button should not be rendered for rows missing true_positives + assert ( + page.locator("button[aria-label='Show confusion matrix detail']").count() + == 0 + ) + assert len(errors) == 0 + browser.close()