pair_kernel

The consolidated pair-accumulation kernel. Every pair-counting loop in the package lives here; the measurement backend classes are thin wrappers that prepare a sample, call accumulate, and do their own reduction / RR / HDF5 writing. See the module docstring for the geometry / binning / backend / jackknife structure.

measureia.pair_kernel

Consolidated pair-accumulation kernel for the measure_IA counting loops.

See docs/REFACTOR_PLAN.md for the design. Every pair-counting loop in the package now lives here; the eight backend classes (MeasureWBox / MeasureMultipolesBox and their …Jackknife twins, MeasureWLightcone / MeasureMultipolesLightcone and their …Jackknife twins) are thin wrappers that prepare a sample, call one function here, and do their own reduction / RR / HDF5 writing.

Structure: - prepare_box_samples / prepare_lightcone_samples — mask application and geometry (box: axis_direction/e size; lightcone: RA/DEC/z → 3D comoving via pyccl, east/north sky basis, e = (e1, e2) pre-scaled by 1/(2R)) → a SampleSet. - binnings: BoxRpPi / BoxRMuR (periodic box) and SkyRpPi / SkyRMuR (lightcone, midpoint LOS n_LOS = (s1+s2)/|s1+s2|), each exposing bin_pairs. - accumulate — the pair loop. chunk_axis="shape" runs the box order (outer loop over shape chunks, positions queried per chunk); chunk_axis="position" runs the lightcone order (outer loop over position chunks, shapes queried). Backends "tree" (KDTree ball query at the outer radius, bit-identical to the legacy) and "brute" (full cross-join, same pairs → allclose). shapes=False is the DD-only count_pairs path. - jackknife (jk=True): union-deletion per-realisation grids DD_jk / Splus_D_jk. Box divides S+ by 2R inline and applies per-realisation responsivity via compute_R_jk; the lightcone bakes 1/(2R) into e and reduces by a plain delete-one, so it has no R_jk. The chunked (outer) axis owns the "always" side of the deletion — shape for the box, position for the lightcone. - multiprocessing stays in the backend wrappers (SharedMemory, temp-file offload, Pool); each worker calls accumulate single-process on its slice, reusing a parent-built tree (pos_tree for the box, shape_tree for the lightcone).

Every public function here is pure with respect to its arguments except where noted (prepare_box_samples / prepare_lightcone_samples mutate the masks dict they are given, matching the legacy in-place default-injection behaviour they replace).

SampleSet dataclass

Position/weight/shape arrays for one measurement call, already masked.

Fields mirror docs/REFACTOR_PLAN.md section 3.1. Box-only fields (LOS_ind, not_LOS) are populated for the box geometry; the lightcone geometry (later migration steps) will add its own fields.

Source code in src/measureia/pair_kernel.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@dataclass
class SampleSet:
    """Position/weight/shape arrays for one measurement call, already masked.

    Fields mirror docs/REFACTOR_PLAN.md section 3.1. Box-only fields
    (``LOS_ind``, ``not_LOS``) are populated for the box geometry; the
    lightcone geometry (later migration steps) will add its own fields.
    """
    pos: np.ndarray
    pos_shape: np.ndarray
    weight: np.ndarray
    weight_shape: np.ndarray
    axis_direction: Optional[np.ndarray] = None
    e: Optional[np.ndarray] = None
    LOS_ind: Optional[int] = None
    not_LOS: Optional[np.ndarray] = None
    # jackknife patch indices (int, per galaxy); jk_pos is position-aligned (full),
    # jk_shape is shape-aligned (chunked like axis_direction/e). Only used when jk=True.
    jk_pos: Optional[np.ndarray] = None
    jk_shape: Optional[np.ndarray] = None
    # Lightcone geometry only (chunk_axis="position"): local sky basis at each position
    # (east/north, N,3) for the ellipticity-angle projection, and n_pos (N,3, radial unit
    # vector). For the lightcone families ``e`` is (M,2) = (e1,e2) pre-scaled by 1/(2R).
    east: Optional[np.ndarray] = None
    north: Optional[np.ndarray] = None
    n_pos: Optional[np.ndarray] = None

Grids dataclass

Accumulated pair-count grids. Splus_D/Scross_D are None when the caller requested shapes=False (DD-only / count_pairs paths). The per-realisation jackknife grids DD_jk/Splus_D_jk are None unless jk=True (and Splus_D_jk also requires shapes=True). Splus_D_jk stores the raw (un-responsivity-divided) S+ contribution — responsivity is applied later in the reduction, matching the legacy jk grids.

The *_gal fields carry the same sums resolved per shape galaxy and are None unless per_galaxy=True; see accumulate for their axes and for the *_gal_jk position-patch decomposition.

Source code in src/measureia/pair_kernel.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
@dataclass
class Grids:
    """Accumulated pair-count grids. ``Splus_D``/``Scross_D`` are None when
    the caller requested ``shapes=False`` (DD-only / count_pairs paths). The
    per-realisation jackknife grids ``DD_jk``/``Splus_D_jk`` are None unless
    ``jk=True`` (and ``Splus_D_jk`` also requires ``shapes=True``). ``Splus_D_jk``
    stores the *raw* (un-responsivity-divided) S+ contribution — responsivity is
    applied later in the reduction, matching the legacy jk grids.

    The ``*_gal`` fields carry the same sums resolved *per shape galaxy* and are
    None unless ``per_galaxy=True``; see ``accumulate`` for their axes and for the
    ``*_gal_jk`` position-patch decomposition."""
    DD: np.ndarray
    Splus_D: Optional[np.ndarray] = None
    Scross_D: Optional[np.ndarray] = None
    DD_jk: Optional[np.ndarray] = None
    Splus_D_jk: Optional[np.ndarray] = None
    DD_gal: Optional[np.ndarray] = None
    Splus_D_gal: Optional[np.ndarray] = None
    DD_gal_jk: Optional[np.ndarray] = None
    Splus_D_gal_jk: Optional[np.ndarray] = None
    # sparse (per_galaxy_jk_sparse) form of the two arrays above: values for only the
    # patches a galaxy actually has pairs in, with the shared patch index array.
    gal_jk_patches: Optional[np.ndarray] = None
    DD_gal_jk_values: Optional[np.ndarray] = None
    Splus_D_gal_jk_values: Optional[np.ndarray] = None

BoxRpPi

(rp, pi) grid binning for a periodic Cartesian box.

Clamping convention (box family, see REFACTOR_PLAN.md section 3.2): an index landing exactly on the upper edge (== num_bins) is folded back into the last bin.

Source code in src/measureia/pair_kernel.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
class BoxRpPi:
    """(rp, pi) grid binning for a periodic Cartesian box.

    Clamping convention (box family, see REFACTOR_PLAN.md section 3.2): an
    index landing exactly on the upper edge (``== num_bins``) is folded back
    into the last bin.
    """

    def __init__(self, base):
        self.r_min = base.r_min
        self.r_max = base.r_max
        self.r_bins = base.r_bins
        self.pi_bins = base.pi_bins
        self.num_bins_r = base.num_bins_r
        self.num_bins_pi = base.num_bins_pi
        self.sub_box_len_logrp = (np.log10(base.r_max) - np.log10(base.r_min)) / base.num_bins_r
        self.sub_box_len_pi = (base.pi_bins[-1] - base.pi_bins[0]) / base.num_bins_pi
        # Candidate region: either the 3D ball enclosing the (rp <= r_max,
        # |pi| <= pi_max) cylinder this binning selects, or the 2D projection of
        # that cylinder. Both are supersets of what bin_pairs keeps, so the pair
        # set is identical either way and only the wasted work differs -- pick
        # whichever is smaller:
        #
        #   ball      (4/3) q^3,  q = sqrt(r_max^2 + pi_max^2)
        #   cylinder  r_max^2 * L    (the full box depth, because a 2D query
        #                             cannot constrain the line of sight at all)
        #
        # The ball wins whenever the box is deep enough -- the usual case, and
        # increasingly so as boxes grow, which is what removes the superlinear
        # scaling. The cylinder wins when pi_max is large next to the box:
        # measured at pi_max=60 in a 205 Mpc/h box, the ball would be 4.1x worse.
        ball_r = np.sqrt(base.r_max ** 2 + base.pi_bins[-1] ** 2)
        boxsize = getattr(base, "boxsize", None)
        if boxsize:
            self.tree_is_3d = bool((4.0 / 3.0) * ball_r ** 3 < base.r_max ** 2 * boxsize)
        else:
            # no box depth to compare against: the ball is bounded, the
            # projected query is not, so prefer the ball
            self.tree_is_3d = True
        self.query_r_max = ball_r if self.tree_is_3d else base.r_max

    def tree_coords(self, coords, not_LOS):
        """Coordinates the KDTree is built/queried on: the full 3D positions.

        This binning selects a cylinder — projected separation within ``r_max``,
        line-of-sight separation within ``pi_max`` — and the obvious tree for that
        is a 2D one on the projection, which is what this used to build. The
        trouble is that a 2D query cannot constrain the line of sight at all, so
        it returns every neighbour in a cylinder through the *entire box depth*
        and ``bin_pairs`` then discards the ones outside the pi window. The cost
        of that grows with the box: candidates per galaxy went 88.8 -> 134.4 ->
        210.4 across three decades at fixed number density, where the (r, mu_r)
        binning stayed flat at ~19 (benchmarks/FINDINGS.md F2), and it left this
        the only measurement path with a superlinear scaling exponent (1.12
        against 1.00-1.03 elsewhere) after the spatial-ordering fix (F5).

        Querying a 3D ball of radius ``sqrt(r_max^2 + pi_max^2)`` instead bounds
        the cylinder exactly: every pair the binning keeps lies inside that ball,
        so the surviving pair set is unchanged, and the ball no longer scales
        with the box. SkyRpPi has always done this; the box was the outlier.

        The ball is not universally better: it grows with ``pi_max`` while the
        cylinder grows with the box depth, so a wide ``pi_max`` in a shallow box
        is cheaper to query in projection. ``__init__`` compares the two volumes
        and sets ``tree_is_3d``, which is what this returns on. Both regions are
        supersets of the pairs ``bin_pairs`` keeps, so the choice cannot change
        the result -- only how much work is discarded.
        """
        return coords if self.tree_is_3d else coords[:, not_LOS]

    def bin_pairs(self, separation, not_LOS, LOS_ind):
        """Bin one shape galaxy's separations to its candidate position neighbours.

        Parameters
        ----------
        separation : (K, 3) ndarray
            ``position_shape[n] - position[candidates]``, already periodicity-wrapped.
        not_LOS : ndarray
            The two axis indices that are not the line-of-sight axis.
        LOS_ind : int
            The line-of-sight axis index.

        Returns
        -------
        mask : (K,) bool ndarray
            Which of the K candidates fall inside the (rp, pi) window.
        ind_r, ind_pi : (mask.sum(),) int ndarrays
            Bin indices for the surviving pairs, in the same order as
            ``separation[mask]``.
        projected_sep : (K, 2) ndarray
            ``separation[:, not_LOS]``, for reuse by the ellipticity-angle calc.
        separation_len : (K,) ndarray
            Projected separation length, for reuse by the ellipticity-angle calc.
        """
        projected_sep = separation[:, not_LOS]
        LOS = separation[:, LOS_ind]
        separation_len = np.sqrt(np.sum(projected_sep ** 2, axis=1))
        mask = (separation_len >= self.r_bins[0]) * (separation_len < self.r_bins[-1]) * \
               (LOS >= self.pi_bins[0]) * (LOS < self.pi_bins[-1])
        ind_r = np.floor(
            np.log10(separation_len[mask]) / self.sub_box_len_logrp
            - np.log10(self.r_bins[0]) / self.sub_box_len_logrp
        )
        ind_r = np.array(ind_r, dtype=int)
        ind_pi = np.floor(
            LOS[mask] / self.sub_box_len_pi - self.pi_bins[0] / self.sub_box_len_pi
        )
        ind_pi = np.array(ind_pi, dtype=int)
        if np.any(ind_pi == self.num_bins_pi):
            ind_pi[ind_pi >= self.num_bins_pi] -= 1
        if np.any(ind_r == self.num_bins_r):
            ind_r[ind_r >= self.num_bins_r] -= 1
        return mask, ind_r, ind_pi, projected_sep, separation_len

