diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 11d1e43f..ceb1c7eb 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -52,14 +52,14 @@ jobs: prefix = "rtichoke/_vendor/rtichoke_viz/" required = { f"{prefix}VENDORED_FROM", - f"{prefix}rtichoke-viz-0.7.0.tar.gz", + f"{prefix}rtichoke-viz-0.9.0.tar.gz", f"{prefix}rtichoke-viz.js", f"{prefix}rtichoke-viz.css", f"{prefix}rtichoke-viz.schema.json", f"{prefix}rtichoke-viz-v2.schema.json", } assert required <= names - assert f"{prefix}rtichoke-viz-0.6.0.tar.gz" not in names + assert f"{prefix}rtichoke-viz-0.7.0.tar.gz" not in names PY - name: Run tests diff --git a/src/rtichoke/_decision_curve_viz_spec_v2.py b/src/rtichoke/_decision_curve_viz_spec_v2.py index ec57e55a..ee836fe4 100644 --- a/src/rtichoke/_decision_curve_viz_spec_v2.py +++ b/src/rtichoke/_decision_curve_viz_spec_v2.py @@ -1,4 +1,4 @@ -"""Canonical static Decision Curve v2 adapter. +"""Canonical Decision Curve v2 adapters. This module translates already-computed production Decision Curve quantities into the shared rtichoke_viz contract. It deliberately does not recompute model @@ -21,6 +21,12 @@ "n", } +_REQUIRED_TIMES_COLUMNS = _REQUIRED_COLUMNS | { + "fixed_time_horizon", + "censoring_heuristic", + "competing_heuristic", +} + def _decision_curve_v2_spec_from_performance_data( performance_data: pl.DataFrame, @@ -172,3 +178,212 @@ def _decision_curve_v2_spec_from_performance_data( "yAxis": {"label": "Net benefit"}, "references": references, } + + +def _decision_curve_times_v2_spec_from_performance_data( + performance_data: pl.DataFrame, + evaluation_metadata: Mapping[str, _EvaluationMetadata], + *, + min_p_threshold: float = 0.0, + max_p_threshold: float = 1.0, +) -> dict[str, object]: + """Build canonical time-dependent Decision Curve v2 from production quantities.""" + missing = _REQUIRED_TIMES_COLUMNS.difference(performance_data.columns) + if missing: + raise ValueError( + "Time-dependent Decision Curve performance data is missing columns: " + + ", ".join(sorted(missing)) + ) + + rows = ( + performance_data.filter( + pl.col("chosen_cutoff").is_finite() & pl.col("net_benefit").is_finite() + ) + .select( + "reference_group", + "fixed_time_horizon", + "censoring_heuristic", + "competing_heuristic", + "chosen_cutoff", + "net_benefit", + "real_positives", + "n", + ) + .to_dicts() + ) + + row_groups = {str(row["reference_group"]) for row in rows} + missing_metadata = row_groups.difference(evaluation_metadata) + if missing_metadata: + raise ValueError( + "Time-dependent Decision Curve rows are missing evaluation metadata: " + + ", ".join(sorted(missing_metadata)) + ) + + ordered_groups = [group for group in evaluation_metadata if group in row_groups] + evaluation_ids = { + group: f"evaluation-{index}" + for index, group in enumerate(ordered_groups, start=1) + } + + evaluations: list[dict[str, object]] = [] + for group in ordered_groups: + metadata = evaluation_metadata[group] + evaluation: dict[str, object] = { + "id": evaluation_ids[group], + "population": metadata.population, + } + if metadata.model is not None: + evaluation["model"] = metadata.model + evaluations.append(evaluation) + + series_keys = list( + dict.fromkeys( + ( + str(row["reference_group"]), + float(row["fixed_time_horizon"]), + str(row["censoring_heuristic"]), + str(row["competing_heuristic"]), + ) + for row in rows + ) + ) + series_ids = { + key: f"series-{index}" for index, key in enumerate(series_keys, start=1) + } + series: list[dict[str, object]] = [] + for key in series_keys: + group, horizon, _, _ = key + metadata = evaluation_metadata[group] + display_value = metadata.model or metadata.population + series.append( + { + "id": series_ids[key], + "evaluationId": evaluation_ids[group], + "horizon": horizon, + "display": { + "label": display_value, + "group": display_value, + "role": "model" if metadata.model is not None else "population", + }, + } + ) + + data = [] + for row in rows: + key = ( + str(row["reference_group"]), + float(row["fixed_time_horizon"]), + str(row["censoring_heuristic"]), + str(row["competing_heuristic"]), + ) + data.append( + { + "seriesId": series_ids[key], + "threshold": float(row["chosen_cutoff"]), + "netBenefit": float(row["net_benefit"]), + } + ) + + # Cutoff 0 event risk (AJ estimate) per (population, horizon) + group_risks = ( + performance_data.filter(pl.col("chosen_cutoff") == 0) + .select( + "reference_group", + "fixed_time_horizon", + (pl.col("real_positives") / pl.col("n")).alias("event_risk"), + ) + .unique() + .to_dicts() + ) + values: dict[tuple[str, float], set[float]] = {} + for row in group_risks: + group = str(row["reference_group"]) + metadata = evaluation_metadata.get(group) + if metadata is None: + continue + key = (metadata.population, float(row["fixed_time_horizon"])) + values.setdefault(key, set()).add(float(row["event_risk"])) + + populations = list( + dict.fromkeys(metadata.population for metadata in evaluation_metadata.values()) + ) + horizons = sorted( + float(value) + for value in performance_data["fixed_time_horizon"].unique().to_list() + ) + population_horizon_risks: dict[tuple[str, float], float] = {} + for key in ( + (population, horizon) + for horizon in horizons + for population in populations + if (population, horizon) in values + ): + candidates = values[key] + if len(candidates) != 1: + raise ValueError( + "Time-dependent Decision Curve must have one calculated event risk per " + f"population and horizon: {key[0]} at {key[1]}" + ) + population_horizon_risks[key] = next(iter(candidates)) + + # Thresholds per (population, horizon) + population_horizon_thresholds: dict[tuple[str, float], list[float]] = {} + for row in rows: + group = str(row["reference_group"]) + population = evaluation_metadata[group].population + horizon = float(row["fixed_time_horizon"]) + threshold = float(row["chosen_cutoff"]) + if 0.0 <= threshold < 1.0: + population_horizon_thresholds.setdefault((population, horizon), []).append( + threshold + ) + + references: list[dict[str, object]] = [ + { + "type": "horizontal", + "scope": "global", + "value": 0.0, + "label": "Treat None", + "benchmark": "treat_none", + } + ] + + for (population, horizon), event_risk in population_horizon_risks.items(): + thresholds = sorted( + set(population_horizon_thresholds.get((population, horizon), [])) + ) + references.append( + { + "type": "path", + "scope": "population_horizon", + "population": population, + "horizon": horizon, + "label": f"Treat All — {population}", + "benchmark": "treat_all", + "points": [ + { + "x": threshold, + "y": event_risk + - (1.0 - event_risk) * threshold / (1.0 - threshold), + } + for threshold in thresholds + ], + } + ) + + return { + "schemaVersion": "2.0", + "type": "decision_curve", + "evaluations": evaluations, + "series": series, + "data": data, + "x": "threshold", + "y": "netBenefit", + "xAxis": { + "label": "Probability threshold", + "domain": [min_p_threshold, max_p_threshold], + }, + "yAxis": {"label": "Net benefit"}, + "references": references, + } diff --git a/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM b/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM index 59558db7..cd02013a 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.7.0 -source_commit=b3564d2824ec1791f791fda406c99b3d7865a68f -archive=rtichoke-viz-0.7.0.tar.gz -sha256=f09c30e231a8be39c2e89ba6ae39c90ed8cab67021213e17681a475066a9806e +release=v0.9.0 +source_commit=56e2ba95f83c889385c38619571368f74250d428 +archive=rtichoke-viz-0.9.0.tar.gz +sha256=6a231c7bc951cdd3f5381e2a0937036a9d9f62b8ea1b36d8dbf81f62c6188ef8 diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.7.0.tar.gz b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.7.0.tar.gz deleted file mode 100644 index f44a1f2f..00000000 Binary files a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.7.0.tar.gz and /dev/null differ diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.9.0.tar.gz b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.9.0.tar.gz new file mode 100644 index 00000000..0d4fc4fc Binary files /dev/null and b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.9.0.tar.gz differ diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json index a278d988..807364ce 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json @@ -3525,53 +3525,111 @@ } }, { - "type": "object", - "required": [ - "type", - "points", - "scope", - "population", - "benchmark" - ], - "properties": { - "type": { - "const": "path", - "type": "string" - }, - "points": { - "minItems": 2, - "type": "array", - "items": { - "type": "object", - "required": [ - "x", - "y" - ], - "properties": { - "x": { - "type": "number" - }, - "y": { - "type": "number" + "anyOf": [ + { + "type": "object", + "required": [ + "type", + "points", + "benchmark", + "scope", + "population" + ], + "properties": { + "type": { + "const": "path", + "type": "string" + }, + "points": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "required": [ + "x", + "y" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } } + }, + "label": { + "type": "string" + }, + "benchmark": { + "const": "treat_all", + "type": "string" + }, + "scope": { + "const": "population", + "type": "string" + }, + "population": { + "type": "string" } } }, - "label": { - "type": "string" - }, - "scope": { - "const": "population", - "type": "string" - }, - "population": { - "type": "string" - }, - "benchmark": { - "const": "treat_all", - "type": "string" + { + "type": "object", + "required": [ + "type", + "points", + "benchmark", + "scope", + "population", + "horizon" + ], + "properties": { + "type": { + "const": "path", + "type": "string" + }, + "points": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "required": [ + "x", + "y" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + } + }, + "label": { + "type": "string" + }, + "benchmark": { + "const": "treat_all", + "type": "string" + }, + "scope": { + "const": "population_horizon", + "type": "string" + }, + "population": { + "type": "string" + }, + "horizon": { + "minimum": 0, + "type": "number" + } + } } - } + ] } ] } diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js index 6a1092b2..8d316471 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js @@ -2966,17 +2966,28 @@ var TreatNoneReferenceSchema = Type.Object({ scope: Type.Literal("global"), benchmark: Type.Literal("treat_none") }); -var TreatAllReferenceSchema = Type.Object({ +var TreatAllGeometry = { type: Type.Literal("path"), points: Type.Array( Type.Object({ x: Type.Number(), y: Type.Number() }), { minItems: 2 } ), label: Type.Optional(Type.String()), - scope: Type.Literal("population"), - population: Type.String(), benchmark: Type.Literal("treat_all") -}); +}; +var TreatAllReferenceSchema = Type.Union([ + Type.Object({ + ...TreatAllGeometry, + scope: Type.Literal("population"), + population: Type.String() + }), + Type.Object({ + ...TreatAllGeometry, + scope: Type.Literal("population_horizon"), + population: Type.String(), + horizon: Type.Number({ minimum: 0 }) + }) +]); var DecisionCurveV2ReferenceSchema = Type.Union([ TreatNoneReferenceSchema, TreatAllReferenceSchema @@ -3270,29 +3281,56 @@ function assertV2ReferentialIntegrity(spec) { decisionCurve.evaluations.forEach((evaluation, index2) => { const expectedId = `evaluation-${index2 + 1}`; if (evaluation.id !== expectedId) throw new Error(`decision curve evaluation ids must be ordinal: expected ${expectedId}`); + }); + const horizonCount = decisionCurve.series.filter((series) => series.horizon !== void 0).length; + if (horizonCount !== 0 && horizonCount !== decisionCurve.series.length) { + throw new Error("decision curve cannot mix static and horizon-qualified series"); + } + const isTimeDependent = horizonCount > 0; + const horizons2 = [...new Set(decisionCurve.series.map((series) => series.horizon).filter((horizon) => horizon !== void 0))]; + const seriesCoverage = /* @__PURE__ */ new Set(); + decisionCurve.series.forEach((series, index2) => { + if (series.id !== `series-${index2 + 1}`) throw new Error(`decision curve series ids must be ordinal: expected series-${index2 + 1}`); + const evaluation = decisionCurve.evaluations.find((candidate) => candidate.id === series.evaluationId); const expectedDisplay = evaluation.model ?? evaluation.population; const expectedRole = evaluation.model === void 0 ? "population" : "model"; - const series = decisionCurve.series[index2]; - if (!series || series.id !== `series-${index2 + 1}` || series.evaluationId !== evaluation.id) { - throw new Error("decision curve series must map one-to-one in evaluation order"); - } if (series.display.label !== expectedDisplay || series.display.group !== expectedDisplay || series.display.role !== expectedRole) { throw new Error("decision curve display must follow evaluation semantics"); } + const coverageKey = `${series.evaluationId}\0${series.horizon ?? "static"}`; + if (seriesCoverage.has(coverageKey)) throw new Error(`duplicate decision curve evaluation-horizon series: ${series.evaluationId}`); + seriesCoverage.add(coverageKey); }); - if (decisionCurve.series.length !== decisionCurve.evaluations.length) throw new Error("decision curve requires exactly one series per evaluation"); + if (isTimeDependent) { + const complete = decisionCurve.evaluations.every( + (evaluation) => horizons2.every((horizon) => seriesCoverage.has(`${evaluation.id}\0${horizon}`)) + ); + if (!complete || decisionCurve.series.length !== decisionCurve.evaluations.length * horizons2.length) { + throw new Error("decision curve requires exactly one series per evaluation and horizon"); + } + } else if (decisionCurve.series.length !== decisionCurve.evaluations.length || decisionCurve.evaluations.some((evaluation) => !seriesCoverage.has(`${evaluation.id}\0static`))) { + throw new Error("decision curve requires exactly one series per evaluation"); + } const treatNone = references.filter((reference) => "benchmark" in reference && reference.benchmark === "treat_none"); if (treatNone.length !== 1) throw new Error("decision curve requires exactly one Treat None reference"); const treatAll = references.filter( (reference) => "benchmark" in reference && reference.benchmark === "treat_all" ); - const treatAllPopulations = /* @__PURE__ */ new Set(); + const treatAllOwners = /* @__PURE__ */ new Set(); for (const reference of treatAll) { - if (treatAllPopulations.has(reference.population)) throw new Error(`duplicate Treat All population: ${reference.population}`); - treatAllPopulations.add(reference.population); + if (isTimeDependent && reference.scope !== "population_horizon") { + throw new Error("time-dependent decision curve Treat All must use population_horizon scope"); + } + if (!isTimeDependent && reference.scope !== "population") { + throw new Error("static decision curve Treat All must use population scope"); + } + const owner = reference.scope === "population_horizon" ? `${reference.population}\0${reference.horizon}` : reference.population; + if (treatAllOwners.has(owner)) throw new Error(`duplicate Treat All owner: ${reference.population}`); + treatAllOwners.add(owner); } - if (treatAllPopulations.size !== populations.size || [...populations].some((population) => !treatAllPopulations.has(population))) { - throw new Error("decision curve requires exactly one Treat All reference per population"); + const expectedTreatAllOwners = isTimeDependent ? [...populations].flatMap((population) => horizons2.map((horizon) => `${population}\0${horizon}`)) : [...populations]; + if (treatAllOwners.size !== expectedTreatAllOwners.length || expectedTreatAllOwners.some((owner) => !treatAllOwners.has(owner))) { + throw new Error(isTimeDependent ? "decision curve requires exactly one Treat All reference per population and horizon" : "decision curve requires exactly one Treat All reference per population"); } } if (spec.type === "interventions_avoided") { @@ -19416,9 +19454,9 @@ function selectHorizonSpec(spec, horizon) { ) }; } -function renderHorizonLineChart(spec, options, x2, y2) { +function renderWithHorizonSelection(spec, render) { const availableHorizons = horizons(spec); - if (availableHorizons.length <= 1) return renderLineChart(spec, options, x2, y2); + if (availableHorizons.length <= 1) return render(spec); const container = document.createElement("div"); container.className = "rtichoke-horizon-chart"; const control = document.createElement("label"); @@ -19434,17 +19472,21 @@ function renderHorizonLineChart(spec, options, x2, y2) { control.append(select); const chart = document.createElement("div"); const draw = (horizon) => { - chart.replaceChildren( - renderLineChart(selectHorizonSpec(spec, horizon), options, x2, y2) - ); + chart.replaceChildren(render(selectHorizonSpec(spec, horizon))); }; select.addEventListener("change", () => draw(Number(select.value))); container.append(control, chart); draw(availableHorizons[0]); return container; } +function renderHorizonLineChart(spec, options, x2, y2) { + return renderWithHorizonSelection( + spec, + (selected) => renderLineChart(selected, options, x2, y2) + ); +} function renderPrecisionRecallV2(spec, options = {}) { - return renderLineChart(spec, options, "sensitivity", "ppv"); + return renderHorizonLineChart(spec, options, "sensitivity", "ppv"); } function renderGainsV2(spec, options = {}) { return renderHorizonLineChart(spec, options, "ppcr", "sensitivity"); @@ -19456,6 +19498,9 @@ function renderLiftV2(spec, options = {}) { // src/render/decision-curve.ts function renderDecisionCurveV2(spec, options = {}) { assertV2ReferentialIntegrity(spec); + return renderWithHorizonSelection(spec, (selected) => renderDecisionCurveChart(selected, options)); +} +function renderDecisionCurveChart(spec, options) { const groups2 = [...new Set(spec.series.map((series) => series.display.group))]; const resolved = resolveV2RenderOptions(groups2, options); const { theme } = resolved; diff --git a/src/rtichoke/utility/decision.py b/src/rtichoke/utility/decision.py index 6142cb88..0412193d 100644 --- a/src/rtichoke/utility/decision.py +++ b/src/rtichoke/utility/decision.py @@ -6,9 +6,9 @@ import numpy as np import polars as pl -from plotly.graph_objs._figure import Figure from rtichoke._decision_curve_viz_spec_v2 import ( + _decision_curve_times_v2_spec_from_performance_data, _decision_curve_v2_spec_from_performance_data, ) from rtichoke._interventions_avoided_viz_spec_v2 import ( @@ -16,6 +16,9 @@ ) from rtichoke._renderers import RtichokeBrowserChart, _validate_renderer from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.performance_data.performance_data_times import ( + prepare_performance_data_times, +) from rtichoke.processing.binary_color_values import _apply_color_values_binary from rtichoke.processing.evaluation_semantics import ( _EvaluationMetadata, @@ -276,8 +279,40 @@ def create_decision_curve_times( "#D1603D", "#585123", ], -) -> Figure: - """Creates a time-dependent Decision Curve using the existing Plotly path.""" + renderer: str = "plotly", +) -> Any: + """Creates a time-dependent Decision Curve. + + ``renderer="plotly"`` preserves the historical default. For time-dependent + conventional Decision Curves, ``"browser"`` and ``"rtichoke_viz"`` return a + canonical :class:`RtichokeBrowserChart` built from already-computed production + values. + """ + selected_renderer = _validate_renderer(renderer) + if selected_renderer != "plotly": + if selected_renderer == "matplotlib" or decision_type != "conventional": + raise ValueError( + "Time-dependent Decision Curves support 'plotly', 'browser', and " + "'rtichoke_viz' renderers for decision_type='conventional'." + ) + performance_data = prepare_performance_data_times( + probs, + reals, + times, + by=by, + fixed_time_horizons=fixed_time_horizons, + heuristics_sets=heuristics_sets, + stratified_by=stratified_by, + ) + evaluation_metadata = _build_evaluation_metadata(probs, reals, times) + spec = _decision_curve_times_v2_spec_from_performance_data( + performance_data, + evaluation_metadata, + min_p_threshold=min_p_threshold, + max_p_threshold=max_p_threshold, + ) + return RtichokeBrowserChart(spec=spec, size=size) + if decision_type == "conventional": curve = "decision" else: diff --git a/tests/test_decision_curve_browser_acceptance.py b/tests/test_decision_curve_browser_acceptance.py index 127ebcee..d97136ed 100644 --- a/tests/test_decision_curve_browser_acceptance.py +++ b/tests/test_decision_curve_browser_acceptance.py @@ -9,7 +9,11 @@ import polars as pl import pytest -from rtichoke.utility.decision import create_decision_curve, plot_decision_curve +from rtichoke.utility.decision import ( + create_decision_curve, + create_decision_curve_times, + plot_decision_curve, +) @contextmanager @@ -72,6 +76,56 @@ def test_static_decision_curve_renders_model_and_references_in_real_browser(tmp_ browser.close() +def test_time_dependent_decision_curve_renders_in_real_browser(tmp_path: Path): + try: + from playwright.sync_api import sync_playwright # type: ignore[import-untyped] + except ImportError: + pytest.skip("playwright is not available") + + probs = { + "Model A": np.array([0.05, 0.15, 0.30, 0.45, 0.60, 0.75, 0.90]), + "Model B": np.array([0.10, 0.20, 0.35, 0.50, 0.65, 0.80, 0.95]), + } + reals = np.array([0, 0, 0, 1, 0, 1, 1]) + times = np.array([1.0, 3.0, 5.0, 2.0, 8.0, 4.0, 10.0]) + + chart = create_decision_curve_times( + probs, + reals, + times, + fixed_time_horizons=[5.0, 10.0], + by=0.1, + min_p_threshold=0.1, + max_p_threshold=0.8, + renderer="browser", + ) + chart.write_html(tmp_path / "time_decision.html") + + with _serve(tmp_path) as base_url: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + errors: list[str] = [] + page.on( + "console", + lambda msg: errors.append(msg.text) + if msg.type in ["error", "warning"] + else None, + ) + page.on("pageerror", lambda err: errors.append(str(err))) + page.goto(f"{base_url}/time_decision.html") + page.wait_for_selector("svg") + + content = page.content() + assert "Model A" in content + assert "Model B" in content + assert "Treat None" in content + assert "Treat All" in content + assert page.locator("svg").count() >= 1 + assert len(errors) == 0, f"Console errors found: {errors}" + browser.close() + + def test_static_interventions_avoided_renders_geometry_references_and_axes_in_real_browser( tmp_path: Path, ): diff --git a/tests/test_rtichoke_viz_vendor.py b/tests/test_rtichoke_viz_vendor.py index e2bb2778..da39f697 100644 --- a/tests/test_rtichoke_viz_vendor.py +++ b/tests/test_rtichoke_viz_vendor.py @@ -2,21 +2,20 @@ import tarfile from pathlib import Path - _VENDOR = Path(__file__).parents[1] / "src" / "rtichoke" / "_vendor" / "rtichoke_viz" -_RELEASE_DIR = "rtichoke-viz-0.7.0" -_SHA256 = "f09c30e231a8be39c2e89ba6ae39c90ed8cab67021213e17681a475066a9806e" -_SOURCE_COMMIT = "b3564d2824ec1791f791fda406c99b3d7865a68f" +_RELEASE_DIR = "rtichoke-viz-0.9.0" +_SHA256 = "6a231c7bc951cdd3f5381e2a0937036a9d9f62b8ea1b36d8dbf81f62c6188ef8" +_SOURCE_COMMIT = "56e2ba95f83c889385c38619571368f74250d428" -def test_vendored_rtichoke_viz_v070_provenance_archive_and_schemas(): +def test_vendored_rtichoke_viz_v090_provenance_archive_and_schemas(): provenance = (_VENDOR / "VENDORED_FROM").read_text() - assert "release=v0.7.0" in provenance + assert "release=v0.9.0" in provenance assert f"source_commit={_SOURCE_COMMIT}" in provenance - assert "archive=rtichoke-viz-0.7.0.tar.gz" in provenance + assert "archive=rtichoke-viz-0.9.0.tar.gz" in provenance assert f"sha256={_SHA256}" in provenance - archive = _VENDOR / "rtichoke-viz-0.7.0.tar.gz" + archive = _VENDOR / "rtichoke-viz-0.9.0.tar.gz" assert hashlib.sha256(archive.read_bytes()).hexdigest() == _SHA256 with tarfile.open(archive, "r:gz") as release: assert set(release.getnames()) == { @@ -29,7 +28,7 @@ def test_vendored_rtichoke_viz_v070_provenance_archive_and_schemas(): } manifest = release.extractfile(f"{_RELEASE_DIR}/MANIFEST") assert manifest is not None - assert manifest.read().decode() == (f"version=0.7.0\ncommit={_SOURCE_COMMIT}\n") + assert manifest.read().decode() == (f"version=0.9.0\ncommit={_SOURCE_COMMIT}\n") for filename in ( "rtichoke-viz.css", "rtichoke-viz.js", @@ -40,7 +39,7 @@ def test_vendored_rtichoke_viz_v070_provenance_archive_and_schemas(): assert packaged is not None assert (_VENDOR / filename).read_bytes() == packaged.read() - assert not (_VENDOR / "rtichoke-viz-0.6.0.tar.gz").exists() + assert not (_VENDOR / "rtichoke-viz-0.7.0.tar.gz").exists() assert (_VENDOR / "rtichoke-viz.js").stat().st_size > 0 assert (_VENDOR / "rtichoke-viz.css").stat().st_size > 0 @@ -52,7 +51,7 @@ def test_vendored_rtichoke_viz_v070_provenance_archive_and_schemas(): assert '"interventions_avoided"' in v2_schema -def test_v070_bundle_keeps_existing_exports_and_adds_interventions_avoided(): +def test_v090_bundle_keeps_existing_exports_and_adds_interventions_avoided(): bundle = (_VENDOR / "rtichoke-viz.js").read_text(encoding="utf-8") for export_name in ( "renderRoc", diff --git a/tests/test_time_decision_curve_v2.py b/tests/test_time_decision_curve_v2.py new file mode 100644 index 00000000..71bffaf0 --- /dev/null +++ b/tests/test_time_decision_curve_v2.py @@ -0,0 +1,114 @@ +import numpy as np +import pytest + +from rtichoke._decision_curve_viz_spec_v2 import ( + _decision_curve_times_v2_spec_from_performance_data, +) +from rtichoke._renderers import RtichokeBrowserChart +from rtichoke.performance_data.performance_data_times import ( + prepare_performance_data_times, +) +from rtichoke.processing.evaluation_semantics import _build_evaluation_metadata +from rtichoke.utility.decision import create_decision_curve_times + + +def test_time_decision_curve_v2_spec_structure(): + probs = { + "Model A": np.array([0.1, 0.4, 0.7, 0.9]), + "Model B": np.array([0.2, 0.3, 0.6, 0.8]), + } + reals = np.array([0, 0, 1, 1]) + times = np.array([5.0, 12.0, 3.0, 8.0]) + fixed_time_horizons = [5.0, 10.0] + heuristics_sets = [ + {"censoring_heuristic": "excluded", "competing_heuristic": "excluded"} + ] + + perf_data = prepare_performance_data_times( + probs, + reals, + times, + fixed_time_horizons=fixed_time_horizons, + heuristics_sets=heuristics_sets, + ) + metadata = _build_evaluation_metadata(probs, reals, times) + + spec = _decision_curve_times_v2_spec_from_performance_data(perf_data, metadata) + + assert spec["schemaVersion"] == "2.0" + assert spec["type"] == "decision_curve" + assert spec["x"] == "threshold" + assert spec["y"] == "netBenefit" + + # Evaluations must have stable IDs across horizons + eval_ids = [e["id"] for e in spec["evaluations"]] + assert eval_ids == ["evaluation-1", "evaluation-2"] + assert spec["evaluations"][0]["model"] == "Model A" + assert spec["evaluations"][1]["model"] == "Model B" + + # Series must be per evaluation × horizon + assert len(spec["series"]) == 4 # 2 models × 2 horizons + series_horizons = [s["horizon"] for s in spec["series"]] + assert set(series_horizons) == {5.0, 10.0} + assert all(s["evaluationId"] in eval_ids for s in spec["series"]) + + # Global Treat None and population_horizon Treat All references + references = spec["references"] + treat_none = [r for r in references if r.get("benchmark") == "treat_none"] + treat_all = [r for r in references if r.get("benchmark") == "treat_all"] + + assert len(treat_none) == 1 + assert treat_none[0]["scope"] == "global" + assert treat_none[0]["value"] == 0.0 + + assert len(treat_all) == 2 # 1 population × 2 horizons + for ref in treat_all: + assert ref["scope"] == "population_horizon" + assert ref["horizon"] in [5.0, 10.0] + assert "points" in ref + assert len(ref["points"]) > 0 + + +def test_create_decision_curve_times_renderer_options(): + probs = {"Model A": np.array([0.2, 0.5, 0.8])} + reals = np.array([0, 1, 1]) + times = np.array([2.0, 5.0, 8.0]) + + browser_chart = create_decision_curve_times( + probs, + reals, + times, + fixed_time_horizons=[5.0], + renderer="browser", + ) + assert isinstance(browser_chart, RtichokeBrowserChart) + assert browser_chart.spec["type"] == "decision_curve" + + alias_chart = create_decision_curve_times( + probs, + reals, + times, + fixed_time_horizons=[5.0], + renderer="rtichoke_viz", + ) + assert isinstance(alias_chart, RtichokeBrowserChart) + + +def test_create_decision_curve_times_rejects_interventions_avoided_in_browser_mode(): + probs = {"Model A": np.array([0.2, 0.5, 0.8])} + reals = np.array([0, 1, 1]) + times = np.array([2.0, 5.0, 8.0]) + + with pytest.raises( + ValueError, + match="Time-dependent Decision Curves support 'plotly', 'browser', and " + "'rtichoke_viz' renderers for decision_type='conventional'.", + ): + create_decision_curve_times( + probs, + reals, + times, + fixed_time_horizons=[5.0], + decision_type="interventions avoided", + renderer="browser", + )