Skip to content

Add between-subjects support to compare() (rudimentary) - #11

Merged
ianarawjo merged 30 commits into
ppi-power-tuning-tuningfrom
worktree-between-subjects-support
Aug 17, 2026
Merged

Add between-subjects support to compare() (rudimentary)#11
ianarawjo merged 30 commits into
ppi-power-tuning-tuningfrom
worktree-between-subjects-support

Conversation

@ianarawjo

@ianarawjo ianarawjo commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Motivation

compare()/analyze() structurally assume within-subjects (paired) data — every entity scored on the same items. Between-subjects data (independent cohorts, no shared items — e.g. comparing competitor apps' review streams) had no valid path through them: the only workaround was faking a shared item index, which silently ran an invalid paired test on data with no such correspondence. This surfaced while writing a paper example and turned out to be a real structural gap, not an edge case.

What changed

  • New design="auto"|"paired"|"unpaired" param on compare(). Auto-detects paired vs. unpaired data; raises a clear error on the mismatch instead of forcing an invalid analysis. design="unpaired" dispatches to a new engine (core/unpaired.py): Kruskal-Wallis/Mann-Whitney (continuous/likert/grade) or one-way ANOVA/Welch's t-test (binary), Bonferroni-corrected CIs + Holm-corrected p-values, PPI support, and now secondary_metric= Pareto-front analysis (via a new per-group joint bootstrap — disjoint groups have no shared item pool for the paired path's shared-index bootstrap to work).
  • Console rendering is now genuinely shared with the paired path, not parallel implementations: the PPI banner, per-entity means table, and pairwise comparison table (interval bars, p-values with stars) are the same functions both paths call. One visible side effect: the unpaired pairwise table now shows a bar per comparison and Δθ (deviation from null) instead of raw θ, replacing the old text-only "Verdict" column — same underlying numbers, better presentation.
  • 9 bugs found and fixed across an integration review, battle-testing, and an independent review pass: 3 critical (multi-run data silently inflating N, a ZeroDivisionError in PPI Kruskal-Wallis at k=2, NaN silently poisoning CIs), the rest silently-dropped kwargs or a decorative routing-table field.

Paired-path safety

The existing paired path is unchanged for every current call pattern — verified by tracing every inserted line (each returns/raises immediately or touches only new locals) and by byte-identical output diffs across representative scenarios (default, binary/Newcombe, explicit bootstrap, Nemenyi) before/after the rendering refactor, plus the full existing test suite (~320 tests) passing throughout.

Testing

  • 52 new tests (tests/test_unpaired.py)
  • Battle-test grid: 192/192 crash-grid + 48/48 Pareto-grid combinations (score type × k × group balance × PPI × direction), Type-I error on target (~0.04–0.06 at α=0.05) with high power under real effects
  • Full targeted regression suite (paired + PPI + Pareto + p-value paths) green throughout

Known scope limits

  • Multi-run (seeded) data and design="unpaired" raise a clear error rather than attempting nested-run resampling.
  • No natural item column → needs a one-line workaround (df["item"] = range(len(df))) before load_from(), since that function is shared by every call and wasn't touched.
  • baseline=, pairwise_test=, show_rank_probabilities= have no effect under design="unpaired" (documented).

Rendering unification, executive summary, housekeeping

Three follow-ups too, since the original description above:

  • summary_unpaired.py merged into summary.py. The between-subjects console printer had grown into its own file mostly to avoid bloating summary.py further — moved back in, next to the paired-path functions it calls, so "is this actually shared or parallel" is easier to see at a glance.
  • Executive summary + critical-difference rank bands added for design="unpaired". Both were listed as explicitly out of scope in GroupComparisonResult's own docstring — turned out to be fully reusable from the paired path's existing machinery (_critical_difference_groups/_assign_significance_groups/_print_executive_summary), which only needs a PairwiseMatrix-like .get(a,b) lookup and a RobustnessResult-like means/CI object, not the ranking-bootstrap the paired path happens to derive its label order from. Two small adapters make it work unmodified; rank order comes from sorting by mean instead. Positioned identically to the paired path: pairwise table (CD bands in its footer) → Pareto section → executive summary (with a Trade-off column when Pareto is present) → Pareto callout.
  • notes/ cleanup. Trimmed down to a single HOW_BETWEEN_SUBJECTS_ADDED.md.
  • Fixed 26 pre-existing, unrelated test failures in test_pareto.py/test_quick_primitives.py — two stale test files that were never updated after earlier parameter renames (secondary=secondary_metric=, group_col=/value_col=factor=/metric=).

Also verified against Python 3.9 directly (this repo's CI matrix includes it) — a full compare_unpaired().summary() run, including the new executive summary/CD-bands code, works cleanly.

ianarawjo and others added 4 commits August 15, 2026 16:39
Real-data validation (ppi_real.py) surfaced two bugs the synthetic
harness never triggered:

- wilcoxon's cross-fit degenerate-variance guard (_walsh_theta_fold_lambda
  and 4 sites sharing the same idiom in evalstats/ppi.py) only checked the
  labeled-side variance relative to the judge-side variance, missing the
  case where the judge side is ALSO exactly degenerate (common with real
  Likert-tied data at small fold sizes). A spurious lambda=0 in one fold
  silently zeroed out the other fold's entire contribution to both the
  point estimate and its variance, driving real-data Type-I error as high
  as 0.515 (nominal 0.05) on some judge pairs.

- _ppi_kruskal_wallis_pairwise (and its mnar_experimental sibling) crashed
  with a ZeroDivisionError whenever a genuine, strong real effect drove
  its bootstrap covariance to exact zero (np.linalg.pinv can't represent
  "infinite precision", collapsing wald_stat/df to 0). The real-data
  harness silently counted every crash as "failed to detect", collapsing
  reported power from 0.83 (uncorrected) to 0.20 (corrected).

Also added a realism improvement to the real-data harness's paired/
repeated Type-I null checks: they previously copied the exact same human
label onto every arm (Y_lab identically 0), which is not just what
triggered the wilcoxon bug but an unrealistically idealized worst case --
real independent human ratings never agree to floating-point precision.
generate_real_paired_null_cell/generate_real_omnibus_repeated_null_cell
now mix in independent small-noise label copies (90% of reps, sigma=0.03)
alongside the exact-tie construction (10%), still a valid null (mean-zero
noise keeps the true difference exactly 0) but more representative.

