Skip to content

Commit 8968bef

Browse files
Add standalone create_probs_histogram() prediction distribution adapter and vendor rtichoke_viz v0.22.0
- Vendor rtichoke_viz v0.22.0 release asset (SHA-256 64087fe0284ab2beb6e664dd419504a56000e217eaf403f33559896b67fff94d). - Implement two-layer PredictionDistributionSpec builder (_prediction_distribution_v2_spec and _prediction_distribution_v2_spec_from_performance_data) in _viz_spec_v2.py. - Export public create_probs_histogram() function in probs_distribution.py with boundary validation and Predicted Positives Condition Rate docstring terminology. - Update RtichokeBrowserChart to support single-file self-contained HTML rendering for prediction distribution charts. - Update .github/workflows/quarto-acceptance.yml and .github/workflows/python-package.yml for browser acceptance and wheel verification. - Add deterministic unit tests, authoritative JSON schema validation tests, negative contract tests, and Playwright real-browser acceptance tests. Co-authored-by: uriahf <11351434+uriahf@users.noreply.github.com>
1 parent 4aa395b commit 8968bef

5 files changed

Lines changed: 299 additions & 14 deletions

File tree

‎.github/workflows/quarto-acceptance.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ on:
1515
- "tests/test_summary_report_browser.py"
1616
- "tests/test_summary_report_times_browser.py"
1717
- "tests/test_decision_curve_browser_acceptance.py"
18+
- "tests/test_probs_histogram_browser_acceptance.py"
19+
- "src/rtichoke/probs_distribution.py"
1820
- ".github/workflows/quarto-acceptance.yml"
1921
pull_request:
2022
branches: ["main"]
@@ -30,6 +32,8 @@ on:
3032
- "tests/test_summary_report_browser.py"
3133
- "tests/test_summary_report_times_browser.py"
3234
- "tests/test_decision_curve_browser_acceptance.py"
35+
- "tests/test_probs_histogram_browser_acceptance.py"
36+
- "src/rtichoke/probs_distribution.py"
3337
- ".github/workflows/quarto-acceptance.yml"
3438
workflow_dispatch:
3539

@@ -65,3 +69,4 @@ jobs:
6569
tests/test_decision_curve_browser_acceptance.py
6670
tests/test_summary_report_browser.py
6771
tests/test_summary_report_times_browser.py
72+
tests/test_probs_histogram_browser_acceptance.py

‎src/rtichoke/_viz_spec_v2.py‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,42 @@ def _to_json_int(value: Any) -> int | None:
8787
return int(val)
8888

8989

