|
| 1 | +# Batch 10 — `ExtrapolationWindow` decomposition (staged plan, no code yet) |
| 2 | + |
| 3 | +> R3-soft KEEP `[_idx 17]`; CODE_REVIEW_2026 §2.5 (high). **This document is a |
| 4 | +> plan only.** Each stage is a separate, independently-mergeable PR gated by the |
| 5 | +> project review workflow (cluster tests + 3-model + CodeRabbit + full-suite). |
| 6 | +> |
| 7 | +> **Revised after two-model external adversarial review (Codex / gpt-5.5 + |
| 8 | +> Antigravity / Gemini), each finding re-verified against the code.** Gemini's |
| 9 | +> verdict was **REJECT** (not safe to execute as originally written); it agreed |
| 10 | +> with every Codex finding and added the Qt-MRO-severance, precedence-reversal, |
| 11 | +> and Stage-3 worker-placement hazards below. Net: the shim/MRO approach is |
| 12 | +> viable, but "behavior-identical pure move" is only credible with the expanded |
| 13 | +> Stage-0 characterization first — original staging was ROI-first, this revision |
| 14 | +> is safety-first. |
| 15 | +> |
| 16 | +> ### Critical MRO hazards (Gemini, verified) — must be encoded in Stage 0 |
| 17 | +> - **Qt base severs the MRO chain.** `ExtrapolationWindow(QMainWindow, …Mixins)` |
| 18 | +> has `QMainWindow` **leftmost** (window.py:467). PySide6 C++ wrappers do NOT |
| 19 | +> cooperatively call `super()`, so any Qt lifecycle override (`closeEvent`, |
| 20 | +> `resizeEvent`, `__init__`) in a split mixin is **silently ignored**. → The |
| 21 | +> plan MUST forbid split mixins from overriding Qt event handlers (or audit for |
| 22 | +> dormant ones being ignored today). |
| 23 | +> - **Splitting reverses definition precedence.** In the monolith a method at the |
| 24 | +> *bottom* shadows a duplicate at the *top*. Split sequentially into A(top) / |
| 25 | +> B / C(bottom) and composed `class Shim(A, B, C)`, left-to-right MRO makes |
| 26 | +> **A shadow C** — the opposite of the original. → Stage 0 must **strictly |
| 27 | +> prohibit duplicate method names across sibling mixins** (a test that fails on |
| 28 | +> any cross-sibling name collision). |
| 29 | +
|
| 30 | +## Current state (measured) |
| 31 | + |
| 32 | +- `app_desktop/window.py` — **3197 lines**, `ExtrapolationWindow` with **~150 |
| 33 | + methods**, composed of **7 mixins** (MRO order): |
| 34 | + `WindowLatexPdfMixin, WindowI18nMixin, WindowImagesMixin, |
| 35 | + WindowStatisticsMixin, WindowDataMixin, WindowFittingMixin, |
| 36 | + WindowExtrapolationMixin`. |
| 37 | +- Mixin sizes (the god-files): |
| 38 | + - `window_statistics_mixin.py` — **1921** ← biggest |
| 39 | + - `window_extrapolation_mixin.py` — **1128** |
| 40 | + - `window_latex_pdf_mixin.py` — 969 |
| 41 | + - fitting already split into 4 (`formatters` 561 / `residuals` 614 / |
| 42 | + `models` 673) behind a 67-line shim — **the proven pattern**. |
| 43 | + - `window_data_mixin.py` 646, `window_i18n_mixin.py` 423, |
| 44 | + `window_images_mixin.py` 388. |
| 45 | +- `app_desktop/views/` (extrapolation/fitting/statistics/error/root_solving.py, |
| 46 | + ~3524 lines) already extracts **widget construction** via |
| 47 | + `build_*_mode_view(owner)` functions. The mixins hold **behavior** |
| 48 | + (run/format/handlers). The separation is: `views/` = build UI, mixins = drive |
| 49 | + it. |
| 50 | + |
| 51 | +## Guiding constraints (why this is staged, not one refactor) |
| 52 | + |
| 53 | +1. **Public API stable.** `window.py` must keep inheriting the same mixin names; |
| 54 | + external imports (`from app_desktop.window_* import Window*Mixin`) keep |
| 55 | + working. Follow the `WindowFittingMixin` shim: an empty subclass that composes |
| 56 | + split files and re-exports the original name. Zero caller changes per stage. |
| 57 | +2. **MRO discipline.** Method-resolution order is load-bearing (some mixins |
| 58 | + override same-named methods). Any split must preserve left-to-right MRO; |
| 59 | + pin it explicitly and document the order (as the fitting shim does). |
| 60 | +3. **Behavior-identical.** No logic changes inside a split — pure move. Verify |
| 61 | + with the existing per-mode desktop UI tests + full suite each stage. |
| 62 | +4. **Surgical, reversible.** One mixin per PR. If a split reveals a real coupling |
| 63 | + bug, fix it in its own commit, not silently. |
| 64 | +5. **Test coverage first.** Before splitting a mixin, confirm the per-mode UI |
| 65 | + test file exercises its public methods (statistics/extrapolation/fitting all |
| 66 | + have `test_desktop_*_ui.py`). Add characterization tests for any method with |
| 67 | + no coverage BEFORE moving it. |
| 68 | + |
| 69 | +## Staged sequence (safest-first, one PR each) |
| 70 | + |
| 71 | +### Stage 0 — Guardrails (prep) — **GO, but expanded per review** |
| 72 | +- Characterization tests: for each mixin to split, list public methods + confirm |
| 73 | + coverage; fill gaps with thin output-pinning tests. No production change. |
| 74 | +- **(review) MRO / provider-order snapshot:** capture `ExtrapolationWindow.__mro__` |
| 75 | + and each split's base order in a test so a re-order is caught. |
| 76 | +- **(review) Shim overrides are NOT always empty:** `window_fitting_mixin.py:48` |
| 77 | + overrides `_on_fit_finished` and calls `super()` before adding fallback-history |
| 78 | + UI. Stage 0 must add checks for (a) duplicate method names across siblings, |
| 79 | + (b) intentional shim-level overrides, (c) **no `__init__` in split mixins** |
| 80 | + (construction order is load-bearing) — and a construction test proving signal |
| 81 | + slots exist before `build_*_mode_view()` runs. |
| 82 | +- **(review) Import-stability is broader than `Window*Mixin`:** tests import |
| 83 | + internal helpers directly, e.g. `_statistics_raw_table_preserving_cells` from |
| 84 | + `window_statistics_mixin.py` (`test_desktop_statistics_ui.py:705`), and the |
| 85 | + release matrix names old FQNs (`test_release_test_matrix.py:70`). Any split |
| 86 | + must **re-export moved internals** from the original module OR update that |
| 87 | + evidence deliberately — Stage 0 enumerates these direct-import sites. |
| 88 | +- Freeze the file-size ratchet baselines for the new split files. |
| 89 | + |
| 90 | +### Stage 1 — Split `window_statistics_mixin.py` (1921 → ~4 files) — biggest win |
| 91 | +Mirror the fitting shim. Suggested responsibility split (validate against the |
| 92 | +actual method groupings before coding): |
| 93 | +- `window_statistics_formatters_mixin.py` — result-text / CSV-row / snapshot |
| 94 | + rendering helpers (pure-ish). |
| 95 | +- `window_statistics_modes_mixin.py` — per-mode execution + dispatch |
| 96 | + (`_run_statistics_mode` and the standard/bootstrap/hypothesis/time-series/ |
| 97 | + matrix/grouped paths). |
| 98 | +- `window_statistics_results_mixin.py` — worker-result handlers + plot wiring. |
| 99 | +- `window_statistics_mixin.py` — 67-line shim composing the above, MRO-pinned, |
| 100 | + re-exporting `WindowStatisticsMixin`. Public import unchanged. |
| 101 | +- **Verdict: CONDITIONAL-GO (review).** **`_on_stats_mode_change` lives in |
| 102 | + `window.py`, NOT in the statistics mixin** (its visibility logic touches |
| 103 | + `views/statistics.py:379` and is called from `workspace_controller.py:981`). |
| 104 | + Do NOT pull it into Stage 1 — either freeze it explicitly in `window.py` or |
| 105 | + make a separate stats-visibility stage. The snapshot/semantic-output path is |
| 106 | + the other tricky seam. Split stats into smaller PRs if the first is too large. |
| 107 | + |
| 108 | +### Stage 2 — `window_extrapolation_mixin.py` (1128) — **NO-GO as written; re-scope (review)** |
| 109 | +**This mixin is misnamed for splitting purposes: it is a cross-family run |
| 110 | +controller, not extrapolation-only.** Its `run_calculation()` dispatches direct |
| 111 | +statistics + fitting paths (`window_extrapolation_mixin.py:389`), |
| 112 | +`_on_calc_finished()` handles statistics results (`:582`), and it owns the |
| 113 | +root/error/statistics/fitting **unit collectors** (`:1035`). A naive |
| 114 | +"extrapolation run" split would break stats/fitting/root/unit-propagation. |
| 115 | +Re-scope as a **cross-family run-controller split** with full mode coverage: |
| 116 | +first extract the pure formatters and the unit-collectors (self-contained), and |
| 117 | +only then consider separating the dispatch — treating it as its own multi-PR |
| 118 | +track, not a low-medium extrapolation task. F10's mode-switch/result-clear timing |
| 119 | +must move verbatim. **Park until Stage 1 + 3 are done and characterized.** |
| 120 | + |
| 121 | +### Stage 3 — Split `window_latex_pdf_mixin.py` (969 → ~2 files) — **GO (cleanest)** |
| 122 | +- Separate compile-worker orchestration from PDF-preview/zoom UI. |
| 123 | +- **(review, Gemini) Also extract the two workers.** `_TectonicInstallWorker` |
| 124 | + (`window_latex_pdf_mixin.py:55`) and `_LatexCompileWorker` (`:135`) are `QThread` |
| 125 | + subclasses defined *inside* this mixin, whereas all other workers |
| 126 | + (`CalcWorker`, `FitWorker`, `RootSolvingWorker`, …) live in `workers_qt.py`. |
| 127 | + Amend Stage 3 to move these two to `workers_qt.py` for architectural |
| 128 | + consistency (do it as part of, or just before, the split). |
| 129 | +- **Risk: low.** Self-contained; good coverage in `test_update_*` / |
| 130 | + `test_latex_compile_*`. |
| 131 | + |
| 132 | +### Stage 4 — Typed window facade `Protocol` — **NO-GO for Batch 10 (review)** |
| 133 | +A `WindowFacade` Protocol would have to cover **182 distinct `owner.*` names** |
| 134 | +across `app_desktop/views/*.py`, and the project already records broad desktop |
| 135 | +statistics mypy as blocked by dynamic-mixin-attr debt (`task_plan.md:277`). One |
| 136 | +giant Protocol is noisy and brittle. **Do not bundle into Batch 10.** If pursued |
| 137 | +later, do it as narrow **per-view / per-helper Protocols**, as its own typing |
| 138 | +spike — not a window-wide facade. |
| 139 | + |
| 140 | +### Explicitly NOT in scope |
| 141 | +- No "delegating controllers" rewrite / no MVC re-architecture. The mixin+shim |
| 142 | + composition is the target end-state — it already fits every file in one editor |
| 143 | + view and keeps the public API. A full controller rewrite is high-risk with no |
| 144 | + proven payoff and is **not recommended**. |
| 145 | + |
| 146 | +## Effort / risk summary (post-review verdicts) |
| 147 | + |
| 148 | +| Stage | File | Effort | Risk | Verdict | |
| 149 | +|---|---|---|---|---| |
| 150 | +| 0 | (tests + MRO/import characterization) | S-M | none | **GO — expanded** | |
| 151 | +| 1 | statistics_mixin | M | medium | **CONDITIONAL-GO** (keep `_on_stats_mode_change` out; split into smaller PRs) | |
| 152 | +| 3 | latex_pdf_mixin | S-M | low | **GO — cleanest** | |
| 153 | +| 2 | extrapolation_mixin (run-controller) | L | med-high | **NO-GO as written** — re-scope as cross-family run-controller, park | |
| 154 | +| 4 | Protocol facade | L | — | **NO-GO for Batch 10** — separate narrow typing spike | |
| 155 | + |
| 156 | +**Revised recommendation (safety-first):** |
| 157 | +1. **Stage 0** — build the characterization/MRO/import-stability guardrails first |
| 158 | + (this is what makes every later "pure move" provable). Do this next if Batch 10 |
| 159 | + proceeds. |
| 160 | +2. **Stage 3** (latex_pdf) — the cleanest, lowest-risk split; good second PR to |
| 161 | + validate the shim mechanics on real code. |
| 162 | +3. **Stage 1** (statistics) — biggest win, but only after 0+3, and only with |
| 163 | + `_on_stats_mode_change` frozen in `window.py`; split into small PRs. |
| 164 | +4. **Stage 2** (the run-controller) — re-scope and revisit LAST, or not at all. |
| 165 | +5. **Stage 4** — out of scope. |
| 166 | + |
| 167 | +Do NOT attempt a monolithic "decompose the whole window" PR. Order changed from |
| 168 | +1→2→3 to **0 → 3 → 1 → (2 re-scoped)** on review: do the provably-safe splits |
| 169 | +first, defer the cross-family run-controller. |
| 170 | + |
| 171 | +## Open question for maintainer |
| 172 | +- Proceed with Stages 0–3 now, or park Batch 10 (the god-class works, is tested, |
| 173 | + and the value is maintainability-only)? The per-stage payoff is real but purely |
| 174 | + structural — no user-facing change and no bug fixed. |
0 commit comments