Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,15 @@ jobs:
prefix = "rtichoke/_vendor/rtichoke_viz/"
required = {
f"{prefix}VENDORED_FROM",
f"{prefix}rtichoke-viz-0.10.0.tar.gz",
f"{prefix}rtichoke-viz-0.14.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",
f"{prefix}rtichoke-viz-report.schema.json",
}
assert required <= names
assert f"{prefix}rtichoke-viz-0.9.0.tar.gz" not in names
assert f"{prefix}rtichoke-viz-0.10.0.tar.gz" not in names
PY

- name: Run tests
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies = [
"polars>=1.31.0",
"reactable>=0.1.5",
"great-tables>=0.18.0",
"scikit-learn>=1.6.1",
]
name = "rtichoke"
version = "0.1.36"
Expand Down Expand Up @@ -59,7 +60,6 @@ dev = [
"pre-commit>=4.2.0",
"dcurves>=1.1.5",
"ty>=0.0.1a12",
"scikit-learn>=1.6.1",
]
docs = [
"great-docs==0.15.0",
Expand Down
6 changes: 5 additions & 1 deletion src/rtichoke/_calibration_viz_spec_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ def _calibration_v2_spec_from_curve_list(
}
)

y_axis: dict[str, object] = {"label": "Observed probability"}
if calibration_type == "discrete":
y_axis["domain"] = [0, 1]

