Skip to content

Commit 758aa46

Browse files
0xC000005claude
andauthored
v0.21.1: TT _dim_order cluster + open issues + perf (#23)
* v0.21.1: ChebyshevTT.inner_product strict _dim_order check Raise ValueError with reorder() hint when self._dim_order != other._dim_order instead of silently computing a meaningless Frobenius product. Mirrors the v0.20.1 binary algebra behavior on _check_compatible_tt mismatches. Closes ultrareview Critical finding. * v0.21.1: _check_compatible — numerical domain comparison Replace exact == / != on .domain and .n_nodes with np.allclose / np.array_equal on coerced arrays. Tolerates tuple-of-tuples vs list-of-lists syntax that silently arises in scalar-mul (which normalizes via [list(b) for b in domain]). Closes issue #22. * v0.21.1: ChebyshevTT.get_evaluation_points returns user-frame columns Permute columns by inverse _dim_order before returning so that eval(get_evaluation_points()[i]) round-trips for any TT regardless of storage permutation. Matches Approximation/Spline/Slider behavior. Closes ultrareview Important finding. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * v0.21.1: ChebyshevTT.roots/min/max validate against user-frame domain Add _user_frame_domain() private helper that returns self.domain permuted into user-frame order. The three v0.21 methods now pass this user-frame view to _calculus._validate_calculus_args instead of the raw storage-frame self.domain. Pre-fix: under non-identity _dim_order with non-uniform per-dim domains, validation referenced the wrong domain. Tests pass before the fix only because uniform domains masked the difference. Closes ultrareview Critical finding. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * v0.21.1: integrate error messages reference user-frame dim (issue #20) Add optional dim_labels= parameter to _normalize_bounds. ChebyshevTT.integrate passes user-frame dims_sorted as dim_labels so out-of-domain bounds errors reference the dim the user passed rather than the storage-frame index. Helper stays user-frame-naive otherwise. Closes #20. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * v0.21.1: eval_multi structural fix — eliminate _dim_order mutation (issue #19) Extract _eval_storage_frame(point_storage, deriv_storage) private helper that takes already-permuted coords. eval() permutes user→storage once at entry and calls _eval_storage_frame; eval_multi() permutes once and calls the helper N times for each derivative_order. Drops the try/finally self._dim_order mutation entirely. Eliminates the race rather than papering over it with a lock. The FD machinery (_fd_single_dim, _fd_cross_deriv, _fd_nested) was updated to call _eval_storage_frame instead of self.eval, since the points it operates on are already in storage frame; routing through self.eval would re-permute and break correctness once the saved-state trick is removed. Closes #19. * v0.21.1: vectorize _calculus._optimize_1d candidate evaluation Replace the Python list comprehension over candidates with a single vectorized barycentric evaluation (BLAS GEMV pattern). _optimize_1d is the canonical 1-D optimizer used by all four classes' minimize/maximize methods; v0.21 routed Slider/TT min/max through it, making the perf hoist worthwhile. Bit-identical results — non-regression tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * v0.21.1: hoist derivative matrix application out of vectorized_eval_batch loop The differentiation matrix (D_T) matmul is point-independent — applying it once to self.tensor_values before the per-point loop, then doing only the barycentric reduction inside the loop, produces identical results with substantial speedup for batch derivative evaluations. Each D_T[d] is applied along axis d of the full tensor (via moveaxis to last, matmul, moveaxis back) to correctly match the per-dim interleaved semantics of vectorized_eval. ChebyshevSpline.eval_batch (which delegates per-piece) inherits the speedup. * v0.21.1: add comparison/demo script Demonstrates each fix in the TT _dim_order cluster with a non-uniform domain and explicit reorder() — the configuration that v0.20.1/v0.21.0 test coverage masked. Native TT sobol_indices deferred to v0.22; not included in this demo. * v0.21.1: docs + release housekeeping Add non-uniform-domain validation note to user-guide. Bump version to 0.21.1. CHANGELOG entry covering all 6 fixes + 2 perf wins, with sobol_indices deferred to v0.22. CLAUDE.md updated with new test count and v0.21.1 architecture line. * v0.21.1: address final review — extend np.allclose fix to TT internal sites Final cumulative review (M1) noted that ChebyshevTT.inner_product and the binary-algebra _check_compatible_tt path also use exact == on self.domain, paralleling the issue #22 fix in _algebra._check_compatible. Apply the same np.allclose pattern to both TT sites for consistency with the v0.21.1 fix. (M2) Add compare_v0211_dim_cluster.py to the CLAUDE.md benchmark roster. * v0.21.1: add ChebyshevTT.sobol_indices() (native TT contraction) Compute first-order + total-order Sobol indices by contracting through the TT coefficient cores in O(d * n * r^2) time. Cross-validated against ChebyshevApproximation.sobol_indices() on dense reconstructions. Closes the v0.21.1 sobol_indices parity item (originally deferred but attempted on user request). * v0.21.1: CHANGELOG/CLAUDE.md — sobol_indices shipped, not deferred * tests: add non-uniform-domain + reorder sobol parity test (Issue 1) Appends test_non_uniform_domain_after_reorder to TestTTSobolParity, covering the exact configuration that masked v0.21.0 _dim_order bugs: non-uniform per-dim domains [(-1,1), (-2,2), (-3,3)] combined with a non-identity _dim_order (reorder([2,0,1])). Verifies TT sobol keys stay in user-frame after reorder, matching ChebyshevApproximation reference. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: extract _apply_derivative_passes helper to barycentric.py (Issue 2) Adds private _apply_derivative_passes(tensor, derivative_order) that applies differentiation-matrix passes along each axis of the full coefficient tensor using np.moveaxis. Used by vectorized_eval_batch to replace the duplicated ~10-line derivative-hoist block, making the batch path self-documenting. vectorized_eval inlines its own per-axis logic (reduces axes as it goes, so the full-tensor moveaxis path doesn't apply there) and is left unchanged to avoid unneeded churn. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf: cache left/right partial contractions for O(d*n*r^2) total-order sobol (Issue 3) Replaces d independent full self-inner-product loops for total-order computation with precomputed L[k] (left partial, dims 0..k-1) and R[k] (right partial, dims k..d-1) matrices. For each dim j, combines L[j], the alpha_j=0 core slice, and R[j+1] via a single einsum instead of re-running all d cores. Reduces from O(d^2*n*r^2) to O(d*n*r^2). Existing TestSobolFromTTCores and TestTTSobolParity tests continue to pass at 1e-9 (2-D) and 1e-7 (3-D) tolerances. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 405386a commit 758aa46

18 files changed

Lines changed: 1104 additions & 83 deletions

CHANGELOG.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,63 @@ All notable changes to PyChebyshev will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.21.1] - 2026-04-27
9+
10+
### Added
11+
12+
- `ChebyshevTT.sobol_indices()` — first-order and total-order Sobol
13+
sensitivity indices computed natively by contracting through the TT
14+
coefficient cores. O(d · n · r²) per dim, no dense materialization.
15+
Mirrors v0.20 `ChebyshevApproximation.sobol_indices` API; keys are
16+
user-frame dim indices regardless of internal `_dim_order`.
17+
18+
### Fixed
19+
20+
- `ChebyshevTT.roots()`/`minimize()`/`maximize()` previously validated
21+
`fixed` values against the storage-frame domain instead of the
22+
user-frame physical domain. Under `with_auto_order()` or `reorder()`
23+
with non-uniform per-dim domains, this could raise misleading errors
24+
or accept invalid inputs. Now validates against user-frame domain.
25+
- `ChebyshevTT.inner_product()` previously returned a meaningless
26+
Frobenius product when `self._dim_order != other._dim_order`, with
27+
no error. Now raises `ValueError` with a `reorder()` alignment hint,
28+
matching v0.20.1 binary algebra behavior.
29+
- `ChebyshevTT.get_evaluation_points()` previously returned columns in
30+
storage order, breaking `eval(get_evaluation_points()[i])` for any
31+
TT with non-identity `_dim_order`. Now returns columns in user-frame
32+
order.
33+
- `ChebyshevTT.eval_multi()` previously mutated `self._dim_order` via
34+
try/finally, racing under concurrent calls (issue #19). Now uses a
35+
private `_eval_storage_frame` helper with no mutation.
36+
- `ChebyshevTT.integrate()` error messages on out-of-domain bounds
37+
previously referenced the storage-frame dim index instead of the
38+
user-frame index passed by the caller (issue #20). Now references
39+
the user-frame index.
40+
- `_algebra._check_compatible` previously raised "Domain mismatch"
41+
when comparing two interpolants with mixed `tuple` vs `list` domain
42+
syntax even when bounds were numerically identical (issue #22). Now
43+
uses `np.allclose` for comparison.
44+
45+
### Performance
46+
47+
- `vectorized_eval_batch` now hoists the differentiation matrix matmul
48+
outside the per-point loop. Significant speedup for derivative
49+
batch evaluations on large point sets.
50+
- `_calculus._optimize_1d` (used by all four classes' `minimize/maximize`)
51+
now uses a single vectorized barycentric evaluation over critical
52+
points + endpoints instead of a Python list comprehension.
53+
54+
### Notes
55+
56+
- Closes the v0.20+v0.20.1 `_dim_order` cluster on `ChebyshevTT`.
57+
All TT methods that read `self.domain[d]` or `self.n_nodes[d]` now
58+
consistently translate user-frame indices to storage-frame
59+
internally.
60+
- No breaking API changes: all "Fixed" items change wrong behavior to
61+
correct behavior. The `inner_product` strict-mode raises on mismatched
62+
`_dim_order` (previously silently returned wrong numbers), which is
63+
a behavior change in the failure path only.
64+
865
## [0.21.0] - 2026-04-27
966

1067
### Added

CLAUDE.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ PyChebyshev is a pip-installable Python library for multi-dimensional Chebyshev
1212
# Setup
1313
uv sync
1414

15-
# Run tests (~1112 tests, ~115s due to 5D Black-Scholes builds)
15+
# Run tests (~1133 tests, ~115s due to 5D Black-Scholes builds)
1616
uv run pytest tests/ -v
1717

1818
# Run a single test
@@ -84,6 +84,12 @@ The installable package. Public classes: `ChebyshevApproximation`, `ChebyshevSpl
8484
- v0.21 adds `ChebyshevSlider.roots()/minimize()/maximize()` and
8585
`ChebyshevTT.roots()/minimize()/maximize()`. After v0.21, all four
8686
classes support the full calculus surface (integrate + roots + min/max).
87+
- v0.21.1 closes the v0.20+v0.20.1 `_dim_order` cluster on `ChebyshevTT`:
88+
`roots/minimize/maximize` validate against user-frame domain;
89+
`inner_product` raises on mismatched `_dim_order`; `get_evaluation_points`
90+
returns user-frame columns; `eval_multi` no longer mutates `_dim_order`.
91+
Perf: vectorized `_optimize_1d` and `vectorized_eval_batch` derivative
92+
hoist. Adds `ChebyshevTT.sobol_indices()` parity (native TT contraction).
8793

8894
### Benchmark Scripts (project root)
8995

@@ -105,6 +111,7 @@ Not part of the library. Compare Chebyshev barycentric against alternative metho
105111
- `compare_calculus_completion.py` — PyChebyshev v0.17 Slider/TT integrate vs MoCaX 4.3.1 (no equivalent — beyond-MoCaX feature)
106112
- `compare_v018_tt_parity.py` — PyChebyshev v0.18 TT surface (extrude/slice/algebra/from_values/to_dense) vs MoCaX 4.3.1
107113
- `compare_v019_build_diagnostics.py` — PyChebyshev v0.19 build optimization (parallel eval, progress bars, visualization) — no MoCaX equivalent
114+
- `compare_v0211_dim_cluster.py` — PyChebyshev v0.21.1 TT `_dim_order` cluster fixes demo (no MoCaX equivalent — internal-correctness fixes)
108115

109116
### Tests (`tests/`)
110117

@@ -129,12 +136,13 @@ Not part of the library. Compare Chebyshev barycentric against alternative metho
129136
`get_evaluation_points`, `get_num_evaluation_points`), `peek_format_version`,
130137
`is_dimensionality_allowed`, `defer_build` + `set_original_function_values`,
131138
`Domain`/`Ns`/`SpecialPoints` typed helpers.
132-
- `test_calculus_completion.py`~101 tests: `ChebyshevSlider.integrate/roots/minimize/maximize`,
139+
- `test_calculus_completion.py`~106 tests: `ChebyshevSlider.integrate/roots/minimize/maximize`,
133140
`ChebyshevTT.integrate/roots/minimize/maximize` (full and partial,
134141
user-frame dim/fixed transparent under `_dim_order`), cross-class
135142
consistency checks, bounds validation. v0.21 additions: 57 tests
136143
across 9 new test classes covering Slider/TT roots/min/max parity
137-
with Approximation/Spline.
144+
with Approximation/Spline. v0.21.1 additions: 5 tests covering
145+
user-frame domain validation fix under non-identity `_dim_order`.
138146
- `test_v018_tt_parity.py`~52 tests: `ChebyshevTT.nodes()`, `from_values()`,
139147
`extrude()`, `slice()`, algebra (`+`, `-`, `*` scalar, in-place, `__neg__`),
140148
`to_dense()`; cross-feature and round-trip checks.

compare_v0211_dim_cluster.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""v0.21.1 demo: TT _dim_order cluster fixes.
2+
3+
Demonstrates each correctness fix with a non-uniform-domain TT under
4+
explicit reorder([2, 0, 1]) — the case that v0.20.1 / v0.21.0 tests
5+
masked with uniform domains.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import time
11+
12+
import numpy as np
13+
14+
from pychebyshev import ChebyshevApproximation, ChebyshevTT
15+
16+
17+
def _check(label: str, ok: bool, detail: str = "") -> None:
18+
status = "OK " if ok else "FAIL"
19+
print(f" [{status}] {label}{(' — ' + detail) if detail else ''}")
20+
21+
22+
def demo_inner_product_strict() -> None:
23+
print("\n=== inner_product strict _dim_order check (Item B) ===")
24+
def f(x, _): return x[0] ** 2 + x[1]
25+
tt = ChebyshevTT(f, num_dimensions=2, domain=[(-1, 1), (-1, 1)], n_nodes=[5, 5])
26+
tt.build(verbose=False)
27+
tt_p = tt.reorder([1, 0])
28+
try:
29+
tt.inner_product(tt_p)
30+
_check("ValueError raised on dim_order mismatch", False)
31+
except ValueError as e:
32+
_check("ValueError raised on dim_order mismatch", True, str(e)[:80])
33+
34+
35+
def demo_get_evaluation_points_round_trip() -> None:
36+
print("\n=== get_evaluation_points user-frame round-trip (Item C) ===")
37+
def f(x, _): return 0.3 * x[0] + 0.7 * x[1] - 0.2 * x[2]
38+
tt = ChebyshevTT(
39+
f, num_dimensions=3,
40+
domain=[(-1, 1), (-2, 2), (-3, 3)], n_nodes=[5, 5, 5],
41+
)
42+
tt.build(verbose=False)
43+
tt_p = tt.reorder([2, 0, 1])
44+
points = tt_p.get_evaluation_points()
45+
max_err = 0.0
46+
for i in range(0, len(points), 25):
47+
pt = points[i]
48+
expected = f(pt, None)
49+
got = float(tt_p.eval(pt.tolist()))
50+
max_err = max(max_err, abs(got - expected))
51+
_check("round-trip eval == f for non-identity _dim_order",
52+
max_err < 1e-9, f"max_err={max_err:.2e}")
53+
54+
55+
def demo_roots_user_frame_validation() -> None:
56+
print("\n=== roots/min/max validate against user-frame domain (Item A) ===")
57+
# User-frame dim 1 has domain [-2, 2]; storage-frame after reorder has different range
58+
def f(x, _): return (x[0] - 0.4) * (1.0 + 0.0 * x[1] + 0.0 * x[2])
59+
tt = ChebyshevTT(
60+
f, num_dimensions=3,
61+
domain=[(-1, 1), (-2, 2), (-3, 3)], n_nodes=[8, 8, 8],
62+
)
63+
tt.build(verbose=False)
64+
tt_p = tt.reorder([2, 0, 1])
65+
# fixed=1.5 is valid in user-frame dim 1, NOT in storage-frame after reorder
66+
try:
67+
roots = tt_p.roots(dim=0, fixed={1: 1.5, 2: 0.0})
68+
_check("roots accepts user-frame-valid fixed value",
69+
abs(float(roots[0]) - 0.4) < 1e-7,
70+
f"root={float(roots[0]):.4f}")
71+
except Exception as e:
72+
_check("roots accepts user-frame-valid fixed value", False,
73+
f"raised {type(e).__name__}: {e}")
74+
75+
76+
def demo_integrate_error_user_frame() -> None:
77+
print("\n=== integrate error message uses user-frame dim (Item E / #20) ===")
78+
def f(x, _): return x[0] + x[1] + x[2]
79+
tt = ChebyshevTT(
80+
f, num_dimensions=3,
81+
domain=[(-1, 1), (-2, 2), (-3, 3)], n_nodes=[5, 5, 5],
82+
)
83+
tt.build(verbose=False)
84+
tt_p = tt.reorder([2, 0, 1])
85+
try:
86+
tt_p.integrate(dims=[1], bounds=[(5.0, 6.0)])
87+
_check("ValueError raised on out-of-domain bounds", False)
88+
except ValueError as e:
89+
msg = str(e)
90+
_check("error references user-frame dim 1",
91+
"dim 1" in msg, msg[:100])
92+
93+
94+
def demo_eval_multi_no_mutation() -> None:
95+
print("\n=== eval_multi no longer mutates _dim_order (Item D / #19) ===")
96+
import inspect
97+
source = inspect.getsource(ChebyshevTT.eval_multi)
98+
_check("eval_multi source contains no 'self._dim_order = ' assignment",
99+
"self._dim_order = " not in source and "self._dim_order=" not in source)
100+
101+
102+
def main() -> None:
103+
print("PyChebyshev v0.21.1 cluster fix demo")
104+
t0 = time.time()
105+
demo_inner_product_strict()
106+
demo_get_evaluation_points_round_trip()
107+
demo_roots_user_frame_validation()
108+
demo_integrate_error_user_frame()
109+
demo_eval_multi_no_mutation()
110+
print(f"\nTotal: {time.time() - t0:.2f}s")
111+
112+
113+
if __name__ == "__main__":
114+
main()

docs/user-guide/calculus.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,8 @@ roots_b = tt_optimized.roots(dim=0, fixed={1: 0.0, 2: 0.0})
570570
np.testing.assert_array_almost_equal(roots_a, roots_b)
571571
```
572572

573+
> **Non-uniform domains:** v0.21.1 closed a latent bug where TT calculus methods validated `fixed` values against the storage-frame domain. With non-uniform per-dim domains and a non-identity `_dim_order` (after `with_auto_order` / `reorder`), this could either reject valid user-frame inputs or silently accept invalid ones. Since v0.21.1, validation always uses the user-frame physical domain.
574+
573575
### Slider example
574576

575577
```python

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "pychebyshev"
7-
version = "0.21.0"
7+
version = "0.21.1"
88
description = "Fast multi-dimensional Chebyshev tensor interpolation with analytical derivatives"
99
readme = "README.md"
1010
license = {text = "MIT"}

src/pychebyshev/_algebra.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,15 @@ def _check_compatible(a, b) -> None:
3838
f"Dimension mismatch: {a.num_dimensions} vs {b.num_dimensions}"
3939
)
4040

41-
if a.n_nodes != b.n_nodes:
41+
if not np.array_equal(np.asarray(a.n_nodes, dtype=int), np.asarray(b.n_nodes, dtype=int)):
4242
raise ValueError(
4343
f"Node count mismatch: {a.n_nodes} vs {b.n_nodes}"
4444
)
4545

46-
if a.domain != b.domain:
46+
if not np.allclose(
47+
np.asarray(a.domain, dtype=float),
48+
np.asarray(b.domain, dtype=float),
49+
):
4750
raise ValueError(
4851
f"Domain mismatch: {a.domain} vs {b.domain}"
4952
)

src/pychebyshev/_calculus.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def _compute_sub_interval_weights(n: int, t_lo: float,
133133
return weights_desc[::-1].copy()
134134

135135

136-
def _normalize_bounds(dims, bounds, domain):
136+
def _normalize_bounds(dims, bounds, domain, dim_labels=None):
137137
"""Normalize and validate the *bounds* parameter for ``integrate()``.
138138
139139
Parameters
@@ -144,6 +144,12 @@ def _normalize_bounds(dims, bounds, domain):
144144
User-provided bounds specification.
145145
domain : list
146146
Per-dimension ``[lo, hi]`` bounds.
147+
dim_labels : list of int or None
148+
Optional override for dim labels used in error messages. When
149+
``None`` (default), error messages use the corresponding entry
150+
from ``dims``. When provided, ``dim_labels[i]`` is used instead
151+
of ``dims[i]`` so callers can present user-frame indices when
152+
``dims`` is in storage frame.
147153
148154
Returns
149155
-------
@@ -173,14 +179,15 @@ def _normalize_bounds(dims, bounds, domain):
173179
result.append(None)
174180
continue
175181
lo, hi = bd
182+
label = dim_labels[i] if dim_labels is not None else dims[i]
176183
if lo > hi:
177-
raise ValueError(f"bounds lo={lo} > hi={hi} for dim {dims[i]}")
184+
raise ValueError(f"bounds lo={lo} > hi={hi} for dim {label}")
178185
d = dims[i]
179186
dom_lo, dom_hi = domain[d]
180187
if lo < dom_lo - 1e-14 or hi > dom_hi + 1e-14:
181188
raise ValueError(
182189
f"bounds ({lo}, {hi}) outside domain [{dom_lo}, {dom_hi}] "
183-
f"for dim {d}"
190+
f"for dim {label}"
184191
)
185192
lo = max(lo, dom_lo)
186193
hi = min(hi, dom_hi)
@@ -259,8 +266,6 @@ def _optimize_1d(values: np.ndarray, nodes: np.ndarray,
259266
-------
260267
(value, location) : (float, float)
261268
"""
262-
from pychebyshev.barycentric import barycentric_interpolate
263-
264269
# Derivative values at nodes
265270
deriv_values = diff_matrix @ values
266271

@@ -271,11 +276,22 @@ def _optimize_1d(values: np.ndarray, nodes: np.ndarray,
271276
a, b = domain
272277
candidates = np.concatenate([[a], critical, [b]])
273278

274-
# Evaluate original function at all candidates
275-
vals = np.array([
276-
barycentric_interpolate(float(x), nodes, values, bary_weights)
277-
for x in candidates
278-
])
279+
# Vectorized barycentric evaluation at all candidates simultaneously.
280+
candidates_arr = np.asarray(candidates, dtype=float).reshape(-1)
281+
diff = candidates_arr[:, None] - nodes[None, :] # shape (M, n)
282+
abs_diff = np.abs(diff)
283+
exact_mask = abs_diff < 1e-14 # (M, n)
284+
has_exact = exact_mask.any(axis=1) # (M,)
285+
# Replace zero diffs with 1.0 to avoid division by zero; overwritten below.
286+
safe_diff = np.where(abs_diff < 1e-14, 1.0, diff)
287+
w_over_diff = bary_weights[None, :] / safe_diff # (M, n)
288+
numer = (w_over_diff * values[None, :]).sum(axis=1)
289+
denom = w_over_diff.sum(axis=1)
290+
vals = numer / denom # (M,)
291+
# For candidates that hit a node exactly, take the node value directly.
292+
if has_exact.any():
293+
exact_node_idx = exact_mask.argmax(axis=1)
294+
vals = np.where(has_exact, values[exact_node_idx], vals)
279295

280296
idx = np.argmin(vals) if mode == "min" else np.argmax(vals)
281297
return float(vals[idx]), float(candidates[idx])

0 commit comments

Comments
 (0)