rpx_benchmark.phi_jedi_summary

Glue between the runner's per-sample metrics and Φ / JEDI.

The runner produces a flat list of per-sample metric dicts annotated with scene, phase, and difficulty. This module groups those rows into a (N, P, K) tensor and calls rpx_benchmark.phi and rpx_benchmark.jedi to produce a single PhiJediSummary payload that the runner attaches to the deployment-readiness report.

Design notes

  • Complete-case analysis (paper §3.2). Scenes that lack predictions for any phase are dropped from the MANOVA; n_eff records how many survived.
  • JEDI uses the same registered MetricSpec set as Φ, but its direction/bounds handling is independent of the MANOVA sign-flipping. The convention paper §3.2 specifies is: lower-is-better metrics are negated before standardisation for Φ, separately from the direction-aware bounds used by JEDI.
  • The runner already aggregates per-sample metrics into a BenchmarkResult; this helper consumes the same list before aggregation so we can group cells without losing the (scene, phase) keying.
  1"""Glue between the runner's per-sample metrics and Φ / JEDI.
  2
  3The runner produces a flat list of per-sample metric dicts annotated with
  4``scene``, ``phase``, and ``difficulty``. This module groups those rows into
  5a ``(N, P, K)`` tensor and calls :mod:`rpx_benchmark.phi` and
  6:mod:`rpx_benchmark.jedi` to produce a single :class:`PhiJediSummary`
  7payload that the runner attaches to the deployment-readiness report.
  8
  9Design notes
 10------------
 11
 12- **Complete-case analysis** (paper §3.2). Scenes that lack predictions for
 13  any phase are dropped from the MANOVA; ``n_eff`` records how many
 14  survived.
 15- **JEDI uses the same registered ``MetricSpec`` set as Φ**, but its
 16  direction/bounds handling is independent of the MANOVA sign-flipping.
 17  The convention paper §3.2 specifies is: lower-is-better metrics are
 18  negated *before standardisation* for Φ, separately from the
 19  direction-aware bounds used by JEDI.
 20- The runner already aggregates per-sample metrics into a ``BenchmarkResult``;
 21  this helper consumes the same list before aggregation so we can group
 22  cells without losing the (scene, phase) keying.
 23"""
 24
 25from __future__ import annotations
 26
 27from dataclasses import dataclass, field
 28from typing import Any, Dict, List, Mapping, Optional, Sequence
 29
 30import numpy as np
 31
 32from .exceptions import MetricError
 33from .jedi import JEDIModelResult, aggregate_jedi, compute_jedi
 34from .logging_utils import get_logger
 35from .metrics.specs import MetricSpec, get_spec, has_spec
 36from .phi import (
 37    PhiMixedDesign,
 38    PhiOneway,
 39    PhiTransition,
 40    compute_phi_mixed_design,
 41    compute_phi_oneway,
 42    compute_phi_per_transition,
 43    phi_interpretation,
 44    standardise,
 45)
 46
 47log = get_logger(__name__)
 48
 49
 50# Canonical phase order matches paper §3.1: Phase 0 = Clutter,
 51# Phase 1 = Interaction, Phase 2 = Clean. Alphabetical sort of the
 52# string labels would put Clean first, which silently mislabels the
 53# per-transition tests. We keep an explicit map so the runner's phase
 54# strings (rpx_benchmark.api.Phase values) always land on the right
 55# tensor axis.
 56CANONICAL_PHASE_ORDER: tuple[str, ...] = ("clutter", "interaction", "clean")
 57CANONICAL_PHASE_SHORT: dict[str, str] = {
 58    "clutter": "clu",
 59    "interaction": "int",
 60    "clean": "cln",
 61}
 62
 63
 64def _phase_sort_key(phase: str) -> tuple[int, str]:
 65    """Return a sort key that places canonical phases in paper order
 66    (Clutter → Interaction → Clean), with any unknown phase strings
 67    appended afterwards in alphabetical order (and clearly flagged in
 68    the summary's `notes`)."""
 69    try:
 70        return (CANONICAL_PHASE_ORDER.index(phase), phase)
 71    except ValueError:
 72        return (len(CANONICAL_PHASE_ORDER), phase)
 73
 74
 75@dataclass
 76class PhiJediSummary:
 77    """Bundled Φ + JEDI output for one (model, task) pair.
 78
 79    Attributes
 80    ----------
 81    phi : PhiOneway | None
 82        One-way Φ from repeated-measures MANOVA. ``None`` when the
 83        data shape rules out the test (e.g. K > N).
 84    phi_per_transition : Dict[str, PhiTransition]
 85        Pairwise Hotelling-T² Φ per transition. Empty if Φ could not
 86        be computed.
 87    phi_interpretation : str | None
 88        Cohen anchor label for the conservative Φ.
 89    jedi : JEDIModelResult | None
 90        Aggregated JEDI score across all (scene, phase) cells.
 91    metrics_used : List[str]
 92        The metric keys that fed the analysis (in the order they appear
 93        on each row of the ``(N, P, K)`` tensor).
 94    n_eff : int
 95        Number of scenes that survived complete-case filtering.
 96    n_dropped : int
 97        Number of scenes dropped because they lacked a complete
 98        ``(P, K)`` block.
 99    notes : List[str]
100        Free-text caveats surfaced during computation (e.g. "Pillai used
101        because Wilks Λ was non-finite").
102    """
103
104    phi: Optional[PhiOneway] = None
105    phi_per_transition: Dict[str, PhiTransition] = field(default_factory=dict)
106    phi_interpretation: Optional[str] = None
107    phi_mixed: Optional[PhiMixedDesign] = None
108    """Two-way mixed-design MANOVA: per-tier Φ + interaction effect.
109    ``None`` when scenes don't carry a ``difficulty`` tag."""
110    jedi: Optional[JEDIModelResult] = None
111    metrics_used: List[str] = field(default_factory=list)
112    n_eff: int = 0
113    n_dropped: int = 0
114    notes: List[str] = field(default_factory=list)
115    # Per-metric per-phase raw means, ``{metric: {phase_label: float}}``.
116    # Populated alongside the MANOVA tensor so the paper-table filler
117    # (``scripts/fill_paper_table.py``) can populate per-phase columns.
118    per_phase_metric_means: Dict[str, Dict[str, float]] = field(default_factory=dict)
119
120    def to_dict(self) -> Dict[str, Any]:
121        return {
122            "phi": self.phi.to_dict() if self.phi else None,
123            "phi_per_transition": {
124                k: v.to_dict() for k, v in self.phi_per_transition.items()
125            },
126            "phi_interpretation": self.phi_interpretation,
127            "phi_mixed": self.phi_mixed.to_dict() if self.phi_mixed else None,
128            "jedi": self.jedi.to_dict() if self.jedi else None,
129            "metrics_used": list(self.metrics_used),
130            "n_eff": self.n_eff,
131            "n_dropped": self.n_dropped,
132            "notes": list(self.notes),
133            "per_phase_metric_means": {
134                m: dict(by_phase) for m, by_phase in self.per_phase_metric_means.items()
135            },
136        }
137
138
139# --------------------------------------------------------------------------- #
140# Internals
141# --------------------------------------------------------------------------- #
142
143
144def _select_metric_keys(
145    per_sample_metrics: Sequence[Mapping[str, Any]],
146    explicit_keys: Optional[Sequence[str]],
147) -> List[str]:
148    """Pick which metric keys to feed into Φ / JEDI.
149
150    If ``explicit_keys`` is provided, validate that each has a registered
151    spec and that at least one row has a numeric value for it. Otherwise,
152    auto-detect: take the intersection of "numeric keys in row 0" and
153    "keys with registered specs", excluding metadata (phase, scene, etc.).
154    """
155    metadata = {"id", "phase", "difficulty", "scene", "latency_ms"}
156
157    if explicit_keys:
158        keys = list(explicit_keys)
159        for k in keys:
160            if not has_spec(k):
161                raise MetricError(
162                    f"Metric {k!r} has no registered MetricSpec",
163                    hint="Register a spec via rpx_benchmark.metrics.register_spec.",
164                )
165        return keys
166
167    if not per_sample_metrics:
168        return []
169    row0 = per_sample_metrics[0]
170    keys = []
171    for k, v in row0.items():
172        if k in metadata:
173            continue
174        if isinstance(v, bool):
175            continue
176        if not isinstance(v, (int, float)):
177            continue
178        if has_spec(k):
179            keys.append(k)
180    return keys
181
182
183def _group_by_scene_phase(
184    per_sample_metrics: Sequence[Mapping[str, Any]],
185    metric_keys: Sequence[str],
186) -> tuple[np.ndarray, List[str], List[Any], int, int, Dict[Any, str]]:
187    """Group per-sample rows into a (N, P, K) tensor.
188
189    Returns
190    -------
191    tensor : ndarray, shape (N_eff, P, K)
192        Per (scene, phase) means of each metric.
193    phase_order : List[str]
194        The phase labels in tensor-axis order. Sorted alphabetically by
195        the string value to keep results stable across runs.
196    scene_ids : List
197        The N_eff scene identifiers retained (complete-case).
198    n_dropped : int
199        Scenes dropped because they lacked at least one (scene, phase)
200        observation in every phase.
201    K : int
202        Number of metric columns kept.
203    """
204    K = len(metric_keys)
205    # Bucket rows by (scene, phase), keeping a list of per-row vectors so
206    # we can average across multiple samples of the same (scene, phase).
207    buckets: Dict[tuple, Dict[Any, List[List[float]]]] = {}
208    phases_seen: set = set()
209    scenes_seen: List[Any] = []
210    scene_difficulty: Dict[Any, str] = {}
211
212    for row in per_sample_metrics:
213        scene = row.get("scene")
214        phase = row.get("phase")
215        if scene is None or phase is None:
216            continue
217        vec: List[float] = []
218        complete = True
219        for k in metric_keys:
220            v = row.get(k)
221            if v is None or not isinstance(v, (int, float)) or isinstance(v, bool):
222                complete = False
223                break
224            vec.append(float(v))
225        if not complete:
226            continue
227        phases_seen.add(phase)
228        if scene not in buckets:
229            buckets[scene] = {}
230            scenes_seen.append(scene)
231        buckets[scene].setdefault(phase, []).append(vec)
232        # Difficulty is assumed constant across phases of a scene (ESD
233        # design). First non-null wins.
234        if scene not in scene_difficulty:
235            diff = row.get("difficulty")
236            if diff is not None:
237                scene_difficulty[scene] = str(
238                    diff.value if hasattr(diff, "value") else diff
239                )
240
241    if not buckets or not phases_seen:
242        return np.zeros((0, 0, K)), [], [], 0, K, {}
243
244    # Canonical paper order: Clutter → Interaction → Clean.
245    # Unknown phase strings (if any) trail afterwards alphabetically.
246    phase_order = sorted((str(p) for p in phases_seen), key=_phase_sort_key)
247    P = len(phase_order)
248    if P < 2:
249        return np.zeros((0, P, K)), phase_order, [], 0, K, scene_difficulty
250
251    # Drop scenes that don't have at least one observation for every phase.
252    complete_scenes: List[Any] = []
253    cells: List[np.ndarray] = []
254    for scene in scenes_seen:
255        cell = np.empty((P, K), dtype=np.float64)
256        ok = True
257        for i, p in enumerate(phase_order):
258            rows = buckets[scene].get(p)
259            if not rows:
260                ok = False
261                break
262            cell[i] = np.mean(rows, axis=0)
263        if ok:
264            cells.append(cell)
265            complete_scenes.append(scene)
266
267    n_eff = len(complete_scenes)
268    n_dropped = len(scenes_seen) - n_eff
269    if n_eff == 0:
270        return np.zeros((0, P, K)), phase_order, [], n_dropped, K, scene_difficulty
271
272    tensor = np.stack(cells, axis=0)  # (N_eff, P, K)
273    return tensor, phase_order, complete_scenes, n_dropped, K, scene_difficulty
274
275
276def _orient_for_phi(
277    tensor: np.ndarray,
278    metric_keys: Sequence[str],
279    specs: Mapping[str, MetricSpec],
280) -> np.ndarray:
281    """Flip lower-is-better columns so higher consistently means better.
282
283    Paper §3.2: *"Lower-is-better metrics are negated before
284    standardisation so that higher values consistently indicate better
285    performance."* The negation is for Φ only; JEDI handles direction
286    via its own bounds policy.
287    """
288    out = tensor.copy()
289    for j, key in enumerate(metric_keys):
290        spec = specs[key]
291        if spec.direction == "lower":
292            out[:, :, j] = -out[:, :, j]
293    return out
294
295
296# --------------------------------------------------------------------------- #
297# Public entry point
298# --------------------------------------------------------------------------- #
299
300
301def summarize_phi_jedi(
302    per_sample_metrics: Sequence[Mapping[str, Any]],
303    *,
304    metric_keys: Optional[Sequence[str]] = None,
305    jedi_epsilon: float = 0.0,
306    apply_holm: bool = True,
307) -> PhiJediSummary:
308    """Compute Φ + JEDI for one (model, task) run.
309
310    Parameters
311    ----------
312    per_sample_metrics : sequence of dict
313        The runner's per-sample metric rows. Each row must carry
314        numeric metric keys plus ``scene`` and ``phase`` metadata.
315    metric_keys : sequence of str, optional
316        Explicit metric keys to feed into the analysis (in canonical
317        order). When ``None``, auto-detected as the numeric keys in
318        row 0 that have registered :class:`MetricSpec`s.
319    jedi_epsilon : float
320        Lower floor on individual desirabilities (paper §3.3). Default
321        ``0.0`` (strict).
322    apply_holm : bool
323        Pass through to :func:`compute_phi_per_transition` for the
324        Holm-Bonferroni adjustment of the three pairwise tests.
325
326    Returns
327    -------
328    PhiJediSummary
329        Populated as fully as the data allows; ``phi`` may be ``None``
330        when N_eff is too small or K is too large.
331    """
332    metric_keys = _select_metric_keys(per_sample_metrics, metric_keys)
333    if not metric_keys:
334        return PhiJediSummary(notes=["No registered metric keys found in inputs."])
335
336    specs = {k: get_spec(k) for k in metric_keys}
337
338    tensor, phase_order, scene_ids, n_dropped, K, scene_difficulty = _group_by_scene_phase(
339        per_sample_metrics, metric_keys
340    )
341    n_eff = tensor.shape[0]
342
343    summary = PhiJediSummary(
344        metrics_used=list(metric_keys),
345        n_eff=n_eff,
346        n_dropped=n_dropped,
347    )
348
349    # Per-phase per-metric raw means: average each metric column across the
350    # N_eff scenes within a phase. Drives the per-phase columns of the
351    # paper's per-task tables.
352    if n_eff > 0 and tensor.shape[1] >= 1:
353        for j, key in enumerate(metric_keys):
354            by_phase: Dict[str, float] = {}
355            for i, phase_label in enumerate(phase_order):
356                by_phase[str(phase_label)] = float(tensor[:, i, j].mean())
357            summary.per_phase_metric_means[key] = by_phase
358
359    if n_eff < 2 or tensor.shape[1] < 2:
360        summary.notes.append(
361            f"Insufficient data for Φ: N_eff={n_eff}, P={tensor.shape[1]} (need ≥2 of each)."
362        )
363        # JEDI can still run per-cell even if Φ can't.
364    elif n_eff <= K:
365        summary.notes.append(
366            f"K={K} ≥ N_eff={n_eff}; MANOVA singular. Φ skipped."
367        )
368    else:
369        z = standardise(_orient_for_phi(tensor, metric_keys, specs))
370        try:
371            # Passing metric_keys lets phi.py's collinearity guard name the
372            # offending pair (e.g. "absrel vs rmse |r|=0.98") in warnings
373            # and error hints, instead of falling back to column indices.
374            phi = compute_phi_oneway(z, metric_names=list(metric_keys))
375            summary.phi = phi
376            summary.phi_interpretation = phi_interpretation(phi.phi_conservative)
377        except MetricError as e:
378            summary.notes.append(f"compute_phi_oneway failed: {e}")
379        try:
380            raw_transitions = compute_phi_per_transition(z, apply_holm=apply_holm)
381            # Translate "0->1" / "1->2" / "0->2" into phase-name labels
382            # ("clu->int" etc.) using the canonical phase_order so the
383            # JSON output is unambiguous and matches paper §3.2.
384            relabelled: Dict[str, PhiTransition] = {}
385            for key, t in raw_transitions.items():
386                try:
387                    p1_idx, p2_idx = (int(x) for x in key.split("->"))
388                    p1_name = phase_order[p1_idx]
389                    p2_name = phase_order[p2_idx]
390                    short_p1 = CANONICAL_PHASE_SHORT.get(p1_name, p1_name)
391                    short_p2 = CANONICAL_PHASE_SHORT.get(p2_name, p2_name)
392                    new_key = f"{short_p1}->{short_p2}"
393                except (ValueError, IndexError):
394                    new_key = key
395                relabelled[new_key] = PhiTransition(
396                    label=new_key,
397                    phi=t.phi,
398                    eta2=t.eta2,
399                    T2=t.T2,
400                    p_value=t.p_value,
401                    p_holm=t.p_holm,
402                    n_eff=t.n_eff,
403                )
404            summary.phi_per_transition = relabelled
405        except MetricError as e:
406            summary.notes.append(f"compute_phi_per_transition failed: {e}")
407        # Flag unknown phase strings if any leaked through.
408        unknown = [p for p in phase_order if p not in CANONICAL_PHASE_ORDER]
409        if unknown:
410            summary.notes.append(
411                f"non-canonical phase labels present: {unknown}; "
412                "transition labels include them with their raw names."
413            )
414
415        # Two-way mixed-design MANOVA: per-tier Φ + interaction effect.
416        # Only runs when every retained scene has a `difficulty` label and
417        # at least two tiers are present.
418        tier_by_scene = [scene_difficulty.get(s) for s in scene_ids]
419        if all(t is not None for t in tier_by_scene) and len(set(tier_by_scene)) >= 2:
420            try:
421                summary.phi_mixed = compute_phi_mixed_design(z, [t for t in tier_by_scene if t is not None])
422            except MetricError as e:
423                summary.notes.append(f"compute_phi_mixed_design failed: {e}")
424        else:
425            missing = sum(1 for t in tier_by_scene if t is None)
426            n_tiers = len(set(t for t in tier_by_scene if t is not None))
427            if missing > 0:
428                summary.notes.append(
429                    f"mixed-design MANOVA skipped: {missing}/{n_eff} scenes "
430                    "lack a `difficulty` tier; ESD tier file probably absent."
431                )
432            elif n_tiers < 2:
433                summary.notes.append(
434                    f"mixed-design MANOVA skipped: only {n_tiers} tier(s) present."
435                )
436
437    # JEDI per cell (doesn't need standardisation; uses raw values via specs).
438    if n_eff > 0 and tensor.shape[1] >= 1:
439        per_cell: List[tuple[str, Any]] = []
440        for i in range(n_eff):
441            for j, phase_label in enumerate(phase_order):
442                raw = {k: float(tensor[i, j, idx]) for idx, k in enumerate(metric_keys)}
443                try:
444                    r = compute_jedi(raw, epsilon=jedi_epsilon, specs=specs)
445                except MetricError as e:
446                    summary.notes.append(
447                        f"compute_jedi failed on scene={scene_ids[i]} phase={phase_label}: {e}"
448                    )
449                    continue
450                per_cell.append((str(phase_label), r))
451        if per_cell:
452            summary.jedi = aggregate_jedi(per_cell)
453
454    return summary
455
456
457__all__ = [
458    "PhiJediSummary",
459    "summarize_phi_jedi",
460]
@dataclass
class PhiJediSummary:
 76@dataclass
 77class PhiJediSummary:
 78    """Bundled Φ + JEDI output for one (model, task) pair.
 79
 80    Attributes
 81    ----------
 82    phi : PhiOneway | None
 83        One-way Φ from repeated-measures MANOVA. ``None`` when the
 84        data shape rules out the test (e.g. K > N).
 85    phi_per_transition : Dict[str, PhiTransition]
 86        Pairwise Hotelling-T² Φ per transition. Empty if Φ could not
 87        be computed.
 88    phi_interpretation : str | None
 89        Cohen anchor label for the conservative Φ.
 90    jedi : JEDIModelResult | None
 91        Aggregated JEDI score across all (scene, phase) cells.
 92    metrics_used : List[str]
 93        The metric keys that fed the analysis (in the order they appear
 94        on each row of the ``(N, P, K)`` tensor).
 95    n_eff : int
 96        Number of scenes that survived complete-case filtering.
 97    n_dropped : int
 98        Number of scenes dropped because they lacked a complete
 99        ``(P, K)`` block.
100    notes : List[str]
101        Free-text caveats surfaced during computation (e.g. "Pillai used
102        because Wilks Λ was non-finite").
103    """
104
105    phi: Optional[PhiOneway] = None
106    phi_per_transition: Dict[str, PhiTransition] = field(default_factory=dict)
107    phi_interpretation: Optional[str] = None
108    phi_mixed: Optional[PhiMixedDesign] = None
109    """Two-way mixed-design MANOVA: per-tier Φ + interaction effect.
110    ``None`` when scenes don't carry a ``difficulty`` tag."""
111    jedi: Optional[JEDIModelResult] = None
112    metrics_used: List[str] = field(default_factory=list)
113    n_eff: int = 0
114    n_dropped: int = 0
115    notes: List[str] = field(default_factory=list)
116    # Per-metric per-phase raw means, ``{metric: {phase_label: float}}``.
117    # Populated alongside the MANOVA tensor so the paper-table filler
118    # (``scripts/fill_paper_table.py``) can populate per-phase columns.
119    per_phase_metric_means: Dict[str, Dict[str, float]] = field(default_factory=dict)
120
121    def to_dict(self) -> Dict[str, Any]:
122        return {
123            "phi": self.phi.to_dict() if self.phi else None,
124            "phi_per_transition": {
125                k: v.to_dict() for k, v in self.phi_per_transition.items()
126            },
127            "phi_interpretation": self.phi_interpretation,
128            "phi_mixed": self.phi_mixed.to_dict() if self.phi_mixed else None,
129            "jedi": self.jedi.to_dict() if self.jedi else None,
130            "metrics_used": list(self.metrics_used),
131            "n_eff": self.n_eff,
132            "n_dropped": self.n_dropped,
133            "notes": list(self.notes),
134            "per_phase_metric_means": {
135                m: dict(by_phase) for m, by_phase in self.per_phase_metric_means.items()
136            },
137        }