tree_coords(coords, not_LOS)

Coordinates the KDTree is built/queried on: the full 3D positions.

This binning selects a cylinder — projected separation within r_max, line-of-sight separation within pi_max — and the obvious tree for that is a 2D one on the projection, which is what this used to build. The trouble is that a 2D query cannot constrain the line of sight at all, so it returns every neighbour in a cylinder through the entire box depth and bin_pairs then discards the ones outside the pi window. The cost of that grows with the box: candidates per galaxy went 88.8 -> 134.4 -> 210.4 across three decades at fixed number density, where the (r, mu_r) binning stayed flat at ~19 (benchmarks/FINDINGS.md F2), and it left this the only measurement path with a superlinear scaling exponent (1.12 against 1.00-1.03 elsewhere) after the spatial-ordering fix (F5).

Querying a 3D ball of radius sqrt(r_max^2 + pi_max^2) instead bounds the cylinder exactly: every pair the binning keeps lies inside that ball, so the surviving pair set is unchanged, and the ball no longer scales with the box. SkyRpPi has always done this; the box was the outlier.

The ball is not universally better: it grows with pi_max while the cylinder grows with the box depth, so a wide pi_max in a shallow box is cheaper to query in projection. __init__ compares the two volumes and sets tree_is_3d, which is what this returns on. Both regions are supersets of the pairs bin_pairs keeps, so the choice cannot change the result -- only how much work is discarded.

Source code in src/measureia/pair_kernel.py
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
def tree_coords(self, coords, not_LOS):
    """Coordinates the KDTree is built/queried on: the full 3D positions.

    This binning selects a cylinder — projected separation within ``r_max``,
    line-of-sight separation within ``pi_max`` — and the obvious tree for that
    is a 2D one on the projection, which is what this used to build. The
    trouble is that a 2D query cannot constrain the line of sight at all, so
    it returns every neighbour in a cylinder through the *entire box depth*
    and ``bin_pairs`` then discards the ones outside the pi window. The cost
    of that grows with the box: candidates per galaxy went 88.8 -> 134.4 ->
    210.4 across three decades at fixed number density, where the (r, mu_r)
    binning stayed flat at ~19 (benchmarks/FINDINGS.md F2), and it left this
    the only measurement path with a superlinear scaling exponent (1.12
    against 1.00-1.03 elsewhere) after the spatial-ordering fix (F5).

    Querying a 3D ball of radius ``sqrt(r_max^2 + pi_max^2)`` instead bounds
    the cylinder exactly: every pair the binning keeps lies inside that ball,
    so the surviving pair set is unchanged, and the ball no longer scales
    with the box. SkyRpPi has always done this; the box was the outlier.

    The ball is not universally better: it grows with ``pi_max`` while the
    cylinder grows with the box depth, so a wide ``pi_max`` in a shallow box
    is cheaper to query in projection. ``__init__`` compares the two volumes
    and sets ``tree_is_3d``, which is what this returns on. Both regions are
    supersets of the pairs ``bin_pairs`` keeps, so the choice cannot change
    the result -- only how much work is discarded.
    """
    return coords if self.tree_is_3d else coords[:, not_LOS]

bin_pairs(separation, not_LOS, LOS_ind)

Bin one shape galaxy's separations to its candidate position neighbours.

Parameters:
  • separation ((K, 3) ndarray) –

    position_shape[n] - position[candidates], already periodicity-wrapped.

  • not_LOS (ndarray) –

    The two axis indices that are not the line-of-sight axis.

  • LOS_ind (int) –

    The line-of-sight axis index.

Returns:
  • mask( (K,) bool ndarray ) –

    Which of the K candidates fall inside the (rp, pi) window.

  • ind_r, ind_pi : (mask.sum(),) int ndarrays

    Bin indices for the surviving pairs, in the same order as separation[mask].

  • projected_sep( (K, 2) ndarray ) –

    separation[:, not_LOS], for reuse by the ellipticity-angle calc.

  • separation_len( (K,) ndarray ) –

    Projected separation length, for reuse by the ellipticity-angle calc.

Source code in src/measureia/pair_kernel.py
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
def bin_pairs(self, separation, not_LOS, LOS_ind):
    """Bin one shape galaxy's separations to its candidate position neighbours.

    Parameters
    ----------
    separation : (K, 3) ndarray
        ``position_shape[n] - position[candidates]``, already periodicity-wrapped.
    not_LOS : ndarray
        The two axis indices that are not the line-of-sight axis.
    LOS_ind : int
        The line-of-sight axis index.

    Returns
    -------
    mask : (K,) bool ndarray
        Which of the K candidates fall inside the (rp, pi) window.
    ind_r, ind_pi : (mask.sum(),) int ndarrays
        Bin indices for the surviving pairs, in the same order as
        ``separation[mask]``.
    projected_sep : (K, 2) ndarray
        ``separation[:, not_LOS]``, for reuse by the ellipticity-angle calc.
    separation_len : (K,) ndarray
        Projected separation length, for reuse by the ellipticity-angle calc.
    """
    projected_sep = separation[:, not_LOS]
    LOS = separation[:, LOS_ind]
    separation_len = np.sqrt(np.sum(projected_sep ** 2, axis=1))
    mask = (separation_len >= self.r_bins[0]) * (separation_len < self.r_bins[-1]) * \
           (LOS >= self.pi_bins[0]) * (LOS < self.pi_bins[-1])
    ind_r = np.floor(
        np.log10(separation_len[mask]) / self.sub_box_len_logrp
        - np.log10(self.r_bins[0]) / self.sub_box_len_logrp
    )
    ind_r = np.array(ind_r, dtype=int)
    ind_pi = np.floor(
        LOS[mask] / self.sub_box_len_pi - self.pi_bins[0] / self.sub_box_len_pi
    )
    ind_pi = np.array(ind_pi, dtype=int)
    if np.any(ind_pi == self.num_bins_pi):
        ind_pi[ind_pi >= self.num_bins_pi] -= 1
    if np.any(ind_r == self.num_bins_r):
        ind_r[ind_r >= self.num_bins_r] -= 1
    return mask, ind_r, ind_pi, projected_sep, separation_len

BoxRMuR

(r, mu_r) grid binning for a periodic Cartesian box (multipoles).

Unlike BoxRpPi, the separation bin r is the full 3D separation length and mu_r = LOS / r; the KDTree therefore operates on the full 3D coordinates (tree_coords returns coords unchanged). An rp_cut on the projected (2D) separation length is applied inside the window mask. The mu_r sub-bin length is 2.0 / num_bins_pi (mu_r runs over [-1, 1] with num_bins_pi bins). Same clamp convention as BoxRpPi (an index landing on num_bins folds into the last bin).

Source code in src/measureia/pair_kernel.py
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
class BoxRMuR:
    """(r, mu_r) grid binning for a periodic Cartesian box (multipoles).

    Unlike ``BoxRpPi``, the separation bin ``r`` is the *full 3D* separation length
    and ``mu_r = LOS / r``; the KDTree therefore operates on the full 3D coordinates
    (``tree_coords`` returns ``coords`` unchanged). An ``rp_cut`` on the projected
    (2D) separation length is applied inside the window mask. The ``mu_r`` sub-bin
    length is ``2.0 / num_bins_pi`` (mu_r runs over [-1, 1] with ``num_bins_pi`` bins).
    Same clamp convention as ``BoxRpPi`` (an index landing on ``num_bins`` folds into
    the last bin).
    """

    def __init__(self, base, rp_cut=0.0):
        self.r_min = base.r_min
        self.r_max = base.r_max
        self.r_bins = base.r_bins
        self.mu_r_bins = base.mu_r_bins
        self.num_bins_r = base.num_bins_r
        self.num_bins_pi = base.num_bins_pi
        self.rp_cut = rp_cut
        self.sub_box_len_logr = (np.log10(base.r_max) - np.log10(base.r_min)) / base.num_bins_r
        self.sub_box_len_mu_r = 2.0 / base.num_bins_pi
        # the r-window is the 3D separation, so the ball is just r_max
        self.query_r_max = base.r_max

    def tree_coords(self, coords, not_LOS):
        """Coordinates the KDTree is built/queried on: the full 3D positions, since the
        (r, mu_r) r-window is the 3D separation."""
        return coords

    def bin_pairs(self, separation, not_LOS, LOS_ind):
        """Bin one shape galaxy's separations to its candidate position neighbours.

        Returns ``(mask, ind_r, ind_mu_r, projected_sep, projected_len)`` where
        ``projected_sep``/``projected_len`` are the 2D projection and its length, used
        by ``accumulate`` for the ellipticity-angle calc (identical to the (rp, pi)
        family). ``ind_r`` is binned on the 3D separation length; ``ind_mu_r`` on
        ``mu_r = LOS / r_3d``. The window keeps pairs with projected length > rp_cut and
        3D length in ``[r_bins[0], r_bins[-1])``.
        """
        projected_sep = separation[:, not_LOS]
        LOS = separation[:, LOS_ind]
        projected_len = np.sqrt(np.sum(projected_sep ** 2, axis=1))
        separation_len = np.sqrt(np.sum(separation ** 2, axis=1))
        with np.errstate(invalid='ignore'):
            mu_r = LOS / separation_len
        mask = (
            (projected_len > self.rp_cut)
            * (separation_len >= self.r_bins[0])
            * (separation_len < self.r_bins[-1])
        )
        ind_r = np.floor(
            np.log10(separation_len[mask]) / self.sub_box_len_logr
            - np.log10(self.r_bins[0]) / self.sub_box_len_logr
        )
        ind_r = np.array(ind_r, dtype=int)
        ind_mu_r = np.floor(
            mu_r[mask] / self.sub_box_len_mu_r - self.mu_r_bins[0] / self.sub_box_len_mu_r
        )
        ind_mu_r = np.array(ind_mu_r, dtype=int)
        if np.any(ind_mu_r == self.num_bins_pi):
            ind_mu_r[ind_mu_r >= self.num_bins_pi] -= 1
        if np.any(ind_r == self.num_bins_r):
            ind_r[ind_r >= self.num_bins_r] -= 1
        return mask, ind_r, ind_mu_r, projected_sep, projected_len

