You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
uw.function.evaluate / global_evaluate: the subsystem needs one pass, not five patches
We have five open issues against the point-evaluation entry points. They are not five
independent bugs. Reading the code and measuring at development @ e475246f, the
subsystem has one architectural problem and four defects that sit on top of it: point
location is done by a hand-rolled kd-tree + half-space walk that has no cheap rejection
path, is re-run several times per call, is not shared between the classifier and the
interpolator, and is bypassed entirely on the one path where its answer is trusted.
Everything below is measured on a 2-D unstructured simplex box, cellSize=1/100
(23 264 cells, 46 929 P2 DOF) unless stated, amr-dev, Darwin arm64, runs sequential
(concurrent PETSc contends ~26%).
1. Parallel anti-scaling: global_evaluate gets slower as ranks are added
Not previously filed. This is the finding with the largest practical cost.
Fixed global query set — 20 000 points, identical on every rank, so the answer is
rank-count independent and wall time should be flat or better as np grows. Best of 3
calls, max over ranks:
np
global_evaluate (s)
vs np=1
efficiency T₁/(np·T_np)
1
0.0790
1.00×
1.00
2
0.1325
0.60×
0.30
4
0.1789
0.44×
0.11
8
0.2232
0.35×
0.044
np=8 is 2.83× slower than serial for identical work.
evaluate on the same fixed global set (timing only — in parallel it classifies against
the rank-local mesh, so the answer is rank-local): 0.0518 / 0.0436 / 0.0382 / 0.0359 s at
np = 1/2/4/8. Efficiency 1.00 / 0.59 / 0.34 / 0.18. It is nearly flat because points_in_domain sees all 20 000 points on every rank at every np (measured: 60 000
points classified per 3 calls, unchanged from np=1 to np=8) — per-rank work proportional
to the GLOBAL point count, the classic signature.
The benign case, for contrast: rank-local nodal points (each rank asks only for its own
DOF coordinates) scale acceptably. At cellSize=1/150 (105 185 P2 DOF), evaluate repeat
cost 0.2623 / 0.1568 / 0.0730 s at np = 1/2/4 → efficiency 1.00 / 0.84 / 0.90. Answers are
correct: max|evaluate − nodal data| = 5.55e-16 at every np.
Where the time goes
Phase attribution inside global_evaluate, 3 calls, 20 000-point global set:
phase
np=1
np=4
np=8
Swarm.migrate (forward)
0.0743 s (31%)
0.3486 s (64%)
0.4656 s (69%)
…of which mesh.points_in_domain
0.1104 s
0.3551 s
0.4359 s
points_in_domain calls per global_evaluate
2
5
6
Swarm + 4 SwarmVariable construction
0.0021 s (0.9%)
0.0023 s (0.4%)
0.0023 s (0.3%)
Constructing a throwaway swarm and four swarm variables per call (_function.pyx:447-492)
is not the problem. The migration claim loop is: it calls the full point classification once
per round, and the number of rounds grows with rank count (2 → 6).
The amplifier: a point you do not own costs 51× a point you do
_get_closest_local_cells_internal (discretisation/discretisation_mesh.py:6073-6101):
points the first containment test rejects enter a k = min(n_cells, 50) nearest-centroid
walk whose only exit is every lost point found.
query set (4 000 points)
µs/point
in-cell test calls
points tested / points asked
owned by this rank (its own nodal coords)
0.67 – 0.77
2
1.0
not owned by this rank
7.45 – 8.91
51
50.7 – 51.0
10–13× wall clock, exactly 51× the containment work. Two compounding faults in that loop:
no cheap rejection. A point beyond max_radius of the nearest local centroid cannot
be in any local cell, but nothing tests that — a genuinely foreign point runs all 50
iterations.
the working set never shrinks.coords[lost_points] is re-tested in full every
iteration, including points already located; and one unfindable point forces all 50
iterations for the whole batch.
In parallel, the fraction of points a rank does not own is what grows with np. That is
the whole mechanism.
Secondary: a hidden O(cells) rebuild inside the first evaluate after every mesh move
_mark_faces_inside_and_out (discretisation_mesh.py:5505-…) is a per-cell Python loop
that builds the face control points, triggered lazily by the first containment test:
local cells
first call
cached
23 264
0.709 s
2 µs
5 726
0.177 s
2 µs
30.5 / 30.9 µs per local cell, linear. It is invalidated by nuke_coords_and_rebuild
(discretisation_mesh.py:2781-2782), i.e. by every deform() and every adapt() — so a
moving-mesh or adaptive run pays it once per mesh update, buried inside whatever evaluate
happens to be first.
This is also the whole of the apparent "nodal points are 6.7× slower than scattered points"
effect: cold, at cellSize=1/150, nodal 2.0347 s vs scattered-interior 0.3037 s — but
1.596 s of the 1.73 s gap is this one-time build, charged to whichever point set ran first. Warm, the nodal/scattered gap is 5% (0.2607 vs 0.2476 s). Nodal points do take ~2× the
in-cell tests (they sit on cell faces, so more of them miss the first test), but at these
sizes that is ~4% of the call.
Is it a regression? No — we tested and rejected the three plausible recent suspects
Not the DMInterpolation cache. Hit/miss was 1 miss + 2 hits in every configuration;
the coords hash is stable across repeat calls, and cache-hit cost is flat with np.
Not the out-of-domain allgather block (_function.pyx:588-640, added f6f0b963, 2026-06-06). local_fallback=False changes nothing: np=4 0.1789 → 0.1802 s,
np=8 0.2232 → 0.2278 s. The extrapolated-flag count is 0 for in-domain nodal queries, so
the block does not fire.
Not c52201b8 ("parallel locator hardening", 2026-05-28), which introduced the
parallel-only _robust_owning_cells / _eval_use_robust_location. Forcing _eval_use_robust_location() False at run time — the pre-c52201b8 routing — moves
nothing: np=8 global_evaluate 0.2249 → 0.2217 s, evaluate/local-nodal 0.0177 →
0.0177 s. Both routes go through the same 50-neighbour walk, which predates it (6cb735b7).
The anti-scaling is structural, not a recent regression. It has simply never been
measured, because the rank-local pattern (which does scale) is what the tests exercise.
2. #491 — evaluate silently substitutes a smoothed degree-1 projection for the expression
Reproduced verbatim at head:
evaluate(d) min -5.6261 max +6.0884
evaluate(d)**2 min +0.0000 negatives 0 <- correct
evaluate(d**2) min -2.6386 negatives 614 of 4000
evaluate(sqrt(d**2)) min -0.0622 negatives 25
max|evaluate(d**2) - evaluate(d)**2| = 3.3521
CONTROL (no derivative): max|evaluate(f**2) - evaluate(f)**2| = 8.7e-19
Mechanism (located by the parallel triage session, confirmed here): when any derivative
appears anywhere in the expression, evaluate_nd (_function.pyx:964-985) replaces the whole composite with an L2 Projection onto a hard-coded degree-1 continuous work
variable plus a gradient penalty (_function.pyx:748-767). The user's expression is never
evaluated pointwise; a smoothed continuous interpolant of it is.
Verdict: BUG, not a contract issue. The decisive number is not the square — it is the
exponential:
expression
evaluate min
d**2
−2.63858
d*d
−2.63858
sqrt(d**2)
−0.06218
f*d (mixed)
−3.22276
exp(d)
−7.25198
exp is strictly positive on the reals. No interpolation-undershoot argument survives that.
"Compose invariants in numpy" is a workaround for a substitution the caller was never told
about, not a documented contract. Unsafe shapes: every expression whose tree contains a Derivative and is not itself linear in that derivative — squares, products of
derivatives, sqrt, exp, any yield/invariant law. Linear-in-derivative expressions
survive only because projection commutes with them.
3. #432 — P1 fields wrong at points exactly on 3-D cell edges
Mechanism (located by the parallel triage session): serial simplex meshes take an unconditionally "exact" location capability (discretisation_mesh.py:6346-6347), which
makes the cell hint authoritative, so petsc_interpolate uses get_closest_cells
(:5941-5985) — a kd-tree nearest-control-point lookup with no containment test at
all — and DMLocatePoints is bypassed (petsc_tools.c:72-99). The only remaining guard
is a componentwise ξ∈[−1,1] box clamp (petsc_tools.c:285-299), which is a no-op on a
tetrahedron (the reference tet is not the reference box). A point on a shared edge gets
whichever cell owns the nearest control point; if that is not a cell containing the point,
the basis is evaluated at reference coordinates outside the tet and extrapolates.
Our instrumentation corroborates the bypass independently: on the serial simplex cache-miss
path we measured get_closest_cells called with all 20 000 query points and zero
containment tests performed on them.
This is the same class as #390 (quad TOP-face silent zeros) — a UW3 hint-policy problem, not
a PETSc one — and it is a recurrence: it has been fixed and returned. There is no regression
test pinning on-edge/on-face queries.
4. #314 / #513 — the np≥4 hangs are one defect, two triggers (#405 is separate)
Mechanism (located by the parallel triage session; #513 now closed into #314): the number
of DMLocatePoints calls on the world mesh DM inside petsc_interpolate is a rank-local
function of that rank's coordinate array. Two rank-local switches produce it:
the DMInterpolationCache is keyed on a per-rank coords hash (_function.pyx:1293, dminterpolation_cache.py:114-135); a hit skips create_structure → DMLocatePoints
entirely, and hit-vs-miss is decided per rank;
_location_capability() is deliberately not reduced (discretisation_mesh.py:6334-6339)
yet is part of that cache key, so a capability split forces a hit on some ranks and a miss
on others.
The entry guards (_function.pyx:1166-1170, :1012-1016) correctly get every rank into petsc_interpolate; nothing keeps them in lockstep once inside. This resolves #513's own
caveat — its non-empty control also hung because emptiness was never the mechanism;
per-rank divergence of coords_array[in_or_not] (_function.pyx:1027) is, and rank-distinct
partially-off-domain points produce that just as reliably as a zero-interior-point rank.
#405 is a different defect and fires earlier: a rank-asymmetric ValueError from
unguarded _radii.min()/.max() (discretisation_mesh.py:6512, 6527) and four sibling sites,
before this path is reached. get_min_radius feeds estimate_dt and penalty scaling at 14
call sites and sits under Swarm.migrate, so a zero-cell rank takes down ordinary
time-stepping, not just evaluation. Fix order: #405, then #314.
5. #279 — the ND↔units boundary is still unenforced
Confirmed still reproducing at head by the parallel triage session: dimensional UnitAwareArray coordinates (250 km, 750 km) against a unit-box ND DM return 0.800 and
0.933 instead of 0.25 and 0.75 — no exception, no warning. evaluate_nd strips the
subclass with np.array(...).view(np.ndarray) (_function.pyx:949) and keeps the numeric
value. Small, mechanical, but it is the exact mechanism by which #267 slipped in.
What a fix has to address
One root cause, in the locator. Items 1, 3 and (via the cache key) 4 are all
consequences of point location being a bespoke kd-tree + half-space walk with no clean
contract. The work is:
Give the walk a rejection path and a shrinking working set
(_get_closest_local_cells_internal:6073-6101). A bounding-box / max-radius pre-test
turns the 51× foreign-point penalty into ~1×; dropping located points from the working
set each iteration removes the rest. This alone should recover most of §1.
Locate once per call.points_in_domain locates the near-boundary points and throws
the owning cells away; petsc_interpolate then re-locates every interior point
(_function.pyx:1026-1032). One classification should produce both the in/out mask and
the cell hints.
Make the cell hint containment-checked, or stop calling it authoritative (§3). Simplex
meshes assert "exact" unconditionally (:6346-6347) while the hint they hand over is a
nearest-control-point lookup. Either check containment before trusting it, or restrict
the bypass to meshes where nearest-control-point provably equals owning-cell. A ξ-clamp
in barycentric coordinates would also close it for simplices; the current box clamp
cannot.
Make the collective structure rank-independent (§4). The DMLocatePoints call count
must not depend on rank-local coords. Either reduce the cache decision and the location
policy across ranks, or move the collective out of the cached region entirely.
Evaluate expressions pointwise (§2). Project the derivative leaves to nodal values,
then compose the expression pointwise at the query points — do not project the composite.
Failing that, evaluate must refuse nonlinear-in-derivative expressions with an
actionable error rather than return a smoothed surrogate. The degree-1 hard-coding is
independently wrong for a P2 field.
Genuinely separate work: #405 (rank-asymmetric raises — different failure mode, fires
first, blocks the rest) and #279 (an API guard, not a locator problem). Everything else is
one pass over the locator plus the derivative-path fix.
Regression tests the fix must land with, since #432/#390 have both recurred:
on-edge / on-face / shared-vertex queries in 2-D and 3-D, against the closed-form P1
edge value (1−t)·u_a + t·u_b (tests/test_0753_nested_mg_prolongation.py::test_reproduces_an_arbitrary_coarse_field
already computes exactly this and deliberately avoids evaluate);
evaluate(exp(d)) > 0 and evaluate(d**2) ≥ 0 for a P2 field;
a np=1/2/4/8 wall-time ceiling on a fixed global query set — the anti-scaling in §1 is
invisible to every existing test because they all use the rank-local pattern.
The mechanism (whole-composite → degree-1 continuous projection) is item 5 above; it is the derivative half of the same "evaluate does not do what it says" problem.
The mechanism (uncontained nearest-control-point hint + a box clamp that is a no-op on a tet) is item 3; it is the same locator. Recurrence of #390 — closing it again without a pinned regression test just restarts the clock.
The mechanism (rank-local DMLocatePoints call count via the coords-hash cache key and the unreduced location policy) is item 4; it is the same locator, seen through the cache. #513 already closed into this.
uw.function.evaluate/global_evaluate: the subsystem needs one pass, not five patchesWe have five open issues against the point-evaluation entry points. They are not five
independent bugs. Reading the code and measuring at
development@e475246f, thesubsystem has one architectural problem and four defects that sit on top of it: point
location is done by a hand-rolled kd-tree + half-space walk that has no cheap rejection
path, is re-run several times per call, is not shared between the classifier and the
interpolator, and is bypassed entirely on the one path where its answer is trusted.
Everything below is measured on a 2-D unstructured simplex box,
cellSize=1/100(23 264 cells, 46 929 P2 DOF) unless stated,
amr-dev, Darwin arm64, runs sequential(concurrent PETSc contends ~26%).
1. Parallel anti-scaling:
global_evaluategets slower as ranks are addedNot previously filed. This is the finding with the largest practical cost.
Fixed global query set — 20 000 points, identical on every rank, so the answer is
rank-count independent and wall time should be flat or better as
npgrows. Best of 3calls, max over ranks:
global_evaluate(s)np=8 is 2.83× slower than serial for identical work.
evaluateon the same fixed global set (timing only — in parallel it classifies againstthe rank-local mesh, so the answer is rank-local): 0.0518 / 0.0436 / 0.0382 / 0.0359 s at
np = 1/2/4/8. Efficiency 1.00 / 0.59 / 0.34 / 0.18. It is nearly flat because
points_in_domainsees all 20 000 points on every rank at every np (measured: 60 000points classified per 3 calls, unchanged from np=1 to np=8) — per-rank work proportional
to the GLOBAL point count, the classic signature.
The benign case, for contrast: rank-local nodal points (each rank asks only for its own
DOF coordinates) scale acceptably. At
cellSize=1/150(105 185 P2 DOF),evaluaterepeatcost 0.2623 / 0.1568 / 0.0730 s at np = 1/2/4 → efficiency 1.00 / 0.84 / 0.90. Answers are
correct:
max|evaluate − nodal data| = 5.55e-16at every np.Where the time goes
Phase attribution inside
global_evaluate, 3 calls, 20 000-point global set:Swarm.migrate(forward)mesh.points_in_domainpoints_in_domaincalls perglobal_evaluateSwarm+ 4SwarmVariableconstructionConstructing a throwaway swarm and four swarm variables per call (
_function.pyx:447-492)is not the problem. The migration claim loop is: it calls the full point classification once
per round, and the number of rounds grows with rank count (2 → 6).
The amplifier: a point you do not own costs 51× a point you do
_get_closest_local_cells_internal(discretisation/discretisation_mesh.py:6073-6101):points the first containment test rejects enter a
k = min(n_cells, 50)nearest-centroidwalk whose only exit is every lost point found.
10–13× wall clock, exactly 51× the containment work. Two compounding faults in that loop:
max_radiusof the nearest local centroid cannotbe in any local cell, but nothing tests that — a genuinely foreign point runs all 50
iterations.
coords[lost_points]is re-tested in full everyiteration, including points already located; and one unfindable point forces all 50
iterations for the whole batch.
In parallel, the fraction of points a rank does not own is what grows with
np. That isthe whole mechanism.
Secondary: a hidden O(cells) rebuild inside the first
evaluateafter every mesh move_mark_faces_inside_and_out(discretisation_mesh.py:5505-…) is a per-cell Python loopthat builds the face control points, triggered lazily by the first containment test:
30.5 / 30.9 µs per local cell, linear. It is invalidated by
nuke_coords_and_rebuild(
discretisation_mesh.py:2781-2782), i.e. by everydeform()and everyadapt()— so amoving-mesh or adaptive run pays it once per mesh update, buried inside whatever
evaluatehappens to be first.
This is also the whole of the apparent "nodal points are 6.7× slower than scattered points"
effect: cold, at
cellSize=1/150, nodal 2.0347 s vs scattered-interior 0.3037 s — but1.596 s of the 1.73 s gap is this one-time build, charged to whichever point set ran first.
Warm, the nodal/scattered gap is 5% (0.2607 vs 0.2476 s). Nodal points do take ~2× the
in-cell tests (they sit on cell faces, so more of them miss the first test), but at these
sizes that is ~4% of the call.
Is it a regression? No — we tested and rejected the three plausible recent suspects
DMInterpolationcache. Hit/miss was 1 miss + 2 hits in every configuration;the coords hash is stable across repeat calls, and cache-hit cost is flat with np.
_function.pyx:588-640, addedf6f0b963, 2026-06-06).local_fallback=Falsechanges nothing: np=4 0.1789 → 0.1802 s,np=8 0.2232 → 0.2278 s. The extrapolated-flag count is 0 for in-domain nodal queries, so
the block does not fire.
c52201b8("parallel locator hardening", 2026-05-28), which introduced theparallel-only
_robust_owning_cells/_eval_use_robust_location. Forcing_eval_use_robust_location()False at run time — the pre-c52201b8routing — movesnothing: np=8
global_evaluate0.2249 → 0.2217 s,evaluate/local-nodal 0.0177 →0.0177 s. Both routes go through the same 50-neighbour walk, which predates it (
6cb735b7).The anti-scaling is structural, not a recent regression. It has simply never been
measured, because the rank-local pattern (which does scale) is what the tests exercise.
2. #491 —
evaluatesilently substitutes a smoothed degree-1 projection for the expressionReproduced verbatim at head:
Mechanism (located by the parallel triage session, confirmed here): when any derivative
appears anywhere in the expression,
evaluate_nd(_function.pyx:964-985) replaces thewhole composite with an L2
Projectiononto a hard-coded degree-1 continuous workvariable plus a gradient penalty (
_function.pyx:748-767). The user's expression is neverevaluated pointwise; a smoothed continuous interpolant of it is.
Verdict: BUG, not a contract issue. The decisive number is not the square — it is the
exponential:
evaluatemind**2d*dsqrt(d**2)f*d(mixed)exp(d)expis strictly positive on the reals. No interpolation-undershoot argument survives that."Compose invariants in numpy" is a workaround for a substitution the caller was never told
about, not a documented contract. Unsafe shapes: every expression whose tree contains a
Derivativeand is not itself linear in that derivative — squares, products ofderivatives,
sqrt,exp, any yield/invariant law. Linear-in-derivative expressionssurvive only because projection commutes with them.
3. #432 — P1 fields wrong at points exactly on 3-D cell edges
Mechanism (located by the parallel triage session): serial simplex meshes take an
unconditionally "exact" location capability (
discretisation_mesh.py:6346-6347), whichmakes the cell hint authoritative, so
petsc_interpolateusesget_closest_cells(
:5941-5985) — a kd-tree nearest-control-point lookup with no containment test atall — and
DMLocatePointsis bypassed (petsc_tools.c:72-99). The only remaining guardis a componentwise ξ∈[−1,1] box clamp (
petsc_tools.c:285-299), which is a no-op on atetrahedron (the reference tet is not the reference box). A point on a shared edge gets
whichever cell owns the nearest control point; if that is not a cell containing the point,
the basis is evaluated at reference coordinates outside the tet and extrapolates.
Our instrumentation corroborates the bypass independently: on the serial simplex cache-miss
path we measured
get_closest_cellscalled with all 20 000 query points and zerocontainment tests performed on them.
This is the same class as #390 (quad TOP-face silent zeros) — a UW3 hint-policy problem, not
a PETSc one — and it is a recurrence: it has been fixed and returned. There is no regression
test pinning on-edge/on-face queries.
4. #314 / #513 — the np≥4 hangs are one defect, two triggers (#405 is separate)
Mechanism (located by the parallel triage session; #513 now closed into #314): the number
of
DMLocatePointscalls on the world mesh DM insidepetsc_interpolateis a rank-localfunction of that rank's coordinate array. Two rank-local switches produce it:
DMInterpolationCacheis keyed on a per-rank coords hash (_function.pyx:1293,dminterpolation_cache.py:114-135); a hit skipscreate_structure→DMLocatePointsentirely, and hit-vs-miss is decided per rank;
_location_capability()is deliberately not reduced (discretisation_mesh.py:6334-6339)yet is part of that cache key, so a capability split forces a hit on some ranks and a miss
on others.
The entry guards (
_function.pyx:1166-1170,:1012-1016) correctly get every rank intopetsc_interpolate; nothing keeps them in lockstep once inside. This resolves #513's owncaveat — its non-empty control also hung because emptiness was never the mechanism;
per-rank divergence of
coords_array[in_or_not](_function.pyx:1027) is, and rank-distinctpartially-off-domain points produce that just as reliably as a zero-interior-point rank.
#405 is a different defect and fires earlier: a rank-asymmetric
ValueErrorfromunguarded
_radii.min()/.max()(discretisation_mesh.py:6512, 6527) and four sibling sites,before this path is reached.
get_min_radiusfeedsestimate_dtand penalty scaling at 14call sites and sits under
Swarm.migrate, so a zero-cell rank takes down ordinarytime-stepping, not just evaluation. Fix order: #405, then #314.
5. #279 — the ND↔units boundary is still unenforced
Confirmed still reproducing at head by the parallel triage session: dimensional
UnitAwareArraycoordinates (250 km, 750 km) against a unit-box ND DM return 0.800 and0.933 instead of 0.25 and 0.75 — no exception, no warning.
evaluate_ndstrips thesubclass with
np.array(...).view(np.ndarray)(_function.pyx:949) and keeps the numericvalue. Small, mechanical, but it is the exact mechanism by which #267 slipped in.
What a fix has to address
One root cause, in the locator. Items 1, 3 and (via the cache key) 4 are all
consequences of point location being a bespoke kd-tree + half-space walk with no clean
contract. The work is:
(
_get_closest_local_cells_internal:6073-6101). A bounding-box / max-radius pre-testturns the 51× foreign-point penalty into ~1×; dropping located points from the working
set each iteration removes the rest. This alone should recover most of §1.
points_in_domainlocates the near-boundary points and throwsthe owning cells away;
petsc_interpolatethen re-locates every interior point(
_function.pyx:1026-1032). One classification should produce both the in/out mask andthe cell hints.
meshes assert
"exact"unconditionally (:6346-6347) while the hint they hand over is anearest-control-point lookup. Either check containment before trusting it, or restrict
the bypass to meshes where nearest-control-point provably equals owning-cell. A ξ-clamp
in barycentric coordinates would also close it for simplices; the current box clamp
cannot.
DMLocatePointscall countmust not depend on rank-local coords. Either reduce the cache decision and the location
policy across ranks, or move the collective out of the cached region entirely.
then compose the expression pointwise at the query points — do not project the composite.
Failing that,
evaluatemust refuse nonlinear-in-derivative expressions with anactionable error rather than return a smoothed surrogate. The degree-1 hard-coding is
independently wrong for a P2 field.
reject or non-dimensionalise
UnitAwareArraycoordinates at the ND entry points (Enforce the ND<->units boundary: evaluate/global_evaluate should reject or nondimensionalise UnitAwareArray input #279);rebuild
_mark_faces_inside_and_outincrementally or vectorise it (30 µs/cell, redone onevery deform).
Genuinely separate work: #405 (rank-asymmetric raises — different failure mode, fires
first, blocks the rest) and #279 (an API guard, not a locator problem). Everything else is
one pass over the locator plus the derivative-path fix.
Regression tests the fix must land with, since #432/#390 have both recurred:
edge value
(1−t)·u_a + t·u_b(tests/test_0753_nested_mg_prolongation.py::test_reproduces_an_arbitrary_coarse_fieldalready computes exactly this and deliberately avoids
evaluate);evaluate(exp(d)) > 0andevaluate(d**2) ≥ 0for a P2 field;invisible to every existing test because they all use the rank-local pattern.
Issues superseded
DMLocatePointscall count via the coords-hash cache key and the unreduced location policy) is item 4; it is the same locator, seen through the cache. #513 already closed into this.Underworld development team with AI support from Claude Code