return {
"schemaVersion": "2.0",
"type": "calibration",
Expand All @@ -161,7 +165,7 @@ def _calibration_v2_spec_from_curve_list(
"x": "predicted",
"y": "observed",
"xAxis": {"label": "Predicted probability", "domain": [0, 1]},
"yAxis": {"label": "Observed probability", "domain": [0, 1]},
"yAxis": y_axis,
"references": [{"type": "identity", "scope": "global"}],
}

Expand Down
5 changes: 4 additions & 1 deletion src/rtichoke/_report_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ def write_html(self, path: str | Path) -> Path:
const spec = JSON.parse(
document.querySelector("#rtichoke-report-spec").textContent
);
document.querySelector("#rtichoke-report").append(renderReport(spec));
document.querySelector("#rtichoke-report").append(renderReport(spec, {{
sectionGroupPresentation: "tabs",
groupPresentation: "stacked"
}}));
</script>
</body>
</html>
Expand Down
242 changes: 175 additions & 67 deletions src/rtichoke/_report_spec.py
Original file line number Diff line number Diff line change
@@ -1,102 +1,210 @@
"""Internal assembler for canonical ``rtichoke_viz`` ReportSpec objects.
"""Internal assembler for canonical ``rtichoke_viz`` ReportSpec v1.1 objects.

The assembler composes complete standalone canonical component specs. It does
not calculate statistics, normalize component specs, hoist evaluations, or
The assembler composes complete standalone canonical component specs into a structured
ReportSpec v1.1 object hierarchy with sections, items (components and groups), and title.
It does not calculate statistics, normalize component specs, hoist evaluations, or
create report-global semantic registries.

This path is intentionally separate from the existing public summary-report
API, which currently delegates to the historical R backend and Quarto
composition. Complete ReportSpec values can now use the internal shared-browser
path backed by the immutable vendored ``rtichoke_viz`` release, while the
existing public report behavior remains unchanged.
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from typing import TypedDict, cast
from typing import Any, TypedDict, cast

_V10_SCHEMA_TYPES = {
"summary_metrics",
}

_SUPPORTED_COMPONENT_TYPES = {
_V20_SCHEMA_TYPES = {
"performance_table",
"roc",
"calibration",
"precision_recall",
"gains",
"lift",
"decision_curve",
"interventions_avoided",
}
_COMPONENT_ID_BASES = {
"performance_table": "performance-table",
"roc": "roc",
"calibration": "calibration",
"precision_recall": "precision-recall",
"gains": "gains",
"lift": "lift",
}

_ALL_SUPPORTED_TYPES = _V10_SCHEMA_TYPES | _V20_SCHEMA_TYPES


class _ReportComponent(TypedDict, total=False):
def _validate_spec_schema_version(spec: Mapping[str, object]) -> None:
"""Validate that spec schemaVersion strictly matches type requirements.

summary_metrics -> "1.0"
all v2 component types -> "2.0"
"""
spec_type = spec.get("type")
if not isinstance(spec_type, str):
raise ValueError("Report component spec is missing a string type")
if spec_type not in _ALL_SUPPORTED_TYPES:
raise ValueError(f"Unsupported ReportSpec component type: {spec_type}")

schema_version = spec.get("schemaVersion")
if not isinstance(schema_version, str):
raise ValueError("Report component spec is missing a string schemaVersion")

if spec_type in _V10_SCHEMA_TYPES:
if schema_version != "1.0":
raise ValueError(
f"Component type {spec_type!r} requires schemaVersion '1.0', got {schema_version!r}"
)
elif spec_type in _V20_SCHEMA_TYPES:
if schema_version != "2.0":
raise ValueError(
f"Component type {spec_type!r} requires schemaVersion '2.0', got {schema_version!r}"
)


class _ReportComponentV11(TypedDict, total=False):
type: str
id: str
title: str
spec: Mapping[str, object]


class _ReportSpec(TypedDict, total=False):
class _ReportGroupV11(TypedDict, total=False):
type: str
id: str
title: str
components: list[_ReportComponentV11]


class _ReportSectionV11(TypedDict, total=False):
id: str
title: str
items: list[_ReportComponentV11 | _ReportGroupV11]


class _ReportSpecV11(TypedDict, total=False):
schemaVersion: str
type: str
title: str
components: list[_ReportComponent]
sections: list[_ReportSectionV11]


def _report_spec_from_components(
components: Sequence[Mapping[str, object]],
def _build_report_spec_v11(
sections: Sequence[Mapping[str, Any]],
*,
title: str | None = None,
) -> _ReportSpec:
"""Compose complete canonical component specs into a ReportSpec.

Component order is preserved exactly. Component IDs are deterministic and
live in a report-local identity domain: the first component of a type gets
its boring base ID (for example ``roc``), and repeats get ``-2``, ``-3``,
and so on. Embedded specs are retained as-is, so evaluation IDs remain
component-local even when equal strings occur in multiple components.
) -> _ReportSpecV11:
"""Compose structured ReportSpec v1.1 hierarchy.

Validates type-aware schemaVersion for every embedded component spec.
"""
if not components:
raise ValueError("ReportSpec requires at least one component")

type_counts: dict[str, int] = {}
report_components: list[_ReportComponent] = []
for component in components:
raw_spec = component.get("spec")
if not isinstance(raw_spec, Mapping):
raise ValueError("Report component is missing spec")
spec = cast(Mapping[str, object], raw_spec)

component_type = spec.get("type")
if not isinstance(component_type, str):
raise ValueError("Report component spec is missing a string type")
if component_type not in _SUPPORTED_COMPONENT_TYPES:
raise ValueError(f"Unsupported ReportSpec component type: {component_type}")

count = type_counts.get(component_type, 0) + 1
type_counts[component_type] = count
base_id = _COMPONENT_ID_BASES[component_type]
component_id = base_id if count == 1 else f"{base_id}-{count}"

assembled: _ReportComponent = {
"id": component_id,
"spec": spec,
if not sections:
raise ValueError("ReportSpec v1.1 requires at least one section")

assembled_sections: list[_ReportSectionV11] = []

for section in sections:
sec_id = section.get("id")
if not isinstance(sec_id, str) or not sec_id:
raise ValueError("Report section must have a non-empty string id")

sec_title = section.get("title")
if sec_title is None:
sec_title = sec_id
elif not isinstance(sec_title, str) or not sec_title:
raise ValueError("Report section must have a non-empty string title")

items_assembled: list[_ReportComponentV11 | _ReportGroupV11] = []

components_raw = section.get("components")
if components_raw is not None:
if not isinstance(components_raw, Sequence):
raise ValueError("Section components must be a sequence")
for comp in components_raw:
if not isinstance(comp, Mapping):
raise ValueError("Report component must be a Mapping")
comp_id = comp.get("id")
raw_spec = comp.get("spec")
if not isinstance(comp_id, str) or not comp_id:
raise ValueError("Report component must have a non-empty string id")
if not isinstance(raw_spec, Mapping):
raise ValueError("Report component is missing spec")
_validate_spec_schema_version(cast(Mapping[str, object], raw_spec))
assembled_comp: _ReportComponentV11 = {
"type": "component",
"id": comp_id,
"spec": raw_spec,
}
comp_title = comp.get("title")
if comp_title is not None:
if not isinstance(comp_title, str):
raise ValueError("Report component title must be a string")
assembled_comp["title"] = comp_title
items_assembled.append(assembled_comp)

groups_raw = section.get("groups")
if groups_raw is not None:
if not isinstance(groups_raw, Sequence):
raise ValueError("Section groups must be a sequence")
for group in groups_raw:
if not isinstance(group, Mapping):
raise ValueError("Report group must be a Mapping")
group_id = group.get("id")
group_title = group.get("title")
if not isinstance(group_id, str) or not group_id:
raise ValueError("Report group must have a non-empty string id")
if not isinstance(group_title, str) or not group_title:
raise ValueError("Report group must have a non-empty string title")

grp_comps_raw = group.get("components")
if not isinstance(grp_comps_raw, Sequence) or not grp_comps_raw:
raise ValueError("Group components must be a non-empty sequence")

grp_components: list[_ReportComponentV11] = []
for comp in grp_comps_raw:
if not isinstance(comp, Mapping):
raise ValueError("Report component must be a Mapping")
comp_id = comp.get("id")
raw_spec = comp.get("spec")
if not isinstance(comp_id, str) or not comp_id:
raise ValueError(
"Report component must have a non-empty string id"
)
if not isinstance(raw_spec, Mapping):
raise ValueError("Report component is missing spec")
_validate_spec_schema_version(cast(Mapping[str, object], raw_spec))
assembled_comp = cast(
_ReportComponentV11,
{
"type": "component",
"id": comp_id,
"spec": raw_spec,
},
)
comp_title = comp.get("title")
if comp_title is not None:
if not isinstance(comp_title, str):
raise ValueError("Report component title must be a string")
assembled_comp["title"] = comp_title
grp_components.append(assembled_comp)

grp_dict: _ReportGroupV11 = {
"type": "group",
"id": group_id,
"title": group_title,
"components": grp_components,
}
items_assembled.append(grp_dict)

if not items_assembled:
raise ValueError(f"Section {sec_id!r} has no components or groups")

assembled_sec: _ReportSectionV11 = {
"id": sec_id,
"title": sec_title,
"items": items_assembled,
}
component_title = component.get("title")
if component_title is not None:
if not isinstance(component_title, str):
raise ValueError("Report component title must be a string")
assembled["title"] = component_title
report_components.append(assembled)

report: _ReportSpec = {
"schemaVersion": "1.0",
assembled_sections.append(assembled_sec)

report: _ReportSpecV11 = {
"schemaVersion": "1.1",
"type": "report",
"components": report_components,
"sections": assembled_sections,
}
if title is not None:
report["title"] = title
Expand Down
Loading
Loading