tree_coords(coords, not_LOS)

Coordinates the KDTree is built/queried on: the full 3D positions, since the (r, mu_r) r-window is the 3D separation.

Source code in src/measureia/pair_kernel.py
474
475
476
477
def tree_coords(self, coords, not_LOS):
    """Coordinates the KDTree is built/queried on: the full 3D positions, since the
    (r, mu_r) r-window is the 3D separation."""
    return coords

bin_pairs(separation, not_LOS, LOS_ind)

Bin one shape galaxy's separations to its candidate position neighbours.

Returns (mask, ind_r, ind_mu_r, projected_sep, projected_len) where projected_sep/projected_len are the 2D projection and its length, used by accumulate for the ellipticity-angle calc (identical to the (rp, pi) family). ind_r is binned on the 3D separation length; ind_mu_r on mu_r = LOS / r_3d. The window keeps pairs with projected length > rp_cut and 3D length in [r_bins[0], r_bins[-1]).

Source code in src/measureia/pair_kernel.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
def bin_pairs(self, separation, not_LOS, LOS_ind):
    """Bin one shape galaxy's separations to its candidate position neighbours.

    Returns ``(mask, ind_r, ind_mu_r, projected_sep, projected_len)`` where
    ``projected_sep``/``projected_len`` are the 2D projection and its length, used
    by ``accumulate`` for the ellipticity-angle calc (identical to the (rp, pi)
    family). ``ind_r`` is binned on the 3D separation length; ``ind_mu_r`` on
    ``mu_r = LOS / r_3d``. The window keeps pairs with projected length > rp_cut and
    3D length in ``[r_bins[0], r_bins[-1])``.
    """
    projected_sep = separation[:, not_LOS]
    LOS = separation[:, LOS_ind]
    projected_len = np.sqrt(np.sum(projected_sep ** 2, axis=1))
    separation_len = np.sqrt(np.sum(separation ** 2, axis=1))
    with np.errstate(invalid='ignore'):
        mu_r = LOS / separation_len
    mask = (
        (projected_len > self.rp_cut)
        * (separation_len >= self.r_bins[0])
        * (separation_len < self.r_bins[-1])
    )
    ind_r = np.floor(
        np.log10(separation_len[mask]) / self.sub_box_len_logr
        - np.log10(self.r_bins[0]) / self.sub_box_len_logr
    )
    ind_r = np.array(ind_r, dtype=int)
    ind_mu_r = np.floor(
        mu_r[mask] / self.sub_box_len_mu_r - self.mu_r_bins[0] / self.sub_box_len_mu_r
    )
    ind_mu_r = np.array(ind_mu_r, dtype=int)
    if np.any(ind_mu_r == self.num_bins_pi):
        ind_mu_r[ind_mu_r >= self.num_bins_pi] -= 1
    if np.any(ind_r == self.num_bins_r):
        ind_r[ind_r >= self.num_bins_r] -= 1
    return mask, ind_r, ind_mu_r, projected_sep, projected_len

SkyRpPi

(rp, pi) grid binning for a lightcone (RA, DEC, z → 3D comoving) sky.

The line-of-sight direction is the pair midpoint radial direction n_LOS = (s_pos + s_shape) / |s_pos + s_shape|; LOS = s . n_LOS is the signed line-of-sight separation (pi runs over [-pi_max, pi_max]) and the projected separation length is sqrt(|s|^2 - LOS^2). Query radius is sqrt(r_max^2 + pi_bins[-1]^2) (the position tree is queried against the shape tree out to query_r_max; there is no inner cut, because bin_pairs already drops anything below r_bins[0]). Clamping convention (lightcone family, see REFACTOR_PLAN.md section 3.2): an index landing exactly on the upper edge (== num_bins) is set to the last bin (num_bins - 1).

Source code in src/measureia/pair_kernel.py
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
class SkyRpPi:
    """(rp, pi) grid binning for a lightcone (RA, DEC, z → 3D comoving) sky.

    The line-of-sight direction is the pair *midpoint* radial direction
    ``n_LOS = (s_pos + s_shape) / |s_pos + s_shape|``; ``LOS = s . n_LOS`` is the signed
    line-of-sight separation (``pi`` runs over ``[-pi_max, pi_max]``) and the projected
    separation length is ``sqrt(|s|^2 - LOS^2)``. Query radius is
    ``sqrt(r_max^2 + pi_bins[-1]^2)`` (the position tree is queried against the shape tree
    out to ``query_r_max``; there is no inner cut, because ``bin_pairs`` already drops
    anything below ``r_bins[0]``). Clamping convention (lightcone family, see
    REFACTOR_PLAN.md section 3.2): an index landing exactly on the upper edge
    (``== num_bins``) is set to the last bin (``num_bins - 1``).
    """

    def __init__(self, base):
        self.r_min = base.r_min
        self.r_max = base.r_max
        self.r_bins = base.r_bins
        self.pi_bins = base.pi_bins
        self.num_bins_r = base.num_bins_r
        self.num_bins_pi = base.num_bins_pi
        self.sub_box_len_logrp = (np.log10(base.r_max) - np.log10(base.r_min)) / base.num_bins_r
        self.sub_box_len_pi = (base.pi_bins[-1] - base.pi_bins[0]) / base.num_bins_pi
        # KDTree query radius for candidate selection (REFACTOR_PLAN.md section 3.2).
        # query_r_min is retained for reference only: the inner query it used to drive was
        # removed as redundant with bin_pairs' own lower bound (benchmarks/FINDINGS.md F1).
        self.query_r_min = base.r_min
        self.query_r_max = np.sqrt(base.r_max ** 2 + base.pi_bins[-1] ** 2)

    def bin_pairs(self, s, n_LOS, base):
        """Bin one position galaxy's separations ``s = s_shape[cand] - s_pos[n]`` to its
        candidate shape neighbours, given the per-pair midpoint LOS unit vectors ``n_LOS``.

        Returns ``(mask, ind_r, ind_pi, s_perp)`` where ``s_perp`` is the projected
        separation vector (``s`` minus its ``n_LOS`` component), used by ``accumulate`` for
        the ellipticity-angle calc.
        """
        LOS = base.calculate_dot_product_arrays(s, n_LOS)
        separation_len = np.sqrt(np.sum(s ** 2, axis=1) - LOS ** 2)
        s_perp = s - np.sum(s * n_LOS, axis=1, keepdims=True) * n_LOS
        mask = (separation_len >= self.r_bins[0]) * (separation_len < self.r_bins[-1]) * \
               (LOS >= self.pi_bins[0]) * (LOS < self.pi_bins[-1])
        ind_r = np.floor(
            np.log10(separation_len[mask]) / self.sub_box_len_logrp
            - np.log10(self.r_bins[0]) / self.sub_box_len_logrp
        )
        ind_r = np.array(ind_r, dtype=int)
        ind_pi = np.floor(
            LOS[mask] / self.sub_box_len_pi - self.pi_bins[0] / self.sub_box_len_pi
        )
        ind_pi = np.array(ind_pi, dtype=int)
        if np.any(ind_r == self.num_bins_r):
            ind_r[np.where(ind_r == self.num_bins_r)] = self.num_bins_r - 1
        if np.any(ind_pi == self.num_bins_pi):
            ind_pi[np.where(ind_pi == self.num_bins_pi)] = self.num_bins_pi - 1
        return mask, ind_r, ind_pi, s_perp

bin_pairs(s, n_LOS, base)

Bin one position galaxy's separations s = s_shape[cand] - s_pos[n] to its candidate shape neighbours, given the per-pair midpoint LOS unit vectors n_LOS.

Returns (mask, ind_r, ind_pi, s_perp) where s_perp is the projected separation vector (s minus its n_LOS component), used by accumulate for the ellipticity-angle calc.

Source code in src/measureia/pair_kernel.py
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
def bin_pairs(self, s, n_LOS, base):
    """Bin one position galaxy's separations ``s = s_shape[cand] - s_pos[n]`` to its
    candidate shape neighbours, given the per-pair midpoint LOS unit vectors ``n_LOS``.

    Returns ``(mask, ind_r, ind_pi, s_perp)`` where ``s_perp`` is the projected
    separation vector (``s`` minus its ``n_LOS`` component), used by ``accumulate`` for
    the ellipticity-angle calc.
    """
    LOS = base.calculate_dot_product_arrays(s, n_LOS)
    separation_len = np.sqrt(np.sum(s ** 2, axis=1) - LOS ** 2)
    s_perp = s - np.sum(s * n_LOS, axis=1, keepdims=True) * n_LOS
    mask = (separation_len >= self.r_bins[0]) * (separation_len < self.r_bins[-1]) * \
           (LOS >= self.pi_bins[0]) * (LOS < self.pi_bins[-1])
    ind_r = np.floor(
        np.log10(separation_len[mask]) / self.sub_box_len_logrp
        - np.log10(self.r_bins[0]) / self.sub_box_len_logrp
    )
    ind_r = np.array(ind_r, dtype=int)
    ind_pi = np.floor(
        LOS[mask] / self.sub_box_len_pi - self.pi_bins[0] / self.sub_box_len_pi
    )
    ind_pi = np.array(ind_pi, dtype=int)
    if np.any(ind_r == self.num_bins_r):
        ind_r[np.where(ind_r == self.num_bins_r)] = self.num_bins_r - 1
    if np.any(ind_pi == self.num_bins_pi):
        ind_pi[np.where(ind_pi == self.num_bins_pi)] = self.num_bins_pi - 1
    return mask, ind_r, ind_pi, s_perp

