Files
ConformalLabpp/code/tests/cgal/test_spherical_functional.cpp
Tarik Moussa a3ee9576d4
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 1m57s
API Docs / doc-build (pull_request) Successful in 1m3s
Markdown link check / check (pull_request) Successful in 44s
C++ Tests / test-cgal (pull_request) Has been skipped
C++ Tests / quality-gates (pull_request) Successful in 2m11s
fix+test: Euclidean holonomy/τ end-to-end + spherical edge-DOF oracle (2026-05-29 audit)
Bundles the 2026-05-29 Java↔C++ math-correctness audit (doc/reviewer/
java-port-audit.md, 11 findings) with two follow-up fixes.

Audit code changes:
- Finding 3 (spherical_functional): edge-DOF replacement parameterization via
  spher_eff_lambda; edge gradient α_opp⁺+α_opp⁻−θ_e (drops additive −(S⁺+S⁻)/2)
- Finding 4 (spherical_hessian): always-compiled edge-DOF throw guard
- Finding 6 (period_matrix): faithful normalizeModulus (0≤Re≤½, Im≥0, |τ|≥1)
- Finding 9 (inversive_distance): degenerate-face limiting angles, no skip
- Findings 1/2 (euclidean): degenerate gradient limiting angles + Hessian guard

Euclidean holonomy/τ fix: develop the cut surface across the dual spanning tree
only (cut_graph now exposes is_dual_tree), so genus-1 cut edges yield
non-degenerate lattice generators. Previously τ came out 0 / NaN / 1e13 on the
bundled tori; now matches the analytic revolution modulus i·√(R²−r²)/r. Re-enabled
τ reporting in the Euclidean CLI; rewrote validation.md §3/§4 accordingly.

Tests (240 CGAL, 0 skipped):
- HolonomyEndToEnd ×3 — tori of revolution (4×4, hex 6×6, 8×8) vs analytic modulus
- SphericalFunctional.EdgeGradient_RegularTetClosedForm — independent closed-form
  π/3 oracle locking the Finding-3 edge formula (the path-integral FD check cannot
  detect a wrong-but-conservative gradient)

Also documents the latent spherical/hyperbolic holonomy-extraction bug (same
single-development pattern, dead code today) in research-track.md (Phase 9c/10),
and adds favour/normalisations to the codespell ignore list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 12:50:16 +02:00

419 lines
20 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// 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);
// Replacement parameterization (Finding 3): when an edge carries a DOF its
// value *replaces* λ°_ij + u_i + u_j entirely, so Λ_ij = λ_e. Here the edge
// DOFs stay at 0 and only the vertex DOFs are perturbed; this checks that the
// gradient is curl-free (energy = Schläfli path integral), not Java-faithfulness
// of the edge formula — that is locked separately by
// EdgeGradient_RegularTetClosedForm below.
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)";
}
// ════════════════════════════════════════════════════════════════════════════
// Closed-form oracle for the edge-DOF gradient (Finding 3, missing-test item 4)
//
// The FD gradient check above can only confirm that G is conservative — the
// spherical energy is *defined* as the path integral of G, so the energy↔gradient
// FD agreement is automatic and CANNOT detect a wrong-but-conservative edge
// formula. This test instead pins the edge gradient against an independent,
// closed-form geometric value, so it would fail if the Finding-3 formula
// (G_e = α_opp⁺ + α_opp⁻ θ_e, dropping the additive (S⁺+S⁻)/2 term) ever
// regressed.
//
// Geometry: the regular spherical tetrahedron has all edges a = arccos(1/3),
// so by the spherical law of cosines every interior corner angle is
// cos α = (cos a cos²a)/sin²a = cos a/(1+cos a) = (1/3)/(2/3) = 1/2
// ⇒ α = 2π/3.
// Each edge is shared by two faces, so both opposite angles equal 2π/3 and
// G_e = 2π/3 + 2π/3 θ_e with θ_e = π (default) = π/3.
//
// Setup: all edges carry DOFs, set to their λ⁰ (the replacement convention then
// reproduces the original tetrahedron metric exactly), vertex DOFs left at 0.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, EdgeGradient_RegularTetClosedForm)
{
const double PI_ = std::acos(-1.0);
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);
// Edge DOF = λ⁰ → Λ_ij = λ⁰ → reproduces the arccos(1/3) tetrahedron.
// Vertex DOFs stay at 0 (ignored by the replacement convention for DOF edges).
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
int n_edge_dofs = 0;
for (auto e : mesh.edges()) {
int ie = maps.e_idx[e];
if (ie >= 0) { x[static_cast<std::size_t>(ie)] = maps.lambda0[e]; ++n_edge_dofs; }
}
ASSERT_EQ(n_edge_dofs, 6) << "regular tetrahedron must have 6 edge DOFs";
auto G = spherical_gradient(mesh, x, maps);
const double expected = PI_ / 3.0; // 2·(2π/3) π
for (auto e : mesh.edges()) {
int ie = maps.e_idx[e];
if (ie < 0) continue;
EXPECT_NEAR(G[static_cast<std::size_t>(ie)], expected, 1e-9)
<< "edge gradient at DOF " << ie
<< " must equal the closed-form value π/3 (Finding 3)";
}
}
// ════════════════════════════════════════════════════════════════════════════
// 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;
}