Full official real-data re-run (reps=200, all checks): wilcoxon corr max
0.515->0.105, mean 0.098->0.052; kruskal corrected power 0.196->1.000;
Holm-confirmed miscalibrated cells 37/2208->1/2208. Added regression tests
for both degenerate cases. See Addendum 39 in
simulations/out/results_why_ppi_shrink_1_over_0.md for the full
investigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_ppi_anova_independent_f_stat's power_tune=True branch had each of the k
groups independently estimate its own PPI power-tuning lambda from that
group's own labeled subsample. Since lambda is chosen specifically to
minimize that group's own reported variance using that same finite
sample's noisy moments, the reported variance carries a systematic
"argmin-then-evaluate-at-the-argmin" optimism bias, distinct from
lambda's own sampling uncertainty (which _lambda_var_inflation already
corrects for separately). Confirmed directly on real data: mean(denom)
was ~14% too small relative to mean(ss_between)/(k-1) under a genuine
null (ratio 1.136, should be ~1.0) -- absent under power_tune=False
(ratio 0.970), isolating this specifically to adaptive power-tuning.

Fix: estimate lambda ONCE, pooled across all k groups' labeled+unlabeled
data (new evalstats/ppi.py:_pooled_k_group_lambda, generalizing the
existing 2-group _pooled_two_group_lambda ttest already uses), instead of
each group independently. Pooling increases the effective sample lambda
is estimated from, shrinking the optimism gap. This is a different
mechanism from Addendum 37's earlier (rejected) k-group-pooled-lambda
attempt, which was evaluated only against a distinct MNAR point-estimate
bias that pooling doesn't address -- this fix targets an MCAR-relevant
variance bias instead, and is validated accordingly.

Validated via ground-truth Monte Carlo: 18 real-data MCAR null cells
across 5 datasets (never worse than the per-group construction, several
cells dropping from ~1.4-1.5x nominal to within noise of nominal), 7
synthetic null scenarios including 2 MNAR (no regression, some
improvement), and power checks on both real and synthetic data (power
unchanged or mildly improved everywhere tested, no cost). 30/30 anova_ind
unit tests and the full 378-test tests/test_ppi_corrections.py +
tests/test_p_values.py suite pass. Official synthetic harness re-check
(reps=500 Type-I + 3 power sweeps) confirms: corr max 0.086/mean 0.051,
0 Holm-confirmed miscalibrated cells, smooth well-calibrated power curves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
wilcoxon's Walsh-average theta is proportion-like on [-0.5, 0.5], so (as
with a binomial's p(1-p)) its sampling variance is maximal at theta=0 and
collapses toward the boundaries -- measured 0.0166 at true theta=0 vs 8e-6
at 0.499, giving corr(sqrt(var), |theta_hat|) = -0.88..-0.95. The plug-in
("Wald") variance is evaluated AT the observed estimate, so a large
|estimate| mechanically arrives with a small se and a two-sided test is
inflated in both tails. This is a property of the ESTIMAND, not of lambda,
which is why fixed lambda=1 was always well calibrated: adaptive lambda
shrinks the correction toward f_lab and concentrates the statistic on the
small labeled sample where the coupling bites, exposing it rather than
creating it.

Evaluate the human term's variance UNDER H0 instead (a score rather than a
Wald construction -- the same reason this package prefers Wilson over Wald
for binary and Tango for paired binary). Under H0 the Walsh count is the
Wilcoxon signed-rank statistic, whose null law is distribution-free;
sign-flip randomization obtains it exactly, handling the heavy ties where
the closed form (2n+1)/(6n(n+1)) is 3.5x wrong on real 88%-tied judge data.
A null variance is constant w.r.t. theta_hat, so unlike a variance-
stabilizing transform it cannot diverge at the boundary where real effects
live. The substitution keeps the estimated correlation and rescales only the
human side, since replacing var_lab alone breaks the quadratic form's
Cauchy-Schwarz consistency (measured: 9% of samples clamp to ~0 se).

Full official ppi_real (reps=200, seed=46, all six corpora), wilcoxon over
192 matched cells: Type-I max 0.1050 -> 0.0800, pooled 0.0522 -> 0.0428
(z=-6.09), cells>0.075 10 -> 1, CI coverage 0.941 -> 0.945, CI width 0.2199
-> 0.1621 (-26%), power unchanged at 1.000. Whole table: Holm-confirmed
miscalibrated cells 1/2208 -> 0/2208. A narrower interval with better
coverage is the direct evidence that cross-fitting's 5-17% SE inflation was
wasteful rather than protective. Of the twelve tests exactly two moved --
wilcoxon (this change) and anova_ind (whose baseline predates 3d64d1f); the
other ten are bit-for-bit identical.

Removes _walsh_theta_fold_lambda and _WILCOXON_CROSSFIT_COV_COEF;
_cross_fit_satterthwaite_df is retained (ttest's two-sample path uses it).
power_tune=False is deliberately untouched -- that path is long-validated
and serves as the harness's classical baseline. Nine other approaches were
tried and rejected first, including an arcsine transform that looked best
synthetically then collapsed real power to 0.462. See Addenda 41/42 in
simulations/out/results_why_ppi_shrink_1_over_0.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ianarawjo
ianarawjo force-pushed the worktree-between-subjects-support branch from 2a59d1e to c554c82 Compare August 16, 2026 15:53
ianarawjo and others added 3 commits August 16, 2026 17:33
… figures

The label-efficiency multiplier is not measured directly -- it is obtained by
INVERTING the classical reference curve (equiv_n_lab = interp(ppi_power,
power_grid, n_grid)). That inversion's gain dN/dP is 800-1250 labels per unit
power wherever the curve is flat, so a binomial SE of 0.02 on ppi_power
becomes +/-16-25 equivalent labels -- at n_lab=15 that is +/-1.05x on the
multiplier. Measured at the old single effect size (frac=0.15): predicted
multiplier sd from binomial noise alone (1.41) EXCEEDED the observed scatter
(0.56), so the sub-1.0x values visible in that sweep were inversion
artifacts, not PPI underperforming a human-only test.

Changes:

* Sweep PPI_LABEL_EFF_EFFECT_FRACS = (0.15, 0.20, 0.25, 0.35) instead of a
  single frac. One effect size cannot keep the whole N_lab grid in the
  curve's steep middle; the eval types also peak at DIFFERENT fracs (binary
  ~0.20, continuous ~0.35, roughly 1.75x apart). Chosen by scanning all three
  eval types: reaching below 0.15 is near-dead for continuous (0/8 usable
  cells) and likert (0/8), while frac=0.50 degrades to 0/8 usable and 3/8
  saturated at the 2.5-3.5x multiplier binary actually achieves. Both source
  builders take effect_frac; scenario names embed ".es=<frac>".