SkyRMuR

(r, mu_r) grid binning for a lightcone sky (multipoles).

Like SkyRpPi but the separation bin r is the full 3D separation length, mu_r = LOS / r (with the same midpoint n_LOS), and there is no pi window. Query radius is r_max (no inner cut). Same lightcone clamp convention as SkyRpPi. mu_r sub-bin length is 2.0 / num_bins_pi.

Source code in src/measureia/pair_kernel.py
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
class SkyRMuR:
    """(r, mu_r) grid binning for a lightcone sky (multipoles).

    Like ``SkyRpPi`` but the separation bin ``r`` is the full 3D separation length,
    ``mu_r = LOS / r`` (with the same midpoint ``n_LOS``), and there is no ``pi`` window.
    Query radius is ``r_max`` (no inner cut). Same lightcone clamp convention as
    ``SkyRpPi``. ``mu_r`` sub-bin length is ``2.0 / num_bins_pi``.
    """

    def __init__(self, base):
        self.r_min = base.r_min
        self.r_max = base.r_max
        self.r_bins = base.r_bins
        self.mu_r_bins = base.mu_r_bins
        self.num_bins_r = base.num_bins_r
        self.num_bins_pi = base.num_bins_pi
        self.sub_box_len_logrp = (np.log10(base.r_max) - np.log10(base.r_min)) / base.num_bins_r
        self.sub_box_len_mu_r = 2.0 / base.num_bins_pi
        self.query_r_min = base.r_min
        self.query_r_max = base.r_max

    def bin_pairs(self, s, n_LOS, base):
        """Returns ``(mask, ind_r, ind_mu_r, s_perp)``; ``ind_r`` binned on the 3D
        separation length, ``ind_mu_r`` on ``mu_r = LOS / r_3d``. Window keeps 3D length
        in ``[r_bins[0], r_bins[-1])`` (no LOS window)."""
        LOS = base.calculate_dot_product_arrays(s, n_LOS)
        separation_len = np.sqrt(np.sum(s ** 2, axis=1))
        with np.errstate(invalid='ignore'):  # coincident pairs give 0/0; masked out below
            mu_r = LOS / separation_len
        s_perp = s - np.sum(s * n_LOS, axis=1, keepdims=True) * n_LOS
        mask = (separation_len >= self.r_bins[0]) * (separation_len < self.r_bins[-1])
        ind_r = np.floor(
            np.log10(separation_len[mask]) / self.sub_box_len_logrp
            - np.log10(self.r_bins[0]) / self.sub_box_len_logrp
        )
        ind_r = np.array(ind_r, dtype=int)
        ind_mu_r = np.floor(
            mu_r[mask] / self.sub_box_len_mu_r - self.mu_r_bins[0] / self.sub_box_len_mu_r
        )
        ind_mu_r = np.array(ind_mu_r, dtype=int)
        if np.any(ind_r == self.num_bins_r):
            ind_r[np.where(ind_r == self.num_bins_r)] = self.num_bins_r - 1
        if np.any(ind_mu_r == self.num_bins_pi):
            ind_mu_r[np.where(ind_mu_r == self.num_bins_pi)] = self.num_bins_pi - 1
        return mask, ind_r, ind_mu_r, s_perp

bin_pairs(s, n_LOS, base)

Returns (mask, ind_r, ind_mu_r, s_perp); ind_r binned on the 3D separation length, ind_mu_r on mu_r = LOS / r_3d. Window keeps 3D length in [r_bins[0], r_bins[-1]) (no LOS window).

Source code in src/measureia/pair_kernel.py
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
def bin_pairs(self, s, n_LOS, base):
    """Returns ``(mask, ind_r, ind_mu_r, s_perp)``; ``ind_r`` binned on the 3D
    separation length, ``ind_mu_r`` on ``mu_r = LOS / r_3d``. Window keeps 3D length
    in ``[r_bins[0], r_bins[-1])`` (no LOS window)."""
    LOS = base.calculate_dot_product_arrays(s, n_LOS)
    separation_len = np.sqrt(np.sum(s ** 2, axis=1))
    with np.errstate(invalid='ignore'):  # coincident pairs give 0/0; masked out below
        mu_r = LOS / separation_len
    s_perp = s - np.sum(s * n_LOS, axis=1, keepdims=True) * n_LOS
    mask = (separation_len >= self.r_bins[0]) * (separation_len < self.r_bins[-1])
    ind_r = np.floor(
        np.log10(separation_len[mask]) / self.sub_box_len_logrp
        - np.log10(self.r_bins[0]) / self.sub_box_len_logrp
    )
    ind_r = np.array(ind_r, dtype=int)
    ind_mu_r = np.floor(
        mu_r[mask] / self.sub_box_len_mu_r - self.mu_r_bins[0] / self.sub_box_len_mu_r
    )
    ind_mu_r = np.array(ind_mu_r, dtype=int)
    if np.any(ind_r == self.num_bins_r):
        ind_r[np.where(ind_r == self.num_bins_r)] = self.num_bins_r - 1
    if np.any(ind_mu_r == self.num_bins_pi):
        ind_mu_r[np.where(ind_mu_r == self.num_bins_pi)] = self.num_bins_pi - 1
    return mask, ind_r, ind_mu_r, s_perp

prepare_box_samples(data, masks, Num_position, Num_shape, *, shapes, ellipticity, base, require_full_masks=False)

Apply masks and compute per-galaxy ellipticity size for a Box measurement.

Reproduces, verbatim, the mask-application and e computation shared by every Box (rp, pi) / (r, mu_r) counting method: mask defaulting rules (Position/Position_shape_sample masks default to "select all"; weight/weight_shape_sample default to the coordinate mask and are written back into masks in place, matching the legacy fallback-default behaviour other code paths rely on), then e = f(q) for the requested ellipticity definition.

require_full_masks selects the mask-indexing convention: the non-jk methods default a missing Position/Position_shape_sample/Axis_Direction/q mask (.get with "select all" / coordinate-mask fallbacks), whereas the box jackknife methods index masks["Position"] etc. directly and raise KeyError on a partial dict — pass require_full_masks=True to reproduce that (see REFACTOR_PLAN.md section 3.1). weight/weight_shape_sample still default to the coordinate mask in both modes.

Parameters:
  • data (dict) –

    The object's self.data.

  • masks (dict or None) –

    Per-call mask dict; mutated in place to inject default weight/ weight_shape_sample masks when absent (legacy behaviour).

  • Num_position (int) –

    Full (unmasked) sample sizes, used as the default "select all" mask length.

  • Num_shape (int) –

    Full (unmasked) sample sizes, used as the default "select all" mask length.

  • shapes (bool) –

    If False, skip Axis_Direction/q/e (DD-only / count_pairs paths).

  • ellipticity (str) –

    'distortion' or 'ellipticity'; see MeasureIABase.get_ellipticity.

  • base (object) –

    The calling instance (unused today; accepted for interface symmetry with future steps that need e.g. responsivity_correction here).

Returns:
Source code in src/measureia/pair_kernel.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def prepare_box_samples(data, masks, Num_position, Num_shape, *, shapes, ellipticity, base,
                        require_full_masks=False):
    """Apply masks and compute per-galaxy ellipticity size for a Box measurement.

    Reproduces, verbatim, the mask-application and ``e`` computation shared by
    every Box (rp, pi) / (r, mu_r) counting method: mask defaulting rules
    (``Position``/``Position_shape_sample`` masks default to "select all";
    ``weight``/``weight_shape_sample`` default to the coordinate mask and are
    written back into ``masks`` in place, matching the legacy fallback-default
    behaviour other code paths rely on), then ``e = f(q)`` for the requested
    ellipticity definition.

    ``require_full_masks`` selects the mask-indexing convention: the non-jk methods
    default a missing ``Position``/``Position_shape_sample``/``Axis_Direction``/``q``
    mask (``.get`` with "select all" / coordinate-mask fallbacks), whereas the box
    jackknife methods index ``masks["Position"]`` etc. **directly** and raise KeyError
    on a partial dict — pass ``require_full_masks=True`` to reproduce that (see
    REFACTOR_PLAN.md section 3.1). ``weight``/``weight_shape_sample`` still default to
    the coordinate mask in both modes.

    Parameters
    ----------
    data : dict
        The object's ``self.data``.
    masks : dict or None
        Per-call mask dict; mutated in place to inject default ``weight``/
        ``weight_shape_sample`` masks when absent (legacy behaviour).
    Num_position, Num_shape : int
        Full (unmasked) sample sizes, used as the default "select all" mask length.
    shapes : bool
        If False, skip ``Axis_Direction``/``q``/``e`` (DD-only / count_pairs paths).
    ellipticity : str
        'distortion' or 'ellipticity'; see ``MeasureIABase.get_ellipticity``.
    base : object
        The calling instance (unused today; accepted for interface symmetry
        with future steps that need e.g. responsivity_correction here).

    Returns
    -------
    SampleSet
    """
    if masks is None:
        positions = data["Position"]
        positions_shape_sample = data["Position_shape_sample"]
        weight = data["weight"]
        weight_shape = data["weight_shape_sample"]
        axis_direction_v = data["Axis_Direction"] if shapes else None
        q = data["q"] if shapes else None
    else:
        if require_full_masks:
            pos_mask = masks["Position"]
            shape_mask = masks["Position_shape_sample"]
        else:
            pos_mask = masks.get("Position", np.ones(Num_position, dtype=bool))
            shape_mask = masks.get("Position_shape_sample", np.ones(Num_shape, dtype=bool))
        positions = data["Position"][pos_mask]
        positions_shape_sample = data["Position_shape_sample"][shape_mask]
        if "weight" not in masks:
            masks["weight"] = pos_mask
        if "weight_shape_sample" not in masks:
            masks["weight_shape_sample"] = shape_mask
        weight = data["weight"][masks["weight"]]
        weight_shape = data["weight_shape_sample"][masks["weight_shape_sample"]]
        if shapes:
            if require_full_masks:
                dir_mask = masks["Axis_Direction"]
                q_mask = masks["q"]
            else:
                dir_mask = masks.get("Axis_Direction", shape_mask)
                q_mask = masks.get("q", shape_mask)
            axis_direction_v = data["Axis_Direction"][dir_mask]
            q = data["q"][q_mask]
        else:
            axis_direction_v = None
            q = None

    axis_direction = None
    e = None
    if shapes:
        axis_direction_len = np.sqrt(np.sum(axis_direction_v ** 2, axis=1))
        axis_direction = (axis_direction_v.transpose() / axis_direction_len).transpose()
        if ellipticity == 'distortion':
            e = (1 - q ** 2) / (1 + q ** 2)
        elif ellipticity == 'ellipticity':
            e = (1 - q) / (1 + q)
        else:
            raise ValueError("Invalid value for ellipticity. Choose 'distortion' or 'ellipticity'.")

    LOS_ind = data["LOS"]
    not_LOS = np.array([0, 1, 2])[np.isin([0, 1, 2], LOS_ind, invert=True)]

    # How many objects sit in both samples, after masking. The analytic RR needs this:
    # a shape galaxy cannot pair with itself, and the pair loop already drops that
    # self-pair because the separation window starts at r_min > 0. Recorded on the
    # caller so every RR call in the backends can use one consistent value.
    # Imported here rather than at module scope to keep the import graph acyclic.
    from .measure_IA_base import count_overlap
    override = getattr(base, "_num_overlap_override", None)
    base.num_overlap = (int(override) if override is not None
                        else count_overlap(positions, positions_shape_sample))

    return SampleSet(
        pos=positions, pos_shape=positions_shape_sample,
        weight=weight, weight_shape=weight_shape,
        axis_direction=axis_direction, e=e,
        LOS_ind=LOS_ind, not_LOS=not_LOS,
    )

