diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index ceb1c7eb..67890a85 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.9.0.tar.gz", + f"{prefix}rtichoke-viz-0.10.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.7.0.tar.gz" not in names + assert f"{prefix}rtichoke-viz-0.9.0.tar.gz" not in names PY - name: Run tests diff --git a/src/rtichoke/_interventions_avoided_viz_spec_v2.py b/src/rtichoke/_interventions_avoided_viz_spec_v2.py index e8b1bfd9..c7273757 100644 --- a/src/rtichoke/_interventions_avoided_viz_spec_v2.py +++ b/src/rtichoke/_interventions_avoided_viz_spec_v2.py @@ -21,6 +21,12 @@ "n", } +_REQUIRED_TIMES_COLUMNS = _REQUIRED_COLUMNS | { + "fixed_time_horizon", + "censoring_heuristic", + "competing_heuristic", +} + def _interventions_avoided_v2_spec_from_performance_data( performance_data: pl.DataFrame, @@ -177,3 +183,198 @@ def _interventions_avoided_v2_spec_from_performance_data( "yAxis": {"label": "Interventions Avoided (per 100)"}, "references": references, } + + +def _interventions_avoided_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 Interventions Avoided v2. + + Model values are copied from the existing production calculation. Only + Treat None reference geometry is derived from the existing AJ event risk. + """ + missing = _REQUIRED_TIMES_COLUMNS.difference(performance_data.columns) + if missing: + raise ValueError( + "Time-dependent Interventions Avoided performance data is missing columns: " + + ", ".join(sorted(missing)) + ) + + rows = ( + performance_data.filter( + pl.col("chosen_cutoff").is_finite() + & pl.col("net_benefit_interventions_avoided").is_finite() + ) + .select( + "reference_group", + "fixed_time_horizon", + "censoring_heuristic", + "competing_heuristic", + "chosen_cutoff", + "net_benefit_interventions_avoided", + "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 Interventions Avoided 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"])) + 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 group, horizon in series_keys: + metadata = evaluation_metadata[group] + display_value = metadata.model or metadata.population + series.append( + { + "id": series_ids[(group, horizon)], + "evaluationId": evaluation_ids[group], + "horizon": horizon, + "display": { + "label": display_value, + "group": display_value, + "role": "model" if metadata.model is not None else "population", + }, + } + ) + + data = [ + { + "seriesId": series_ids[ + (str(row["reference_group"]), float(row["fixed_time_horizon"])) + ], + "threshold": float(row["chosen_cutoff"]), + "interventionsAvoided": float(row["net_benefit_interventions_avoided"]), + } + for row in rows + ] + + risk_rows = ( + 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() + ) + risk_values: dict[tuple[str, float], set[float]] = {} + for row in risk_rows: + group = str(row["reference_group"]) + metadata = evaluation_metadata.get(group) + if metadata is None: + continue + key = (metadata.population, float(row["fixed_time_horizon"])) + risk_values.setdefault(key, set()).add(float(row["event_risk"])) + + thresholds: dict[tuple[str, float], list[float]] = {} + for row in rows: + group = str(row["reference_group"]) + key = ( + evaluation_metadata[group].population, + float(row["fixed_time_horizon"]), + ) + threshold = float(row["chosen_cutoff"]) + if 0.0 < threshold <= 1.0: + thresholds.setdefault(key, []).append(threshold) + + references: list[dict[str, object]] = [ + { + "type": "horizontal", + "scope": "global", + "value": 0.0, + "label": "Treat All", + "benchmark": "treat_all", + } + ] + 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() + ) + for population in populations: + for horizon in horizons: + key = (population, horizon) + candidates = risk_values.get(key, set()) + if not candidates: + continue + if len(candidates) != 1: + raise ValueError( + "Time-dependent Interventions Avoided must have one calculated " + f"event risk per population and horizon: {population} at {horizon}" + ) + event_risk = next(iter(candidates)) + references.append( + { + "type": "path", + "scope": "population_horizon", + "population": population, + "horizon": horizon, + "label": f"Treat None — {population}", + "benchmark": "treat_none", + "points": [ + { + "x": threshold, + "y": 100.0 + * ( + 1.0 + - event_risk + - event_risk * (1.0 - threshold) / threshold + ), + } + for threshold in sorted(set(thresholds.get(key, []))) + ], + } + ) + + return { + "schemaVersion": "2.0", + "type": "interventions_avoided", + "evaluations": evaluations, + "series": series, + "data": data, + "x": "threshold", + "y": "interventionsAvoided", + "xAxis": { + "label": "Probability Threshold", + "domain": [min_p_threshold, max_p_threshold], + }, + "yAxis": {"label": "Interventions Avoided (per 100)"}, + "references": references, + } diff --git a/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM b/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM index cd02013a..abc0d7db 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.9.0 -source_commit=56e2ba95f83c889385c38619571368f74250d428 -archive=rtichoke-viz-0.9.0.tar.gz -sha256=6a231c7bc951cdd3f5381e2a0937036a9d9f62b8ea1b36d8dbf81f62c6188ef8 +release=v0.10.0 +source_commit=b65b903e9456b5eb323dbe0bac823e9ff0c1bd01 +archive=rtichoke-viz-0.10.0.tar.gz +sha256=030a9e58c6367147b4904d73cf641d8ef3990d95e70b66022a99d6c8d37b3233 diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.10.0.tar.gz b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.10.0.tar.gz new file mode 100644 index 00000000..87d00a4c Binary files /dev/null and b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.10.0.tar.gz 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 deleted file mode 100644 index 0d4fc4fc..00000000 Binary files a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.9.0.tar.gz and /dev/null 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 807364ce..f5113042 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json @@ -4345,53 +4345,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_none", + "type": "string" + }, + "scope": { + "const": "population", + "type": "string" + }, + "population": { + "type": "string" } } }, - "label": { - "type": "string" - }, - "scope": { - "const": "population", - "type": "string" - }, - "population": { - "type": "string" - }, - "benchmark": { - "const": "treat_none", - "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_none", + "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 8d316471..915f87d5 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js @@ -3046,17 +3046,28 @@ var InterventionsAvoidedTreatAllReferenceSchema = Type.Object({ scope: Type.Literal("global"), benchmark: Type.Literal("treat_all") }); -var InterventionsAvoidedTreatNoneReferenceSchema = Type.Object({ +var TreatNoneGeometry = { 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_none") -}); +}; +var InterventionsAvoidedTreatNoneReferenceSchema = Type.Union([ + Type.Object({ + ...TreatNoneGeometry, + scope: Type.Literal("population"), + population: Type.String() + }), + Type.Object({ + ...TreatNoneGeometry, + scope: Type.Literal("population_horizon"), + population: Type.String(), + horizon: Type.Number({ minimum: 0 }) + }) +]); var InterventionsAvoidedV2ReferenceSchema = Type.Union([ InterventionsAvoidedTreatAllReferenceSchema, InterventionsAvoidedTreatNoneReferenceSchema @@ -3339,17 +3350,37 @@ function assertV2ReferentialIntegrity(spec) { interventionsAvoided.evaluations.forEach((evaluation, index2) => { const expectedId = `evaluation-${index2 + 1}`; if (evaluation.id !== expectedId) throw new Error(`interventions avoided evaluation ids must be ordinal: expected ${expectedId}`); + }); + const horizonCount = interventionsAvoided.series.filter((series) => series.horizon !== void 0).length; + if (horizonCount !== 0 && horizonCount !== interventionsAvoided.series.length) { + throw new Error("interventions avoided cannot mix static and horizon-qualified series"); + } + const isTimeDependent = horizonCount > 0; + const horizons2 = [...new Set(interventionsAvoided.series.map((series) => series.horizon).filter((horizon) => horizon !== void 0))]; + const seriesCoverage = /* @__PURE__ */ new Set(); + interventionsAvoided.series.forEach((series, index2) => { + if (series.id !== `series-${index2 + 1}`) throw new Error(`interventions avoided series ids must be ordinal: expected series-${index2 + 1}`); + const evaluation = interventionsAvoided.evaluations.find((candidate) => candidate.id === series.evaluationId); + if (!evaluation) throw new Error(`unknown evaluation id: ${series.evaluationId}`); const expectedDisplay = evaluation.model ?? evaluation.population; const expectedRole = evaluation.model === void 0 ? "population" : "model"; - const series = interventionsAvoided.series[index2]; - if (!series || series.id !== `series-${index2 + 1}` || series.evaluationId !== evaluation.id) { - throw new Error("interventions avoided 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("interventions avoided display must follow evaluation semantics"); } + const coverageKey = `${series.evaluationId}\0${series.horizon ?? "static"}`; + if (seriesCoverage.has(coverageKey)) throw new Error(`duplicate interventions avoided evaluation-horizon series: ${series.evaluationId}`); + seriesCoverage.add(coverageKey); }); - if (interventionsAvoided.series.length !== interventionsAvoided.evaluations.length) throw new Error("interventions avoided requires exactly one series per evaluation"); + if (isTimeDependent) { + const complete = interventionsAvoided.evaluations.every( + (evaluation) => horizons2.every((horizon) => seriesCoverage.has(`${evaluation.id}\0${horizon}`)) + ); + if (!complete || interventionsAvoided.series.length !== interventionsAvoided.evaluations.length * horizons2.length) { + throw new Error("interventions avoided requires exactly one series per evaluation and horizon"); + } + } else if (interventionsAvoided.series.length !== interventionsAvoided.evaluations.length || interventionsAvoided.evaluations.some((evaluation) => !seriesCoverage.has(`${evaluation.id}\0static`))) { + throw new Error("interventions avoided requires exactly one series per evaluation"); + } const treatAll = references.filter( (reference) => "benchmark" in reference && reference.benchmark === "treat_all" ); @@ -3360,13 +3391,21 @@ function assertV2ReferentialIntegrity(spec) { const treatNone = references.filter( (reference) => "benchmark" in reference && reference.benchmark === "treat_none" ); - const treatNonePopulations = /* @__PURE__ */ new Set(); + const treatNoneOwners = /* @__PURE__ */ new Set(); for (const reference of treatNone) { - if (treatNonePopulations.has(reference.population)) throw new Error(`duplicate Treat None population: ${reference.population}`); - treatNonePopulations.add(reference.population); + if (isTimeDependent && reference.scope !== "population_horizon") { + throw new Error("time-dependent interventions avoided Treat None must use population_horizon scope"); + } + if (!isTimeDependent && reference.scope !== "population") { + throw new Error("static interventions avoided Treat None must use population scope"); + } + const owner = reference.scope === "population_horizon" ? `${reference.population}\0${reference.horizon}` : reference.population; + if (treatNoneOwners.has(owner)) throw new Error(`duplicate Treat None owner: ${reference.population}`); + treatNoneOwners.add(owner); } - if (treatNonePopulations.size !== populations.size || [...populations].some((population) => !treatNonePopulations.has(population))) { - throw new Error("interventions avoided requires exactly one Treat None reference per population"); + const expectedTreatNoneOwners = isTimeDependent ? [...populations].flatMap((population) => horizons2.map((horizon) => `${population}\0${horizon}`)) : [...populations]; + if (treatNoneOwners.size !== expectedTreatNoneOwners.length || expectedTreatNoneOwners.some((owner) => !treatNoneOwners.has(owner))) { + throw new Error(isTimeDependent ? "interventions avoided requires exactly one Treat None reference per population and horizon" : "interventions avoided requires exactly one Treat None reference per population"); } } } @@ -19518,9 +19557,9 @@ Net Benefit: ${datum2.netBenefit.toFixed(theme.tip.digits)}` const marks2 = []; for (const reference of spec.references) { if (reference.benchmark === "treat_none") { - marks2.push(ruleY([0], { ...referenceStyle, title: reference.label ?? "Treat None" })); + marks2.push(ruleY([0], { ...referenceStyle, title: () => reference.label ?? "Treat None" })); } else { - marks2.push(line(reference.points, { x: "x", y: "y", ...referenceStyle, title: reference.label ?? `Treat All \u2014 ${reference.population}` })); + marks2.push(line(reference.points, { x: "x", y: "y", ...referenceStyle, title: () => reference.label ?? `Treat All \u2014 ${reference.population}` })); } } marks2.push( @@ -19551,6 +19590,9 @@ Net Benefit: ${datum2.netBenefit.toFixed(theme.tip.digits)}` // src/render/interventions-avoided.ts function renderInterventionsAvoidedV2(spec, options = {}) { assertV2ReferentialIntegrity(spec); + return renderWithHorizonSelection(spec, (selected) => renderInterventionsAvoidedChart(selected, options)); +} +function renderInterventionsAvoidedChart(spec, options) { const groups2 = [...new Set(spec.series.map((series) => series.display.group))]; const resolved = resolveV2RenderOptions(groups2, options); const { theme } = resolved; @@ -19568,9 +19610,9 @@ Interventions Avoided: ${datum2.interventionsAvoided.toFixed(theme.tip.digits)}` const marks2 = []; for (const reference of spec.references) { if (reference.benchmark === "treat_all") { - marks2.push(ruleY([0], { ...referenceStyle, title: reference.label ?? "Treat All" })); + marks2.push(ruleY([0], { ...referenceStyle, title: () => reference.label ?? "Treat All" })); } else { - marks2.push(line(reference.points, { x: "x", y: "y", ...referenceStyle, title: reference.label ?? `Treat None \u2014 ${reference.population}` })); + marks2.push(line(reference.points, { x: "x", y: "y", ...referenceStyle, title: () => reference.label ?? `Treat None \u2014 ${reference.population}` })); } } marks2.push( diff --git a/src/rtichoke/utility/decision.py b/src/rtichoke/utility/decision.py index 0412193d..05a300cd 100644 --- a/src/rtichoke/utility/decision.py +++ b/src/rtichoke/utility/decision.py @@ -12,6 +12,7 @@ _decision_curve_v2_spec_from_performance_data, ) from rtichoke._interventions_avoided_viz_spec_v2 import ( + _interventions_avoided_times_v2_spec_from_performance_data, _interventions_avoided_v2_spec_from_performance_data, ) from rtichoke._renderers import RtichokeBrowserChart, _validate_renderer @@ -284,16 +285,20 @@ def create_decision_curve_times( """Creates a time-dependent Decision Curve. ``renderer="plotly"`` preserves the historical default. For time-dependent - conventional Decision Curves, ``"browser"`` and ``"rtichoke_viz"`` return a + 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": + if selected_renderer == "matplotlib" or decision_type not in { + "conventional", + "interventions avoided", + }: raise ValueError( "Time-dependent Decision Curves support 'plotly', 'browser', and " - "'rtichoke_viz' renderers for decision_type='conventional'." + "'rtichoke_viz' renderers for decision_type='conventional' or " + "decision_type='interventions avoided'." ) performance_data = prepare_performance_data_times( probs, @@ -305,7 +310,12 @@ def create_decision_curve_times( stratified_by=stratified_by, ) evaluation_metadata = _build_evaluation_metadata(probs, reals, times) - spec = _decision_curve_times_v2_spec_from_performance_data( + adapter = ( + _decision_curve_times_v2_spec_from_performance_data + if decision_type == "conventional" + else _interventions_avoided_times_v2_spec_from_performance_data + ) + spec = adapter( performance_data, evaluation_metadata, min_p_threshold=min_p_threshold, diff --git a/tests/test_decision_curve_browser_acceptance.py b/tests/test_decision_curve_browser_acceptance.py index d97136ed..e34de91f 100644 --- a/tests/test_decision_curve_browser_acceptance.py +++ b/tests/test_decision_curve_browser_acceptance.py @@ -58,9 +58,11 @@ def test_static_decision_curve_renders_model_and_references_in_real_browser(tmp_ errors: list[str] = [] page.on( "console", - lambda msg: errors.append(msg.text) - if msg.type in ["error", "warning"] - else None, + 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}/decision.html") @@ -108,9 +110,11 @@ def test_time_dependent_decision_curve_renders_in_real_browser(tmp_path: Path): errors: list[str] = [] page.on( "console", - lambda msg: errors.append(msg.text) - if msg.type in ["error", "warning"] - else None, + 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") @@ -173,9 +177,11 @@ def test_static_interventions_avoided_renders_geometry_references_and_axes_in_re errors: list[str] = [] page.on( "console", - lambda msg: errors.append(msg.text) - if msg.type in ["error", "warning"] - else None, + 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}/interventions-avoided.html") @@ -191,3 +197,64 @@ def test_static_interventions_avoided_renders_geometry_references_and_axes_in_re assert page.locator("svg").count() >= 1 assert len(errors) == 0, f"Console errors found: {errors}" browser.close() + + +def test_time_interventions_avoided_horizon_switch_replaces_geometry_in_real_browser( + tmp_path: Path, +): + try: + from playwright.sync_api import sync_playwright # type: ignore[import-untyped] # ty: ignore[unresolved-import] + except ImportError: + pytest.skip("playwright is not available") # ty: ignore[too-many-positional-arguments] + + 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], + decision_type="interventions avoided", + by=0.1, + min_p_threshold=0.1, + max_p_threshold=0.8, + renderer="browser", + ) + chart.write_html(tmp_path / "time-interventions-avoided.html") + + 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 error: errors.append(str(error))) + page.goto(f"{base_url}/time-interventions-avoided.html") + selector = page.locator("select[aria-label='Fixed Time Horizon']") + selector.wait_for() + page.wait_for_selector("svg") + + assert selector.locator("option").all_text_contents() == ["5", "10"] + initial_paths = page.locator("svg path").evaluate_all( + "nodes => nodes.map(node => node.getAttribute('d'))" + ) + initial_svg_count = page.locator("svg").count() + assert "Treat All" in page.content() + assert "Treat None" in page.content() + + selector.select_option("10") + page.wait_for_function( + "document.querySelector(\"select[aria-label='Fixed Time Horizon']\").value === '10'" + ) + switched_paths = page.locator("svg path").evaluate_all( + "nodes => nodes.map(node => node.getAttribute('d'))" + ) + + assert switched_paths != initial_paths + assert page.locator("svg").count() == initial_svg_count + assert "Treat All" in page.content() + assert "Treat None" in page.content() + assert not errors + browser.close() diff --git a/tests/test_rtichoke_viz_vendor.py b/tests/test_rtichoke_viz_vendor.py index da39f697..1c8c6cc4 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.9.0" -_SHA256 = "6a231c7bc951cdd3f5381e2a0937036a9d9f62b8ea1b36d8dbf81f62c6188ef8" -_SOURCE_COMMIT = "56e2ba95f83c889385c38619571368f74250d428" +_RELEASE_DIR = "rtichoke-viz-0.10.0" +_SHA256 = "030a9e58c6367147b4904d73cf641d8ef3990d95e70b66022a99d6c8d37b3233" +_SOURCE_COMMIT = "b65b903e9456b5eb323dbe0bac823e9ff0c1bd01" -def test_vendored_rtichoke_viz_v090_provenance_archive_and_schemas(): +def test_vendored_rtichoke_viz_v0100_provenance_archive_and_schemas(): provenance = (_VENDOR / "VENDORED_FROM").read_text() - assert "release=v0.9.0" in provenance + assert "release=v0.10.0" in provenance assert f"source_commit={_SOURCE_COMMIT}" in provenance - assert "archive=rtichoke-viz-0.9.0.tar.gz" in provenance + assert "archive=rtichoke-viz-0.10.0.tar.gz" in provenance assert f"sha256={_SHA256}" in provenance - archive = _VENDOR / "rtichoke-viz-0.9.0.tar.gz" + archive = _VENDOR / "rtichoke-viz-0.10.0.tar.gz" assert hashlib.sha256(archive.read_bytes()).hexdigest() == _SHA256 with tarfile.open(archive, "r:gz") as release: assert set(release.getnames()) == { @@ -28,7 +28,9 @@ def test_vendored_rtichoke_viz_v090_provenance_archive_and_schemas(): } manifest = release.extractfile(f"{_RELEASE_DIR}/MANIFEST") assert manifest is not None - assert manifest.read().decode() == (f"version=0.9.0\ncommit={_SOURCE_COMMIT}\n") + assert manifest.read().decode() == ( + f"version=0.10.0\ncommit={_SOURCE_COMMIT}\n" + ) for filename in ( "rtichoke-viz.css", "rtichoke-viz.js", @@ -39,7 +41,7 @@ def test_vendored_rtichoke_viz_v090_provenance_archive_and_schemas(): assert packaged is not None assert (_VENDOR / filename).read_bytes() == packaged.read() - assert not (_VENDOR / "rtichoke-viz-0.7.0.tar.gz").exists() + assert not (_VENDOR / "rtichoke-viz-0.9.0.tar.gz").exists() assert (_VENDOR / "rtichoke-viz.js").stat().st_size > 0 assert (_VENDOR / "rtichoke-viz.css").stat().st_size > 0 @@ -51,7 +53,7 @@ def test_vendored_rtichoke_viz_v090_provenance_archive_and_schemas(): assert '"interventions_avoided"' in v2_schema -def test_v090_bundle_keeps_existing_exports_and_adds_interventions_avoided(): +def test_v0100_bundle_keeps_existing_exports_and_time_dependent_surfaces(): bundle = (_VENDOR / "rtichoke-viz.js").read_text(encoding="utf-8") for export_name in ( "renderRoc", @@ -63,6 +65,8 @@ def test_v090_bundle_keeps_existing_exports_and_adds_interventions_avoided(): "DecisionCurveV2SpecSchema", "renderInterventionsAvoidedV2", "InterventionsAvoidedV2SpecSchema", + "renderPrecisionRecallV2", + "PrecisionRecallV2SpecSchema", "RtichokeChartSpecV2Schema", "renderPerformanceTable", "renderReport", diff --git a/tests/test_time_decision_curve_v2.py b/tests/test_time_decision_curve_v2.py index 71bffaf0..3164ba6c 100644 --- a/tests/test_time_decision_curve_v2.py +++ b/tests/test_time_decision_curve_v2.py @@ -94,15 +94,14 @@ def test_create_decision_curve_times_renderer_options(): assert isinstance(alias_chart, RtichokeBrowserChart) -def test_create_decision_curve_times_rejects_interventions_avoided_in_browser_mode(): +def test_create_decision_curve_times_rejects_matplotlib_consistently(): 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'.", + match="Time-dependent Decision Curves support", ): create_decision_curve_times( probs, @@ -110,5 +109,5 @@ def test_create_decision_curve_times_rejects_interventions_avoided_in_browser_mo times, fixed_time_horizons=[5.0], decision_type="interventions avoided", - renderer="browser", + renderer="matplotlib", ) diff --git a/tests/test_time_interventions_avoided_v2.py b/tests/test_time_interventions_avoided_v2.py new file mode 100644 index 00000000..94d4bcbc --- /dev/null +++ b/tests/test_time_interventions_avoided_v2.py @@ -0,0 +1,152 @@ +from pathlib import Path +from typing import Any, cast + +import numpy as np +import polars as pl +from plotly.graph_objects import Figure + +from rtichoke._interventions_avoided_viz_spec_v2 import ( + _interventions_avoided_times_v2_spec_from_performance_data, +) +from rtichoke._renderers import RtichokeBrowserChart +from rtichoke.processing.evaluation_semantics import _EvaluationMetadata +from rtichoke.utility.decision import create_decision_curve_times + + +def _time_data(*, equal_risk: bool = True) -> pl.DataFrame: + rows = [] + risks = {"a": (2, 8), "b": ((2, 8) if equal_risk else (4, 8))} + for group, (positives, n) in risks.items(): + for horizon, offset in ((5.0, 0.0), (10.0, 10.0)): + for threshold, value in ((0.0, -99.0), (0.25, 1.25 + offset)): + rows.append( + { + "reference_group": group, + "fixed_time_horizon": horizon, + "censoring_heuristic": "excluded", + "competing_heuristic": "excluded", + "chosen_cutoff": threshold, + "net_benefit_interventions_avoided": value, + "real_positives": positives, + "n": n, + } + ) + return pl.DataFrame(rows) + + +def test_time_adapter_preserves_identity_values_and_reference_ownership(): + metadata = { + "a": _EvaluationMetadata("a", "a", "model-a", "population-1"), + "b": _EvaluationMetadata("b", "b", "model-b", "population-1"), + } + spec = cast( + dict[str, Any], + _interventions_avoided_times_v2_spec_from_performance_data( + _time_data(), metadata + ), + ) + + assert spec["type"] == "interventions_avoided" + assert [item["id"] for item in spec["evaluations"]] == [ + "evaluation-1", + "evaluation-2", + ] + assert len(spec["series"]) == 4 + assert len({item["id"] for item in spec["series"]}) == 4 + assert {item["horizon"] for item in spec["series"]} == {5.0, 10.0} + assert {item["evaluationId"] for item in spec["series"]} == { + "evaluation-1", + "evaluation-2", + } + assert [row["interventionsAvoided"] for row in spec["data"]] == [ + -99.0, + 1.25, + -99.0, + 11.25, + -99.0, + 1.25, + -99.0, + 11.25, + ] + + treat_all = [r for r in spec["references"] if r["benchmark"] == "treat_all"] + treat_none = [r for r in spec["references"] if r["benchmark"] == "treat_none"] + assert treat_all == [ + { + "type": "horizontal", + "scope": "global", + "value": 0.0, + "label": "Treat All", + "benchmark": "treat_all", + } + ] + assert len(treat_none) == 2 + assert {(r["population"], r["horizon"]) for r in treat_none} == { + ("population-1", 5.0), + ("population-1", 10.0), + } + assert all(r["scope"] == "population_horizon" for r in treat_none) + + +def test_equal_risk_distinct_populations_remain_distinct_per_horizon(): + metadata = { + "a": _EvaluationMetadata("a", "a", None, "population-a"), + "b": _EvaluationMetadata("b", "b", None, "population-b"), + } + spec = cast( + dict[str, Any], + _interventions_avoided_times_v2_spec_from_performance_data( + _time_data(), metadata + ), + ) + refs = [r for r in spec["references"] if r["benchmark"] == "treat_none"] + + assert len(refs) == 4 + assert {(r["population"], r["horizon"]) for r in refs} == { + ("population-a", 5.0), + ("population-a", 10.0), + ("population-b", 5.0), + ("population-b", 10.0), + } + assert refs[0]["points"] == refs[2]["points"] + + +def test_public_time_interventions_avoided_browser_is_opt_in(tmp_path: Path): + probs = {"Model A": np.array([0.2, 0.5, 0.8, 0.9])} + reals = np.array([0, 0, 1, 1]) + times = np.array([2.0, 7.0, 3.0, 8.0]) + default = create_decision_curve_times( + probs, + reals, + times, + fixed_time_horizons=[5.0, 10.0], + decision_type="interventions avoided", + by=0.25, + ) + browser = create_decision_curve_times( + probs, + reals, + times, + fixed_time_horizons=[5.0, 10.0], + decision_type="interventions avoided", + by=0.25, + renderer="browser", + ) + alias = create_decision_curve_times( + probs, + reals, + times, + fixed_time_horizons=[5.0, 10.0], + decision_type="interventions avoided", + by=0.25, + renderer="rtichoke_viz", + ) + + assert isinstance(default, Figure) + assert isinstance(browser, RtichokeBrowserChart) + assert isinstance(alias, RtichokeBrowserChart) + assert browser.spec["type"] == "interventions_avoided" + html = browser.write_html(tmp_path / "time-ia.html").read_text(encoding="utf-8") + assert "renderInterventionsAvoidedV2" in html + bundle = (tmp_path / "rtichoke-viz.js").read_text(encoding="utf-8") + assert "Fixed Time Horizon" in bundle