* Smoothed, strictly-monotone reference curve (_smooth_monotone_power_curve).
  A BIAS fix, not cosmetic: at ref_n_mc=3000 the raw MC curve ties across
  adjacent grid points, and inverting a tie resolves to the lowest tied N,
  biasing equiv_n_lab downward exactly in the flat small-n_lab region.
  Measured: n_lab=15/r=0.30 goes 1.06x -> 1.52x.

* CIs on every multiplier (_multiplier_ci), propagating ppi_power's binomial
  SE through the curve's local slope. These are wide and that is the point:
  at reps=200 most individual cells do not exclude 1.0x, so reporting point
  estimates alone overstates precision.

* n_grid cap 500 -> 1500 (28 -> 36 points) in both the label-efficiency and
  N-formula checks. np.interp CLAMPS at the endpoints, so the cap was a hard
  ceiling on any reportable multiplier: binary's kappa=0.80 tier reaches ~4x,
  needing equiv ~800, but could only report 500/200 = 2.50x -- silently
  truncating the BEST-performing eval type into looking worse than likert.

* Figures: save_ppi_label_efficiency_invariance_plot (multiplier vs effect
  size, one line per judge-quality tier -- flat lines mean the multiplier is
  a property of the judge, which is what licenses pooling across arms) and
  save_ppi_label_efficiency_threshold_plot (multiplier vs judge-human
  agreement, with a shaded <1.25x "not worth the trouble" band, because a
  multiplier can be statistically above 1.0 while being practically
  pointless -- at agreement 0.4 the medians are 1.14x/1.01x/1.03x). Both are
  emitted automatically alongside the pooled and per-arm plots.

* effect_frac, multiplier_lo and multiplier_hi are now columns in the results
  CSV, and effect_frac in the raw per-method CSV, so the arms stay separable
  and the invariance check is reproducible from the artifacts.

Validated on a reps=200 sweep: 823/840 arm-pairs agree within 95% CI (98.0%;
continuous 100%, likert 98.2%, binary 95.7%), saturation down to 15/576, and
median CI width falls monotonically with effect size (0.53 -> 0.23).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up polish to ebc964f's saturated-point handling:

- Shrink the caret from 11pt to 7pt (and its edge from 0.8 to 0.7). At 11pt,
  a marker pinned at y_max with clip_on=False projected far enough above the
  axes to overlap the subplot titles.