prepare_lightcone_samples(data, masks, *, shapes, cosmology, over_h, responsivity_correction, base, print_num=True)

Apply masks and build the 3D comoving sky geometry for a Lightcone measurement.

Reproduces, verbatim, the shared head of every lightcone (rp, pi) / (r, mu_r) counting method: RA/DEC/Redshift/e1/e2 mask-application (direct masks["RA"]-style indexing — a partial dict raises KeyError, as in the legacy; weight/weight_shape_sample default to the RA/RA_shape_sample coordinate masks and are written back into masks in place), redshift → comoving distance via pyccl (default cosmology when None; over_h scales by h), the unit-sphere direction vectors, the position/shape comoving vectors s_pos/s_shape, and — for shapes=True — the per-shape ellipticity e = (e1, e2) pre-scaled by 1/(2R) when responsivity_correction (responsivity is baked into e here, not divided in the pair loop, unlike the box families — REFACTOR_PLAN.md section 3.2), plus the local east/north sky basis at each position.

SampleSet layout for the lightcone geometry: pos = s_pos (N,3), pos_shape = s_shape (M,3), weight/weight_shape; and when shapes: e (M,2), east (N,3), north (N,3), n_pos (N,3).

Parameters:
  • data (dict) –

    The object's self.data.

  • masks (dict or None) –

    Per-call mask dict; mutated in place to inject default weight/ weight_shape_sample masks when absent (legacy behaviour).

  • shapes (bool) –

    If False, skip e1/e2/responsivity and the east/north basis (DD-only / count_pairs paths).

  • cosmology (Cosmology or None) –

    Cosmology for redshift→comoving distance; a fixed default is built (and, when print_num, announced) if None, matching the legacy.

  • over_h (bool) –

    If True, multiply comoving distances by h (positions in cMpc/h).

  • responsivity_correction (bool) –

    If True (and shapes), pre-scale e1, e2 by 1/(2R) with R the weighted responsivity over the shape sample. Note the lightcone default is False (unlike the box families) — pass getattr(self, "responsivity_correction", False).

  • base (object) –

    The calling instance (accepted for interface symmetry; unused here).

  • print_num (bool, default: True ) –

    Gate on the "No cosmology given" informational print, matching the legacy.

Returns:
Source code in src/measureia/pair_kernel.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
def prepare_lightcone_samples(data, masks, *, shapes, cosmology, over_h,
                              responsivity_correction, base, print_num=True):
    """Apply masks and build the 3D comoving sky geometry for a Lightcone measurement.

    Reproduces, verbatim, the shared head of every lightcone (rp, pi) / (r, mu_r) counting
    method: RA/DEC/Redshift/e1/e2 mask-application (direct ``masks["RA"]``-style indexing —
    a partial dict raises KeyError, as in the legacy; ``weight``/``weight_shape_sample``
    default to the ``RA``/``RA_shape_sample`` coordinate masks and are written back into
    ``masks`` in place), redshift → comoving distance via ``pyccl`` (default cosmology when
    None; ``over_h`` scales by ``h``), the unit-sphere direction vectors, the position/shape
    comoving vectors ``s_pos``/``s_shape``, and — for ``shapes=True`` — the per-shape
    ellipticity ``e = (e1, e2)`` **pre-scaled by 1/(2R)** when ``responsivity_correction``
    (responsivity is baked into ``e`` here, not divided in the pair loop, unlike the box
    families — REFACTOR_PLAN.md section 3.2), plus the local ``east``/``north`` sky basis at
    each position.

    ``SampleSet`` layout for the lightcone geometry: ``pos = s_pos`` (N,3), ``pos_shape =
    s_shape`` (M,3), ``weight``/``weight_shape``; and when ``shapes``: ``e`` (M,2), ``east``
    (N,3), ``north`` (N,3), ``n_pos`` (N,3).

    Parameters
    ----------
    data : dict
        The object's ``self.data``.
    masks : dict or None
        Per-call mask dict; mutated in place to inject default ``weight``/
        ``weight_shape_sample`` masks when absent (legacy behaviour).
    shapes : bool
        If False, skip ``e1``/``e2``/responsivity and the ``east``/``north`` basis
        (DD-only / count_pairs paths).
    cosmology : pyccl.Cosmology or None
        Cosmology for redshift→comoving distance; a fixed default is built (and, when
        ``print_num``, announced) if None, matching the legacy.
    over_h : bool
        If True, multiply comoving distances by ``h`` (positions in cMpc/h).
    responsivity_correction : bool
        If True (and ``shapes``), pre-scale ``e1, e2`` by ``1/(2R)`` with ``R`` the
        weighted responsivity over the shape sample. Note the lightcone default is False
        (unlike the box families) — pass ``getattr(self, "responsivity_correction", False)``.
    base : object
        The calling instance (accepted for interface symmetry; unused here).
    print_num : bool
        Gate on the "No cosmology given" informational print, matching the legacy.

    Returns
    -------
    SampleSet
    """
    if masks is None:
        redshift = data["Redshift"]
        redshift_shape_sample = data["Redshift_shape_sample"]
        RA = data["RA"]
        RA_shape_sample = data["RA_shape_sample"]
        DEC = data["DEC"]
        DEC_shape_sample = data["DEC_shape_sample"]
        weight = data["weight"]
        weight_shape = data["weight_shape_sample"]
        if shapes:
            e1 = data["e1"]
            e2 = data["e2"]
    else:
        redshift = data["Redshift"][masks["Redshift"]]
        redshift_shape_sample = data["Redshift_shape_sample"][masks["Redshift_shape_sample"]]
        RA = data["RA"][masks["RA"]]
        RA_shape_sample = data["RA_shape_sample"][masks["RA_shape_sample"]]
        DEC = data["DEC"][masks["DEC"]]
        DEC_shape_sample = data["DEC_shape_sample"][masks["DEC_shape_sample"]]
        if shapes:
            e1 = data["e1"][masks["e1"]]
            e2 = data["e2"][masks["e2"]]
        if "weight" not in masks:
            masks["weight"] = masks["RA"]
        if "weight_shape_sample" not in masks:
            masks["weight_shape_sample"] = masks["RA_shape_sample"]
        weight = data["weight"][masks["weight"]]
        weight_shape = data["weight_shape_sample"][masks["weight_shape_sample"]]

    Num_position = len(RA)

    if cosmology is None:
        cosmology = ccl.Cosmology(Omega_c=0.225, Omega_b=0.045, sigma8=0.8, h=0.7, n_s=1.0)
        if print_num:
            print("No cosmology given, using Omega_m=0.27, Omega_b=0.045, sigma8=0.8, h=0.7, n_s=1.")
    h = cosmology["h"]

    LOS_all = ccl.comoving_radial_distance(cosmology, 1 / (1 + redshift))
    LOS_all_shape_sample = ccl.comoving_radial_distance(cosmology, 1 / (1 + redshift_shape_sample))
    if over_h:
        LOS_all *= h
        LOS_all_shape_sample *= h

    e = None
    east = None
    north = None
    if shapes:
        if responsivity_correction:
            R = sum(weight_shape * (1 - (e1 ** 2 + e2 ** 2) / 2.0)) / sum(weight_shape)
            e1, e2 = e1 / (2 * R), e2 / (2 * R)
        e = np.array([e1, e2]).transpose()

    RA_rad = RA / 180 * np.pi
    RA_shape_sample_rad = RA_shape_sample / 180 * np.pi
    DEC_rad = DEC / 180 * np.pi
    DEC_shape_sample_rad = DEC_shape_sample / 180 * np.pi
    n_shape = np.array([np.cos(DEC_shape_sample_rad) * np.cos(RA_shape_sample_rad),
                        np.cos(DEC_shape_sample_rad) * np.sin(RA_shape_sample_rad),
                        np.sin(DEC_shape_sample_rad)]).transpose()
    s_shape = n_shape * np.array([LOS_all_shape_sample]).transpose()
    n_pos = np.array([np.cos(DEC_rad) * np.cos(RA_rad),
                      np.cos(DEC_rad) * np.sin(RA_rad),
                      np.sin(DEC_rad)]).transpose()
    if shapes:
        east = np.array([-np.sin(RA_rad), np.cos(RA_rad), np.zeros(Num_position)]).transpose()
        north = np.array([
            -np.sin(DEC_rad) * np.cos(RA_rad),
            -np.sin(DEC_rad) * np.sin(RA_rad),
            np.cos(DEC_rad)
        ]).transpose()
    s_pos = np.array([LOS_all]).transpose() * n_pos

    return SampleSet(
        pos=s_pos, pos_shape=s_shape,
        weight=weight, weight_shape=weight_shape,
        e=e, east=east, north=north, n_pos=n_pos,
    )

