diff --git a/CHANGELOG.md b/CHANGELOG.md index 1300ca7..722f505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **The R2 method for partial replication is reported as ``ma2``, not ``hybrid``.** The + docs described a per-cell hybrid for design state 3 (exact deviation where a cell has + n ≥ 2, moving average where n = 1) and said it was validated by Monte Carlo. The code + has not done that since cb037f8 (2026-03-05): with any singleton cell, R2 is Bishop's + ungrouped 2-point moving average over the full canonical sequence (Eq 13.7–13.9) for + every observation, and the 89 ADS 3 reference assertions hold against that. Only the word + survived, in six docs pages (three of which described it three different ways), the + detector's method token, the analysis-plan limitation text, two docstrings and two test + names. All now say what the code does. ``R2Method`` is ``Literal['exact', 'ma2']``; + ``SDSRegistry.get_r2_method`` returns ``'ma2'`` where it returned ``'hybrid'``; + ``get_sds_characteristics`` and ``SDSAnalysisPlan.residual_calculation_method`` use the + same two spellings in place of ``'within_cell'``, ``'moving_average'`` and ``'hybrid'``. + No arithmetic changed; none of these names is exported from the package top level. + ### Fixed - **``evaluate`` no longer raises on a constant column.** Binning a column with no spread (every value identical) returned the documented no-spread result for ``equal_freq`` but diff --git a/docs/appendix/wheeler-terminology.md b/docs/appendix/wheeler-terminology.md index f021ede..87848ee 100644 --- a/docs/appendix/wheeler-terminology.md +++ b/docs/appendix/wheeler-terminology.md @@ -94,7 +94,7 @@ Wheeler identifies six design states that determine valid analysis approaches. **ProcessBehavior Detection**: Some cells with n=1, others with n>=2 -**Capabilities**: Hybrid variance estimation +**Capabilities**: R2 by moving average over the full sequence (mixed cell sizes) --- diff --git a/docs/getting-started/key-concepts.md b/docs/getting-started/key-concepts.md index b4b9ca0..284b514 100644 --- a/docs/getting-started/key-concepts.md +++ b/docs/getting-started/key-concepts.md @@ -46,7 +46,7 @@ See [DS Definitions](../reference/sds_definitions.md) for the formal classificat The DS determines: - Which chart types are valid -- How within-group variance is estimated (R2 method: exact, ma2, or hybrid) +- How within-group variance is estimated (R2 method: exact or ma2) - Which VAS residuals can be computed - What conclusions you can draw diff --git a/docs/reference/sds_definitions.md b/docs/reference/sds_definitions.md index 97aa443..cfc271f 100644 --- a/docs/reference/sds_definitions.md +++ b/docs/reference/sds_definitions.md @@ -52,7 +52,7 @@ For background on the lineage itself (Planned → Observed → Analytical), see - **Grid Status**: Semi-Complete - **Description**: Mix of cells - some have n=1, others have n≥2 - **Implications**: - - Hybrid R2 calculation required (exact where n≥2, moving average where n=1) + - R2 by moving average over the full sequence (any singleton cell forces `ma2` for every observation) - **Most common in real-world data** - Partial interaction effect estimation - Requires careful handling of mixed replication @@ -121,10 +121,10 @@ To determine the DS for a dataset: | 0 | N/A | No VAS decomposition | | 1 | Exact (within-cell) | Pooled within-cell variance | | 2 | Moving Average | Approximate via sequential differences | -| 3 | Hybrid | Exact where n≥2, moving average where n=1 | +| 3 | Moving Average | Any singleton cell: `ma2` over the full sequence, same as DS 2 | | 4 | Exact (present cells) | Within-cell for available data | | 5 | Moving Average | All present cells are unreplicated | -| 6 | Hybrid | Complex handling of missing + mixed | +| 6 | Moving Average | Collapses to ADS 3 after cleaning; `ma2` over the full sequence | ## References diff --git a/docs/user-guide/chart-types.md b/docs/user-guide/chart-types.md index 8bf1150..3f59d1f 100644 --- a/docs/user-guide/chart-types.md +++ b/docs/user-guide/chart-types.md @@ -309,7 +309,7 @@ Do you have factors? ├── No → Use Xbar to compare factors └── Yes → Do you have replication (n>=2 per cell)? ├── All cells → DS 1: Full Xbar-S + VAS residuals - ├── Some cells → DS 3: Hybrid Xbar-S + limited VAS + ├── Some cells → DS 3: Xbar-S with MA2-based limits + limited VAS └── No cells → DS 2: Xbar-S with MR-based limits ``` diff --git a/docs/user-guide/residuals.md b/docs/user-guide/residuals.md index c3c6326..5a4678a 100644 --- a/docs/user-guide/residuals.md +++ b/docs/user-guide/residuals.md @@ -81,7 +81,7 @@ design states. **Formula by DS**: - **DS 1 (Full Replication)**: R2 = Y - Y̅kt (exact within-cell deviation) - **DS 2 (No Replication)**: R2 = (Yj - Yj-1) / 2 (backward 2-point moving average) -- **DS 3 (Partial)**: Hybrid approach +- **DS 3 (Partial)**: same moving average as DS 2, over the full sequence **Chart**: S chart with `value='R2'` (for replicated data) or X @@ -255,17 +255,20 @@ Re-centering formulas: |-----|----|----|----|----|-----|-----| | 1 (Full Replication) | ✅ | ✅ Within-cell | ✅ | ✅ | ✅ | ✅ | | 2 (No Replication) | ✅ | ✅ MR-based | ✅ | ✅ | ✅ | ✅ | -| 3 (Partial) | ✅ | ✅ Hybrid | ✅ | ✅ | ✅ | ✅ | +| 3 (Partial) | ✅ | ✅ MR-based | ✅ | ✅ | ✅ | ✅ | | 4 (Incomplete, No Singletons) → ADS 1 | ✅ | ✅ Within-cell | ✅ | ✅ | ✅ | ✅ | | 5 (Incomplete, No Replication) → ADS 2 | ✅ | ✅ MR-based | ✅ | ✅ | ✅ | ✅ | -| 6 (Incomplete, With Singletons) → ADS 3 | ✅ | ✅ Hybrid | ✅ | ✅ | ✅ | ✅ | +| 6 (Incomplete, With Singletons) → ADS 3 | ✅ | ✅ MR-based | ✅ | ✅ | ✅ | ✅ | R6 requires factors (it is computed from R5 and R2 at `execute()` time). -**Note on R2 calculation**: R2 adapts to your sampling structure: -- **DS 1**: Within-cell deviation (`R2 = Y - Ȳ_kt`) -- **DS 2, 6**: Moving average method (`R2 = Y - MA2`) for unreplicated/sparse designs -- **DS 3, 4, 5**: Hybrid approach (within-cell for n>1 cells, zero for n=1 cells) +**Note on R2 calculation**: R2 is the one residual whose formula depends on structure, and the +choice is made on the analytical design state (ADS), after cleaning: +- **ADS 1** (every cell n ≥ 2): within-cell deviation, `R2 = Y - Ȳ_kt` (Eq 59) +- **ADS 2 and 3** (any singleton cell): 2-point moving average over the full canonical sequence, + `R2 = (Y_j - Y_{j-1}) / 2` (Eq 13.7–13.9), for every observation; no per-cell mixing + +DS 4, 5 and 6 are sampling states; they collapse to ADS 1, 2 and 3 once empty cells are dropped. ## Analysis Workflow with Residuals diff --git a/docs/user-guide/sds-detection.md b/docs/user-guide/sds-detection.md index 9fe654b..191f9f7 100644 --- a/docs/user-guide/sds-detection.md +++ b/docs/user-guide/sds-detection.md @@ -278,14 +278,14 @@ The design report shows: ``` **Capabilities**: -- ⚠️ Hybrid R2 estimation (exact where n >= 2, ma2 where n = 1) +- ⚠️ R2 by the 2-point moving average over the full sequence (any singleton cell forces `ma2` for every observation) - ⚠️ VAS residuals available but interpretation requires care -- ✅ Xbar-S analysis with hybrid limits +- ✅ Xbar-S analysis with R2-based limits **Valid Charts**: Histogram, Xbar, S, X, mR -!!! note "Why Mixed is treated conservatively" - For R2 calculation, DS 3 uses the hybrid method: exact within-cell deviation where cells have n >= 2, and the ma2 (moving average) method where cells have n = 1. This conservative approach was validated by Monte Carlo simulation — it produces more reliable variance estimates than attempting to use only the replicated cells. The recommended chart is X (not Xbar) because the mixed replication makes subgroup-mean interpretation less straightforward. +!!! note "Why DS 3 does not mix methods" + For R2 in DS 3 the library does not combine an exact estimate from the replicated cells with a moving-average estimate from the singletons. Bishop's moving-average residual (Eq 13.7–13.9) is defined over the whole ordered sequence, j = 2 … J, with no grouping by cell. Once any cell is a singleton, every observation gets R2 = (Y_j − Y_{j−1}) / 2 on the canonical sort, and only the first observation in the sequence has no value. The replicated cells' within-cell deviations are not used for R2. This is the same calculation as DS 2, and the ADS 3 reference assertions in the [validation page](../reference/validation.md) hold against it. The recommended chart is X rather than Xbar because subgroup means over mixed cell sizes are uneven to interpret, not because the variance estimate differs. ## DS 4: Incomplete, No Singletons @@ -379,7 +379,7 @@ The R2 method is determined by the tidy data structure (ADS), not the raw DS: |-----|-----------|-------------| | 1 | exact | Within-cell standard deviation (`R2 = Y - Ȳ_kt`) | | 2 | ma2 | 2-point moving average for unreplicated designs | -| 3 | hybrid | Exact where n >= 2, ma2 where n = 1 | +| 3 | ma2 | Any singleton cell: moving average over the full sequence (same as DS 2) | ### 2. Available Charts @@ -389,7 +389,7 @@ The R2 method is determined by the tidy data structure (ADS), not the raw DS: |-----|--------|----------------| | 1 | ✅ | ✅ | | 2 | ✅ (MR-based limits) | ✅ | -| 3 | ✅ (hybrid limits) | ✅ | +| 3 | ✅ (R2-based limits) | ✅ | | 4 | ❌ | ✅ | | 5 | ❌ | ✅ | | 6 | ❌ | ✅ | @@ -486,10 +486,10 @@ print(cell_counts['n'].value_counts()) |----------------|-----|-----------------------|---------------| | Full replication (n>=2 per cell) | 1 | 1 | Xbar with full VAS | | One observation per cell | 2 | 2 | X with MA2-based R2 | -| Mixed replication | 3 | 3 | X with hybrid R2 | +| Mixed replication | 3 | 3 | X with MA2 R2 | | Incomplete grid, all observed replicated | 4 | 1 | Xbar with full VAS | | Incomplete grid, all observed n=1 | 5 | 2 | X with MA2-based R2 | -| Incomplete grid, mixed observed | 6 | 3 | X with hybrid R2 | +| Incomplete grid, mixed observed | 6 | 3 | X with MA2 R2 | ## Next Steps diff --git a/processbehavior/analysis_dataset.py b/processbehavior/analysis_dataset.py index 68846e8..2779228 100644 --- a/processbehavior/analysis_dataset.py +++ b/processbehavior/analysis_dataset.py @@ -183,7 +183,7 @@ def tidy_structure_summary(self) -> dict | None: - n_cell_min: minimum observations per cell - n_cell_max: maximum observations per cell - K_obs: number of unique factor levels observed - - r2_method: 'exact', 'ma2', or 'hybrid' + - r2_method: 'exact' or 'ma2' Returns None if structure stats haven't been computed. """ if self._structure_stats is None: diff --git a/processbehavior/datasets/synthetic.py b/processbehavior/datasets/synthetic.py index 8c1501d..8415fd4 100644 --- a/processbehavior/datasets/synthetic.py +++ b/processbehavior/datasets/synthetic.py @@ -416,13 +416,13 @@ def make_sds3( # noqa: C901 Characteristics: --------------- - Some cells have n=1, others have n≥2 - - Requires hybrid R2 estimation + - Mixed cell sizes: R2 by MA2 across the full sequence - Reflects real-world data collection reality - Most challenging to analyze correctly Use Cases: --------- - - Testing hybrid variance estimation algorithms + - Testing R2 selection with mixed cell sizes - Demonstrating real-world data structures - Validating robustness to missing data - Development of SDS detection logic diff --git a/processbehavior/residual_calculator.py b/processbehavior/residual_calculator.py index 9111fca..6411bbc 100644 --- a/processbehavior/residual_calculator.py +++ b/processbehavior/residual_calculator.py @@ -338,14 +338,14 @@ def calculate_r2(df: pd.DataFrame, y: str, r2_method: R2Method, n_per_cell: pd.S R2 is the ONLY structure-dependent residual. The method is determined by observed cell sizes (via SDSRegistry.get_r2_method()): - - exact (state 1): R2 = Y - Ȳ_kt (Eq 59), all cells have n >= 2 - - ma2 (states 2 & 3): R2 = (Y_j - Y_{j-1}) / 2 (Eq 13.8-13.9), + - exact (ADS 1): R2 = Y - Ȳ_kt (Eq 59), every cell has n >= 2 + - ma2 (ADS 2 and 3): R2 = (Y_j - Y_{j-1}) / 2 (Eq 13.7-13.9), any cell has n = 1 When any cell has n=1, MA2 is applied to ALL observations across the - entire canonical-sorted stream — no grouping by rsg_key, no hybrid - per-cell selection. Bishop Eq 13.7-13.9 specify j=2,...,J with no - grouping; only j=1 gets R2=0. + entire canonical-sorted stream — no grouping by rsg_key, no per-cell + selection between exact and MA2. Bishop Eq 13.7-13.9 specify j=2,...,J + with no grouping; only j=1 has no value. Parameters ---------- @@ -354,8 +354,9 @@ def calculate_r2(df: pd.DataFrame, y: str, r2_method: R2Method, n_per_cell: pd.S y : str Name of response variable r2_method : R2Method - 'exact', 'ma2', or 'hybrid' — retained for API compatibility. - Branching is driven by n_per_cell. + 'exact' or 'ma2', as chosen by SDSRegistry.get_r2_method(). Kept for + callers and logging; the branch below re-derives it from n_per_cell so + the two can never disagree. n_per_cell : pd.Series, optional Pre-computed observations per cell. Pass from ADS to avoid recomputation. @@ -427,7 +428,7 @@ def calculate_vas_residuals( spec : FormulationSpec Analysis specification r2_method : R2Method - 'exact', 'ma2', or 'hybrid' - determines R2 calculation + 'exact' or 'ma2' - determines R2 calculation n_per_cell : pd.Series, optional Pre-computed observations per cell. Pass from ADS to avoid recomputation. ybar_kt : pd.Series, optional diff --git a/processbehavior/sds_detector.py b/processbehavior/sds_detector.py index 1cda6a0..f11c581 100644 --- a/processbehavior/sds_detector.py +++ b/processbehavior/sds_detector.py @@ -39,7 +39,7 @@ ] # R2 calculation method - structure-driven, not SDS-driven -R2Method = Literal['exact', 'ma2', 'hybrid'] +R2Method = Literal['exact', 'ma2'] @dataclass(frozen=True) @@ -116,7 +116,7 @@ class SDSAnalysisPlan: residuals_available : list[str] Which residuals can be calculated residual_calculation_method : str - How R2 is calculated ('exact', 'moving_average', 'hybrid', or 'none') + How R2 is calculated ('exact', 'ma2', or 'none') main_effects_supported : bool Whether main effect calculations are supported interaction_effects_supported : bool @@ -158,7 +158,7 @@ class SDSAnalysisPlan: # VAS residual capabilities vas_residuals_supported: bool residuals_available: list[str] - residual_calculation_method: str # 'exact', 'moving_average', 'hybrid', 'none' + residual_calculation_method: str # 'exact', 'ma2', 'none' # Effects and interactions main_effects_supported: bool @@ -383,7 +383,7 @@ class SDSRegistry: **SDS 3**: Semi-Complete - Partial replication - Min N_kt = 1 and Max N_kt ≥ 2 (mixed) - - Hybrid variance estimation + - R2 by moving average across the full sequence (any singleton forces MA2) **Incomplete** (has empty cells, requires sampling plan to detect): @@ -664,7 +664,7 @@ def get_sds_characteristics(self, sds: int) -> dict: >>> info['description'] 'Full replication (all cells n≥2)' >>> info['r2_method'] - 'within_cell' + 'exact' >>> info['capabilities'] ['full_vas', 'all_residuals', 'interactions', 'main_effects'] """ @@ -680,7 +680,7 @@ def get_sds_characteristics(self, sds: int) -> dict: 1: { 'description': 'Full replication (all cells n≥2)', 'replication_type': 'full', - 'r2_method': 'within_cell', + 'r2_method': 'exact', 'capabilities': ['full_vas', 'all_residuals', 'interactions', 'main_effects'], 'interaction_analysis': True, 'variance_decomposition': True, @@ -688,7 +688,7 @@ def get_sds_characteristics(self, sds: int) -> dict: 2: { 'description': 'No replication (all cells n=1)', 'replication_type': 'none', - 'r2_method': 'moving_average', + 'r2_method': 'ma2', 'capabilities': ['all_residuals', 'limited_interactions', 'main_effects'], 'interaction_analysis': 'limited', 'variance_decomposition': True, @@ -696,7 +696,7 @@ def get_sds_characteristics(self, sds: int) -> dict: 3: { 'description': 'Partial replication (mixed n=1 and n≥2)', 'replication_type': 'partial', - 'r2_method': 'hybrid', + 'r2_method': 'ma2', # Any singleton: MA2 over the full sequence (Bishop Eq 13.7-13.9) 'capabilities': ['all_residuals', 'partial_interactions', 'main_effects'], 'interaction_analysis': 'partial', 'variance_decomposition': True, @@ -720,7 +720,7 @@ def get_sds_characteristics(self, sds: int) -> dict: 6: { 'description': 'Incomplete grid with singletons (has 0s, 1s, and ≥2s)', 'replication_type': 'partial', - 'r2_method': 'hybrid', + 'r2_method': 'ma2', 'capabilities': ['partial_vas', 'main_effects', 'stratification'], 'interaction_analysis': False, 'variance_decomposition': True, @@ -751,23 +751,24 @@ def get_r2_method(self, stats: StructureStats) -> R2Method: Returns ------- R2Method - 'exact' if all cells have replication (n_cell_min >= 2) - 'ma2' if all cells are singletons (n_cell_max == 1) - 'hybrid' for mixed replication (exact where n >= 2, MA2 where n = 1) + 'exact' if every cell has replication (n_cell_min >= 2) + 'ma2' if any cell is a singleton (n_cell_min == 1), whether all + cells are singletons (ADS 2) or only some (ADS 3) Notes ----- Deterministic rule based on Bishop methodology: - - Eq 59 (exact): R2 = Y - Ȳ_kt, requires replication - - Eq 66 (MA2): R2 = (Y_j - Y_{j-1}) / 2, for singletons - - Hybrid: exact where replicated, MA2 where singleton + - Eq 59 (exact): R2 = Y - Ȳ_kt, requires replication in every cell + - Eq 13.7-13.9 (MA2): R2 = (Y_j - Y_{j-1}) / 2 over the whole ordered + sequence, j = 2..J, with no grouping by cell. Once any cell is a + singleton this applies to every observation; the replicated cells' + within-cell deviations are not used for R2. There is no per-cell + mixing of the two (the earlier "hybrid" was removed in cb037f8, and + the ADS 3 reference assertions validate the ungrouped MA2). """ if stats.n_cell_min >= 2: return 'exact' - elif stats.n_cell_max == 1: - return 'ma2' - else: - return 'hybrid' + return 'ma2' def validate_sds_for_analysis(self, sds: int, analysis_type: str) -> bool: """ @@ -1296,7 +1297,7 @@ def get_analysis_plan(sds: int, min_cell_size: int = 0) -> SDSAnalysisPlan: invalid_charts=[], vas_residuals_supported=True, residuals_available=['R1', 'R2', 'R3', 'R4', 'R5', 'R6'], - residual_calculation_method='moving_average', + residual_calculation_method='ma2', main_effects_supported=True, interaction_effects_supported=True, supports_stratification=True, @@ -1324,7 +1325,7 @@ def get_analysis_plan(sds: int, min_cell_size: int = 0) -> SDSAnalysisPlan: invalid_charts=[], vas_residuals_supported=True, residuals_available=['R1', 'R2', 'R3', 'R4', 'R5', 'R6'], - residual_calculation_method='hybrid', + residual_calculation_method='ma2', main_effects_supported=True, interaction_effects_supported=True, supports_stratification=True, @@ -1335,7 +1336,8 @@ def get_analysis_plan(sds: int, min_cell_size: int = 0) -> SDSAnalysisPlan: 'Pilot studies with targeted replication', ], limitations=[ - 'R2 uses hybrid calculation (exact where possible, approximate elsewhere)', + 'R2 uses the 2-point moving average over the full sequence (Bishop Eq 13.7-13.9); ' + 'the replicated cells contribute no separate within-cell estimate', 'Variance estimates less precise than SDS 1', 'May have unequal subgroup sizes', ], diff --git a/tests/test_residual_calculator.py b/tests/test_residual_calculator.py index a8324df..0f164dd 100644 --- a/tests/test_residual_calculator.py +++ b/tests/test_residual_calculator.py @@ -4,7 +4,7 @@ Tests cover: - All mean calculations (Ybar, Ybar_k, Ybar_kt, Ybar_t) - All residuals (R1-R5) -- R2 method variants (exact, ma2, hybrid) +- R2 method variants (exact, ma2) - Orchestration via calculate_vas_residuals - Tom Bishop validation data """ @@ -178,7 +178,7 @@ def test_calculate_r2_ma2(): assert pytest.approx(result.iloc[2], 0.01) == 0.5 -def test_calculate_r2_hybrid(): +def test_calculate_r2_mixed_cells_uses_ma2_for_all(): """R2 with any singletons uses MA2 across entire sorted stream. When any cell has n=1, ALL observations use MA2 on the full @@ -198,7 +198,7 @@ def test_calculate_r2_hybrid(): ) n_per_cell = pd.Series([2, 2, 1, 1]) - result = calculate_r2(df, 'weight', r2_method='hybrid', n_per_cell=n_per_cell) + result = calculate_r2(df, 'weight', r2_method='ma2', n_per_cell=n_per_cell) # MA2 across full sorted stream (no grouping): # j=0 (10.0): first obs → NaN (no predecessor) @@ -370,7 +370,7 @@ def test_calculate_vas_residuals_sds2_uses_moving_average(spec_sds1): assert pytest.approx(result['R2'].iloc[2], 0.01) == 0.5 -def test_calculate_vas_residuals_sds3_hybrid_uses_ma2_for_all(spec_sds1): +def test_calculate_vas_residuals_sds3_uses_ma2_for_all(spec_sds1): """SDS 3 (any singletons) uses MA2 across entire sorted stream. When any cell has n=1, ALL observations use MA2 on the full @@ -381,7 +381,7 @@ def test_calculate_vas_residuals_sds3_hybrid_uses_ma2_for_all(spec_sds1): sds3_df = pd.DataFrame({'lane': ['A', 'A', 'B', 'B'], 'time': [1, 1, 1, 2], 'weight': [10.0, 10.5, 9.0, 11.0]}) df = _prepare_for_vas(sds3_df, spec_sds1) n_per_cell = df.groupby('cell_key', observed=True)['weight'].transform('size') - result = calculate_vas_residuals(df, spec_sds1, r2_method='hybrid', n_per_cell=n_per_cell) + result = calculate_vas_residuals(df, spec_sds1, r2_method='ma2', n_per_cell=n_per_cell) # MA2 across full sorted stream (A×1(0), A×1(1), B×1(0), B×2(0)): # Only the very first observation in the entire stream gets R2=NaN @@ -490,7 +490,7 @@ def sds2_df(): @pytest.fixture def sds3_df(): - """SDS 3 data - mixed cell sizes (uses hybrid).""" + """SDS 3 data - mixed cell sizes (R2 by MA2 for every observation).""" return pd.DataFrame( { 'lane': ['A', 'A', 'A', 'A', 'B', 'B', 'B'], @@ -505,7 +505,7 @@ def sds3_df(): [ ('exact', 'sds1'), ('ma2', 'sds2'), - ('hybrid', 'sds3'), + ('ma2', 'sds3'), ], ) def test_no_nan_residuals_all_r2_methods(r2_method, df_key, sds1_df, sds2_df, sds3_df, spec_sds1): @@ -535,7 +535,7 @@ def test_no_nan_residuals_all_r2_methods(r2_method, df_key, sds1_df, sds2_df, sd for col in ['R2', 'R3', 'R4', 'R5']: assert result[col].isna().sum() == 0, f'{col} has NaN with r2_method=exact' else: - # MA2 methods (ma2, hybrid): j=1 has no predecessor → exactly 1 NaN + # MA2 (any singleton cell): j=1 has no predecessor → exactly 1 NaN for col in ['R2', 'R3', 'R4', 'R5']: assert result[col].isna().sum() == 1, ( f'{col} should have exactly 1 NaN (j=1) with r2_method={r2_method}, got {result[col].isna().sum()}' diff --git a/tests/test_sds_detector.py b/tests/test_sds_detector.py index 67fa204..02bdfc3 100644 --- a/tests/test_sds_detector.py +++ b/tests/test_sds_detector.py @@ -15,7 +15,7 @@ from processbehavior import ProcessBehavior from processbehavior.exceptions import ValidationError from processbehavior.formulation_spec import FormulationSpec -from processbehavior.sds_detector import SDSRegistry +from processbehavior.sds_detector import SDSRegistry, StructureStats # ============================================================================ # Fixtures @@ -263,7 +263,7 @@ def test_get_sds_characteristics_sds1(detector): assert info['sds'] == 1 assert 'Full replication' in info['description'] assert info['replication_type'] == 'full' - assert info['r2_method'] == 'within_cell' + assert info['r2_method'] == 'exact' assert info['interaction_analysis'] is True assert info['variance_decomposition'] is True assert 'full_vas' in info['capabilities'] @@ -275,7 +275,7 @@ def test_get_sds_characteristics_sds2(detector): assert info['sds'] == 2 assert 'No replication' in info['description'] - assert info['r2_method'] == 'moving_average' + assert info['r2_method'] == 'ma2' assert info['interaction_analysis'] == 'limited' @@ -285,7 +285,7 @@ def test_get_sds_characteristics_sds3(detector): assert info['sds'] == 3 assert 'Partial replication' in info['description'] - assert info['r2_method'] == 'hybrid' + assert info['r2_method'] == 'ma2' # any singleton: MA2 over the full sequence, no per-cell mixing assert info['interaction_analysis'] == 'partial' @@ -425,7 +425,7 @@ def test_should_calculate_vas_sds2_with_xbar_returns_true(detector): def test_should_calculate_vas_sds3_with_xbar_returns_true(detector): - """SDS 3 with Xbar - uses VAS with hybrid approach.""" + """SDS 3 with Xbar - VAS residuals are supported (R2 by MA2).""" result = detector.should_calculate_vas_residuals(sds=3, analysis_type='Xbar') assert result is True @@ -607,7 +607,7 @@ def test_realistic_scenario_manufacturing_4_lanes_hourly(detector): assert result.sds == 1 # Full replication # min_cell_size is kt-level: 32 cells (4 lanes × 8 hours), each with n=5 assert result.min_cell_size == 5 - assert info['r2_method'] == 'within_cell' + assert info['r2_method'] == 'exact' assert should_calc_vas is True @@ -1309,3 +1309,31 @@ def test_all_na_response_raises(self): df = pd.DataFrame({'y': ['*', 'N/A', '*', 'N/A'], 'time': [1, 2, 3, 4]}) with pytest.raises(ValidationError): ProcessBehavior(df).formulate(response='y', time='time') + + +class TestR2MethodSelection: + """``get_r2_method`` has two answers, and structure alone decides between them. + + The docs once described a third, per-cell "hybrid" for mixed cell sizes. It was removed in + cb037f8: with any singleton cell, R2 is Bishop's ungrouped MA2 (Eq 13.7-13.9) for every + observation, which the ADS 3 reference assertions validate. These pin the vocabulary to that. + """ + + @staticmethod + def _stats(n_min, n_max): + return StructureStats(has_grouping=True, has_order=True, n_cell_min=n_min, n_cell_max=n_max, K_obs=2) + + def test_all_replicated_is_exact(self, detector): + assert detector.get_r2_method(self._stats(2, 3)) == 'exact' + + def test_all_singletons_is_ma2(self, detector): + assert detector.get_r2_method(self._stats(1, 1)) == 'ma2' + + def test_mixed_cells_is_ma2_not_a_third_method(self, detector): + assert detector.get_r2_method(self._stats(1, 3)) == 'ma2' + + def test_analysis_plans_use_one_spelling(self, detector): + methods = {SDSRegistry.get_analysis_plan(sds=s).residual_calculation_method for s in (1, 2, 3)} + assert methods == {'exact', 'ma2'} + chars = {detector.get_sds_characteristics(s)['r2_method'] for s in (1, 2, 3, 4, 5, 6)} + assert chars == {'exact', 'ma2'}