@@ -88,6 +88,15 @@ def __init__(
8888
8989 self ._preloads = preloads
9090
91+ # The Cholesky factor of the passive submatrix that the positive-only solve builds, and
92+ # the global indices it was built on (`None` when the solve took the full system).
93+ # Published by `reconstruction` and read by `log_det_curvature_reg_matrix_term`, which
94+ # reads `log det(F + lambda*H)` off it instead of factorising that matrix a second time.
95+ # Per-instance and never a module global: one inversion's factor must never be able to
96+ # answer another inversion's log determinant.
97+ self ._nnls_factor = None
98+ self ._nnls_factor_ids = None
99+
91100 @property
92101 def _xp (self ):
93102 if self .use_jax :
@@ -355,15 +364,23 @@ def regularization_matrix_reduced(self) -> Optional[np.ndarray]:
355364 # Zero rows and columns in the matrix we want to ignore
356365 return self .regularization_matrix [ids_to_keep ][:, ids_to_keep ]
357366
358- @property
367+ @cached_property
359368 def curvature_reg_matrix (self ) -> np .ndarray :
360369 """
361370 The linear system of equations solves for F + regularization_coefficient*H, which is computed below.
362371
363- For a single mapper, this function overwrites the cached `curvature_matrix`, because for large matrices this
364- avoids overheads in memory allocation. The `curvature_matrix` is removed as a cached property as a result,
365- to ensure if we access it after computing the `curvature_reg_matrix` it is correctly recalculated in a new
366- array of memory.
372+ The addition is **out-of-place**: `curvature_matrix` is left intact and a new `[params, params]` array is
373+ returned. An earlier version of this property added `H` into `F` in place and then deleted the cached
374+ `curvature_matrix` to force its recomputation, and this docstring described that; the in-place form was
375+ removed in `0766edd4` (2025-06-26), so nothing here invalidates anything (and on the imaging inversions,
376+ where the matrix is large, `curvature_matrix` is itself a `cached_property`).
377+
378+ Cached because the likelihood reaches it more than once per evaluation (`reconstruction` and
379+ `curvature_reg_matrix_reduced`, and previously `log_det_curvature_reg_matrix_term` through the latter),
380+ and each access re-ran the add -- ~2 ms per access of an (n, n) sum at n = 1500. It was a
381+ `cached_property` until the blanket `e819fa12` (2025-11-05) sweep turned it into a plain `property` along
382+ with a dozen others. The cost of the cache is one extra (n, n) array (18 MB at n = 1500, on top of `F`
383+ and `H`) held for the inversion's lifetime.
367384 """
368385 if not self .has (cls = AbstractRegularization ):
369386 return self .curvature_matrix
@@ -606,6 +623,13 @@ def reconstruction(self) -> np.ndarray:
606623 # so it is safe to consult here and nowhere higher up.
607624 ids_to_keep = self .solve_ids_to_keep
608625
626+ # The solve's Cholesky factor of its passive submatrix, published through this
627+ # out-dict and stored on the instance below so that
628+ # `log_det_curvature_reg_matrix_term` can read `log det(F + lambda*H)` off it.
629+ # `reconstruction_positive_only_from` clears it on entry, so it is empty whenever
630+ # no solve completed.
631+ factor = {}
632+
609633 if ids_to_keep is not None :
610634
611635 # Use advanced indexing to select rows/columns
@@ -624,9 +648,13 @@ def reconstruction(self) -> np.ndarray:
624648 fingerprint = self ._nnls_warm_start_fingerprint (
625649 ids_to_keep = ids_to_keep
626650 ),
651+ factor = factor ,
627652 )
628653 )
629654
655+ self ._nnls_factor = factor
656+ self ._nnls_factor_ids = ids_to_keep
657+
630658 # Allocate full solution array
631659 reconstruction = self ._xp .zeros (self .data_vector .shape [0 ])
632660
@@ -642,14 +670,20 @@ def reconstruction(self) -> np.ndarray:
642670
643671 else :
644672
645- return inversion_util .reconstruction_positive_only_from (
673+ reconstruction = inversion_util .reconstruction_positive_only_from (
646674 data_vector = self .data_vector ,
647675 curvature_reg_matrix = self .curvature_reg_matrix ,
648676 settings = self .settings ,
649677 xp = self ._xp ,
650678 fingerprint = self ._nnls_warm_start_fingerprint (),
679+ factor = factor ,
651680 )
652681
682+ self ._nnls_factor = factor
683+ self ._nnls_factor_ids = None
684+
685+ return reconstruction
686+
653687 return inversion_util .reconstruction_positive_negative_from (
654688 data_vector = self .data_vector ,
655689 curvature_reg_matrix = self .curvature_reg_matrix ,
@@ -898,14 +932,102 @@ def log_det_curvature_reg_matrix_term(self) -> float:
898932 """
899933 The log determinant of [F + reg_coeff*H] is used to determine the Bayesian evidence of the solution.
900934
901- This uses the Cholesky decomposition which is already computed before solving the reconstruction
902- (or ``slogdet`` when ``Settings.log_det_method == "slogdet"`` — see :meth:`_log_det_symmetric_from`).
935+ On the NumPy path with the positive-only solver and the default ``"cholesky"`` method this term is read
936+ off the Cholesky factor the reconstruction's non-negative least squares solve has already built, rather
937+ than factorizing ``F + reg_coeff*H`` a second time.
938+
939+ ``fnnls_cholesky`` finishes holding a factor ``U`` of ``M[P][:, P]``, where ``M`` is
940+ ``curvature_reg_matrix_reduced`` and ``P`` is the solve's final passive set -- 1485 of the 1500 columns
941+ on the production HST Delaunay ``AdaptSplit`` system. The block-determinant identity then gives the
942+ full log determinant from that factor plus a Schur complement over the ~15 remaining columns (see
943+ :func:`inversion_util.log_det_from_passive_cholesky_from`): ~5 ms in place of a ~40 ms dense
944+ factorization of the same matrix, agreeing with it to <= 2e-12 nats.
945+
946+ Because the factor comes from the solve, **this property evaluates** :attr:`reconstruction` (a
947+ ``cached_property``) before reading it. The likelihood is unaffected -- it already evaluates
948+ ``regularization_term``, and through it the reconstruction, before this term -- but a diagnostic that
949+ asks for this term alone now pays for the reconstruction solve, where before it paid only for one
950+ Cholesky.
951+
952+ The fast path is taken only when every one of these holds; anything else, and any
953+ ``np.linalg.LinAlgError`` raised while reading the factor, takes the dense route below unchanged (which
954+ carries the test-mode guard in :meth:`_log_det_symmetric_from`):
955+
956+ - the NumPy backend (the factor is a NumPy buffer built by a numba solver);
957+ - ``Settings.log_det_method == "cholesky"`` (``"slogdet"`` is a different value by construction, and is
958+ reached through the dense route);
959+ - ``Settings.use_positive_only_solver`` (nothing else builds a factor, and this also keeps the
960+ positive-negative route from evaluating the reconstruction it would not otherwise need);
961+ - every linear object is regularized, so ``curvature_reg_matrix_reduced`` is the whole
962+ ``curvature_reg_matrix`` the solve was handed rather than a ``mapper_indices`` subset of it;
963+ - a factor is present with a non-empty passive block, and its matrix shape matches;
964+ - the solve covered the whole reduced system. Note this is "no subset **or** the identity subset", not
965+ "no subset": with ``use_edge_zeroed_pixels`` on but no edge pixels to zero, ``solve_ids_to_keep``
966+ returns ``arange(n)`` rather than ``None``, which is the production configuration of the system
967+ above.
903968 """
904969 if not self .has (cls = AbstractRegularization ):
905970 return 0.0
906971
972+ if (
973+ self ._xp is np
974+ and self .settings .log_det_method == "cholesky"
975+ and self .settings .use_positive_only_solver
976+ and self .all_linear_obj_have_regularization
977+ ):
978+ matrix = self .curvature_reg_matrix_reduced
979+
980+ # Evaluates the solve (a `cached_property`), which is what publishes the factor
981+ # read below. Bound to `_` because the value is not what is wanted here -- the
982+ # side effect of the solve having run is.
983+ _ = self .reconstruction
984+
985+ factor = self ._nnls_factor
986+ ids = self ._nnls_factor_ids
987+
988+ if (
989+ factor
990+ and factor .get ("U_buffer" ) is not None
991+ and factor .get ("k_active" , 0 ) > 0
992+ and factor .get ("matrix_shape" ) == tuple (matrix .shape )
993+ and self ._nnls_factor_ids_cover (ids = ids , total = matrix .shape [0 ])
994+ ):
995+ try :
996+ return inversion_util .log_det_from_passive_cholesky_from (
997+ matrix = matrix ,
998+ U_buffer = factor ["U_buffer" ],
999+ k_active = factor ["k_active" ],
1000+ passive_set = factor ["passive_set" ],
1001+ )
1002+ except np .linalg .LinAlgError :
1003+ # The Schur complement of the passive block was not positive-definite. That
1004+ # means this matrix is at the edge of what fp64 resolves as positive-definite,
1005+ # and the dense factorization below is the authority on it: it either returns
1006+ # its own value or raises the same error, where the test-mode guard and the
1007+ # likelihood's `FitException` resampling handle it as they always have. It must
1008+ # not be answered with a fabricated 0.0 here.
1009+ pass
1010+
9071011 return self ._log_det_symmetric_from (self .curvature_reg_matrix_reduced )
9081012
1013+ @staticmethod
1014+ def _nnls_factor_ids_cover (ids : Optional [np .ndarray ], total : int ) -> bool :
1015+ """
1016+ Did the solve whose factor was published cover every parameter of the reduced system?
1017+
1018+ `None` means the solve took the full system. An explicit index array covers it only if it is the
1019+ identity `arange(total)`, which `solve_ids_to_keep` does return: `use_edge_zeroed_pixels` with no edge
1020+ pixels to zero yields `arange(n)`, not `None`, and that is the production configuration of the HST
1021+ Delaunay system this fast path was measured on. Any other index array is a proper subset, whose factor
1022+ says nothing about the full matrix's determinant.
1023+ """
1024+ if ids is None :
1025+ return True
1026+
1027+ ids = np .asarray (ids )
1028+
1029+ return ids .shape == (total ,) and np .array_equal (ids , np .arange (total ))
1030+
9091031 @property
9101032 def log_det_regularization_matrix_term (self ) -> float :
9111033 """
0 commit comments