spatial_order(coords)

Visit order that makes consecutive chunks compact in space.

accumulate processes the chunked sample 100 at a time, builds a KDTree of each chunk and queries it against the full tree of the other sample. A dual-tree traversal can only prune when the chunk occupies a small region, and nothing about a catalogue's storage order guarantees that: a sample stored grouped by halo, or by id, or shuffled, gives chunks whose bounding box spans essentially the whole volume, and the query degenerates towards brute force.

Measured on the package's own mock at 100,000 galaxies, the chunk extent was 619 Mpc inside a 711 Mpc box, the query took 6.20 s, and its cost scaled as N^1.63. Visiting the same galaxies in the order returned here made the chunks 243 Mpc across, the query 0.34 s, and the scaling N^1.01 -- an 18x speed-up from the identical set of pairs (benchmarks/FINDINGS.md F5).

The key is a Morton (Z-order) code on a 1024-per-axis grid spanning the sample, which interleaves the bits of the per-axis cell indices so that points close in space stay close in the ordering. Works for 2D coordinates (the (rp, pi) box binning projects out the line of sight) as well as 3D.

Parameters:
  • coords ((N, D) ndarray) –

    Coordinates in the same metric the KDTree is built on, i.e. whatever binning.tree_coords returns.

Returns:
  • (N,) ndarray of int

    Indices into coords, spatially ordered. A stable sort, so the order is deterministic for a given input.

Source code in src/measureia/pair_kernel.py
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
def spatial_order(coords):
    """Visit order that makes consecutive chunks compact in space.

    ``accumulate`` processes the chunked sample 100 at a time, builds a KDTree of
    each chunk and queries it against the full tree of the other sample. A
    dual-tree traversal can only prune when the chunk occupies a small region, and
    nothing about a catalogue's storage order guarantees that: a sample stored
    grouped by halo, or by id, or shuffled, gives chunks whose bounding box spans
    essentially the whole volume, and the query degenerates towards brute force.

    Measured on the package's own mock at 100,000 galaxies, the chunk extent was
    619 Mpc inside a 711 Mpc box, the query took 6.20 s, and its cost scaled as
    N^1.63. Visiting the same galaxies in the order returned here made the chunks
    243 Mpc across, the query 0.34 s, and the scaling N^1.01 -- an 18x speed-up
    from the identical set of pairs (benchmarks/FINDINGS.md F5).

    The key is a Morton (Z-order) code on a 1024-per-axis grid spanning the
    sample, which interleaves the bits of the per-axis cell indices so that points
    close in space stay close in the ordering. Works for 2D coordinates (the
    (rp, pi) box binning projects out the line of sight) as well as 3D.

    Parameters
    ----------
    coords : (N, D) ndarray
        Coordinates in the same metric the KDTree is built on, i.e. whatever
        ``binning.tree_coords`` returns.

    Returns
    -------
    (N,) ndarray of int
        Indices into ``coords``, spatially ordered. A stable sort, so the order is
        deterministic for a given input.

    """
    coords = np.asarray(coords, dtype=float)
    n, dim = coords.shape
    bits = 10
    lo = coords.min(axis=0)
    span = coords.max(axis=0) - lo
    span[span <= 0] = 1.0
    cell = np.clip(((coords - lo) / span * (1 << bits)).astype(np.int64),
                   0, (1 << bits) - 1)
    key = np.zeros(n, dtype=np.int64)
    for b in range(bits):
        for d in range(dim):
            key |= ((cell[:, d] >> b) & 1) << (b * dim + d)
    return np.argsort(key, kind="stable")

accumulate(sample_set, binning, *, base, R=None, shapes=True, chunk_axis='shape', chunk_size_outer=100, jk=False, num_box=None, pos_tree=None, shape_tree=None, backend='tree', per_galaxy=False, per_galaxy_proj=None, per_galaxy_jk=False, per_galaxy_jk_sparse=False)

Run the pair-accumulation loop and return the resulting grids.

Implemented so far: box geometry, BoxRpPi / BoxRMuR binnings, chunk_axis="shape", backends "tree" and "brute", optional jackknife. Later migration steps (REFACTOR_PLAN.md section 6, steps 6-7) extend this same function to the lightcone geometry.

jk=True (with num_box = number of jackknife realisations) additionally accumulates the union-deletion per-realisation grids DD_jk (and, when shapes, the raw Splus_D_jk): every pair contributes to the shape's patch, and to the position's patch only where the two patches differ. sample_set must then carry jk_pos (position-aligned) and jk_shape (shape-aligned) patch indices. This is a per-batch quantity in the mp path — the parent sums the partial jk grids and computes R_jk separately via compute_R_jk.

Iteration order (outer loop over shape-sample chunks of chunk_size_outer, inner loop over the chunk, vectorized np.add.at per shape galaxy) is fixed by the float-summation-order rule in REFACTOR_PLAN.md section 4 and must not change without re-deriving bit-identity against the legacy tree/mp paths.

backend selects how each shape galaxy's candidate positions are chosen: - "tree": KDTree ball query at r_max against the position tree (the legacy tree/mp order — bit-identical). There is no inner r_min query: the binning mask already drops everything below r_bins[0], so the extra query and the set difference it fed were removed as redundant (benchmarks/FINDINGS.md F1). Candidates below r_min are therefore passed to bin_pairs and masked out there. - "brute": every position is a candidate (full cross-join per chunk); the [r_min, r_max) window is applied by the binning mask. This runs on the same shape-chunk order as the tree backend rather than the legacy brute's position-outer order, so it matches the legacy brute only to floating-point tolerance (allclose), not bit-identically — a deliberate consolidation choice (REFACTOR_PLAN.md section 4). It counts exactly the same pairs the legacy brute did (same window mask), so integer (unit-weight) DD grids still match exactly.

per_galaxy=True (box path only) additionally resolves the same sums per shape galaxy, i.e. without summing over the shape sample. It changes nothing about the grids above: the per-galaxy arrays are accumulated in their own branch and satisfy per_galaxy_arrays.sum(axis=0) == the corresponding grid by construction. The galaxy axis is indexed as in sample_set.pos_shape (so, in the multiprocessing path, local to the batch — the caller concatenates batches in order).

  • per_galaxy_proj=None: DD_gal/Splus_D_gal have shape (M, num_bins_r, num_bins_pi), the same axes as the grids.
  • per_galaxy_proj=W with W of shape (num_bins_r, num_bins_pi): the second bin axis is contracted with W as it is accumulated, giving Splus_D_gal of shape (M, num_bins_r). This is how the multipole kernel (Legendre weight / RR, both analytic in the box) is folded in without ever materialising the mu_r axis per galaxy. DD_gal is not contracted with W — it is the plain pair count per radial bin, which is what a per-galaxy regression design matrix needs — so it has shape (M, num_bins_r) too.

per_galaxy_jk_sparse=True stores that decomposition only for the patches each galaxy actually has pairs in. A galaxy's neighbours span a ball of radius r_max, so it reaches at most a handful of sub-boxes however many there are in total, and the rest of the patch axis is structurally zero. The dense (M, num_box, num_bins_r) form becomes gal_jk_patches (M, K) plus *_gal_jk_values (M, K, num_bins_r), with K the largest number of patches any one galaxy touches and unused slots padded with patch -1 and zero values. DD and S+D share one patch array, since a pair contributes to both. This is a pure change of representation: the stored floats are the same values, so results are bit-identical (asserted by a test).

per_galaxy_jk=True further decomposes the per-galaxy arrays by the jackknife patch of the position-sample partner, giving (M, num_box, num_bins_r). Combined with the patch of the shape galaxy itself this reproduces the union-deletion of DD_jk/Splus_D_jk exactly: realisation n drops every shape galaxy with jk_shape == n and subtracts column n from the rest. It requires per_galaxy_proj (and sample_set.jk_pos/jk_shape), since the undecomposed mu_r axis would multiply the footprint by num_bins_pi.

All of this is inert when per_galaxy=False (the default): the pair loop, its iteration order and its float summation order are untouched, so the bit-identity guarantee of REFACTOR_PLAN.md section 4 still holds and normal measurements pay nothing beyond one branch test per shape galaxy.

pos_tree may be a prebuilt KDTree over sample_set.pos[:, not_LOS] (tree backend only). The multiprocessing path passes the tree it built once in the parent process (shared to every worker) rather than rebuilding it per batch; when None the tree is built here. sample_set.pos must be the same full position array the tree was built from either way.

