M4 — pls4all source split: EXTRACTION RECIPE

Status: scaffolded — 39 target stub files landed at their final paths. The actual function-body extraction is deferred to a focused refactor session because each cut must keep n4m_tests (265/265) green between commits and the surgery is genuinely 4 days of careful work per the roadmap budget. This document is the maintenance protocol that guides that session.

Source plan: docs/merge/M4/SPLIT_PLAN.md (produced by the Explore agent in parallel with M3).

Pre-flight per-extraction checklist

Before any single extraction commit:

  1. n4m_tests is currently green (./build/dev-release/cpp/tests/n4m_tests reports 265 run, 0 failures, 0 skipped).

  2. Work on the merge/import-donor branch.

  3. The pls4all CMakeLists at cpp/src/CMakeLists.txt still references the source monoliths (core/model.cpp, core/extra_pls.cpp).

Phase 1 — extract helpers FIRST (load-bearing)

The agent’s analysis flags four cross-cutting helper blocks that MUST be extracted before any algorithm files, because anonymous-namespace helpers cannot be called across translation units:

Helper block

Lines in source

Target file

Linkage transition

model.cpp anon-namespace (L18–L1524, ~1500 lines) — center_scale_in_place, copy_matrix_checked, validate_fit_request, invert_square, dot, matrix_vector_product, read_value/write_value

model.cpp:18-1524

cpp/src/core/models/core/_common.{cpp,hpp}

anonymous → n4m::core::detail:: (or n4m::core::detail:: pre-M5) namespace; declare in _common.hpp

model.cpp PLS-shared helpers (simple_simpls is the cross-call from extra_pls; dominant_svd_pair, largest_symmetric_eigenvector, canonicalize_direction_sign, canonicalize_svd_pair_sign)

model.cpp:281-1524 selectively

cpp/src/core/models/pls/_common.{cpp,hpp}

same

extra_pls.cpp anon-namespace (L18–L306, ~290 lines) — soft_threshold, column_means, subtract_means, copy_matrix, squared_norm

extra_pls.cpp:18-306

cpp/src/core/models/sparse/_common.{cpp,hpp}

same

Critical: simple_simpls (extra_pls.cpp:97) is called by both fit_pls_sparse_simpls (model.cpp) AND extra_pls sparse variants. It MUST be extracted to a shared location accessible to both — the recommended location is models/pls/_common.{cpp,hpp} (since SIMPLS is canonically PLS-side).

Phase 1 commit pattern

M4 phase 1: extract models/core/_common — N anonymous-namespace helpers
  - cpp/src/core/models/core/_common.cpp (+~300 lines)
  - cpp/src/core/models/core/_common.hpp (+~80 lines, declarations)
  - cpp/src/core/model.cpp (−~300 lines, replaced by include of _common.hpp)
  - cpp/src/CMakeLists.txt (+1 entry: core/models/core/_common.cpp)
  - Verify: n4m_tests 265/265 still green

Repeat for pls/_common and sparse/_common. After Phase 1 (3 commits), 50 % of model.cpp’s bulk is extracted but no functional change.

Phase 2 — core PLS solvers (8 commits)

In topological order (dependencies first):

Order

Function

Source lines

Target

Helper deps

1

fit_pls_canonical_nipals + _svd

model.cpp:1819-1838

models/pls/canonical.cpp

core/_common, pls/_common

2

fit_pls_regression_nipals

model.cpp:1528-1818

models/pls/nipals.cpp

core/_common, pls/_common

3

fit_pls_regression_simpls

model.cpp:3685-3884

models/pls/simpls.cpp

core/_common, pls/_common (simple_simpls)

4

fit_pls_regression_svd

model.cpp:2895-3060

models/pls/svd.cpp

core/_common, pls/_common

5

fit_pls_svd (PLSSVD)

model.cpp:1839-1999

models/pls/plssvd.cpp

core/_common, pls/_common

6

fit_pls_regression_power

model.cpp:3061-3226

models/pls/power.cpp

core/_common, pls/_common

7

fit_pls_regression_randomized_svd

model.cpp:3227-3396

models/pls/randomized_svd.cpp

core/_common, pls/_common, common/svd.{c,h} (post-M2.5)

8

fit_pls_regression_kernel

model.cpp:2615-2894

models/pls/kernel.cpp

core/_common, pls/_common

9

fit_pls_regression_orthogonal_scores

model.cpp:2350-2614

models/pls/oscores.cpp

core/_common, pls/_common

Phase 3 — derived (2 commits)

#

Function

Source lines

Target

1

fit_pcr_svd

model.cpp:3397-3684

models/pcr/pcr.cpp

2

fit_opls_nipals

model.cpp:2000-2349

models/opls/opls.cpp

Phase 4 — classification (3 commits)

#

Function

Source lines

Target

1

fit_di_pls

model.cpp:3921-4162

models/classification/pls_da.cpp

2

fit_pls_qda

extra_pls.cpp:847-944

models/classification/pls_qda.cpp

3