- Pad the subplot titles by 10pt, since the caret still deliberately
  straddles the axis line (that overhang is what makes it read as "runs off
  the chart" rather than "sits at the top of the chart").
- Shorten the legend entry to "saturated". The longer wording widened the
  legend box enough to squeeze the panels; the caret's meaning as a lower
  bound now belongs in the figure caption.
- Raise the main title (suptitle y=0.99) and lift tight_layout's rect top
  from 0.94 to 0.96. The rect change is load-bearing: without it
  tight_layout reclaims the new headroom for the axes and the gap closes
  straight back up.

Rendered against the reps=200 sweep to confirm no marker/title overlap
remains in any panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fficiency

The label-efficiency rule of thumb was stated in three different metrics
(kappa for binary, quadratic weighted kappa for likert, Pearson r for
continuous), which left open the reviewer question of whether the cross-type
agreement was an artifact of that choice. It was not, but the unifying
quantity turned out to be none of the three: it is rho^2, the squared
within-group correlation between judge score and human label.

PPI++ with tuned lambda is a control variate, so

    saving = 1 / (1 - rho^2 * (1 - n_lab/N))

Validated over a 48-cell grid (3 eval types x 4 judge-noise x 4 judge-bias,
3000 reps each) against measured Var(human-subset)/Var(PPI): R^2=0.9968, mean
error -0.15%, max 5.5%, under ADAPTIVE power-tuned lambda. Pooled across all
three eval types rho^2 scores R^2=0.975 with a 1.07x spread at matched value,
against ICC/CCC at 0.703/1.58x and Krippendorff's alpha at 0.553/1.87x.

rho^2 wins because it is invariant to judge BIAS, correctly: PPI's rectifier
removes additive bias, so at fixed noise a 4x bias increase drops ICC
0.857 -> 0.532 while the realized saving holds at 3.30x -> 3.19x. An
agreement-metric threshold would discard a judge still worth 3.2x.

Changes:
- _alignment_metric_dict now carries pearson_r for ALL three eval types (it
  was continuous-only) plus a derived rho2, so the axis is measurable on a
  common footing. Also adds the full IRR panel a reviewer may ask for:
  Krippendorff's alpha (nominal/ordinal/interval), Lin's CCC, Gwet's AC1,
  PABAK, Kendall's tau-b, linear-weighted kappa, ICC(2,1) for binary.
  Library implementations used wherever they exist (sklearn/scipy); only
  alpha, AC1 and CCC are hand-rolled, each validated against an independent
  reference rather than a recalled constant.
- _ppi_predicted_savings implements the formula, with the derivation and an
  explicit warning against the asymptotic 1/(1-rho^2) form, which overstates
  badly for a strong judge (claims 100x against a measured 40x at rho^2=0.99)
  because sensitivity to the correction grows with judge quality.
- _calibrate_noise_for_alignment returns the full realized metric panel
  instead of discarding all but the tuned metric; it already computed it.
- Calibration CSV gains rho2/pearson_r and the rest of the panel; results CSV
  gains rho2, predicted_mult, predicted_mult_asymptotic.
- Main label-efficiency plot draws a per-tier dotted prediction curve. It sits
  ABOVE the measured line at strong-judge tiers by construction: the
  prediction is variance-scale, equiv_n_lab inverts a saturating power curve.

Two bugs found while validating the new metrics: Gwet's AC1 had a spurious
factor of 2 in its chance term (divide-by-zero on balanced binary marginals),
and PABAK used the binary-only 2*Po-1, returning -0.303 for a judge
simultaneously scoring weighted kappa 0.60; it now uses the K-category
Brennan-Prediger form, which reduces to 2*Po-1 at K=2.

Note kappa == quadratic weighted kappa == ICC(2,1) exactly for two raters
(residual scales as 1/n) -- a real unification, but not the right axis, and it
requires passing an explicit category grid or an absent category shifts kappa
by up to 0.044.

Existing primary metrics are unchanged and verified bit-identical across 355
values, so prior results remain comparable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ianarawjo
ianarawjo force-pushed the worktree-between-subjects-support branch from c554c82 to 1f925b2 Compare August 16, 2026 23:47
ianarawjo and others added 21 commits August 16, 2026 20:30
…l types

The axis was calibrated and labeled per eval type (kappa for binary, quadratic
weighted kappa for likert, Pearson r for continuous), so a shared "IRR~=0.8"
legend entry meant three different things: those tiers realize rho^2 = 0.667 /
0.683 / 0.640. Close enough to look alignable while asserting an equivalence
that does not hold, which made the panels not directly comparable.

rho^2 is what actually predicts the multiplier (control-variate saving is
1/(1 - rho^2*(1 - n_lab/N))), so calibrating on it directly makes a tier mean
the same judge quality in every panel and puts the axis in the units the rule
of thumb is stated in. Predicted savings are now identical across eval types
at each tier (3.23/3.22/3.22 at rho^2=0.70; 1.97/1.97/1.97 at 0.50), where
before they differed by tier definition.

- _LABEL_EFF_ALIGNMENT_METRIC: rho2 for all three; legend reads "ρ²~=".
- Tier ladder retargeted onto the rho^2 scale: (0.70 ... 0.20), straddling the
  rho^2=0.5 rule-of-thumb threshold rather than sitting at an edge. The top is
  0.70 because BINARY CANNOT EXCEED rho^2 ~= 0.733 at any noise level -- its
  held-constant flip-probability bias caps phi -- so a 0.8 tier would silently
  under-deliver on one arm. _NFORMULA_ALIGNMENT_TARGETS moved to match.
- Threshold/invariance plot x-axis relabeled to name rho^2 explicitly.

Two bugs fixed in the process:

_calibrate_noise_for_alignment cannot bisect on rho^2 directly. For binary,
llm_noise is a flip PROBABILITY, so past 0.5 the judge is systematically
inverted and rho^2 -- which discards the sign -- climbs back toward 1
(measured: r goes 0.850 -> -0.010 at noise 0.50 -> -1.000 by 2.0, so rho^2
traces 0.72 -> 0.00 -> 1.00). The monotone-decrease assumption sailed past the
zero crossing and converged on a perfectly ANTI-correlated judge reported as
rho^2=1.0, on every binary tier. It now bisects on the SIGNED pearson_r
against sqrt(target), which is monotone across the whole range, and reports
rho^2 from the same final measurement. All 18 tiers now land within 0.0006.
The old per-type metrics never hit this because kappa also goes negative.

fit_nformula_rule_of_thumb.py computed 1 - alignment_value**2, which was
already wrong (it squared kappa as though it were a correlation for binary and
likert) and would now be wrong twice over, since alignment_value IS rho^2. It
takes 1 - alignment_value.

Also stops clamping the predicted-savings curve to the axis ceiling; clamped
points drew a flat run along the top edge that read as a real measurement
topping out. They are dropped instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moves _detect_paired out of labeling.py into a new zero-dependency leaf
module, evalstats/core/design.py, as a public detect_paired(). Pure
refactor -- labeling.py re-exports the same function under its old
private name so its one call site is unchanged. This lets compare()'s
upcoming design="auto" routing share the exact same implementation
without importing the labeling-CLI-focused module.

Added tests/test_design.py since no test coverage existed for this
function at all before now (pre-existing gap, not introduced here) --
covers paired/unpaired/threshold-boundary/no-factor/single-level cases,
plus an identity check that labeling.py's re-export isn't a copy.

Also checked in the finalized architecture plan (PLAN_between_subjects_
extension.md) and this session's own working instructions
(IMPLEMENTATION_INSTRUCTIONS.md) for reference through the rest of this
multi-phase implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rwise

Two additive pieces of Phase 1 groundwork:

1. AUTO_UNPAIRED_METHOD_TABLE in config.py: the compare(design="unpaired")
   routing table. Two rows in practice -- binary -> anova_oneway (omnibus)
   + ttest (pairwise, Welch's); continuous/likert/grade -> kruskalwallis
   (omnibus + pairwise) + mannwhitney (k=2 special case). Matches the
   finalized decisions in PLAN_between_subjects_extension.md exactly,
   with the same reasoning inline as "reason" strings, following
   AUTO_ANALYZE_METHOD_TABLE's existing convention.

2. _ppi_kruskal_wallis_pairwise (evalstats/tests/__init__.py) now also
   returns "boots" and "pair_p" (per-pair two-sided bootstrap p-values,
   same convention as TestResult.corrected_p_value) alongside its
   existing keys -- purely additive, no existing caller reads new keys.
   Needed so the unpaired dispatcher can Holm-correct a family of
   per-pair p-values; the function only exposed a single omnibus wald_p
   before. Verified: the public kruskalwallis() wrapper (which doesn't
   read these new keys) is unaffected, and the 37 existing kruskal-
   related tests in tests/test_ppi_corrections.py still pass unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The core of Phase 1: compare_unpaired(), a sibling to core/paired.py's
machinery, not a branch inside it -- the paired path's whole design
assumes item-matched differences, which has no meaning for genuinely
disjoint groups.

- GroupStat / GroupDiffResult / GroupComparisonResult: new result
  dataclasses (not PairedDiffResult/AnalysisBundle -- those carry
  per_input_diffs, which doesn't exist here). .to_dict()/.to_frame()
  included from the start (confirmed load-bearing -- evalstats is a
  library API, not just a reporting tool). .plot() raises
  NotImplementedError for now (deferred phase).
- Two test families per AUTO_UNPAIRED_METHOD_TABLE: binary ->
  anova_oneway (omnibus, k>=3) + pairwise Welch's-style Δp CIs; everything
  else -> kruskalwallis (omnibus, k>=3) + pairwise θ=P(a>b) CIs. Both
  families' pairwise engines work uniformly at any k>=2 (Bonferroni/Holm
  correction is a no-op at a family of 1 pair), so k=2 needs no special
  case -- it's just the k>=3 pairwise machinery with an empty omnibus.
- Two independent correction axes matching the paired path's own
  convention: Bonferroni for the pairwise CIs (not Šidák -- needs no
  unverified independence assumption on this bootstrap's correlation
  structure), Holm for the pairwise p-values (reusing
  core/stats_utils.correct_pvalues, already used elsewhere).
- Non-PPI pairwise post-hocs for both families are new, small additions
  (kruskalwallis() only ever populated its pairwise breakdown under PPI;
  a plain-bootstrap and closed-form-Welch's analog didn't exist before).
- Synthetic-item-column fallback (mirrors labeling.py's) so data with no
  natural row/reviewer id doesn't hard-error before this code runs.
- Per-group descriptive stats reuse the exact machinery
  evalstats.quick.summarize() uses internally (resolve_auto_robustness_
  method + robustness_metrics), called directly with multi_ci=True to get
  gradient CI bands without touching summarize()'s own public signature.
- core/summary_unpaired.py: .summary() printer, reusing core/summary.py's
  low-level rendering primitives (_choose_interval_line et al. -- these
  already take plain floats, no bundle object needed) and mirroring the
  paired path's pink PPI banner exactly, printing the caller's real
  AlignmentResult inline.

Found and fixed one integration bug while smoke-testing: every
evalstats.tests function with labels prints its own internal alignment
report unconditionally (not gated by print_result -- existing, validated
behavior for direct callers, left untouched), which produced a confusing
duplicate/stale ("selection=unknown") report when called internally here
alongside our own correctly-disclosed one. Fixed by suppressing stdout
around just that internal call, not by touching evalstats.tests.

Verified end-to-end via direct smoke tests (not yet wired into compare()):
k=2 and k>=3, both families, with and without PPI, unbalanced groups, and
the synthetic-item-column fallback -- all produce correct, readable
output. Also added a brief clarifying note when the Bonferroni-CI verdict
and the independently Holm-corrected p-value rarely disagree right at the
boundary (both valid, just different corrections).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_ppi_kruskal_wallis_pairwise's finite-sample F-correction divided by
nu * df unconditionally once nu > df, but df (the covariance's own
matrix rank) can come back 0 when the covariance is numerically
degenerate -- most likely at k=2, where there's only one pair and a
1x1 covariance can collapse to ~zero variance for some data/seed
combinations. Found by the between-subjects battle-test grid (6/192
cells crashed). Guard df == 0 explicitly and report no evidence of a
difference (wald_p = 1.0) rather than dividing by zero; only the
exact function on the call path is touched, not its MNAR-experimental
twin.
Adds design: Literal["auto","paired","unpaired"] to compare(), inserted
as a self-contained block right before the existing single-factor
dispatch paths -- it either returns/raises immediately or falls through
completely unchanged, so paired-path behavior is provably untouched for
every existing call (design="paired" never evaluates the new branches;
design="auto" on genuinely paired data calls the read-only
detect_paired() and falls through). design="auto" on between-subjects
data raises a clear ValueError instead of silently forcing a paired
analysis onto it.

Also folds in several fixes found during integration review and
battle-testing of compare_unpaired():
- NaN in the metric column now drops-and-warns per group (matching
  evalstats.quick's convention) instead of silently poisoning CIs or
  crashing inside a Wilson-CI helper; group_arrays/group_lab_arrays are
  built from the same per-row mask so PPI labels stay aligned.
- PPI dispatch now runs through the same label-sanitization guards
  every other PPI caller has (>=15-label floor, zero-labeled-group
  check) -- it was calling the private pairwise engines directly and
  skipping them.
- method=/backend=/score_range= are now handled explicitly: score_range=
  threads through to the per-group CI's auto-method resolution;
  explicit method= overrides raise a clear "not supported" error instead
  of being silently dropped (the auto-detect warning was also actively
  misleading when this happened).
- Multi-run (seeded) data and secondary_metric=+run_col combinations now
  raise clear guidance instead of silently inflating N or being ignored.
- AUTO_UNPAIRED_METHOD_TABLE's family field is now the actual dispatch
  key (previously re-derived from pairwise_method by string comparison
  elsewhere, so editing the table didn't change behavior).
- p_values=/omnibus= are now honored under design="unpaired", with
  unpaired-specific defaults of True (compare()'s own default is False,
  but unset here preserves the always-shown behavior this path was
  built and battle-tested with).

Adds secondary_metric= (Pareto-front analysis) support for
design="unpaired": pareto_bootstrap_unpaired() in core/pareto.py is a
new function (the existing paired-path pareto_bootstrap() is untouched)
that resamples each group's own rows independently rather than sharing
one per-item index across entities, since disjoint groups have no
shared item pool to preserve correlation through -- it still preserves
each row's own primary/secondary pairing. classify_pareto_status() is
reused unchanged. GroupComparisonResult.pareto_status/
pareto_frontier_probability mirror ComparisonResult's own attributes.

Battle-tested: 192/192 crash-grid combinations (score_type x k x
balance x PPI x seed), 48/48 Pareto-grid combinations, Type-I error
calibration on target (~0.04-0.06 at nominal alpha=0.05) with high
power under real effects.
The unpaired summary printer had grown its own parallel implementations
of things the paired path already renders -- same visual style today,
but two copies that could silently drift apart. Unifies all three:

- _print_ppi_banner(): extracted from a copy-pasted block in both
  _print_bundle_summary and print_group_comparison_summary into one
  shared function.
- _print_mean_advantage(): generalized to take plain per-entity arrays
  (labels/mean/std/ci_low/ci_high/multi_ci) instead of requiring a
  paired-specific RobustnessResult, so both paths call the literal same
  function. Verified byte-identical paired output before/after.
- _print_pairwise_section(): the bigger piece. Refactored into
  _prepare_paired_pairwise_rows()/_prepare_unpaired_pairwise_rows(),
  each resolving their own design's method-specific logic (six CI/
  p-value method families plus Friedman/Nemenyi for paired; one fixed
  Bonferroni-CI/Holm-p scheme for unpaired) into a common row+metadata
  shape, feeding one shared axis/legend/header/row-rendering core.
  Verified byte-identical paired output across three scenarios (default
  Wilcoxon/Romano-Wolf, binary/Newcombe, explicit bootstrap) plus a
  manual Nemenyi check, by diffing snapshots taken before the refactor.
  The Behavioral Agreement (McNemar-style pass/fail) subsection is
  pulled into its own _print_behavioral_agreement_section(), paired-
  only, since it needs the same item scored by both entities -- no
  between-subjects equivalent exists.
- _print_pareto_section(): already dict-generic (only ever reads
  .labels/.mean/.ci_low/.ci_high off the pareto dict's robustness
  objects), so a small adapter (_GroupStatsAsRobustness in
  core/unpaired.py) lets it render the unpaired case -- including the
  ASCII scatterplot -- with no new display code at all.

Net effect: the unpaired pairwise table now shows an interval-plot bar
per comparison and the dominance family's theta as a signed deviation
from its null (Deltatheta, so the shared zero-centered axis math applies
uniformly) with p-values and significance stars, replacing the old
text-only "Verdict: significant (A < B)" column. Numbers are unchanged
(Deltatheta = theta - 0.5, same underlying estimate); only the
presentation changed, to match the paired path exactly.
tests/test_unpaired.py: 52 tests covering compare_unpaired(), design=
routing in compare(), NaN/PPI guards, p_values=/omnibus= toggles,
Pareto-front support, and (for the shared rendering functions) a
tripwire asserting they resolve to core.summary, not a reimplementation.

simulations/investigate_unpaired_battle_test.py: crash/sanity grid
(score_type x k x balance x PPI x seed), Type-I/power calibration
check, and a Pareto-front crash grid -- kept in the repo matching the
existing simulations/investigate_*.py convention.

EXECUTIVE_SUMMARY.md: session report.
EXECUTIVE_SUMMARY.md, IMPLEMENTATION_INSTRUCTIONS.md, and
PLAN_between_subjects_extension.md were sitting at the repo root,
cluttering it alongside the actual package. Moved into a new notes/
folder; updated the handful of code comments that referenced their old
root-level path.
… bands

Two PR review requests addressed together, since they touch the same
code: the between-subjects console printer (print_group_comparison_
summary) had grown into its own file mostly to avoid bloating
summary.py further, but that made "is this actually shared or just
parallel" harder to see at a glance. It now lives directly in
core/summary.py next to the paired-path functions it calls -- deleted
core/summary_unpaired.py entirely (its deletion landed in the prior
commit due to a staging order accident; functionally it's part of this
one).

Also closes a real gap: GroupComparisonResult's own docstring said "no
executive summary, critical-difference rank bands" -- unlike every
other piece of unpaired rendering, these two were never connected to
the paired path's existing machinery. Both turned out to be reusable
outright: _critical_difference_groups/_assign_significance_groups/
_print_executive_summary only ever read a PairwiseMatrix-like
.get(a,b) lookup and a RobustnessResult-like .mean/.ci_low/.ci_high --
no ranking-bootstrap dependency despite the paired path deriving their
label order from one. Two small adapters in core/unpaired.py
(_GroupDiffResultsAsPairwiseMatrix, _GroupComparisonResultAsBundle,
alongside the existing _GroupStatsAsRobustness) let both paired-path
functions render the unpaired case unmodified -- rank order comes from
sorting by mean descending instead of a rank_dist bootstrap, and
significance uses the same CI-exclusion check GroupDiffResult.significant
already does (via a simultaneous_ci_method sentinel on the adapter that
routes _critical_difference_groups away from its p-value-threshold
branch, which this engine has no equivalent of).

Positioned identically to the paired path: pairwise table (with CD
bands in its footer) -> Pareto section, when secondary_metric= was
passed -> executive summary (showing the Trade-off column when Pareto
is present) -> Pareto callout.

Verified on real data (k=2, k=3+, with/without Pareto) and via the full
battle-test grid (192/192 crash-grid + 48/48 Pareto-grid combinations,
all exercising .summary() end to end) plus the full regression suite
(300 tests) and confirmation that the 23 pre-existing, unrelated
test_pareto.py failures are unchanged.
PLAN_between_subjects_extension.md (design planning) and
IMPLEMENTATION_INSTRUCTIONS.md (a scratch task brief for the
autonomous implementation session) were working documents, not
something worth keeping in the repo long-term. Deleted both.
EXECUTIVE_SUMMARY.md is the one worth keeping -- renamed to
HOW_BETWEEN_SUBJECTS_ADDED.md, a clearer name for what it actually is:
a write-up of what got added and why.

Also removed the resulting dangling "see notes/PLAN_between_subjects_
extension.md §X" pointers from code comments in config.py, summary.py,
unpaired.py, and test_unpaired.py -- the surrounding rationale text
stands on its own without them.
Both files were failing (26 tests total) for reasons unrelated to the
between-subjects work, confirmed via git-stash comparison at the start
of that effort. Root cause, found via git log -S on each renamed
parameter:

- 5df55f2 renamed compare()/tradeoff()'s secondary= to secondary_metric=
  but never touched tests/test_pareto.py (23 failures, all
  "unknown keyword argument 'secondary'").
- 12604ea renamed summarize()'s group_col=/value_col= to factor=/metric=
  but never touched tests/test_quick_primitives.py (3 failures).

Both were genuine "renamed the API, forgot this one test file" gaps,
not real regressions -- the local variable `secondary` in
test_pareto.py's pareto_bootstrap() tests and the point_secondary=/
replicate_secondary= params elsewhere were left untouched, since
neither is the renamed parameter. Updated the two now-misleadingly-
named test functions in test_quick_primitives.py to match
(test_summarize_dataframe_group_col -> ..._factor_metric, etc.); left
test_pareto.py's test names alone since they were never
parameter-specific. All 83 tests in both files now pass; full
targeted regression suite (383 tests total) confirmed clean.
Stress-testing compare() across paired/unpaired designs, k=2..20, and
malformed CSVs (missing columns, empty data, bad dtypes, etc.) turned
up three real issues before this ships to developers:

1. load_from() crashed with a raw pandas TypeError on duplicate column
   names (a common CSV merge/export artifact) instead of a clear,
   attributed error. Now rejected upfront with EvalLoadError.

2. A NaN in the factor/grouping column silently became its own group
   at load time, only surfacing later as a misleading "scores contain
   N NaN cells" error that blamed the metric column instead of the
   real cause. Now rejected immediately in compare() with a correctly
   attributed message.

3. The Executive Summary's "Grp" rank column could go non-monotonic
   when critical-difference bands overlap/chain (A~B and B~C each
   individually non-significant, but A~C significant -- the classic
   CD-diagram transitivity caveat). _assign_significance_groups used
   to number CD-band members and leftover singletons in two separate
   passes, so a singleton sandwiched between two bands could get
   pushed behind both. Rewritten as a single rank-ordered pass that
   merges chained bands into one group, since this table needs exactly
   one ID per entity. This is shared code used by both the paired and
   unpaired reporting paths -- not new-feature-specific, just a
   pre-existing bug this grid happened to trigger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The three bugs fixed in 234e01d had no permanent test coverage -- they
were only caught by a standalone stress script, not pytest. Adds:

- test_load_from_raises_on_duplicate_column_names: guards the
  EvalLoadError on duplicate column names in loader.py.
- test_compare_raises_clear_error_on_nan_in_factor_column: guards the
  correctly-attributed ValueError on NaN in the factor column.
- test_assign_significance_groups_merges_chained_bands_and_stays_monotonic:
  reproduces the exact chained-CD-band scenario (isolated performer
  sandwiched between two overlapping non-significance bands) and
  asserts group numbers stay non-decreasing down the rank-sorted list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Standalone script exercising compare() across paired vs. unpaired
design, k=2..20, n=15/30/50/200, and with/without a biased judge
(PPI), plus a battery of deliberately malformed CSVs (missing
columns, empty data, non-numeric/all-NaN scores, duplicate columns,
NaN in the factor column, etc.). This is the script that surfaced the
three bugs fixed in 234e01d.

Kept for reuse as a regression check ahead of future releases:

    .venv/bin/python -m simulations.investigate_final_stress_test

Includes an automated check that the Executive Summary's "Grp" column
stays non-decreasing down the rank-sorted table, so a regression of
the chained-CD-band bug would be caught by this grid even without
rereading printed output by eye.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…able

Delta-theta (stochastic dominance / probability of superiority) is the
between-subjects path's primary pairwise statistic for continuous/
likert/grade data, but it doesn't say how far apart two groups are on
the metric's own scale -- e.g. a 1.04-point gap on a 1-5 satisfaction
score reads more intuitively than "P(A>B)=0.71". Add a secondary
Delta-mean column (point estimate only, no separate CI -- same
convention the paired path's own ES/rank-biserial column already
uses), reusing the marginal means already computed for the "Mean
Performance" section above the table. The binary/proportion-difference
family is unaffected: its primary column already *is* the raw
difference, so a second copy would be redundant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
compare(design="unpaired", alignment=...)'s per-group marginal mean (the
"Mean Performance" table, and the Delta-mean pairwise column added in
147fff2) was silently computed from raw judge scores regardless of
alignment= -- only the pairwise Delta-theta/Delta-p was genuinely PPI-
corrected. _compute_group_stats() called the generic robustness_metrics()
helper, which has no PPI/alignment parameter at all, so the already-
validated human-labeled subset (group_lab_arrays) was built and sanitized
upstream but never used for the marginal estimate.

Found by directly comparing compare()'s output with and without
alignment= on real biased-judge App Store data and noticing the
"PPI-corrected" group means were bit-for-bit identical to the
uncorrected ones -- a check the PR's earlier stress test (234e01d)
should have done and didn't (it only verified PPI output didn't crash
and looked visually sane).

Fix: wire the same PPI machinery the paired path already uses
(_ppi_robustness_dispatch, resolved via is_binary_scores/
is_bounded_01_scores -> resolve_ppi_auto_methods, same GRADIENT_CI_ALPHAS
sweep for the gradient CI bands) into _compute_group_stats, applied per
between-subjects group. Every group is guaranteed at least one label by
this point (compare_unpaired already validates that upstream), so unlike
the paired path there's no "entity has zero labels, keep it uncorrected"
fallback needed.

Verified on real data: a biased judge's group mean moved from 1.56 (raw)
to 2.61 (corrected), much closer to its true value (2.24), while a
well-calibrated group barely moved -- matching the paired path's
behavior. Confirmed the same correction direction/magnitude pattern
holds for binary data.

simulations/investigate_unpaired_ppi_calibration.py is the numerical
(not just visual) stress test that should have caught this originally:
300-rep Monte Carlo empirical Type-I error and power, comparing raw vs.
PPI-corrected pairwise significance under a deliberately biased judge.
Results: a biased judge alone produces 100% false positives (continuous
and likert); PPI correction brings that back to ~5.7% (continuous,
essentially nominal) and ~10% (likert -- roughly 2x nominal, a real but
pre-existing mild under-coverage in the ppi_t_interval/"unbounded"
method, not introduced by this fix) while retaining 95-100% power when
a real difference exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… guard

Rebased worktree-between-subjects-support onto ppi-power-tuning-tuning
(4 new commits, including a99ae87 "Fix wilcoxon real-data Type-I
inflation and kruskal power collapse"). That commit independently
discovered and fixed the exact same _ppi_kruskal_wallis_pairwise
ZeroDivisionError my own 28617d7 ("Fix ZeroDivisionError in PPI
Kruskal-Wallis pairwise at k=2") had already patched -- both trace to
the same underlying degenerate-covariance scenario, just found via
different routes (my between-subjects battle-test grid vs. a99ae87's
real-data harness). a99ae87's fix is more complete: it recognizes that
exact-zero bootstrap variance around a nonzero effect is a maximally
CONFIDENT result, not "no evidence" -- my simpler guard would have
under-reported significance in exactly the strong-effect case a99ae87's
own commit message describes collapsing kruskal's real-data power from
0.83 to 0.20.

The rebase's merge conflict initially resolved by keeping BOTH fixes
(mine layered inside a99ae87's else-branch, guarding a residual
df==0 case that seemed theoretically possible even after a99ae87's
check). Per direction to prefer the incoming, more rigorously
real-data-tested version: verified empirically that a99ae87's fix
ALONE (without my extra guard) still passes
test_ppi_k2_pairwise_survives_degenerate_covariance_seeds (the exact
regression test written for my own battle-test crashes) and the full
PPI regression suite (test_unpaired.py, test_analyze.py,
test_compound_ppi_fwer.py, test_ppi_corrections.py -- 440 tests, 0
failures). My guard was redundant; removing it here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ank 1

_assign_significance_groups (fixed for monotonicity in an earlier commit
this session) let the #1 tier extend transitively through chained CD
bands, same as #2+. That's wrong specifically for #1: _exec_verdict
turns #1 membership into an explicit "tied with X as best" prose claim,
so it has to mean "provably indistinguishable from the actual top
performer" -- not "reachable from it via a chain of individually-
nonsignificant neighbors" (the same Demsar 2006 transitivity caveat
behind the earlier monotonicity fix, just biting a different part of
the output this time).

Found via a real case: FlipFlop and the top-ranked ClipCraze both
showed "#1 / Tied with 5 others as best" in an unpaired executive
summary, even though the pairwise table two lines above showed them
significantly different (Delta-theta=-0.282, p<0.0001) -- ClipCraze
was only connected to FlipFlop through two intermediate
individually-nonsignificant links (ClipCraze~Loopster~Wavelength~...),
not directly. A reader has no way to know a "#1" tag hides that.

Fix: #1 membership is now restricted to the single maximal CD band
that directly contains the rank-1 entity (no transitive extension).
Everything past that band still gets its own tier via the existing
(chain-merging) algorithm -- monotonicity is unaffected, and #2+ never
made an individualized "tied with X" claim in the first place, so
chaining there was never misleading the same way.

Verified: the FlipFlop example's exec summary now shows ClipCraze/
Loopster as "#1, tied" and everyone else (including FlipFlop) as "#2,
Significant drop-off" -- matching the pairwise table exactly. Full
regression suite (test_unpaired.py, test_analyze.py, test_pareto.py,
test_critical_difference_plot.py, test_compare.py -- 184 tests) passes
with no other change in behavior; spot-checked a k=20 paired case to
confirm #1 tiers still form sensible cliques there too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… output)

For collecting more real data for the paper's FlipFlop example without
touching the shared judge-bias simulation corpus. Clones just the RSS-
fetching logic from collect_judge_bias_data.py's fetch_appstore_items
(same endpoint, same retry/rate-limit handling), but:

- Writes to its own gitignored simulations/out/appstore_scenario_
  reviews.csv, never simulations/out/judge_bias_appstore*.csv (which
  the harness/tests read as shared ground truth -- growing it here
  would silently perturb every simulation depending on it).
- Loops per app to a target N (default 300), instead of pooling every
  app's reviews and truncating to one global n_items.
- Does no judge scoring at all -- that's a deliberate follow-up step
  the user runs themselves, separately.

Re-running is safe and additive (dedupes against already-collected
item_ids), since Apple's feed only serves recent reviews and won't
hit 300/app in one run for most apps.

Smoke-tested: fetched 5 real TikTok reviews, confirmed judge_bias_
appstore.csv was byte-identical before/after, confirmed re-running
correctly skips already-collected items.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Scores reviews collected by collect_appstore_reviews_only.py with one
or more LLM judges, writing to its own separate output (simulations/
out/appstore_scenario_judge_scores.csv + a merged item+human_label+
judge_score view) -- never judge_bias_appstore_scores.csv, same
separation principle as the review collector itself.

Reuses the actual judge-calling machinery (client construction, retry
logic, the App Store prompt/response format) from collect_judge_bias_
data.py directly via import, rather than re-implementing it -- one
place that knows how to talk to OpenRouter/Ollama, one prompt template.
Supports mixing backends in a single run via --model-backends (e.g.
thinkingmachines/inkling=openrouter alongside a free local Ollama model
for variety), since OpenRouter needs a paid API key while Ollama models
are free/local.

Smoke-tested end-to-end with a local Ollama model (gemma3:1b, no API
key/cost): scored 3 items, verified output schema, verified judge_bias_
appstore.csv stayed byte-identical, then verified resume/dedup behavior
on a second run with the same --limit (correctly skipped the first 3
already-scored items and picked the next 3 unprocessed ones -- zero
duplicate (item_id, model, run) rows).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ianarawjo and others added 2 commits August 16, 2026 20:33
Points at the standalone collector's output (appstore_scenario_reviews
.csv + appstore_scenario_judge_scores.csv) instead of the original
50/app single-judge dataset, and switches the judge to anthropic/
claude-haiku-4.5 -- selected from the 5 judges scored against this data
for having the widest per-app kappa spread (0.65 on FlipFlop vs. 0.88
on Wavelength) while keeping a solid pooled headline (0.78). All 5
judges independently showed their own worst calibration on FlipFlop,
so this isn't specific to one model's quirks.

Also documents in the module docstring that the in-paper explanation
for FlipFlop's miscalibration ("meme-fluent, ironic register") is
deliberately fictionalized narrative color, not a claim about the
actual mechanism -- the real driver (found by digging into the
mismatched reviews) is that a striking share of FlipFlop's 5-star
reviews contain bug reports/feature requests/account-complaint text,
which every judge reads as negative in isolation even though the star
rating is positive.

With real N=300/app (not 50), the result is now the cleanest version of
this story yet: raw judge scores show FlipFlop significantly worse
than Wavelength (mean diff -0.67, p<0.0001); PPI correction with just
15 labels/app (5% of the pool) collapses that to +0.01, p=1.0 --
matching the full-sample ground truth (-0.22, p=0.52) almost exactly.
Omnibus Kruskal-Wallis flips from p<0.0001 (raw) to p=0.90 (corrected).
Robustness check (10 independent label draws) unchanged: 0/10
reproduce the raw false positive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ample

Carves out .gitignore exceptions for these two files (same pattern
already used for judge_bias_privacy_judge*.csv / judge_bias_
iclr_metareview*.csv -- specific curated datasets committed despite the
blanket simulations/out/* ignore rule), so paper_flipflop_example.py is
reproducible for anyone who clones the repo without needing to re-run
the two standalone collector scripts (which hit Apple's live RSS feed
and a paid/local LLM judge -- neither guaranteed to reproduce the same
sample or scores on a later run).

appstore_scenario_reviews.csv: 1200 real App Store reviews (300/app,
4 apps: TikTok, Google Maps, Instagram, Facebook -- fictionalized as
FlipFlop/Wavelength/Snippet/Bubblegum in the paper), collected via
collect_appstore_reviews_only.py. Each review's real star rating is
the human_label ground truth.

appstore_scenario_judge_scores.csv: predictions from 5 independent LLM
judges (anthropic/claude-haiku-4.5, ibm-granite/granite-4.1-8b,
openai/gpt-oss-20b, qwen/qwen3.7-flash, thinkingmachines/inkling)
against those reviews, collected via collect_appstore_judge_scores_
only.py. paper_flipflop_example.py uses claude-haiku-4.5 by default;
switching JUDGE picks up any of the other four for comparison.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ianarawjo
ianarawjo force-pushed the worktree-between-subjects-support branch from 1f925b2 to eeb70c9 Compare August 17, 2026 00:33
@ianarawjo
ianarawjo merged commit 5f5e22a into ppi-power-tuning-tuning Aug 17, 2026
4 checks passed
@ianarawjo
ianarawjo deleted the worktree-between-subjects-support branch August 17, 2026 00:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant