Skip to content

Composite Gauss-Legendre rule for the lambda_tr selection integral - #10

Open
estevesjh wants to merge 2 commits into
mainfrom
selection-ltr-panels
Open

estevesjh wants to merge 2 commits into
mainfrom
selection-ltr-panels

Conversation

@estevesjh

Copy link
Copy Markdown
Owner

Problem

SelectionFunction.S_i and first_moment_i integrated λ_tr with one 64-node Gauss–Legendre rule over the whole bracket [max(0, μ−8σ), μ+8σ]. The bin kernel steps at every richness edge, centred where μ(λ_tr) = λ_e, over the kernel width (3 richness units for EmgParams(-1.5, 3.0, 0.3, 0.12)). Meanwhile the bracket of a 10¹⁵ h⁻¹M☉ halo spans ~900. So the rule's error oscillated as the nodes slid past the steps:

  • LogNormalMor + that kernel, z = 0.4: S_i(M) of [60, 200) was off by up to 1.3e-2 of its peak (7.4e-4 at 128 nodes, 6.7e-7 at 256), a ripple of period ~0.3 in ln M. No ln M rule can average it out: it kept ClusterCounts at ~1e-3.
  • HodMor's density jumps from zero at λ_cen = 1. One rule across a jump converges only as O(1/n): 5e-4 of the [20, 30) peak at 64 nodes, still 1.3e-4 at 256, with the fixed kernel.

Change

The bracket logic is unchanged. The bracket is now cut into panels, each with n_quad GL nodes (new default 20 per panel), at:

  1. λ_tr_e ± 5 w_e around every edge's step. The new richness_edge_steps gives centre (λ_e − Δμ)/b_μ and width σ/b_μ, exact for the Y3 fit's b_μ ≠ 1. Each step sits mid-panel and hands its neighbours only a Φ(−5) remnant. Breaking at the step centres alone was measured and rejected: it leaves a half-step at the end of a panel hundreds of units long (2e-5 at 32 nodes per panel).
  2. The relation's mean. P(λ_tr|M) is integrated in two halves, with its peak at a panel end.
  3. HodMor.support_min, a new optional MOR hook (λ_cen). LogNormalMor needs none: its density vanishes continuously at zero.

Breaks outside the bracket leave empty panels with zero weight, and the integrand is evaluated only on live nodes. That keeps the kernel off λ_tr = 0, where the Y3 σ ∝ λ^{a_σ} vanishes, and fixes a ValueError for HodMor + Y3 below M_min, where the bracket is the single point zero. New public panel_edges shows the layout.

Measured

Against adaptive quadrature (quad_vec with breakpoints): 2 relations × {fixed, Y3} kernel × 3 binnings ([20,30,45,60,200), [20,30,45,1000), [19.5,20.5)) × z ∈ {0.2, 0.4, 0.6}, 201 masses in [10¹³, 10^15.5] h⁻¹M☉.

per-panel order max error / bin peak max relative error where S_i > 1e-5 live nodes / cell
16 3.1e-7 4.5e-6 116
20 (default) 3.4e-9 4.5e-8 145
24 2.1e-10 4.2e-9 174

The old single 64-node rule had up to 1.3e-2 / bin peak on the same sweep, and up to 55% relative error in the small-S_i tails.

  • Counts: with the (separate, still uncommitted) GL ClusterCounts patch applied, convergence() on number_counts.py's example goes from 9.6e-4 to 1.3e-6 at the default 96-node ln M rule. It is unchanged at 40 nodes per panel, so the residual belongs to the counts rule.
  • Cost: ~2.3× the old node count. Per 96-mass call, S_i goes 1.8 → 5.8 ms and first_moment_i 27 → 65–82 ms. The time is spent in the kernel itself, not in the panel bookkeeping.

Tests

13 new tests in tests/test_selection.py:

  • exponential convergence in the panel order (three setups);
  • adaptive-quadrature checks at the ripple masses and for HodMor.buzzard() + Y3;
  • first_moment_i convergence;
  • panel layout, the HOD jump as a panel edge, richness_edge_steps;
  • the degenerate bracket and n_quad validation.

7 of the accuracy tests fail on the old rule. Full suite: 905 passed, 22 skipped, 33 xfailed (also passes with the GL-counts patch applied: 910 passed).

Not in this PR

  • Inner λ_ob rule: richness_bin_first_moment's own fixed 32-node λ_ob rule is unconverged across bins much wider than the kernel: 2.2% in [60, 200) at σ = 3, worse in [45, 1000); Y3 is fine in the DES bins. That bounds first_moment_i / mean_richness there whatever the λ_tr rule does.
  • Docs: docs/selection_function.md still shows the old 64-node output block (S_[60,200)(10¹⁵, z=0.3) is now 0.1533, not 0.1618) and says "one Gauss–Legendre rule".
  • Changed meaning of n_quad: it now counts nodes per panel. Notes scripts passing n_quad=96 still work but are slower than needed; dropping the argument gives the default.

🤖 Generated with Claude Code

estevesjh and others added 2 commits October 19, 2026 01:42
SelectionFunction.S_i / first_moment_i integrated lambda_tr with ONE
64-node GL rule over the whole bracket. The bin kernel steps at every
richness edge over the kernel width (3 richness units for the example
kernel) while a 1e15 bracket spans ~900, so S_i(M) of [60,200) rippled by
up to 1.3% of its peak (period ~0.3 in ln M) and ClusterCounts could not
converge past ~1e-3. HodMor's density also jumps from zero at
lambda_cen = 1, which a single rule integrates only to O(1/n).

The bracket (unchanged) is now cut into panels, n_quad nodes each:
lambda_tr_e +- 5 w_e around every edge's step (new
richness_edge_steps: centre (lambda_e - dmu)/b_mu, width sigma/b_mu),
the MOR mean, and HodMor.support_min (new, optional MOR hook). Empty
panels carry zero weight and the integrand is evaluated only on live
nodes (also fixes a ValueError for HodMor + Y3 below M_min).

Measured against adaptive quadrature (2 MORs x fixed/Y3 kernel x 3
binnings x z=0.2/0.4/0.6, 201 masses 1e13-10^15.5): default 20 nodes per
panel is within 3e-9 of every bin's peak (old rule: 1.3e-2), ~145 live
nodes per cell (~2.3x the old cost). With the GL ClusterCounts patch,
the module example's convergence() goes 9.6e-4 -> 1.3e-6.

Not fixed here: richness_bin_first_moment's own fixed 32-node lambda_ob
rule is unconverged across bins much wider than the kernel (2.2% in
[60,200) at sigma=3), which bounds first_moment_i there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
astral-sh/setup-uv@v3 has no python-version input (it only warned), so
`uv sync` used whatever Python the runner had: 3.12 on ubuntu for every
matrix entry, and on the current macos image Homebrew 3.15, where scipy
1.17.1 has no wheel and the source build fails for lack of a Fortran
compiler. Set UV_PYTHON from the matrix at job level and drop the
ignored input.

Checked locally: UV_PYTHON=3.13 and 3.11 `uv sync --locked --extra dev`
pick CPython 3.13.13 / 3.11.15, and the full suite with coverage passes
on both (905 passed, 22 skipped, 33 xfailed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant