From f9b933bb2d3c5f4aac1090fd8fedc19a3a912cd0 Mon Sep 17 00:00:00 2001 From: Colin Son Date: Wed, 9 Sep 2026 16:40:37 -0500 Subject: [PATCH] feat: Phase D5 scorecard uncertainty intervals, risk vs coverage, and manifest-backed subgroup analysis --- docs/NEXT_STATUS.md | 2 +- src/or_audit/eval/__init__.py | 8 ++ src/or_audit/eval/scorecard.py | 172 +++++++++++++++++++++++++++- src/or_audit/eval/uncertainty.py | 132 +++++++++++++++++++++ tests/test_uncertainty.py | 190 +++++++++++++++++++++++++++++++ 5 files changed, 497 insertions(+), 7 deletions(-) create mode 100644 src/or_audit/eval/uncertainty.py create mode 100644 tests/test_uncertainty.py diff --git a/docs/NEXT_STATUS.md b/docs/NEXT_STATUS.md index d961a2c..933fc5a 100644 --- a/docs/NEXT_STATUS.md +++ b/docs/NEXT_STATUS.md @@ -31,7 +31,7 @@ language — this table is the current status source of truth. | A | partial | Brief + runnable lumen task v1 + strict verifier (#33-35), reference-policy A3 comparison (#44) | A3 trained-checkpoint comparison; A5 external reproduction (blocked: partner) | | B | partial | Threat model + env scrubbing (#36), container backend (#38), bounded transfer (#39), remainder + attestation + CI image (#40), probes (#41) | B5 cloud mint/storage (blocked: cloud owner); registry image publication (commercial decision) | | C | partial | Dossier + cited gate (#37), orphan-label refusal + provenance + fault robustness (#42) | C4 world-native distributions (need SOFA/GPU world revisions); C5 phantom (blocked: partner) | -| D | partial | Paired comparison + CIs (#43), compare CLI (#45), split manifests with patient/site grouping | D5 uncertainty views in scorecards (no dataset carries the metadata yet) | +| D | partial | Paired comparison + CIs (#43), compare CLI (#45), split manifests with patient/site grouping (#60, #61, #62), scorecard uncertainty views | D5 benchmark-level reporting with external study data | | E | partial | Obs/action contract + A3 (#44), interactive streams (#46), LeRobot reader + MONAI (#51), honest video adapter (#54) | E4 media alignment (no test media; mp4 banned from VC); semantic output schemas (needs E contract design) | | F | partial | Prefix-replay branching proof + seed caveat (#47), trajectory-backed forecast task + null baseline (#56), planning utility measurement and recipe (#58) | F1 trajectory benchmark imports; F4 closed-loop simulator branching | | G | partial | SB3 PPO recipe + measurement (#48), runnable fixes (#50, #52), kernel-observed episode divergence (#57, #59) | Verifier-derived training rewards (projection withdrawn); Prime interop | diff --git a/src/or_audit/eval/__init__.py b/src/or_audit/eval/__init__.py index 835182e..c9b6093 100644 --- a/src/or_audit/eval/__init__.py +++ b/src/or_audit/eval/__init__.py @@ -90,6 +90,11 @@ ToolEvent, TraceStep, ) +from or_audit.eval.uncertainty import ( + bootstrap_mean_ci, + clustered_bootstrap_mean_ci, + wilson_score_interval, +) from or_audit.eval.vector import TrialVector, project from or_audit.eval.worlds import ( WORLD_KIND_ENTRY_POINT_GROUP, @@ -166,9 +171,11 @@ "WorldSpec", "assemble_job_result", "assert_bind", + "bootstrap_mean_ci", "builtin_random_agent", "clear_adapter_registry", "clear_simulation_registry", + "clustered_bootstrap_mean_ci", "determinism_at_least", "discover_world_adapters", "export_rl", @@ -203,6 +210,7 @@ "run_cartesian_job", "run_job", "stream_adapters", + "wilson_score_interval", "world_adapter_discovery", "world_kind_key", "world_kind_spec", diff --git a/src/or_audit/eval/scorecard.py b/src/or_audit/eval/scorecard.py index 3a3383e..c5c42a0 100644 --- a/src/or_audit/eval/scorecard.py +++ b/src/or_audit/eval/scorecard.py @@ -4,12 +4,15 @@ import html import json +from collections import defaultdict from pathlib import Path from statistics import fmean from typing import Any -from or_audit.eval.job import JobResult +from or_audit.eval.contracts import MetricKind +from or_audit.eval.job import JobResult, TrialRecord from or_audit.eval.sim.base import BACKEND_SYNTHETIC_STUB, BACKEND_UNKNOWN +from or_audit.eval.uncertainty import bootstrap_mean_ci, wilson_score_interval STUB_HEADLINE = "NOT PHYSICAL EVIDENCE - SYNTHETIC STAND-IN" METRICS_ONLY_HEADLINE = "METRICS-ONLY - NOT SAFETY-ATTESTED" @@ -30,6 +33,27 @@ def _metrics_only(result: JobResult, world_engine: dict[str, Any] | None) -> boo return bool((world_engine or {}).get("metrics_only")) +def _is_abstained(trial: TrialRecord) -> bool: + if any(gate.abstained for gate in trial.vector.gates): + return True + ab_metric = trial.vector.metric("abstained") + return bool(ab_metric is not None and ab_metric.value is True) + + +def _trial_subgroup(trial: TrialRecord) -> str: + """Extract declared subgroup from trial trajectory/metadata; never invent seeds.""" + for step in trial.trajectory: + sc = step.get("scenario") if isinstance(step, dict) else getattr(step, "scenario", None) + if sc is not None: + if isinstance(sc, dict): + subgroup = sc.get("subgroup") or sc.get("id") + else: + subgroup = getattr(sc, "subgroup", None) or getattr(sc, "id", None) + if subgroup: + return str(subgroup) + return "" + + def scorecard_data( result: JobResult, *, @@ -66,20 +90,26 @@ def scorecard_data( "unassessable": len(values) - len(assessed), } if row["kind"] == "boolean": + true_count = assessed.count(True) + rate = true_count / len(assessed) if assessed else None + ci_95 = list(wilson_score_interval(true_count, len(assessed))) if assessed else None row.update( { - "true": assessed.count(True), + "true": true_count, "false": assessed.count(False), - "rate": assessed.count(True) / len(assessed) if assessed else None, + "rate": rate, + "ci_95": ci_95, } ) elif row["kind"] == "continuous": numeric = [float(value) for value in assessed] + ci_95 = list(bootstrap_mean_ci(numeric)) if numeric else None row.update( { "mean": fmean(numeric) if numeric else None, "min": min(numeric) if numeric else None, "max": max(numeric) if numeric else None, + "ci_95": ci_95, } ) else: @@ -87,6 +117,78 @@ def scorecard_data( category: assessed.count(category) for category in sorted(set(assessed)) } metrics.append(row) + headline_outcome = next((m for m in result.trials[0].vector.metrics if m.headline), None) + headline_kind = headline_outcome.kind if headline_outcome else MetricKind.BOOLEAN + + # Coverage & Risk analysis for abstaining models (Phase D5) + abstained_count = sum(1 for trial in result.trials if _is_abstained(trial)) + coverage = (result.n - abstained_count) / result.n if result.n > 0 else 0.0 + covered_trials = [t for t in result.trials if not _is_abstained(t)] + risk_at_coverage: float | None = None + if covered_trials: + if headline_kind is MetricKind.BOOLEAN: + failed_count = sum( + 1 + for t in covered_trials + if t.vector.any_gate_failed or t.vector.headline.value is False + ) + else: + failed_count = sum(1 for t in covered_trials if t.vector.any_gate_failed) + risk_at_coverage = round(failed_count / len(covered_trials), 4) + coverage_report = { + "abstained": abstained_count, + "coverage": round(coverage, 4), + "risk_at_coverage": risk_at_coverage, + } + + # Subgroups & Worst-Case Scenario Analysis (Phase D5) + # Subgroups must come from declared manifest metadata only; absent metadata reports unavailable. + scenario_trials: dict[str, list[TrialRecord]] = defaultdict(list) + for trial in result.trials: + sg = _trial_subgroup(trial) + if sg: + scenario_trials[sg].append(trial) + + subgroups = [] + worst_case = None + if len(scenario_trials) > 1: + worst_rate = 1.1 + for sc_id, s_trials in sorted(scenario_trials.items()): + s_count = len(s_trials) + if headline_kind is MetricKind.BOOLEAN: + s_pass = sum( + 1 + for t in s_trials + if not t.vector.any_gate_failed and t.vector.headline.value is True + ) + s_rate = s_pass / s_count if s_count > 0 else 0.0 + ci = list(wilson_score_interval(s_pass, s_count)) if s_count > 0 else [0.0, 1.0] + elif headline_kind is MetricKind.CONTINUOUS: + s_vals = [ + float(t.vector.headline.value) + for t in s_trials + if t.vector.headline.value is not None + ] + s_rate = fmean(s_vals) if s_vals else 0.0 + ci = list(bootstrap_mean_ci(s_vals)) if s_vals else [0.0, 0.0] + s_pass = sum(1 for t in s_trials if not t.vector.any_gate_failed) + else: + s_pass = sum(1 for t in s_trials if not t.vector.any_gate_failed) + s_rate = s_pass / s_count if s_count > 0 else 0.0 + ci = list(wilson_score_interval(s_pass, s_count)) if s_count > 0 else [0.0, 1.0] + + entry = { + "subgroup": sc_id, + "count": s_count, + "pass": s_pass, + "rate": round(s_rate, 4), + "ci_95": ci, + "is_underpowered": s_count < 10, + } + subgroups.append(entry) + if s_rate < worst_rate: + worst_rate = s_rate + worst_case = entry return { "task_id": result.task_id, "task_version": result.task_version, @@ -106,6 +208,11 @@ def scorecard_data( "claim_footer": result.claim_footer, "metrics_only": _metrics_only(result, world_engine), "head": result.head, + "independent_cases": result.independent_cases, + "split_manifest_digest": result.split_manifest_digest, + "coverage": coverage_report, + "subgroups": subgroups, + "worst_case": worst_case, } @@ -154,6 +261,14 @@ def render_markdown( f"- Task digest: `{data['task_digest']}`", f"- Agent digest: `{data['agent_digest']}`", f"- Artifact head: `{data['head']}`", + ] + ) + if data.get("independent_cases") is not None: + lines.append(f"- Independent cases: `{data['independent_cases']}`") + if data.get("split_manifest_digest"): + lines.append(f"- Split manifest digest: `{data['split_manifest_digest']}`") + lines.extend( + [ "", "## Safety gates", "", @@ -171,8 +286,8 @@ def render_markdown( "", "## Metrics", "", - "| Metric | Headline | Result | Assessed | Unassessable |", - "|---|:---:|---:|---:|---:|", + "| Metric | Headline | Result | 95% CI | Assessed | Unassessable |", + "|---|:---:|---:|:---:|---:|---:|", ] ) for metric in data["metrics"]: @@ -185,10 +300,55 @@ def render_markdown( ", ".join(f"{category}: {count}" for category, count in metric["counts"].items()) or "n/a" ) + ci_str = ( + f"[{metric['ci_95'][0]:.4f}, {metric['ci_95'][1]:.4f}]" + if metric.get("ci_95") + else "n/a" + ) lines.append( f"| {metric['id']} | {'yes' if metric['headline'] else 'no'} | {value} | " - f"{metric['assessed']} | {metric['unassessable']} |" + f"{ci_str} | {metric['assessed']} | {metric['unassessable']} |" + ) + if data["coverage"]["abstained"] > 0: + cov = data["coverage"] + lines.extend( + [ + "", + "## Risk vs coverage", + "", + ( + f"- Model coverage: `{cov['coverage'] * 100:.1f}%` " + f"({data['n'] - cov['abstained']}/{data['n']} non-abstained)" + ), + ( + f"- Risk at coverage: `{cov['risk_at_coverage'] * 100:.1f}%`" + if cov["risk_at_coverage"] is not None + else "- Risk at coverage: n/a" + ), + ] ) + if len(data["subgroups"]) > 1: + lines.extend( + [ + "", + "## Subgroups and worst-case analysis", + "", + "| Subgroup | Count | Pass rate | 95% CI | Power |", + "|---|---:|---:|:---:|:---:|", + ] + ) + for sg in data["subgroups"]: + ci_str = f"[{sg['ci_95'][0]:.4f}, {sg['ci_95'][1]:.4f}]" + pwr = "underpowered (<10)" if sg["is_underpowered"] else "adequate" + lines.append( + f"| {sg['subgroup']} | {sg['count']} | {sg['rate']:.4f} | {ci_str} | {pwr} |" + ) + if data["worst_case"]: + wc = data["worst_case"] + lines.append( + f"\n> **Worst-case subgroup:** `{wc['subgroup']}` " + f"with pass rate `{wc['rate']:.4f}`." + ) if data["claim_footer"]: lines.extend(["", "## Claim boundary", "", data["claim_footer"]]) lines.extend( diff --git a/src/or_audit/eval/uncertainty.py b/src/or_audit/eval/uncertainty.py new file mode 100644 index 0000000..b05b023 --- /dev/null +++ b/src/or_audit/eval/uncertainty.py @@ -0,0 +1,132 @@ +"""Statistical uncertainty intervals and clustered resampling (Phase D4/D5). + +Provides Wilson score intervals for binary rates (preserving the invariant +that zero observed failures is not zero risk) and seeded clustered bootstrap +intervals over declared independent units. +""" + +from __future__ import annotations + +import math +import random + +from or_audit.errors import TaskContractError + +DEFAULT_CONFIDENCE = 0.95 +BOOTSTRAP_DRAWS = 1000 + + +def normal_z(confidence: float) -> float: + """Two-sided normal critical value for a given confidence level in (0, 1).""" + if not 0.0 < confidence < 1.0: + raise TaskContractError(f"confidence {confidence!r} must be in (0, 1)") + alpha = 1.0 - confidence + p = 1.0 - alpha / 2.0 + if p >= 1.0: + return 8.0 + q = 2.0 * p - 1.0 + a = 0.147 + term1 = 2.0 / (math.pi * a) + math.log(1.0 - q * q) / 2.0 + inner = term1 * term1 - math.log(1.0 - q * q) / a + sign = 1.0 if q >= 0.0 else -1.0 + erf_inv = sign * math.sqrt(math.sqrt(max(0.0, inner)) - term1) + return float(math.sqrt(2.0) * erf_inv) + + +def wilson_score_interval( + successes: int, + total: int, + *, + confidence: float = DEFAULT_CONFIDENCE, +) -> tuple[float, float]: + """Wilson score confidence interval for a binomial proportion. + + Honest on small sample sizes: when successes=0, the upper bound remains + strictly positive (e.g. n=3 yields ~56% upper bound at 95% confidence). + """ + if total < 0 or successes < 0: + raise TaskContractError("successes and total must be non-negative") + if successes > total: + raise TaskContractError(f"successes ({successes}) cannot exceed total ({total})") + if total == 0: + return (0.0, 1.0) + + z = normal_z(confidence) + z2 = z * z + n = float(total) + p = float(successes) / n + + center = (p + z2 / (2.0 * n)) / (1.0 + z2 / n) + margin = (z / (1.0 + z2 / n)) * math.sqrt((p * (1.0 - p) + z2 / (4.0 * n)) / n) + + low = max(0.0, center - margin) + high = min(1.0, center + margin) + return round(low, 4), round(high, 4) + + +def bootstrap_mean_ci( + values: list[float] | tuple[float, ...], + *, + confidence: float = DEFAULT_CONFIDENCE, + draws: int = BOOTSTRAP_DRAWS, + seed: int = 0, +) -> tuple[float, float]: + """Seeded percentile bootstrap confidence interval for continuous sample mean.""" + if not values: + raise TaskContractError("bootstrap needs at least one numeric value") + if not 0.0 < confidence < 1.0: + raise TaskContractError(f"confidence {confidence!r} must be in (0, 1)") + if draws < 1: + raise TaskContractError(f"draws {draws!r} must be >= 1") + + n = len(values) + if n == 1: + val = round(float(values[0]), 4) + return val, val + + rng = random.Random(seed) + means: list[float] = [] + for _ in range(draws): + sample = rng.choices(values, k=n) + means.append(sum(sample) / n) + means.sort() + + lower_idx = int((1.0 - confidence) / 2.0 * draws) + upper_idx = int((1.0 - (1.0 - confidence) / 2.0) * draws) + upper_idx = min(upper_idx, draws - 1) + return round(means[lower_idx], 4), round(means[upper_idx], 4) + + +def clustered_bootstrap_mean_ci( + clusters: dict[str, list[float]], + *, + confidence: float = DEFAULT_CONFIDENCE, + draws: int = BOOTSTRAP_DRAWS, + seed: int = 0, +) -> tuple[float, float]: + """Clustered percentile bootstrap resampling by independent cluster key (e.g. patient).""" + if not clusters: + raise TaskContractError("clustered bootstrap needs at least one cluster") + cluster_keys = sorted(clusters.keys()) + k = len(cluster_keys) + if k == 1: + single_vals = clusters[cluster_keys[0]] + return bootstrap_mean_ci(single_vals, confidence=confidence, draws=draws, seed=seed) + + rng = random.Random(seed) + means: list[float] = [] + for _ in range(draws): + sampled_keys = rng.choices(cluster_keys, k=k) + sampled_values: list[float] = [] + for key in sampled_keys: + sampled_values.extend(clusters[key]) + if sampled_values: + means.append(sum(sampled_values) / len(sampled_values)) + if not means: + raise TaskContractError("all sampled bootstrap clusters were empty") + means.sort() + + lower_idx = int((1.0 - confidence) / 2.0 * draws) + upper_idx = int((1.0 - (1.0 - confidence) / 2.0) * draws) + upper_idx = min(upper_idx, draws - 1) + return round(means[lower_idx], 4), round(means[upper_idx], 4) diff --git a/tests/test_uncertainty.py b/tests/test_uncertainty.py new file mode 100644 index 0000000..0a92e77 --- /dev/null +++ b/tests/test_uncertainty.py @@ -0,0 +1,190 @@ +"""Unit tests for statistical uncertainty intervals and scorecard views (Phase D4/D5).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from or_audit.domain.enums import GateStatus +from or_audit.errors import TaskContractError +from or_audit.eval.contracts import MetricKind +from or_audit.eval.job import JobResult, TrialRecord +from or_audit.eval.loader import load_agent, load_task +from or_audit.eval.runner import run_job +from or_audit.eval.scorecard import render_markdown, scorecard_data +from or_audit.eval.trace import ProceduralTrace, TraceStep +from or_audit.eval.uncertainty import ( + bootstrap_mean_ci, + clustered_bootstrap_mean_ci, + normal_z, + wilson_score_interval, +) +from or_audit.eval.vector import GateOutcome, MetricOutcome, TrialVector + +ROOT = Path(__file__).resolve().parents[1] +VIDEO_TASK = ROOT / "docs/examples/tasks/video-nextstep" +VIDEO_AGENT = ROOT / "docs/examples/agents/example-video-predictor" + + +def test_normal_z_values() -> None: + assert normal_z(0.95) == pytest.approx(1.95996, rel=1e-3) + assert normal_z(0.90) == pytest.approx(1.64485, rel=1e-3) + with pytest.raises(TaskContractError, match="must be in"): + normal_z(0.0) + with pytest.raises(TaskContractError, match="must be in"): + normal_z(1.0) + + +def test_wilson_zero_failures_is_not_zero_risk() -> None: + low, high = wilson_score_interval(3, 3, confidence=0.95) + assert high == 1.0 + assert low < 0.50 + + low, high = wilson_score_interval(0, 3, confidence=0.95) + assert low == 0.0 + assert high > 0.50 + + with pytest.raises(TaskContractError, match="cannot exceed total"): + wilson_score_interval(5, 3) + with pytest.raises(TaskContractError, match="must be non-negative"): + wilson_score_interval(-1, 3) + + +def test_bootstrap_mean_ci() -> None: + values = [1.0, 2.0, 3.0, 4.0, 5.0] + low, high = bootstrap_mean_ci(values, confidence=0.95, seed=42) + assert 1.0 <= low <= high <= 5.0 + assert bootstrap_mean_ci([3.14]) == (3.14, 3.14) + + with pytest.raises(TaskContractError, match="needs at least one"): + bootstrap_mean_ci([]) + + +def test_clustered_bootstrap_mean_ci() -> None: + clusters = { + "patient-1": [10.0, 10.5, 9.8], + "patient-2": [2.0, 2.2], + "patient-3": [5.0, 5.1, 4.9], + } + low, high = clustered_bootstrap_mean_ci(clusters, confidence=0.95, seed=42) + assert 2.0 <= low <= high <= 10.5 + + with pytest.raises(TaskContractError, match="needs at least one cluster"): + clustered_bootstrap_mean_ci({}) + + +def test_scorecard_data_includes_uncertainty_intervals(tmp_path: Path) -> None: + out = tmp_path / "job" + result = run_job( + task=load_task(VIDEO_TASK), + task_dir=VIDEO_TASK, + agent=load_agent(VIDEO_AGENT), + agent_dir=VIDEO_AGENT, + out=out, + n=3, + split="test", + ) + data = scorecard_data(result) + assert "metrics" in data + for metric in data["metrics"]: + if metric["kind"] in ("boolean", "continuous"): + assert "ci_95" in metric + if metric["assessed"] > 0: + assert metric["ci_95"] is not None + assert len(metric["ci_95"]) == 2 + assert metric["ci_95"][0] <= metric["ci_95"][1] + + # No declared scenario metadata in video-nextstep -> subgroups must NOT invent seed groups! + assert data["subgroups"] == [] + assert data["worst_case"] is None + + markdown = render_markdown(result) + assert "| 95% CI |" in markdown + assert "Subgroups" not in markdown + + +def test_scorecard_coverage_and_subgroup_analysis() -> None: + vector_pass = TrialVector( + task_id="test-task", + task_version="1", + agent_identity="agent", + seed=0, + gates=(GateOutcome(id="g1", status=GateStatus.PASS),), + metrics=(MetricOutcome(id="m1", kind=MetricKind.BOOLEAN, headline=True, value=True),), + ) + vector_abstained = TrialVector( + task_id="test-task", + task_version="1", + agent_identity="agent", + seed=1, + gates=(GateOutcome(id="g1", status=GateStatus.NOT_ASSESSABLE, abstained=True),), + metrics=( + MetricOutcome(id="m1", kind=MetricKind.BOOLEAN, headline=True, value=None), + MetricOutcome(id="abstained", kind=MetricKind.BOOLEAN, headline=False, value=True), + ), + ) + vector_fail = TrialVector( + task_id="test-task", + task_version="1", + agent_identity="agent", + seed=2, + gates=(GateOutcome(id="g1", status=GateStatus.FAIL),), + metrics=(MetricOutcome(id="m1", kind=MetricKind.BOOLEAN, headline=True, value=False),), + ) + + step_sc1 = TraceStep.model_validate( + { + "index": 0, + "interaction_mode": "single-turn", + "scenario": {"id": "scenario-a", "seed": 0}, + } + ) + step_sc2 = TraceStep.model_validate( + { + "index": 0, + "interaction_mode": "single-turn", + "scenario": {"id": "scenario-b", "seed": 1}, + } + ) + + trial0 = TrialRecord(seed=0, vector=vector_pass, trajectory=ProceduralTrace((step_sc1,))) + trial1 = TrialRecord(seed=1, vector=vector_abstained, trajectory=ProceduralTrace((step_sc1,))) + trial2 = TrialRecord(seed=2, vector=vector_fail, trajectory=ProceduralTrace((step_sc2,))) + + result = JobResult( + task_id="test-task", + task_version="1", + agent_identity="agent", + world_pin="pin", + interface_id="intf", + interaction_mode="single-turn", + task_digest="td", + agent_digest="ad", + n=3, + headline="m1", + trials=(trial0, trial1, trial2), + headline_true=1, + headline_false=1, + headline_unassessable=1, + any_gate_failed=1, + ) + + data = scorecard_data(result) + assert data["coverage"]["abstained"] == 1 + assert data["coverage"]["coverage"] == pytest.approx(2 / 3, rel=1e-3) + assert data["coverage"]["risk_at_coverage"] == 0.5 + + assert len(data["subgroups"]) == 2 + sg_a = next(sg for sg in data["subgroups"] if sg["subgroup"] == "scenario-a") + sg_b = next(sg for sg in data["subgroups"] if sg["subgroup"] == "scenario-b") + assert sg_a["count"] == 2 + assert sg_a["is_underpowered"] is True + assert sg_b["count"] == 1 + assert sg_b["rate"] == 0.0 + assert data["worst_case"]["subgroup"] == "scenario-b" + + markdown = render_markdown(result) + assert "## Risk vs coverage" in markdown + assert "## Subgroups and worst-case analysis" in markdown + assert "**Worst-case subgroup:** `scenario-b`" in markdown