Bundled Φ + JEDI output for one (model, task) pair.

Attributes

phi : PhiOneway | None One-way Φ from repeated-measures MANOVA. None when the data shape rules out the test (e.g. K > N). phi_per_transition : Dict[str, PhiTransition] Pairwise Hotelling-T² Φ per transition. Empty if Φ could not be computed. phi_interpretation : str | None Cohen anchor label for the conservative Φ. jedi : JEDIModelResult | None Aggregated JEDI score across all (scene, phase) cells. metrics_used : List[str] The metric keys that fed the analysis (in the order they appear on each row of the (N, P, K) tensor). n_eff : int Number of scenes that survived complete-case filtering. n_dropped : int Number of scenes dropped because they lacked a complete (P, K) block. notes : List[str] Free-text caveats surfaced during computation (e.g. "Pillai used because Wilks Λ was non-finite").

PhiJediSummary( phi: Optional[rpx_benchmark.phi.PhiOneway] = None, phi_per_transition: Dict[str, rpx_benchmark.phi.PhiTransition] = <factory>, phi_interpretation: Optional[str] = None, phi_mixed: Optional[rpx_benchmark.phi.PhiMixedDesign] = None, jedi: Optional[rpx_benchmark.jedi.JEDIModelResult] = None, metrics_used: List[str] = <factory>, n_eff: int = 0, n_dropped: int = 0, notes: List[str] = <factory>, per_phase_metric_means: Dict[str, Dict[str, float]] = <factory>)
phi: Optional[rpx_benchmark.phi.PhiOneway] = None
phi_per_transition: Dict[str, rpx_benchmark.phi.PhiTransition]
phi_interpretation: Optional[str] = None
phi_mixed: Optional[rpx_benchmark.phi.PhiMixedDesign] = None