Source code in src/measureia/pair_kernel.py
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
def accumulate(sample_set, binning, *, base, R=None, shapes=True,
               chunk_axis="shape", chunk_size_outer=100, jk=False, num_box=None,
               pos_tree=None, shape_tree=None, backend="tree",
               per_galaxy=False, per_galaxy_proj=None, per_galaxy_jk=False,
               per_galaxy_jk_sparse=False):
    """Run the pair-accumulation loop and return the resulting grids.

    Implemented so far: box geometry, ``BoxRpPi`` / ``BoxRMuR`` binnings,
    ``chunk_axis="shape"``, backends ``"tree"`` and ``"brute"``, optional jackknife.
    Later migration steps (REFACTOR_PLAN.md section 6, steps 6-7) extend this same
    function to the lightcone geometry.

    ``jk=True`` (with ``num_box`` = number of jackknife realisations) additionally
    accumulates the union-deletion per-realisation grids ``DD_jk`` (and, when
    ``shapes``, the raw ``Splus_D_jk``): every pair contributes to the shape's patch,
    and to the position's patch only where the two patches differ. ``sample_set``
    must then carry ``jk_pos`` (position-aligned) and ``jk_shape`` (shape-aligned)
    patch indices. This is a per-batch quantity in the mp path — the parent sums the
    partial jk grids and computes ``R_jk`` separately via ``compute_R_jk``.

    Iteration order (outer loop over shape-sample chunks of ``chunk_size_outer``,
    inner loop over the chunk, vectorized ``np.add.at`` per shape galaxy) is fixed
    by the float-summation-order rule in REFACTOR_PLAN.md section 4 and must not
    change without re-deriving bit-identity against the legacy tree/mp paths.

    ``backend`` selects how each shape galaxy's candidate positions are chosen:
      - ``"tree"``: KDTree ball query at ``r_max`` against the position tree (the
        legacy tree/mp order — bit-identical). There is no inner ``r_min`` query:
        the binning mask already drops everything below ``r_bins[0]``, so the extra
        query and the set difference it fed were removed as redundant
        (benchmarks/FINDINGS.md F1). Candidates below ``r_min`` are therefore passed
        to ``bin_pairs`` and masked out there.
      - ``"brute"``: every position is a candidate (full cross-join per chunk);
        the ``[r_min, r_max)`` window is applied by the binning mask. This runs on
        the *same* shape-chunk order as the tree backend rather than the legacy
        brute's position-outer order, so it matches the legacy brute only to
        floating-point tolerance (``allclose``), not bit-identically — a
        deliberate consolidation choice (REFACTOR_PLAN.md section 4). It counts
        exactly the same pairs the legacy brute did (same window mask), so integer
        (unit-weight) DD grids still match exactly.

    ``per_galaxy=True`` (box path only) additionally resolves the same sums **per shape
    galaxy**, i.e. without summing over the shape sample. It changes nothing about the
    grids above: the per-galaxy arrays are accumulated in their own branch and satisfy
    ``per_galaxy_arrays.sum(axis=0) == the corresponding grid`` by construction. The
    galaxy axis is indexed as in ``sample_set.pos_shape`` (so, in the multiprocessing
    path, local to the batch — the caller concatenates batches in order).

      - ``per_galaxy_proj=None``: ``DD_gal``/``Splus_D_gal`` have shape
        ``(M, num_bins_r, num_bins_pi)``, the same axes as the grids.
      - ``per_galaxy_proj=W`` with ``W`` of shape ``(num_bins_r, num_bins_pi)``: the
        second bin axis is contracted with ``W`` as it is accumulated, giving
        ``Splus_D_gal`` of shape ``(M, num_bins_r)``. This is how the multipole kernel
        (Legendre weight / RR, both analytic in the box) is folded in without ever
        materialising the ``mu_r`` axis per galaxy. ``DD_gal`` is *not* contracted with
        ``W`` — it is the plain pair count per radial bin, which is what a per-galaxy
        regression design matrix needs — so it has shape ``(M, num_bins_r)`` too.

    ``per_galaxy_jk_sparse=True`` stores that decomposition only for the patches each
    galaxy actually has pairs in. A galaxy's neighbours span a ball of radius ``r_max``, so
    it reaches at most a handful of sub-boxes however many there are in total, and the rest
    of the patch axis is *structurally* zero. The dense ``(M, num_box, num_bins_r)`` form
    becomes ``gal_jk_patches`` ``(M, K)`` plus ``*_gal_jk_values`` ``(M, K, num_bins_r)``,
    with ``K`` the largest number of patches any one galaxy touches and unused slots padded
    with patch ``-1`` and zero values. ``DD`` and ``S+D`` share one patch array, since a
    pair contributes to both. This is a pure change of representation: the stored floats
    are the same values, so results are bit-identical (asserted by a test).

    ``per_galaxy_jk=True`` further decomposes the per-galaxy arrays by the jackknife
    patch of the *position-sample* partner, giving ``(M, num_box, num_bins_r)``. Combined
    with the patch of the shape galaxy itself this reproduces the union-deletion of
    ``DD_jk``/``Splus_D_jk`` exactly: realisation ``n`` drops every shape galaxy with
    ``jk_shape == n`` and subtracts column ``n`` from the rest. It requires
    ``per_galaxy_proj`` (and ``sample_set.jk_pos``/``jk_shape``), since the undecomposed
    ``mu_r`` axis would multiply the footprint by ``num_bins_pi``.

    All of this is inert when ``per_galaxy=False`` (the default): the pair loop, its
    iteration order and its float summation order are untouched, so the bit-identity
    guarantee of REFACTOR_PLAN.md section 4 still holds and normal measurements pay
    nothing beyond one branch test per shape galaxy.

    ``pos_tree`` may be a prebuilt ``KDTree`` over ``sample_set.pos[:, not_LOS]``
    (tree backend only). The multiprocessing path passes the tree it built once in
    the parent process (shared to every worker) rather than rebuilding it per
    batch; when None the tree is built here. ``sample_set.pos`` must be the same
    full position array the tree was built from either way.
    """
    if backend not in ("tree", "brute"):
        raise NotImplementedError(
            f"pair_kernel.accumulate: unknown backend {backend!r} (expected 'tree' or 'brute')."
        )
    if chunk_axis == "position":
        if not isinstance(binning, (SkyRpPi, SkyRMuR)):
            raise NotImplementedError(
                "pair_kernel.accumulate: chunk_axis='position' (lightcone) requires a "
                "SkyRpPi / SkyRMuR binning."
            )
        if jk and num_box is None:
            raise ValueError("pair_kernel.accumulate: jk=True requires num_box (num_jk).")
        if backend == "brute" and shape_tree is not None:
            raise ValueError(
                "pair_kernel.accumulate: shape_tree is meaningless with backend='brute'."
            )
        return _accumulate_lightcone(
            sample_set, binning, base=base, shapes=shapes,
            chunk_size_outer=chunk_size_outer, backend=backend,
            jk=jk, num_jk=num_box, shape_tree=shape_tree,
        )
    if chunk_axis != "shape":
        raise NotImplementedError(
            "pair_kernel.accumulate: only chunk_axis='shape' (box) and "
            "chunk_axis='position' (lightcone) are implemented."
        )
    if not isinstance(binning, (BoxRpPi, BoxRMuR)):
        raise NotImplementedError(
            "pair_kernel.accumulate: only BoxRpPi / BoxRMuR binnings are implemented "
            "for the box (chunk_axis='shape') path."
        )
    if backend == "brute" and pos_tree is not None:
        raise ValueError(
            "pair_kernel.accumulate: pos_tree is meaningless with backend='brute' "
            "(no KDTree is built); pass pos_tree only with backend='tree'."
        )
    if jk and num_box is None:
        raise ValueError("pair_kernel.accumulate: jk=True requires num_box.")
    if per_galaxy_jk and not per_galaxy:
        raise ValueError(
            "pair_kernel.accumulate: per_galaxy_jk=True requires per_galaxy=True."
        )
    if per_galaxy_jk and per_galaxy_proj is None:
        raise ValueError(
            "pair_kernel.accumulate: per_galaxy_jk=True requires per_galaxy_proj, so that "
            "the mu_r/pi axis is contracted before the per-patch decomposition (an "
            "undecomposed axis would multiply the per-galaxy footprint by num_bins_pi)."
        )
    if per_galaxy_jk and num_box is None:
        raise ValueError("pair_kernel.accumulate: per_galaxy_jk=True requires num_box.")
    if per_galaxy_jk_sparse and not per_galaxy_jk:
        raise ValueError(
            "pair_kernel.accumulate: per_galaxy_jk_sparse=True requires per_galaxy_jk=True."
        )
    if per_galaxy_jk and (sample_set.jk_pos is None or sample_set.jk_shape is None):
        raise ValueError(
            "pair_kernel.accumulate: per_galaxy_jk=True requires sample_set.jk_pos and "
            "sample_set.jk_shape."
        )
    if per_galaxy_proj is not None:
        per_galaxy_proj = np.asarray(per_galaxy_proj, dtype=float)
        expected = (binning.num_bins_r, binning.num_bins_pi)
        if per_galaxy_proj.shape != expected:
            raise ValueError(
                f"pair_kernel.accumulate: per_galaxy_proj has shape {per_galaxy_proj.shape}, "
                f"expected {expected} (num_bins_r, num_bins_pi)."
            )

    DD = np.array([[0.0] * binning.num_bins_pi] * binning.num_bins_r)
    Splus_D = np.array([[0.0] * binning.num_bins_pi] * binning.num_bins_r) if shapes else None
    Scross_D = np.array([[0.0] * binning.num_bins_pi] * binning.num_bins_r) if shapes else None
    DD_jk = np.zeros((num_box, binning.num_bins_r, binning.num_bins_pi)) if jk else None
    Splus_D_jk = np.zeros((num_box, binning.num_bins_r, binning.num_bins_pi)) if (jk and shapes) else None

    DD_gal = Splus_D_gal = DD_gal_jk = Splus_D_gal_jk = None
    if per_galaxy:
        num_gal = len(sample_set.pos_shape)
        if per_galaxy_proj is None:
            gal_shape = (num_gal, binning.num_bins_r, binning.num_bins_pi)
        else:
            gal_shape = (num_gal, binning.num_bins_r)
        DD_gal = np.zeros(gal_shape)
        Splus_D_gal = np.zeros(gal_shape) if shapes else None
        if per_galaxy_jk and not per_galaxy_jk_sparse:
            jk_gal_shape = (num_gal, num_box, binning.num_bins_r)
            DD_gal_jk = np.zeros(jk_gal_shape)
            Splus_D_gal_jk = np.zeros(jk_gal_shape) if shapes else None
    # Sparse jackknife decomposition: accumulate one outer chunk at a time into a small
    # dense buffer, then keep only the patches that chunk actually touched. The buffer is
    # (chunk_size_outer, num_box, num_bins_r), which stays small however large num_box is.
    sp_buffer_DD = sp_buffer_Splus = None
    sp_chunks_patches, sp_chunks_DD, sp_chunks_Splus = [], [], []
    if per_galaxy and per_galaxy_jk_sparse:
        sp_buffer_DD = np.zeros((chunk_size_outer, num_box, binning.num_bins_r))
        sp_buffer_Splus = np.zeros((chunk_size_outer, num_box, binning.num_bins_r)) if shapes else None

    positions = sample_set.pos
    positions_shape_sample = sample_set.pos_shape
    weight = sample_set.weight
    weight_shape = sample_set.weight_shape
    not_LOS = sample_set.not_LOS
    LOS_ind = sample_set.LOS_ind
    jk_pos = sample_set.jk_pos

    if backend == "brute":
        all_positions = np.arange(len(positions))
        # No tree, so nothing to prune and nothing to gain; keeping array order
        # here also keeps the brute path bit-identical to previous releases.
        order = np.arange(len(positions_shape_sample))
    else:
        if pos_tree is None:
            pos_tree = KDTree(binning.tree_coords(positions, not_LOS), boxsize=base.boxsize)
        # Visit shape galaxies in spatial order so each chunk's KDTree covers a
        # small region and the dual-tree query can prune (FINDINGS.md F5). The
        # order is computed on tree_coords, i.e. the same metric the trees use.
        # Results are unchanged up to float summation order; the per-galaxy
        # branches below index their outputs by the *original* galaxy id so that
        # what the caller gets back is still in the caller's order.
        order = spatial_order(binning.tree_coords(positions_shape_sample, not_LOS))
    for i in np.arange(0, len(positions_shape_sample), chunk_size_outer):
        i2 = min(len(positions_shape_sample), i + chunk_size_outer)
        sel = order[i:i2]
        if sp_buffer_DD is not None:
            sp_buffer_DD[:i2 - i] = 0.0
            if sp_buffer_Splus is not None:
                sp_buffer_Splus[:i2 - i] = 0.0
        positions_shape_sample_i = positions_shape_sample[sel]
        weight_shape_i = weight_shape[sel]
        if shapes:
            axis_direction_i = sample_set.axis_direction[sel]
            e_i = sample_set.e[sel]
        if jk:
            jk_shape_i = sample_set.jk_shape[sel]
        if backend == "brute":
            # every position is a candidate for every shape in the chunk
            ind_rbin_i = [all_positions] * len(positions_shape_sample_i)
        else:
            shape_tree = KDTree(binning.tree_coords(positions_shape_sample_i, not_LOS), boxsize=base.boxsize)
            # One query, at the outer radius only. The inner r_min query and the
            # set difference it fed were redundant: binning.bin_pairs already
            # drops every pair with separation < r_bins[0], and r_bins[0] == r_min
            # exactly, on the same metric this tree is built on. The extra
            # candidates are masked out in the same (sorted) order, so the
            # surviving pairs and their float summation order are unchanged.
            # Worth ~30% of a box measurement -- see benchmarks/FINDINGS.md F1.
            # asarray keeps the downstream fancy-indexing off Python lists.
            ind_rbin_i = [np.asarray(c, dtype=np.intp)
                          for c in shape_tree.query_ball_tree(pos_tree, binning.query_r_max)]

        for n in np.arange(0, len(positions_shape_sample_i)):
            if len(ind_rbin_i[n]) > 0:
                separation = positions_shape_sample_i[n] - positions[ind_rbin_i[n]]
                if base.periodicity:
                    separation[separation > base.L_0p5] -= base.boxsize
                    separation[separation < -base.L_0p5] += base.boxsize

                mask, ind_r, ind_pi, projected_sep, proj_len = binning.bin_pairs(
                    separation, not_LOS, LOS_ind
                )

                if shapes:
                    with np.errstate(invalid='ignore'):
                        separation_dir = (projected_sep.transpose() / proj_len).transpose()
                        phi = np.arccos(
                            separation_dir[:, 0] * axis_direction_i[n, 0]
                            + separation_dir[:, 1] * axis_direction_i[n, 1]
                        )
                    e_plus, e_cross = base.get_ellipticity(e_i[n], phi)
                    e_plus[np.isnan(e_plus)] = 0.0
                    e_cross[np.isnan(e_cross)] = 0.0
                    np.add.at(Splus_D, (ind_r, ind_pi),
                              (weight[ind_rbin_i[n]][mask] * weight_shape_i[n] * e_plus[mask]) / (2 * R))
                    np.add.at(Scross_D, (ind_r, ind_pi),
                              (weight[ind_rbin_i[n]][mask] * weight_shape_i[n] * e_cross[mask]) / (2 * R))
                np.add.at(DD, (ind_r, ind_pi), weight[ind_rbin_i[n]][mask] * weight_shape_i[n])

                if jk:
                    # union (two-sided) deletion: every pair contributes to the shape's
                    # patch, and to the position's patch only where the two patches differ.
                    # Splus_D_jk stores the raw (un-/2R) S+ contribution; responsivity is
                    # applied later in the wrapper reduction. Order matches the legacy jk
                    # loop (Splus_D_jk before DD_jk).
                    shape_patch = jk_shape_i[n]
                    pos_patches = jk_pos[ind_rbin_i[n]][mask]
                    pos_diff = np.where(pos_patches != shape_patch)[0]
                    w_pairs = weight[ind_rbin_i[n]][mask] * weight_shape_i[n]
                    if shapes:
                        np.add.at(Splus_D_jk, (shape_patch, ind_r, ind_pi), w_pairs * e_plus[mask])
                        np.add.at(Splus_D_jk,
                                  (pos_patches[pos_diff], ind_r[pos_diff], ind_pi[pos_diff]),
                                  (w_pairs * e_plus[mask])[pos_diff])
                    np.add.at(DD_jk, (shape_patch, ind_r, ind_pi), w_pairs)
                    np.add.at(DD_jk,
                              (pos_patches[pos_diff], ind_r[pos_diff], ind_pi[pos_diff]),
                              w_pairs[pos_diff])

                if per_galaxy:
                    # Same pairs, same weights, resolved on the shape-galaxy axis. Kept
                    # last and in its own branch so that nothing above changes when
                    # per_galaxy is off.
                    # the caller's galaxy id, not the visit position: the loop
                    # walks the sample in spatial order (see `order` above), so
                    # i + n would attribute each galaxy's row to a different one
                    gj = sel[n]
                    w_pairs_gal = weight[ind_rbin_i[n]][mask] * weight_shape_i[n]
                    if per_galaxy_proj is None:
                        np.add.at(DD_gal[gj], (ind_r, ind_pi), w_pairs_gal)
                        if shapes:
                            np.add.at(Splus_D_gal[gj], (ind_r, ind_pi),
                                      w_pairs_gal * e_plus[mask] / (2 * R))
                    else:
                        proj_gal = per_galaxy_proj[ind_r, ind_pi]
                        # DD_gal stays the plain pair count per radial bin: the projection
                        # belongs to the estimator (S+D), not to the design matrix.
                        np.add.at(DD_gal[gj], ind_r, w_pairs_gal)
                        if shapes:
                            np.add.at(Splus_D_gal[gj], ind_r,
                                      proj_gal * w_pairs_gal * e_plus[mask] / (2 * R))
                    if per_galaxy_jk:
                        # Decompose by the position partner's patch only: the shape
                        # galaxy's own patch is known from jk_shape, and delete-one drops
                        # such a galaxy wholesale. Reconstructing realisation n as
                        #   sum_{j: jk_shape[j] != n} (total_j - column_n_j)
                        # therefore reproduces the union deletion of DD_jk / Splus_D_jk.
                        # Splus_D_gal_jk is raw (not divided by 2R), matching Splus_D_jk.
                        pos_patches_gal = jk_pos[ind_rbin_i[n]][mask]
                        # dense target, or this chunk's buffer row when storing sparsely
                        target_DD = sp_buffer_DD[n] if per_galaxy_jk_sparse else DD_gal_jk[gj]
                        np.add.at(target_DD, (pos_patches_gal, ind_r), w_pairs_gal)
                        if shapes:
                            target_S = (sp_buffer_Splus[n] if per_galaxy_jk_sparse
                                        else Splus_D_gal_jk[gj])
                            np.add.at(target_S, (pos_patches_gal, ind_r),
                                      proj_gal * w_pairs_gal * e_plus[mask])

        if sp_buffer_DD is not None:
            patches_c, DD_c, Splus_c = _compress_jk_chunk(
                sp_buffer_DD, sp_buffer_Splus, i2 - i)
            sp_chunks_patches.append(patches_c)
            sp_chunks_DD.append(DD_c)
            if Splus_c is not None:
                sp_chunks_Splus.append(Splus_c)

    gal_jk_patches = DD_gal_jk_values = Splus_D_gal_jk_values = None
    if sp_buffer_DD is not None:
        gal_jk_patches = _pad_and_stack(sp_chunks_patches, fill=-1, dtype=np.int32)
        DD_gal_jk_values = _pad_and_stack(sp_chunks_DD, fill=0.0)
        if sp_chunks_Splus:
            Splus_D_gal_jk_values = _pad_and_stack(sp_chunks_Splus, fill=0.0)
        # The sparse arrays are built one chunk at a time and concatenated, so
        # their galaxy axis follows the *visit* order rather than the caller's.
        # The dense per-galaxy arrays do not need this because they are written
        # at gj = sel[n] as they go; these cannot be, so map them back here.
        # (order is the identity on the brute backend, where this is a no-op.)
        gal_jk_patches = _restore_galaxy_order(gal_jk_patches, order, fill=-1)
        DD_gal_jk_values = _restore_galaxy_order(DD_gal_jk_values, order, fill=0.0)
        if Splus_D_gal_jk_values is not None:
            Splus_D_gal_jk_values = _restore_galaxy_order(
                Splus_D_gal_jk_values, order, fill=0.0)

    return Grids(DD=DD, Splus_D=Splus_D, Scross_D=Scross_D, DD_jk=DD_jk, Splus_D_jk=Splus_D_jk,
                 DD_gal=DD_gal, Splus_D_gal=Splus_D_gal,
                 DD_gal_jk=DD_gal_jk, Splus_D_gal_jk=Splus_D_gal_jk,
                 gal_jk_patches=gal_jk_patches,
                 DD_gal_jk_values=DD_gal_jk_values,
                 Splus_D_gal_jk_values=Splus_D_gal_jk_values)