fit_pls_cox

extra_pls.cpp:945-1029

models/classification/pls_cox.cpp

Phase 5 — sparse (4 commits)

#

Function

Source lines

Target

1

fit_pls_sparse_simpls

model.cpp:4163-4387

models/sparse/sparse_simpls.cpp

2

fit_sparse_pls_da

extra_pls.cpp:307-366

models/sparse/sparse_pls_da.cpp

3

fit_group_sparse_pls

extra_pls.cpp:367-444

models/sparse/group_sparse.cpp

4

fit_fused_sparse_pls

extra_pls.cpp:445-480

models/sparse/fused_sparse.cpp

Phase 6 — specialized + ensembles + transfer (12 commits)

#

Function

Source lines

Target

1

fit_cppls

extra_pls.cpp:481-550

models/specialized/cppls.cpp

2

fit_weighted_pls

extra_pls.cpp:551-616

models/specialized/weighted.cpp

3

fit_robust_pls

extra_pls.cpp:617-677

models/specialized/robust_huber.cpp

4

fit_ridge_pls

extra_pls.cpp:678-736

models/specialized/ridge.cpp

5

fit_continuum_regression

extra_pls.cpp:737-799

models/specialized/continuum.cpp

6

fit_missing_aware_nipals

extra_pls.cpp:1333-1397

models/specialized/missing_aware_nipals.cpp

7

fit_pls_glm

extra_pls.cpp:800-846

models/glm/pls_glm.cpp

8

fit_mir_pls

extra_pls.cpp:1243-1332

models/local/mir_pls.cpp

9

fit_pds

extra_pls.cpp:1030-1140

transfer/pds.cpp

10

fit_ds

extra_pls.cpp:1141-1242

transfer/ds.cpp

11

fit_bagging_pls + fit_boosting_pls + fit_random_subspace_pls

extra_pls.cpp:1477-1647

models/ensembles/{bagging,boosting,random_subspace}.cpp (3 commits)

Phase 7 — core dispatch (2 commits)

#

Function

Source lines

Target

1

predict_into + fit_model + transform_into + model_array

model.cpp:3885-3920 and 4388-4541

models/core/dispatch.cpp

2

approximate_press

extra_pls.cpp:1398-1476

models/core/approximate_press.cpp

Final cleanup

After all 7 phases land:

  • cpp/src/core/model.cpp reduces to either an empty file (and is deleted) or to a tiny placeholder that just re-includes the new files (for backward-compat).

  • Same for cpp/src/core/extra_pls.cpp.

  • cpp/src/CMakeLists.txt no longer references the two monoliths; instead it picks up cpp/src/core/models/**/*.cpp and cpp/src/core/transfer/*.cpp (still explicit listing, no GLOB).

Per-commit gate

Each phase’s individual commit must pass:

  1. cmake --build --preset dev-release builds without errors.

  2. ./build/dev-release/cpp/tests/n4m_tests reports 265 run, 0 failures, 0 skipped.

  3. ./build/dev-release/cpp/cli/n4m_cli --selfcheck reports selfcheck OK.

  4. Optional: objdump -T build/dev-release/cpp/src/libn4m.so.1 | grep ' n4m_' | wc -l returns the same count as before the extraction (no symbol drop or duplication).

What is in M4 today (this commit)

  • 39 stub files at all target paths under cpp/src/core/models/{pls,opls,pcr,sparse,multiblock,local,ensembles,specialized,classification,glm,core}/ and cpp/src/core/transfer/. Each stub carries a structured header naming the origin source, the line range to extract, and the function(s) it will receive.

  • Two reports (SPLIT_PLAN.md from the Explore agent + this EXTRACTION_RECIPE.md).

  • No changes to model.cpp, extra_pls.cpp, or cpp/src/CMakeLists.txt. pls4all builds unchanged, n4m_tests 265/265 green.

What is NOT done in M4

  • The actual function-body extraction (Phases 1–7 above). The 28 algorithm extractions plus 3 helper extractions plus the cleanup are roadmap-budgeted at 4 days of careful work. They are deferred to a focused refactor session that can dedicate a build/test cycle per extraction.

  • Multiblock subcategory has no stubs because all multiblock methods (mb_pls, n_pls, o2pls, so_pls, on_pls, rosa, multiblock_extensions) live in separate cpp/src/core/*.cpp files already — they need re-categorisation rather than extraction. Those moves are simpler and can land in M5/M6.

Sequencing within the merge

Per MERGE_ROADMAP.md §9 critical path: M3 M4 M5 M6 M7 M8 ...

The M4 scaffold unblocks M5 (ABI rename) because M5 operates on the monoliths in place. After M5, the post-rename monoliths can be extracted into the M4 stubs as planned here. The execution becomes:

  1. M5 mechanical n4m_* n4m_* rename on model.cpp, extra_pls.cpp in place.

  2. M4 Phase 1 (extract helpers) on the renamed monoliths.

  3. M4 Phases 2–7 on each algorithm.

This is the order the agent’s plan implies.