mesh: strong relax objective never ranks an inverted element below a valid one

In the max-min phase a valid element's term q^-16 reached 1e96 as q -> 0+, far above
an invalid element's 2e8, so the descent kept elements inverted rather than pass
through flat: the distorted plate stayed tangled (4 invalid) once its mesh was the
same on every platform. The shape term now continues as its tangent line below
q = 0.3 (finite, steep) and an invalid element costs 1e11, above any valid one.

Adds a test that the plate's mesh, its perturbation and the strong relaxation are
the same bits with 1 and 4 threads and match a fingerprint taken on macOS (CI checks
it on Linux).

Part of #438.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-16 18:30:00 -04:00
1 parent 700a7e3d10
commit 2456912850
2 files changed
+65 -10

No files matched your search

+31 -10
View File
@@ -12,9 +12,10 @@
//! B (2 − q) for an invalid element (B large),
//! ```
//!
//! growing without bound as an element degenerates (a barrier: no step that lowers `F` inverts an
//! element), while the invalid branch falls as an inverted element untangles and lies far above
//! any valid configuration. The size term is there because the scaled Jacobian does not see an
//! φ continuing as its tangent line below q = 0.3, so it rises steeply but stays finite as an
//! element degenerates, while the invalid branch falls as an inverted element untangles and lies
//! above every valid element's term (a barrier: no step that lowers `F` inverts an element unless
//! it untangles worse ones; and passing through q → 0⁺ never costs more than staying inverted). The size term is there because the scaled Jacobian does not see an
//! element flattening along one direction: without it the optimiser squeezed elements against
//! the boundary to square up their neighbours (the Pi 5 base's largest edge ratio went from 5 to
//! 34). For the same reason an element longer than the region's largest edge ratio before costs
@@ -43,8 +44,14 @@ use fieldcraft_model::Model;
use super::{Constraints, Elem, Freedom, P, RegionQuality, State, add, dist, dot, measure, region_quality, scale, sub};
use crate::{Error, Result};
/// Weight of an invalid element in the objective.
const INVALID: f64 = 1e8;
/// Weight of an invalid element in the objective: above every valid element's term (the shape
/// term is at most [`shape`] at 0, a few 1e9, plus the size term), so no step that untangles an
/// element ever looks worse than staying inverted.
const INVALID: f64 = 1e11;
/// Below this quality the shape term continues as its tangent line, so it stays finite (and
/// below [`INVALID`]) as an element degenerates instead of growing without bound.
const KNEE: f64 = 0.3;
/// Weight of the size term.
const SIZE_WEIGHT: f64 = 0.5;
@@ -66,16 +73,30 @@ enum Phase {
fn phi(phase: Phase, q: f64, valid: bool, v: f64, v0: f64) -> f64 {
if valid && q > 0.0 {
let g = if v > 0.0 && v0 > 0.0 { libm::log(v / v0) } else { 0.0 };
let shape = match phase {
Phase::Smooth => (1.0 / q - 1.0).powi(2),
Phase::MinMax => q.powi(-MIN_MAX_POWER) - 1.0,
};
shape + SIZE_WEIGHT * g * g
shape(phase, q) + SIZE_WEIGHT * g * g
} else {
INVALID * (2.0 - q.min(1.0))
}
}
/// The shape term of a valid element of quality `q > 0`: `φ(q)` from [`KNEE`] up, its tangent
/// line below. Unbounded, `q⁻¹⁶` reached 1e96 as an element neared flat, so far above an
/// invalid element's term that the descent kept elements inverted rather than pass through
/// q → 0⁺ (#438: the distorted plate stayed tangled).
fn shape(phase: Phase, q: f64) -> f64 {
// φ and its derivative at `x`.
let at = |x: f64| match phase {
Phase::Smooth => ((1.0 / x - 1.0).powi(2), -2.0 * (1.0 / x - 1.0) / (x * x)),
Phase::MinMax => (x.powi(-MIN_MAX_POWER) - 1.0, -f64::from(MIN_MAX_POWER) * x.powi(-MIN_MAX_POWER - 1)),
};
if q >= KNEE {
at(q).0
} else {
let (v, d) = at(KNEE);
v + d * (q - KNEE)
}
}
/// A linear hexahedron's scaled Jacobian at its corners and centre (the samples
/// [`crate::quality::element_quality`] takes), straight from the trilinear map: the sweeps'
/// inner loop, where the certified check would dominate. A state is certified before it is kept.