compute_R_jk(e, weight_shape, jk_shape, num_box, responsivity_correction)

Per-realisation (delete-one) responsivity: R_jk[i] is the responsivity over the shapes not in patch i.

This is a standalone reduction over the shape sample (not part of the pair loop), so the multiprocessing path calls it once in the parent from the full shape sample rather than per batch. Reproduces the legacy inline computation verbatim (including the responsivity_correction/empty-patch fallback to 0.5).

Source code in src/measureia/pair_kernel.py
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
def compute_R_jk(e, weight_shape, jk_shape, num_box, responsivity_correction):
    """Per-realisation (delete-one) responsivity: ``R_jk[i]`` is the responsivity over
    the shapes **not** in patch ``i``.

    This is a standalone reduction over the shape sample (not part of the pair loop),
    so the multiprocessing path calls it once in the parent from the *full* shape
    sample rather than per batch. Reproduces the legacy inline computation verbatim
    (including the ``responsivity_correction``/empty-patch fallback to ``0.5``).
    """
    R_jk = np.zeros(num_box)
    for i in np.arange(num_box):
        jk_mask = np.where(jk_shape != i)
        R_jk[i] = sum(weight_shape[jk_mask] * (1 - e[jk_mask] ** 2 / 2.0)) / sum(weight_shape[jk_mask]) \
            if responsivity_correction and sum(weight_shape[jk_mask]) > 0 else 0.5
    return R_jk