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'}