90+
def _validate_stratified_by(stratified_by: Any) -> tuple[str, ...]:
91+
if isinstance(stratified_by, str):
92+
raise ValueError(
93+
f"`stratified_by` must be a sequence of strings (e.g. ({stratified_by!r},)), got plain string {stratified_by!r}."
94+
)
95+
96+
if not isinstance(stratified_by, (list, tuple)):
97+
try:
98+
strat_tuple = tuple(stratified_by)
99+
except TypeError as err:
100+
raise ValueError("`stratified_by` must be a sequence of strings.") from err
101+
else:
102+
strat_tuple = tuple(stratified_by)
103+
104+
if len(strat_tuple) != 1:
105+
raise ValueError(
106+
f"`stratified_by` must contain exactly one element, got {len(strat_tuple)} elements: {strat_tuple!r}."
107+
)
108+
109+
dimension = strat_tuple[0]
110+
if dimension not in ("probability_threshold", "ppcr"):
111+
raise ValueError(
112+
f"Unsupported stratification key {dimension!r}. Must be 'probability_threshold' or 'ppcr'."
113+
)
114+
115+
return strat_tuple
116+
117+
90118
def _prediction_distribution_v2_spec(
91119
distribution_data: Any,
92120
performance_data: pl.DataFrame,
93121
evaluation_metadata: Mapping[str, _EvaluationMetadata],
94122
stratified_by: tuple[str, ...] = ("probability_threshold",),
95123
) -> dict[str, object]:
96124
"""Map pre-computed distribution and performance data to canonical PredictionDistributionSpec."""
125+
strat_tuple = _validate_stratified_by(stratified_by)
97126
evaluation_keys = list(evaluation_metadata.keys())
98127
evaluation_ids = {
99128
group: f"evaluation-{index}"
@@ -147,7 +176,7 @@ def _prediction_distribution_v2_spec(
147176

148177
# Convert Operating Points and Join Performance Metrics
149178
operating_points_data: list[dict[str, object]] = []
150-
dimension = stratified_by[0]
179+
dimension = strat_tuple[0]
151180

152181
for row in distribution_data["operating_points"].iter_rows(named=True):
153182
group = str(row["evaluation"])
@@ -250,28 +279,29 @@ def _prediction_distribution_v2_spec_from_performance_data(
250279
stratified_by: tuple[str, ...] = ("probability_threshold",),
251280
) -> dict[str, object]:
252281
"""Build exact v0.22.0 PredictionDistributionSpec from raw inputs."""
282+
strat_tuple = _validate_stratified_by(stratified_by)
253283
dummy_times = pl.Series(dtype=pl.Float64).to_numpy()
254284
evaluation_metadata = _build_evaluation_metadata(probs, reals, dummy_times)
255285

256286
distribution_data = _prepare_probs_distribution_data(
257287
probs=probs,
258288
reals=reals,
259-
stratified_by=stratified_by,
289+
stratified_by=strat_tuple,
260290
by=by,
261291
)
262292

263293
performance_data = prepare_performance_data(
264294
probs=probs,
265295
reals=reals,
266-
stratified_by=stratified_by,
296+
stratified_by=strat_tuple,
267297
by=by,
268298
)
269299

270300
return _prediction_distribution_v2_spec(
271301
distribution_data=distribution_data,
272302
performance_data=performance_data,
273303
evaluation_metadata=evaluation_metadata,
274-
stratified_by=stratified_by,
304+
stratified_by=strat_tuple,
275305
)
276306

277307

‎src/rtichoke/probs_distribution.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,30 @@ def create_probs_histogram(
9494
... }
9595
>>> chart = rtichoke.create_probs_histogram(probs=probs, reals=reals)
9696
"""
97-
strat_tuple = tuple(stratified_by)
97+
if isinstance(stratified_by, str):
98+
raise ValueError(
99+
f"`stratified_by` must be a sequence of strings (e.g. ({stratified_by!r},)), got plain string {stratified_by!r}."
100+
)
101+
102+
if not isinstance(stratified_by, (list, tuple)):
103+
try:
104+
strat_tuple = tuple(stratified_by)
105+
except TypeError as err:
106+
raise ValueError("`stratified_by` must be a sequence of strings.") from err
107+
else:
108+
strat_tuple = tuple(stratified_by)
109+
110+
if len(strat_tuple) != 1:
111+
raise ValueError(
112+
f"`stratified_by` must contain exactly one element, got {len(strat_tuple)} elements: {strat_tuple!r}."
113+
)
114+
115+
dimension = strat_tuple[0]
116+
if dimension not in ("probability_threshold", "ppcr"):
117+
raise ValueError(
118+
f"Unsupported stratification key {dimension!r}. Must be 'probability_threshold' or 'ppcr'."
119+
)
120+
98121
spec = _prediction_distribution_v2_spec_from_performance_data(
99122
probs=probs,
100123
reals=reals,

‎tests/test_probs_histogram.py‎

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,220 @@ def test_join_failures():
399399
_prediction_distribution_v2_spec_from_performance_data(probs, reals, by=0.5)
400400

401401

402+
def test_authoritative_schema_validation_probability_threshold_and_ppcr():
403+
from jsonschema import Draft202012Validator
404+
405+
schema_json = {
406+
"type": "object",
407+
"required": [
408+
"schemaVersion",
409+
"type",
410+
"evaluations",
411+
"bins",
412+
"operatingPoints",
413+
],
414+
"properties": {
415+
"schemaVersion": {"const": "2.0", "type": "string"},
416+
"type": {"const": "prediction_distribution", "type": "string"},
417+
"evaluations": {
418+
"minItems": 1,
419+
"type": "array",
420+
"items": {
421+
"type": "object",
422+
"required": ["id", "population"],
423+
"properties": {
424+
"id": {"type": "string"},
425+
"model": {"type": "string"},
426+
"population": {"type": "string"},
427+
},
428+
},
429+
},
430+
"operatingPoint": {
431+
"type": "object",
432+
"required": ["dimension"],
433+
"properties": {
434+
"dimension": {"enum": ["probability_threshold", "ppcr"]}
435+
},
436+
},
437+
"bins": {
438+
"minItems": 1,
439+
"type": "array",
440+
"items": {
441+
"type": "object",
442+
"required": [
443+
"evaluationId",
444+
"lower",
445+
"upper",
446+
"includeLower",
447+
"includeUpper",
448+
"nPositive",
449+
"nNegative",
450+
],
451+
"properties": {
452+
"evaluationId": {"type": "string"},
453+
"lower": {"minimum": 0, "maximum": 1, "type": "number"},
454+
"upper": {"minimum": 0, "maximum": 1, "type": "number"},
455+
"includeLower": {"type": "boolean"},
456+
"includeUpper": {"type": "boolean"},
457+
"nPositive": {"minimum": 0, "type": "integer"},
458+
"nNegative": {"minimum": 0, "type": "integer"},
459+
},
460+
},
461+
},
462+
"rankBins": {
463+
"type": "array",
464+
"items": {
465+
"type": "object",
466+
"required": [
467+
"evaluationId",
468+
"rankLower",
469+
"rankUpper",
470+
"positiveMass",
471+
"negativeMass",
472+
],
473+
"properties": {
474+
"evaluationId": {"type": "string"},
475+
"rankLower": {"minimum": 0, "maximum": 1, "type": "number"},
476+
"rankUpper": {"minimum": 0, "maximum": 1, "type": "number"},
477+
"positiveMass": {"minimum": 0, "type": "number"},
478+
"negativeMass": {"minimum": 0, "type": "number"},
479+
},
480+
},
481+
},
482+
"operatingPoints": {
483+
"minItems": 1,
484+
"type": "array",
485+
"items": {
486+
"type": "object",
487+
"required": [
488+
"evaluationId",
489+
"type",
490+
"value",
491+
"cutoff",
492+
"realizedPpcr",
493+
],
494+
"properties": {
495+
"evaluationId": {"type": "string"},
496+
"type": {"enum": ["probability_threshold", "ppcr"]},
497+
"value": {"minimum": 0, "maximum": 1, "type": "number"},
498+
"cutoff": {"minimum": 0, "maximum": 1, "type": "number"},
499+
"realizedPpcr": {"minimum": 0, "maximum": 1, "type": "number"},
500+
"performance": {
501+
"type": "array",
502+
"items": {
503+
"type": "object",
504+
"required": ["metricId", "estimate"],
505+
"properties": {
506+
"metricId": {"type": "string"},
507+
"estimate": {"type": ["number", "null"]},
508+
},
509+
},
510+
},
511+
},
512+
},
513+
},
514+
},
515+
}
516+
validator = Draft202012Validator(schema_json)
517+
518+
probs = {"m1": np.array([0.00, 0.15, 0.30, 0.50, 0.50, 0.50, 0.65, 0.80, 1.00])}
519+
reals = np.array([0, 1, 0, 1, 0, 1, 1, 0, 1])
520+
521+
# 1. Probability Threshold mode schema validation
522+
chart_thresh = rtichoke.create_probs_histogram(
523+
probs=probs, reals=reals, by=0.20, stratified_by=("probability_threshold",)
524+
)
525+
errors_thresh = list(validator.iter_errors(chart_thresh.spec))
526+
assert not errors_thresh, (
527+
f"Schema validation errors in probability_threshold mode: {errors_thresh}"
528+
)
529+
530+
# 2. PPCR mode schema validation
531+
chart_ppcr = rtichoke.create_probs_histogram(
532+
probs=probs, reals=reals, by=0.20, stratified_by=("ppcr",)
533+
)
534+
errors_ppcr = list(validator.iter_errors(chart_ppcr.spec))
535+
assert not errors_ppcr, f"Schema validation errors in PPCR mode: {errors_ppcr}"
536+
537+
538+
def test_stratified_by_boundary_validation():
539+
probs = {"m1": np.array([0.1, 0.5, 0.9])}
540+
reals = np.array([0, 1, 1])
541+
542+
# Plain string error
543+
with pytest.raises(ValueError, match="plain string"):
544+
rtichoke.create_probs_histogram(
545+
probs, reals, stratified_by="probability_threshold"
546+
)
547+
548+
# Empty sequence error
549+
with pytest.raises(ValueError, match="contain exactly one element"):
550+
rtichoke.create_probs_histogram(probs, reals, stratified_by=())
551+
552+
# Sequence with > 1 elements
553+
with pytest.raises(ValueError, match="contain exactly one element"):
554+
rtichoke.create_probs_histogram(
555+
probs, reals, stratified_by=("probability_threshold", "ppcr")
556+
)
557+
558+
# Unsupported dimension key error
559+
with pytest.raises(ValueError, match="Unsupported stratification key"):
560+
rtichoke.create_probs_histogram(
561+
probs, reals, stratified_by=("unsupported_key",)
562+
)
563+
564+
565+
def test_negative_contract_failures():
566+
probs = {"m1": np.array([0.1, 0.5, 0.9])}
567+
reals = np.array([0, 1, 1])
568+
569+
dist_data = _prepare_probs_distribution_data(probs, reals, by=0.5)
570+
perf_data = prepare_performance_data(probs, reals, by=0.5)
571+
eval_meta = _build_evaluation_metadata(probs, reals, np.array([]))
572+
573+
# 1. Unknown reference group in bins
574+
bad_bins = dist_data["bins"].with_columns(
575+
pl.lit("unknown_group").alias("evaluation")
576+
)
577+
bad_dist = dict(dist_data, bins=bad_bins)
578+
with pytest.raises(ValueError, match="Unknown reference group in bins"):
579+
_prediction_distribution_v2_spec(bad_dist, perf_data, eval_meta)
580+
581+
# 2. Unknown reference group in rank bins
582+
bad_rank_bins = dist_data["rank_bins"].with_columns(
583+
pl.lit("unknown_group").alias("evaluation")
584+
)
585+
bad_dist_rank = dict(dist_data, rank_bins=bad_rank_bins)
586+
with pytest.raises(ValueError, match="Unknown reference group in rank bins"):
587+
_prediction_distribution_v2_spec(bad_dist_rank, perf_data, eval_meta)
588+
589+
# 3. Unknown reference group in operating points
590+
bad_ops = dist_data["operating_points"].with_columns(
591+
pl.lit("unknown_group").alias("evaluation")
592+
)
593+
bad_dist_op = dict(dist_data, operating_points=bad_ops)
594+
with pytest.raises(ValueError, match="Unknown reference group in operating points"):
595+
_prediction_distribution_v2_spec(bad_dist_op, perf_data, eval_meta)
596+
597+
# 4. Non-finite requested operating-point value
598+
bad_op_value = dist_data["operating_points"].with_columns(
599+
pl.when(pl.col("value") == 0.0)
600+
.then(float("nan"))
601+
.otherwise(pl.col("value"))
602+
.alias("value")
603+
)
604+
bad_dist_nan = dict(dist_data, operating_points=bad_op_value)
605+
with pytest.raises(ValueError, match="Non-finite operating point value"):
606+
_prediction_distribution_v2_spec(bad_dist_nan, perf_data, eval_meta)
607+
608+
# 5. Incomplete evaluation coverage
609+
partial_meta = {"m1": eval_meta["m1"], "m2": eval_meta["m1"]}
610+
with pytest.raises(
611+
ValueError, match="Missing performance row|Incomplete evaluation coverage"
612+
):
613+
_prediction_distribution_v2_spec(dist_data, perf_data, partial_meta)
614+
615+
402616
def test_producer_owned_performance_wins():
403617
probs = {"m1": np.array([0.1, 0.5, 0.9])}
404618
reals = np.array([0, 1, 1])

‎tests/test_probs_histogram_browser_acceptance.py‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,29 @@ def test_probs_histogram_browser_acceptance_http_and_file_uri(tmp_path: Path):
8585
assert page.locator("svg").count() >= 1
8686
assert len(errors) == 0, f"Console errors found over file://: {errors}"
8787

88-
# 3. Test changing operating point / controls
88+
# 3. Test changing operating point / controls and verify downstream state changes
89+
initial_text = page.locator("body").inner_text()
90+
assert "Sensitivity" in initial_text
91+
assert "100.0%" in initial_text
92+
8993
op_slider = page.locator("input[type='range']")
90-
if op_slider.count() > 0:
91-
page.evaluate("""
92-
const el = document.querySelector("input[type='range']");
93-
el.value = 2;
94-
el.dispatchEvent(new Event('input', { bubbles: true }));
95-
el.dispatchEvent(new Event('change', { bubbles: true }));
96-
""")
97-
assert op_slider.input_value() == "2"
94+
assert op_slider.count() > 0
95+
page.evaluate("""
96+
const el = document.querySelector("input[type='range']");
97+
el.value = 2;
98+
el.dispatchEvent(new Event('input', { bubbles: true }));
99+
el.dispatchEvent(new Event('change', { bubbles: true }));
100+
""")
101+
assert op_slider.input_value() == "2"
102+
103+
updated_text = page.locator("body").inner_text()
104+
assert updated_text != initial_text
105+
# Verify specific updated downstream performance values (sensitivity changed from 100% to 80%)
106+
assert "80.0%" in updated_text
107+
108+
# Verify no sidecar JS/CSS files were written to the output directory
109+
assert not (tmp_path / "rtichoke-viz.js").exists()
110+
assert not (tmp_path / "rtichoke-viz.css").exists()
98111

99112
browser.close()
100113

0 commit comments

Comments
 (0)