Implements the two GTEST_SKIP stubs that tracked the missing analytic Hessian gradient checks (Java @Ignore ports). Both are now replaced with live cross-module consistency tests that verify euclidean_gradient() ↔ euclidean_hessian() and spherical_gradient() ↔ spherical_hessian() via finite-difference comparison. Result: 176 tests from 35 test suites — 176 PASSED, 0 SKIPPED. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
359 lines
17 KiB
C++
359 lines
17 KiB
C++
// test_spherical_functional.cpp (Phase 3c + 3e)
|
||
//
|
||
// Phase 3c — SphericalFunctional ported to ConformalMesh.
|
||
//
|
||
// Corresponds to de.varylab.discreteconformal.functional.SphericalFunctionalTest.
|
||
//
|
||
// Test map (Java → C++)
|
||
// ──────────────────────
|
||
// testHessian (Ignored) → GradientCheck_Hessian (ported)
|
||
// testGradientWithHyperIdeal… → GradientCheck_OctaFaceVertex (ported)
|
||
// testGradientInExtendedDomain → GradientCheck_SpherTetVertex (ported)
|
||
// testGradientWithHyperelliptic → GradientCheck_SpherTetAllDofs (ported)
|
||
// testFunctionalAtNaNValue → AnglesFiniteAtKnownPoint (ported)
|
||
//
|
||
// Energy model
|
||
// ────────────
|
||
// The energy is computed as the Schläfli path integral E(x) = ∫₀¹⟨G(tx),x⟩dt
|
||
// using 10-point Gauss-Legendre quadrature. The gradient check therefore
|
||
// verifies that G is curl-free (the integrability / exactness condition of
|
||
// the spherical discrete conformal functional). This is equivalent to the
|
||
// Java FunctionalTest gradient check.
|
||
|
||
#include "conformal_mesh.hpp"
|
||
#include "mesh_builder.hpp"
|
||
#include "spherical_functional.hpp"
|
||
#include "spherical_hessian.hpp"
|
||
#include <gtest/gtest.h>
|
||
#include <cmath>
|
||
#include <vector>
|
||
|
||
using namespace conformallab;
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Cross-module Hessian check: spherical_gradient() ↔ spherical_hessian()
|
||
//
|
||
// Java @Ignore reason: "no Hessian implemented" — the Java functional test
|
||
// was written before the Hessian existed. In C++ the analytic spherical
|
||
// Hessian (spherical_hessian.hpp, Phase 3f) is complete.
|
||
//
|
||
// This test verifies cross-module consistency between the functional and
|
||
// the Hessian module. The spherical Hessian is NSD (negative semi-definite)
|
||
// because the spherical energy is concave — hessian_check_spherical() uses
|
||
// the sign-corrected FD check appropriate for the spherical case.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(SphericalFunctional, GradientCheck_Hessian)
|
||
{
|
||
auto mesh = make_spherical_tetrahedron();
|
||
auto maps = setup_spherical_maps(mesh);
|
||
compute_lambda0_from_mesh(mesh, maps);
|
||
int n = assign_vertex_dof_indices(mesh, maps);
|
||
|
||
std::vector<double> x(static_cast<std::size_t>(n), -0.2);
|
||
|
||
EXPECT_TRUE(hessian_check_spherical(mesh, x, maps))
|
||
<< "Cross-module: spherical_gradient() and spherical_hessian() are inconsistent";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Angle formula: octahedron-face triangle has all angles = π/2
|
||
//
|
||
// The triangle (1,0,0)–(0,1,0)–(0,0,1) has l_ij = π/2 for all edges.
|
||
// Half-angle formula: s = 3π/4, s_ij = π/4 for all three.
|
||
// All angles = π/2 (right-angled spherical triangle).
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(SphericalFunctional, OctaFaceAnglesAreRightAngles)
|
||
{
|
||
// l_ij = π/2 for all edges (octahedron face on unit sphere)
|
||
const double l = PI_SPHER / 2.0;
|
||
auto fa = spherical_angles(l, l, l);
|
||
|
||
ASSERT_TRUE(fa.valid) << "Equilateral spherical triangle must be valid";
|
||
EXPECT_NEAR(PI_SPHER / 2.0, fa.alpha1, 1e-12);
|
||
EXPECT_NEAR(PI_SPHER / 2.0, fa.alpha2, 1e-12);
|
||
EXPECT_NEAR(PI_SPHER / 2.0, fa.alpha3, 1e-12);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Angle sum of a spherical triangle exceeds π (positive curvature)
|
||
//
|
||
// For the spherical tetrahedron face (arccos(−1/3) ≈ 1.9106 per edge):
|
||
// The dihedral angle = arccos(1/3) ≈ 70.53°; by symmetry the face angles
|
||
// (vertex angles of the spherical triangle) are all equal.
|
||
// Angle sum must be > π and equal 3·arccos(1/3) ≈ 3·1.2310 ≈ 3.693 rad.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(SphericalFunctional, SpherTetAngleSumExceedsPi)
|
||
{
|
||
// Edge length of spherical tetrahedron face: arccos(−1/3)
|
||
const double l = std::acos(-1.0 / 3.0);
|
||
auto fa = spherical_angles(l, l, l);
|
||
|
||
ASSERT_TRUE(fa.valid);
|
||
EXPECT_GT(fa.alpha1 + fa.alpha2 + fa.alpha3, PI_SPHER)
|
||
<< "Angle sum of spherical triangle must exceed π";
|
||
|
||
// By symmetry all three angles must be equal
|
||
EXPECT_NEAR(fa.alpha1, fa.alpha2, 1e-12);
|
||
EXPECT_NEAR(fa.alpha2, fa.alpha3, 1e-12);
|
||
|
||
// For a regular spherical tetrahedron with edge arccos(−1/3):
|
||
// half-angle: tan(α/2) = √(sin(l/2)/sin(3l/2)) = √3 → α/2 = π/3 → α = 2π/3.
|
||
// (arccos(1/3) ≈ 1.231 is the 3D dihedral angle of a Euclidean tetrahedron, not this.)
|
||
double expected = 2.0 * PI_SPHER / 3.0; // 120°
|
||
EXPECT_NEAR(fa.alpha1, expected, 1e-10);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Gradient check: octahedron-face triangle, vertex DOFs only
|
||
//
|
||
// Sets λ° from mesh geometry (unit sphere), all u_i = −0.3 (slightly smaller).
|
||
// Mirrors Java testGradientWithHyperIdeal… on a single-triangle mesh.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(SphericalFunctional, GradientCheck_OctaFaceVertex)
|
||
{
|
||
auto mesh = make_octahedron_face();
|
||
auto maps = setup_spherical_maps(mesh);
|
||
compute_lambda0_from_mesh(mesh, maps);
|
||
int n = assign_vertex_dof_indices(mesh, maps);
|
||
|
||
// Small uniform conformal factor: shrink the triangle slightly.
|
||
std::vector<double> x(static_cast<std::size_t>(n), -0.3);
|
||
|
||
EXPECT_TRUE(gradient_check_spherical(mesh, x, maps))
|
||
<< "Gradient check failed on octahedron-face triangle (vertex DOFs)";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Gradient check: spherical tetrahedron (4 faces), vertex DOFs only
|
||
//
|
||
// Closed surface; exercises accumulation over multiple faces per vertex.
|
||
// Mirrors Java testGradientInTheExtendedDomain.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(SphericalFunctional, GradientCheck_SpherTetVertex)
|
||
{
|
||
auto mesh = make_spherical_tetrahedron();
|
||
auto maps = setup_spherical_maps(mesh);
|
||
compute_lambda0_from_mesh(mesh, maps);
|
||
int n = assign_vertex_dof_indices(mesh, maps);
|
||
|
||
std::vector<double> x(static_cast<std::size_t>(n), -0.2);
|
||
|
||
EXPECT_TRUE(gradient_check_spherical(mesh, x, maps))
|
||
<< "Gradient check failed on spherical tetrahedron (vertex DOFs)";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Gradient check: spherical tetrahedron, all DOFs (vertex + edge)
|
||
//
|
||
// Exercises the edge-gradient branch: G_e = α_opp⁺ + α_opp⁻ − π.
|
||
// Mirrors Java testGradientWithHyperellipticCurve.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(SphericalFunctional, GradientCheck_SpherTetAllDofs)
|
||
{
|
||
auto mesh = make_spherical_tetrahedron();
|
||
auto maps = setup_spherical_maps(mesh);
|
||
compute_lambda0_from_mesh(mesh, maps);
|
||
int n = assign_all_spherical_dof_indices(mesh, maps);
|
||
|
||
// Small but non-zero values; vertex DOFs negative, edge DOFs zero.
|
||
// Edge DOF adjusts the effective log-length Λ_ij = λ°_ij + u_i + u_j + λ_e.
|
||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||
// Set vertex DOFs (indices 0..3) to -0.2 to keep triangle well-formed.
|
||
for (int i = 0; i < 4; ++i) x[static_cast<std::size_t>(i)] = -0.2;
|
||
|
||
EXPECT_TRUE(gradient_check_spherical(mesh, x, maps))
|
||
<< "Gradient check failed on spherical tetrahedron (all DOFs)";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Angles are finite at a known interior point
|
||
//
|
||
// Mirrors Java testFunctionalAtNaNValue: choose DOFs that could hit
|
||
// a degenerate branch (l_ij → 0 or triangle inequality fails) and check
|
||
// the gradient vector is free of NaN/Inf.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(SphericalFunctional, AnglesFiniteAtKnownPoint)
|
||
{
|
||
auto mesh = make_spherical_tetrahedron();
|
||
auto maps = setup_spherical_maps(mesh);
|
||
compute_lambda0_from_mesh(mesh, maps);
|
||
int n = assign_vertex_dof_indices(mesh, maps);
|
||
|
||
// u_i = -1.5: contracts the triangle heavily but stays non-degenerate.
|
||
std::vector<double> x(static_cast<std::size_t>(n), -1.5);
|
||
auto G = spherical_gradient(mesh, x, maps);
|
||
|
||
for (std::size_t i = 0; i < G.size(); ++i) {
|
||
EXPECT_FALSE(std::isnan(G[i])) << "Gradient component " << i << " is NaN";
|
||
EXPECT_FALSE(std::isinf(G[i])) << "Gradient component " << i << " is Inf";
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Gradient check: fan-4 mesh on unit sphere, vertex DOFs only
|
||
//
|
||
// Make a fan of 4 triangles around the north pole (0,0,1);
|
||
// rim vertices projected onto the equator.
|
||
// Exercises high-valence vertex gradient accumulation.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(SphericalFunctional, GradientCheck_SpherFan4Vertex)
|
||
{
|
||
// Build a fan with 4 spherical triangles manually (can't use make_fan
|
||
// directly because those vertices are not on the unit sphere).
|
||
ConformalMesh mesh;
|
||
auto center = mesh.add_vertex(Point3(0, 0, 1)); // north pole
|
||
const int n_rim = 4;
|
||
std::vector<Vertex_index> rim(n_rim);
|
||
const double dtheta = 2.0 * PI_SPHER / n_rim;
|
||
const double phi = PI_SPHER / 4.0; // 45° colatitude
|
||
for (int i = 0; i < n_rim; ++i) {
|
||
double theta = i * dtheta;
|
||
rim[i] = mesh.add_vertex(Point3(
|
||
std::sin(phi) * std::cos(theta),
|
||
std::sin(phi) * std::sin(theta),
|
||
std::cos(phi)));
|
||
}
|
||
for (int i = 0; i < n_rim; ++i)
|
||
mesh.add_face(center, rim[i], rim[(i + 1) % n_rim]);
|
||
|
||
auto maps = setup_spherical_maps(mesh);
|
||
compute_lambda0_from_mesh(mesh, maps);
|
||
int ndof = assign_vertex_dof_indices(mesh, maps);
|
||
|
||
std::vector<double> x(static_cast<std::size_t>(ndof), -0.3);
|
||
|
||
EXPECT_TRUE(gradient_check_spherical(mesh, x, maps))
|
||
<< "Gradient check failed on spherical fan-4 mesh";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Gradient check: mixed pinned/variable vertices
|
||
//
|
||
// One vertex pinned (u_v = 0 fixed), others variable.
|
||
// Verifies that the gradient accumulation skips pinned vertices correctly.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(SphericalFunctional, GradientCheck_MixedPinnedVertices)
|
||
{
|
||
auto mesh = make_octahedron_face();
|
||
auto maps = setup_spherical_maps(mesh);
|
||
compute_lambda0_from_mesh(mesh, maps);
|
||
|
||
// Pin v0, make v1 and v2 variable.
|
||
auto vit = mesh.vertices().begin();
|
||
Vertex_index v0 = *vit++;
|
||
Vertex_index v1 = *vit++;
|
||
Vertex_index v2 = *vit;
|
||
|
||
maps.v_idx[v0] = -1; // pinned
|
||
maps.v_idx[v1] = 0;
|
||
maps.v_idx[v2] = 1;
|
||
|
||
std::vector<double> x = {-0.2, -0.4};
|
||
|
||
EXPECT_TRUE(gradient_check_spherical(mesh, x, maps))
|
||
<< "Gradient check failed for mixed pinned/variable vertices";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Phase 3e — Gauge-fix for closed spherical surfaces
|
||
//
|
||
// On a closed spherical surface, the functional has a gauge mode:
|
||
// E(u + t·1) is maximised at some t*.
|
||
// At t*, the sum of all vertex gradients equals zero: Σ G_v = 0.
|
||
//
|
||
// Test: start from a point with non-zero ΣG_v, apply the gauge shift,
|
||
// and verify ΣG_v(x + t*·1) ≈ 0.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(SphericalFunctional, GaugeFix_SpherTetVertexZerosSumGv)
|
||
{
|
||
auto mesh = make_spherical_tetrahedron();
|
||
auto maps = setup_spherical_maps(mesh);
|
||
compute_lambda0_from_mesh(mesh, maps);
|
||
int n = assign_vertex_dof_indices(mesh, maps);
|
||
|
||
// Off-gauge starting point: all u_i = -0.5
|
||
std::vector<double> x(static_cast<std::size_t>(n), -0.5);
|
||
|
||
// Compute ΣG_v before gauge shift.
|
||
{
|
||
auto G = spherical_gradient(mesh, x, maps);
|
||
double sum = 0.0;
|
||
for (auto v : mesh.vertices()) {
|
||
int iv = maps.v_idx[v];
|
||
if (iv >= 0) sum += G[static_cast<std::size_t>(iv)];
|
||
}
|
||
// At -0.5 the surface is compressed; ΣG_v should be non-zero.
|
||
EXPECT_NE(sum, 0.0) << "Pre-gauge ΣG_v should be non-zero";
|
||
}
|
||
|
||
// Compute gauge shift and apply.
|
||
double t = spherical_gauge_shift(mesh, x, maps);
|
||
std::vector<double> x_fixed = x;
|
||
for (auto v : mesh.vertices()) {
|
||
int iv = maps.v_idx[v];
|
||
if (iv >= 0)
|
||
x_fixed[static_cast<std::size_t>(iv)] += t;
|
||
}
|
||
|
||
// Verify ΣG_v ≈ 0 at the gauge-fixed point.
|
||
{
|
||
auto G = spherical_gradient(mesh, x_fixed, maps);
|
||
double sum = 0.0;
|
||
for (auto v : mesh.vertices()) {
|
||
int iv = maps.v_idx[v];
|
||
if (iv >= 0) sum += G[static_cast<std::size_t>(iv)];
|
||
}
|
||
EXPECT_NEAR(sum, 0.0, 1e-6)
|
||
<< "After gauge fix, Σ G_v should vanish; t* = " << t;
|
||
}
|
||
}
|
||
|
||
TEST(SphericalFunctional, GaugeFix_ApplyInPlace)
|
||
{
|
||
auto mesh = make_spherical_tetrahedron();
|
||
auto maps = setup_spherical_maps(mesh);
|
||
compute_lambda0_from_mesh(mesh, maps);
|
||
int n = assign_vertex_dof_indices(mesh, maps);
|
||
|
||
// x = -0.3: compressed but inside the valid spherical domain.
|
||
std::vector<double> x(static_cast<std::size_t>(n), -0.3);
|
||
|
||
apply_spherical_gauge(mesh, x, maps);
|
||
|
||
// After in-place gauge fix, ΣG_v must be near 0.
|
||
auto G = spherical_gradient(mesh, x, maps);
|
||
double sum = 0.0;
|
||
for (auto v : mesh.vertices()) {
|
||
int iv = maps.v_idx[v];
|
||
if (iv >= 0) sum += G[static_cast<std::size_t>(iv)];
|
||
}
|
||
EXPECT_NEAR(sum, 0.0, 1e-6)
|
||
<< "apply_spherical_gauge must drive Σ G_v to zero";
|
||
}
|
||
|
||
TEST(SphericalFunctional, GaugeFix_AlreadyAtGaugeReturnsTNearZero)
|
||
{
|
||
// A symmetric, equilateral spherical tetrahedron at x=0 is already
|
||
// at the gauge maximum (by symmetry, ΣG_v = 0).
|
||
auto mesh = make_spherical_tetrahedron();
|
||
auto maps = setup_spherical_maps(mesh);
|
||
compute_lambda0_from_mesh(mesh, maps);
|
||
int n = assign_vertex_dof_indices(mesh, maps);
|
||
|
||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||
double t = spherical_gauge_shift(mesh, x, maps);
|
||
// Symmetric starting point → t* should be very close to 0.
|
||
EXPECT_NEAR(t, 0.0, 1e-5)
|
||
<< "Gauge shift from the symmetric point should be ~0; got " << t;
|
||
}
|