Two-way mixed-design MANOVA: per-tier Φ + interaction effect. None when scenes don't carry a difficulty tag.

jedi: Optional[rpx_benchmark.jedi.JEDIModelResult] = None
metrics_used: List[str]
n_eff: int = 0
n_dropped: int = 0
notes: List[str]
per_phase_metric_means: Dict[str, Dict[str, float]]
def to_dict(self) -> Dict[str, Any]:
121    def to_dict(self) -> Dict[str, Any]:
122        return {
123            "phi": self.phi.to_dict() if self.phi else None,
124            "phi_per_transition": {
125                k: v.to_dict() for k, v in self.phi_per_transition.items()
126            },
127            "phi_interpretation": self.phi_interpretation,
128            "phi_mixed": self.phi_mixed.to_dict() if self.phi_mixed else None,
129            "jedi": self.jedi.to_dict() if self.jedi else None,
130            "metrics_used": list(self.metrics_used),
131            "n_eff": self.n_eff,
132            "n_dropped": self.n_dropped,
133            "notes": list(self.notes),
134            "per_phase_metric_means": {
135                m: dict(by_phase) for m, by_phase in self.per_phase_metric_means.items()
136            },
137        }
def summarize_phi_jedi( per_sample_metrics: Sequence[Mapping[str, Any]], *, metric_keys: Optional[Sequence[str]] = None, jedi_epsilon: float = 0.0, apply_holm: bool = True) -> PhiJediSummary:
302def summarize_phi_jedi(
303    per_sample_metrics: Sequence[Mapping[str, Any]],
304    *,
305    metric_keys: Optional[Sequence[str]] = None,
306    jedi_epsilon: float = 0.0,
307    apply_holm: bool = True,
308) -> PhiJediSummary:
309    """Compute Φ + JEDI for one (model, task) run.
310
311    Parameters
312    ----------
313    per_sample_metrics : sequence of dict
314        The runner's per-sample metric rows. Each row must carry
315        numeric metric keys plus ``scene`` and ``phase`` metadata.
316    metric_keys : sequence of str, optional
317        Explicit metric keys to feed into the analysis (in canonical
318        order). When ``None``, auto-detected as the numeric keys in
319        row 0 that have registered :class:`MetricSpec`s.
320    jedi_epsilon : float
321        Lower floor on individual desirabilities (paper §3.3). Default
322        ``0.0`` (strict).
323    apply_holm : bool
324        Pass through to :func:`compute_phi_per_transition` for the
325        Holm-Bonferroni adjustment of the three pairwise tests.
326
327    Returns
328    -------
329    PhiJediSummary
330        Populated as fully as the data allows; ``phi`` may be ``None``
331        when N_eff is too small or K is too large.
332    """
333    metric_keys = _select_metric_keys(per_sample_metrics, metric_keys)
334    if not metric_keys:
335        return PhiJediSummary(notes=["No registered metric keys found in inputs."])
336
337    specs = {k: get_spec(k) for k in metric_keys}
338
339    tensor, phase_order, scene_ids, n_dropped, K, scene_difficulty = _group_by_scene_phase(
340        per_sample_metrics, metric_keys
341    )
342    n_eff = tensor.shape[0]
343
344    summary = PhiJediSummary(
345        metrics_used=list(metric_keys),
346        n_eff=n_eff,
347        n_dropped=n_dropped,
348    )
349
350    # Per-phase per-metric raw means: average each metric column across the
351    # N_eff scenes within a phase. Drives the per-phase columns of the
352    # paper's per-task tables.
353    if n_eff > 0 and tensor.shape[1] >= 1:
354        for j, key in enumerate(metric_keys):
355            by_phase: Dict[str, float] = {}
356            for i, phase_label in enumerate(phase_order):
357                by_phase[str(phase_label)] = float(tensor[:, i, j].mean())
358            summary.per_phase_metric_means[key] = by_phase
359
360    if n_eff < 2 or tensor.shape[1] < 2:
361        summary.notes.append(
362            f"Insufficient data for Φ: N_eff={n_eff}, P={tensor.shape[1]} (need ≥2 of each)."
363        )
364        # JEDI can still run per-cell even if Φ can't.
365    elif n_eff <= K:
366        summary.notes.append(
367            f"K={K} ≥ N_eff={n_eff}; MANOVA singular. Φ skipped."
368        )
369    else:
370        z = standardise(_orient_for_phi(tensor, metric_keys, specs))
371        try:
372            # Passing metric_keys lets phi.py's collinearity guard name the
373            # offending pair (e.g. "absrel vs rmse |r|=0.98") in warnings
374            # and error hints, instead of falling back to column indices.
375            phi = compute_phi_oneway(z, metric_names=list(metric_keys))
376            summary.phi = phi
377            summary.phi_interpretation = phi_interpretation(phi.phi_conservative)
378        except MetricError as e:
379            summary.notes.append(f"compute_phi_oneway failed: {e}")
380        try:
381            raw_transitions = compute_phi_per_transition(z, apply_holm=apply_holm)
382            # Translate "0->1" / "1->2" / "0->2" into phase-name labels
383            # ("clu->int" etc.) using the canonical phase_order so the
384            # JSON output is unambiguous and matches paper §3.2.
385            relabelled: Dict[str, PhiTransition] = {}
386            for key, t in raw_transitions.items():
387                try:
388                    p1_idx, p2_idx = (int(x) for x in key.split("->"))
389                    p1_name = phase_order[p1_idx]
390                    p2_name = phase_order[p2_idx]
391                    short_p1 = CANONICAL_PHASE_SHORT.get(p1_name, p1_name)
392                    short_p2 = CANONICAL_PHASE_SHORT.get(p2_name, p2_name)
393                    new_key = f"{short_p1}->{short_p2}"
394                except (ValueError, IndexError):
395                    new_key = key
396                relabelled[new_key] = PhiTransition(
397                    label=new_key,
398                    phi=t.phi,
399                    eta2=t.eta2,
400                    T2=t.T2,
401                    p_value=t.p_value,
402                    p_holm=t.p_holm,
403                    n_eff=t.n_eff,
404                )
405            summary.phi_per_transition = relabelled
406        except MetricError as e:
407            summary.notes.append(f"compute_phi_per_transition failed: {e}")
408        # Flag unknown phase strings if any leaked through.
409        unknown = [p for p in phase_order if p not in CANONICAL_PHASE_ORDER]
410        if unknown:
411            summary.notes.append(
412                f"non-canonical phase labels present: {unknown}; "
413                "transition labels include them with their raw names."
414            )
415
416        # Two-way mixed-design MANOVA: per-tier Φ + interaction effect.
417        # Only runs when every retained scene has a `difficulty` label and
418        # at least two tiers are present.
419        tier_by_scene = [scene_difficulty.get(s) for s in scene_ids]
420        if all(t is not None for t in tier_by_scene) and len(set(tier_by_scene)) >= 2:
421            try:
422                summary.phi_mixed = compute_phi_mixed_design(z, [t for t in tier_by_scene if t is not None])
423            except MetricError as e:
424                summary.notes.append(f"compute_phi_mixed_design failed: {e}")
425        else:
426            missing = sum(1 for t in tier_by_scene if t is None)
427            n_tiers = len(set(t for t in tier_by_scene if t is not None))
428            if missing > 0:
429                summary.notes.append(
430                    f"mixed-design MANOVA skipped: {missing}/{n_eff} scenes "
431                    "lack a `difficulty` tier; ESD tier file probably absent."
432                )
433            elif n_tiers < 2:
434                summary.notes.append(
435                    f"mixed-design MANOVA skipped: only {n_tiers} tier(s) present."
436                )
437
438    # JEDI per cell (doesn't need standardisation; uses raw values via specs).
439    if n_eff > 0 and tensor.shape[1] >= 1:
440        per_cell: List[tuple[str, Any]] = []
441        for i in range(n_eff):
442            for j, phase_label in enumerate(phase_order):
443                raw = {k: float(tensor[i, j, idx]) for idx, k in enumerate(metric_keys)}
444                try:
445                    r = compute_jedi(raw, epsilon=jedi_epsilon, specs=specs)
446                except MetricError as e:
447                    summary.notes.append(
448                        f"compute_jedi failed on scene={scene_ids[i]} phase={phase_label}: {e}"
449                    )
450                    continue
451                per_cell.append((str(phase_label), r))
452        if per_cell:
453            summary.jedi = aggregate_jedi(per_cell)
454
455    return summary

Compute Φ + JEDI for one (model, task) run.

Parameters

per_sample_metrics : sequence of dict The runner's per-sample metric rows. Each row must carry numeric metric keys plus scene and phase metadata. metric_keys : sequence of str, optional Explicit metric keys to feed into the analysis (in canonical order). When None, auto-detected as the numeric keys in row 0 that have registered MetricSpecs. jedi_epsilon : float Lower floor on individual desirabilities (paper §3.3). Default 0.0 (strict). apply_holm : bool Pass through to compute_phi_per_transition() for the Holm-Bonferroni adjustment of the three pairwise tests.

Returns

PhiJediSummary Populated as fully as the data allows; phi may be None when N_eff is too small or K is too large.