test: Java golden-value oracles for the five DCE math cores + P1-2/P1-3 fixes
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 1m57s
API Docs / doc-build (pull_request) Successful in 59s
Markdown link check / check (pull_request) Successful in 51s
C++ Tests / test-cgal (pull_request) Has been skipped
C++ Tests / quality-gates (pull_request) Successful in 2m19s

Add bit-for-bit (1e-12) golden-value oracle tests pinning the C++ pure-math
and functional cores against the compiled upstream Java library (openjdk 17):

- HyperIdealGoldenJava: Clausen/Л/ImLi2, ζ13/14/15/ζ, both tetrahedron-volume
  formulas (real de.varylab…Clausen / HyperIdealUtility).
- EuclideanGoldenJava / SphericalGoldenJava: angle formulas + β relations + Л
  energy terms, plus FULL-MESH oracles driving the real EuclideanCyclicFunctional
  / SphericalFunctional on a shared tetrahedron — per-vertex gradient (Θ−Σα) and
  ΔE = E(x)−E(0) (C++ Gauss-Legendre path integral vs Java closed form).
- SphericalGoldenJava.FullMeshEdgeDofGradient: edge-DOF gradient (vertex + edge
  components, α_opp⁺+α_opp⁻−θ_e) vs raw conformalEnergyAndGradient — locks
  Finding 3 at the solution level (audit items 4 & 5).
- PeriodMatrix.NormalizeModulus_GoldenJava: τ-reduction fold convention vs the
  real DiscreteEllipticUtility.normalizeModulus (audit items 7 & 8).

Subtlety documented: the spherical oracles call Java's raw
conformalEnergyAndGradient, not evaluate() (which pre-runs a Brent gauge
maximization that C++ factors into the Newton solver's spherical_gauge_shift).

Also:
- P1-2 (layout.hpp): Euclidean holonomy now uses a per-cut-edge rigid-motion fit
  g(z)=a·z+b, exposing residual_rotation = |arg(a)| as a diagnostic; non-
  regressive (flat case a=1 reduces to the old midpoint formula).
- P1-3 (period_matrix.hpp): is_in_fundamental_domain fixed to the correct
  half-open SL(2,ℤ) domain (−½ ≤ Re < ½). Updated the now-exposed
  ComputePeriodMatrix_ReducedTau_InFD to assert the normalizeModulus domain
  (closed +½ edge) instead.

Test counts (single source of truth = doc/api/tests.md): 272/272 pass, 0
skipped (26 non-CGAL + 246 CGAL).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-29 19:08:37 +02:00
parent 18b9c61492
commit ba83974525
9 changed files with 580 additions and 61 deletions

View File

@@ -176,6 +176,14 @@ struct HolonomyData {
std::vector<Eigen::Vector2d> translations; ///< Euclidean / spherical translation per cut edge.
std::vector<MobiusMap> mobius_maps; ///< Hyperbolic Möbius isometry per cut edge (Phase 7).
std::vector<std::size_t> cut_edge_indices; ///< Index (in the cut-graph edge list) of each holonomy entry.
/// Residual rotation |arg(a)| (radians) of the Euclidean deck isometry
/// z ↦ a·z + b per cut edge. Zero for a perfectly flat cone metric;
/// a non-zero value signals under-convergence or a genuine cone-angle
/// defect, in which case `translations[i]` (the isometry's translation
/// part b) is only an approximate lattice generator. Empty for the
/// hyperbolic (Möbius) path.
std::vector<double> residual_rotation;
};
// ── Internal helpers ──────────────────────────────────────────────────────────
@@ -490,11 +498,23 @@ inline void set_root_huv_2d(
// different locations. The rigid motion identifying those two copies is the
// deck transformation of the generator loop (cut edge + tree path) — i.e. the
// holonomy. For a flat cone metric (Θ ≡ 2π) the linear part is trivial, so the
// holonomy is the pure translation given by the displacement of the shared
// edge's midpoint between the two developments. That translation is exactly
// the lattice generator ω consumed by compute_period_matrix().
// holonomy is the pure translation that identifies the two developments of the
// shared edge. We recover it as a full rigid motion z ↦ a·z + b fitted to the
// correspondence (Bs,Bt) ↦ (As,At): the shared edge endpoints S,T as developed
// in face B map to the same endpoints as developed in face A. Because both
// faces use the same edge length, |a| = 1 and the fit is an exact
// orientation-preserving isometry. Its translation part b is the lattice
// generator ω consumed by compute_period_matrix(); the rotation magnitude
// |arg(a)| is returned as a convergence diagnostic. For a perfectly flat cone
// metric (Θ ≡ 2π) a = 1 and b reduces to the midpoint displacement midA midB
// (the previous formula), so converged flat tori are bit-for-bit unchanged.
struct EuclideanHolonomyResult {
std::vector<Eigen::Vector2d> translations; ///< ω_i = translation part b
std::vector<double> residual_rotation; ///< |arg(a_i)| per cut edge
};
template <typename EdgeLenFn>
inline std::vector<Eigen::Vector2d> euclidean_holonomy(
inline EuclideanHolonomyResult euclidean_holonomy(
const ConformalMesh& mesh,
const CutGraph& cut,
EdgeLenFn&& edge_len)
@@ -565,9 +585,10 @@ inline std::vector<Eigen::Vector2d> euclidean_holonomy(
for (auto f : mesh.faces())
if (!face_done[static_cast<std::size_t>(f.idx())]) develop(f);
// ── Holonomy translation per cut edge ─────────────────────────────────────
std::vector<Eigen::Vector2d> omega;
omega.reserve(cut.cut_edge_indices.size());
// ── Holonomy isometry per cut edge ─────────────────────────────────────────
EuclideanHolonomyResult out;
out.translations.reserve(cut.cut_edge_indices.size());
out.residual_rotation.reserve(cut.cut_edge_indices.size());
for (std::size_t ce : cut.cut_edge_indices) {
Edge_index e = *std::next(mesh.edges().begin(), static_cast<std::ptrdiff_t>(ce));
Halfedge_index h = mesh.halfedge(e);
@@ -575,7 +596,8 @@ inline std::vector<Eigen::Vector2d> euclidean_holonomy(
if (mesh.is_border(h) || mesh.is_border(ho)
|| !face_done[static_cast<std::size_t>(mesh.face(h).idx())]
|| !face_done[static_cast<std::size_t>(mesh.face(ho).idx())]) {
omega.push_back(Eigen::Vector2d::Zero());
out.translations.push_back(Eigen::Vector2d::Zero());
out.residual_rotation.push_back(0.0);
continue;
}
// Face A = face(h) places edge e as halfedge h: S=source(h), T=target(h)
@@ -585,12 +607,15 @@ inline std::vector<Eigen::Vector2d> euclidean_holonomy(
// target(ho)=S → S=pos of source(next(ho)), T=pos of source(ho)
C Bt = hpos[static_cast<std::size_t>(ho.idx())];
C Bs = hpos[static_cast<std::size_t>(mesh.next(ho).idx())];
C midA = 0.5 * (As + At);
C midB = 0.5 * (Bs + Bt);
C w = midA - midB; // pure translation for a flat cone metric
omega.push_back(Eigen::Vector2d(w.real(), w.imag()));
// Fit the deck isometry g(z) = a·z + b with (Bs,Bt) ↦ (As,At).
// |a| = 1 exactly (shared edge length); b is the translation part.
C edgeB = Bt - Bs;
C a = (std::abs(edgeB) > 1e-300) ? (At - As) / edgeB : C(1.0, 0.0);
C b = As - a * Bs;
out.translations.push_back(Eigen::Vector2d(b.real(), b.imag()));
out.residual_rotation.push_back(std::abs(std::arg(a)));
}
return omega;
return out;
}
} // namespace detail
@@ -719,7 +744,9 @@ inline Layout2D euclidean_layout(
if (cut && holonomy) {
holonomy->cut_edge_indices = cut->cut_edge_indices;
holonomy->mobius_maps.clear();
holonomy->translations = detail::euclidean_holonomy(mesh, *cut, edge_len);
auto eh = detail::euclidean_holonomy(mesh, *cut, edge_len);
holonomy->translations = std::move(eh.translations);
holonomy->residual_rotation = std::move(eh.residual_rotation);
// Preserve the per-cut-edge seam UV in halfedge_uv (texture atlas), as
// before, so HalfedgeUV-based tests still see the seam-crossing layout.

View File

@@ -114,11 +114,18 @@ inline std::complex<double> reduce_to_fundamental_domain(std::complex<double> ta
// is_in_fundamental_domain — check membership in F with tolerance tol.
// ─────────────────────────────────────────────────────────────────────────────
/// `true` iff `τ` lies inside the standard SL(2,) fundamental domain
/// with tolerance `tol`.
/// `F = { Im τ > 0, −½ ≤ Re τ < ½, |τ| ≥ 1 }` with tolerance `tol`.
///
/// This is the half-open domain produced by `reduce_to_fundamental_domain`
/// (the right boundary `Re τ = +½ ≡ −½` is excluded via `T`). It is NOT the
/// mirror-folded `0 ≤ Re τ ≤ ½` domain produced by `normalizeModulus` (used
/// inside `compute_period_matrix`); a τ with `Re τ < 0` is a legitimate member
/// of `F` here but would be folded to `Re ≥ 0` by `normalizeModulus`.
inline bool is_in_fundamental_domain(std::complex<double> tau, double tol = 1e-9)
{
if (tau.imag() <= 0.0) return false;
if (std::abs(tau.real()) > 0.5 + tol) return false;
if (tau.real() < -0.5 - tol) return false; // left boundary closed
if (tau.real() > 0.5 - tol) return false; // right boundary Re = +½ excluded (≡ −½)
if (std::abs(tau) < 1.0 - tol) return false;
return true;
}

View File

@@ -26,6 +26,7 @@
#include "euclidean_geometry.hpp"
#include "euclidean_functional.hpp"
#include "euclidean_hessian.hpp"
#include "clausen.hpp"
#include <gtest/gtest.h>
#include <cmath>
#include <vector>
@@ -290,3 +291,117 @@ TEST(EuclideanFunctional, GradientCheck_MixedPinnedVertices)
EXPECT_TRUE(gradient_check_euclidean(mesh, x, maps))
<< "Gradient check failed for mixed pinned/variable vertices";
}
// ─────────────────────────────────────────────────────────────────────────────
// Golden-value oracle — pin the Euclidean angle formula and the 2·Л(α) energy
// term bit-for-bit against the upstream Java reference (EuclideanCyclicFunctional
// .triangleEnergyAndAlphas, lines 341-361), captured by running the compiled Java
// library (openjdk 17) with the real de.varylab…Clausen.Л on these exact edge
// lengths. Companion to HyperIdealGoldenJava and SphericalGoldenJava: locks the
// absolute angle/energy values against an independent implementation, catching
// silent index/sign drift the curl-free path-integral gradient check cannot see.
//
// To regenerate: /tmp/oracle/EucOracle.java. Values are Java printf %.17g.
// ─────────────────────────────────────────────────────────────────────────────
TEST(EuclideanGoldenJava, AngleAndLobachevskyEnergyFromLengths)
{
auto check = [](double l12, double l23, double l31,
double a1_g, double a2_g, double a3_g, double L_g) {
auto fa = euclidean_angles_from_lengths(l12, l23, l31);
EXPECT_TRUE(fa.valid);
EXPECT_NEAR(fa.alpha1, a1_g, 1e-12);
EXPECT_NEAR(fa.alpha2, a2_g, 1e-12);
EXPECT_NEAR(fa.alpha3, a3_g, 1e-12);
const double Lterm = 2.0 * Lobachevsky(fa.alpha1)
+ 2.0 * Lobachevsky(fa.alpha2)
+ 2.0 * Lobachevsky(fa.alpha3);
EXPECT_NEAR(Lterm, L_g, 1e-12);
};
check(1.0, 1.2, 0.9,
1.3637649752769678, 0.82416964552030680, 0.95365803279251860,
1.9456273836230942);
check(1.0, 1.0, 1.0,
1.0471975511965979, 1.0471975511965979, 1.0471975511965979,
2.0298832128193070);
}
// ─────────────────────────────────────────────────────────────────────────────
// FULL-MESH golden oracle — the strongest cross-check: drives the REAL upstream
// EuclideanCyclicFunctional (openjdk 17) on a tetrahedron loaded from a shared
// OBJ (identical topology + geometry to make_tetrahedron()), and pins BOTH the
// per-vertex gradient G_v = Θ_v Σα AND the energy difference ΔE = E(x) E(0)
// bit-for-bit against it.
//
// This closes audit missing-test item 5 (full-mesh energy + gradient at a known
// x). It is genuinely independent of the C++ implementation in two ways:
// • the gradient is the upstream library's own analytic gradient (not an FD
// check, which only proves curl-freeness of the C++ self-consistent energy);
// • the energy is Java's CLOSED-FORM functional value, whereas C++ computes it
// as a Gauss-Legendre PATH INTEGRAL of its gradient — two different methods
// that must agree on ΔE (the initialEnergy constant and the φ·λ⁰ term cancel
// in the difference, and there are no edge DOFs here).
//
// Setup parity (verified against UnwrapUtility.prepareInvariantDataEuclidean):
// closed mesh, ALL 4 vertices variable (no pin), Θ_v = 2π, no edge DOFs,
// λ°_e = 2·log(|p_i p_j|), per-vertex u(P) = 0.10·X 0.07·Y + 0.13·Z.
//
// To regenerate: /tmp/oracle/{tet.obj,EucMeshOracle.java}. Values are Java %.17g.
// ─────────────────────────────────────────────────────────────────────────────
TEST(EuclideanGoldenJava, FullMeshGradientAndEnergy_Tetrahedron)
{
constexpr double TWO_PI = 2.0 * 3.14159265358979323846264338328;
auto mesh = make_tetrahedron(); // same 4 vertices as /tmp/oracle/tet.obj
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
// All four vertices are DOFs (Java prepareInvariantData makes every interior
// vertex variable on a closed mesh); Θ_v = 2π; no edge DOFs.
int idx = 0;
for (auto v : mesh.vertices()) {
maps.v_idx[v] = idx++;
maps.theta_v[v] = TWO_PI;
}
auto u_of = [](const Point3& p) {
return 0.10 * p.x() - 0.07 * p.y() + 0.13 * p.z();
};
std::vector<double> x(static_cast<std::size_t>(idx), 0.0);
for (auto v : mesh.vertices())
x[static_cast<std::size_t>(maps.v_idx[v])] = u_of(mesh.point(v));
auto G = euclidean_gradient(mesh, x, maps);
// Java golden gradients keyed by vertex position (order-independent lookup).
struct GoldRow { double X, Y, Z, G; };
const GoldRow gold[4] = {
{ 1, 1, 1, 3.5277511803984396},
{ 1, -1, -1, 3.2837611905358440},
{-1, 1, -1, 2.3437485291237100},
{-1, -1, 1, 3.4111097143011780},
};
for (auto v : mesh.vertices()) {
const auto& p = mesh.point(v);
const double g = G[static_cast<std::size_t>(maps.v_idx[v])];
bool matched = false;
for (const auto& row : gold) {
if (std::abs(p.x() - row.X) < 1e-9 &&
std::abs(p.y() - row.Y) < 1e-9 &&
std::abs(p.z() - row.Z) < 1e-9) {
EXPECT_NEAR(g, row.G, 1e-12)
<< "gradient mismatch at (" << p.x() << "," << p.y()
<< "," << p.z() << ")";
matched = true;
break;
}
}
EXPECT_TRUE(matched) << "unexpected vertex position";
}
// ΔE = E(x) E(0). C++ path integral vs Java closed-form functional value.
std::vector<double> x0(static_cast<std::size_t>(idx), 0.0);
const double dE = euclidean_energy(mesh, x, maps)
- euclidean_energy(mesh, x0, maps);
EXPECT_NEAR(dE, 0.15962619236187336, 1e-12);
}

View File

@@ -338,12 +338,52 @@ TEST(PeriodMatrix, ComputePeriodMatrix_UnitSquare)
TEST(PeriodMatrix, ComputePeriodMatrix_ReducedTau_InFD)
{
// ω_1 = (1, 0), ω_2 = (0.5, 0.25) → τ = 0.5 + 0.25i
// |τ| = sqrt(0.25 + 0.0625) ≈ 0.559 < 1 → needs S step
// |τ| = sqrt(0.25 + 0.0625) ≈ 0.559 < 1 → needs S step.
// compute_period_matrix reduces with normalizeModulus (Finding 6), whose
// mirror-folded target domain is { 0 ≤ Re ≤ ½, Im > 0, |τ| ≥ 1 } — note the
// RIGHT boundary Re = +½ is CLOSED here. This is NOT the half-open SL(2,)
// domain of is_in_fundamental_domain (−½ ≤ Re < ½), which excludes Re = +½
// because +½ ≡ −½ under T. For this input normalizeModulus lands exactly on
// τ = ½ + i, so we must check the normalizeModulus domain, not the SL(2,)
// one (asserting is_in_fundamental_domain here would wrongly fail on +½).
HolonomyData hol;
hol.translations = { Eigen::Vector2d(1.0, 0.0), Eigen::Vector2d(0.5, 0.25) };
PeriodData pd = compute_period_matrix(hol, /*reduce=*/true);
EXPECT_TRUE(pd.in_fundamental_domain);
EXPECT_TRUE(is_in_fundamental_domain(pd.tau, 1e-9));
const double tol = 1e-9;
EXPECT_GT(pd.tau.imag(), 0.0);
EXPECT_GE(pd.tau.real(), 0.0 - tol); // 0 ≤ Re (mirror fold)
EXPECT_LE(pd.tau.real(), 0.5 + tol); // Re ≤ ½ (closed right edge)
EXPECT_GE(std::abs(pd.tau), 1.0 - tol); // |τ| ≥ 1
// Concretely: τ = ½ + i.
EXPECT_NEAR(pd.tau.real(), 0.5, 1e-12);
EXPECT_NEAR(pd.tau.imag(), 1.0, 1e-12);
}
// ─────────────────────────────────────────────────────────────────────────────
// Golden-value oracle — pin normalizeModulus (the τ reduction used by
// compute_period_matrix, Finding 6) bit-for-bit against the upstream Java
// reference (de.varylab.discreteconformal.util.DiscreteEllipticUtility.
// normalizeModulus), captured by calling the compiled Java method (openjdk 17)
// on these exact τ. This locks the sign/fold conventions of the SL(2,)+mirror
// reduction (0 ≤ Re ≤ ½, Im ≥ 0, |τ| ≥ 1) against an independent implementation,
// catching drift the existing in-FD membership checks cannot (they only assert
// the result lies in F, not that it is the SAME representative Java picks).
//
// To regenerate: /tmp/oracle/TauOracle.java. Values are Java printf %.17g.
// ─────────────────────────────────────────────────────────────────────────────
TEST(PeriodMatrix, NormalizeModulus_GoldenJava)
{
auto chk = [](double re, double im, double re_g, double im_g) {
C n = conformallab::normalizeModulus(C(re, im));
EXPECT_NEAR(n.real(), re_g, 1e-12);
EXPECT_NEAR(n.imag(), im_g, 1e-12);
};
chk(0.3, 0.5, 0.11764705882352933, 1.4705882352941178); // |τ|<1 → S + folds
chk(-0.4, 1.3, 0.40000000000000000, 1.3000000000000000); // Re<0 mirror fold
chk(2.7, 0.8, 0.41095890410958880, 1.0958904109589043); // large Re → T
chk(0.1, 2.0, 0.10000000000000000, 2.0000000000000000); // already in F
chk(-1.6, 0.9, 0.41237113402061850, 0.92783505154639180); // T + S + mirror
}
// ════════════════════════════════════════════════════════════════════════════

View File

@@ -27,6 +27,8 @@
#include "mesh_builder.hpp"
#include "spherical_functional.hpp"
#include "spherical_hessian.hpp"
#include "spherical_geometry.hpp"
#include "clausen.hpp"
#include <gtest/gtest.h>
#include <cmath>
#include <vector>
@@ -416,3 +418,237 @@ TEST(SphericalFunctional, GaugeFix_AlreadyAtGaugeReturnsTNearZero)
EXPECT_NEAR(t, 0.0, 1e-5)
<< "Gauge shift from the symmetric point should be ~0; got " << t;
}
// ─────────────────────────────────────────────────────────────────────────────
// Golden-value oracle — pin the spherical law-of-cosines angle formula, the β
// half-angle relations, and the Lobachevsky energy term bit-for-bit against the
// upstream Java reference (SphericalFunctional.triangleEnergyAndAlphas, lines
// 401-453), captured by running the compiled Java library (openjdk 17) with the
// real de.varylab…Clausen.Л on these exact arc lengths. Unlike the Schläfli
// path-integral gradient check (which only verifies curl-freeness), this locks
// the absolute angle/β/energy values against an independent implementation,
// catching any silent index/sign/convention drift in the spherical port.
//
// To regenerate: /tmp/oracle/SphereOracle.java (recipe in doc/reviewer/
// java-port-audit.md). Values are Java printf %.17g. Tolerance 1e-12.
//
// Index map (C++ spherical_angles ↔ Java): with l12=lij, l23=ljk, l31=lki the
// returned alpha1/alpha2/alpha3 are Java αi/αj/αk (angle opposite ljk/lki/lij).
// ─────────────────────────────────────────────────────────────────────────────
TEST(SphericalGoldenJava, AngleBetaEnergyFromLengths)
{
auto check = [](double lij, double ljk, double lki,
double ai_g, double aj_g, double ak_g,
double bi_g, double bj_g, double bk_g, double L_g) {
auto fa = spherical_angles(lij, ljk, lki);
EXPECT_TRUE(fa.valid);
EXPECT_NEAR(fa.alpha1, ai_g, 1e-12);
EXPECT_NEAR(fa.alpha2, aj_g, 1e-12);
EXPECT_NEAR(fa.alpha3, ak_g, 1e-12);
const double ai = fa.alpha1, aj = fa.alpha2, ak = fa.alpha3;
const double bi = 0.5 * (PI + ai - aj - ak);
const double bj = 0.5 * (PI - ai + aj - ak);
const double bk = 0.5 * (PI - ai - aj + ak);
EXPECT_NEAR(bi, bi_g, 1e-12);
EXPECT_NEAR(bj, bj_g, 1e-12);
EXPECT_NEAR(bk, bk_g, 1e-12);
const double Lterm =
Lobachevsky(ai) + Lobachevsky(aj) + Lobachevsky(ak) +
Lobachevsky(bi) + Lobachevsky(bj) + Lobachevsky(bk) +
Lobachevsky(0.5 * (PI - ai - aj - ak));
EXPECT_NEAR(Lterm, L_g, 1e-12);
};
// Scalene spherical triangle (all Δ > 0, Δ_ijk < 2π).
check(1.0, 1.2, 0.9,
1.5305813141072122, 0.99684981272794600, 1.1246069519101605,
1.2753586015294494, 0.74162710015018330, 0.86938423933239760,
1.3576100185550408);
// Equilateral spherical triangle.
check(0.7, 0.7, 0.7,
1.1225596283199812, 1.1225596283199812, 1.1225596283199812,
1.0095165126349062, 1.0095165126349060, 1.0095165126349060,
1.6806828584976297);
}
// ─────────────────────────────────────────────────────────────────────────────
// FULL-MESH golden oracle (spherical) — drives the REAL upstream SphericalFunctional
// on the shared tetrahedron (scaled so every arc length stays < π) and pins BOTH
// the per-vertex gradient G_v = Θ_v Σα AND ΔE = E(x) E(0) bit-for-bit.
//
// IMPORTANT — the oracle calls Java's `conformalEnergyAndGradient` (the RAW
// θ−Σα gradient), NOT `evaluate()`: `evaluate()` first runs a 1-D Brent
// maximization over the global-scale gauge direction (`maximizeInNegativeDirection`),
// which the C++ `spherical_gradient` deliberately does NOT — C++ factors that
// gauge into the Newton solver's `spherical_gauge_shift` instead. Comparing
// against `evaluate()` would (wrongly) drive every component to ~0. The raw
// gradient is the piece that corresponds 1:1 to the C++ functional.
//
// Setup parity (UnwrapUtility.prepareInvariantDataHyperbolicAndSpherical, scale):
// closed mesh, ALL 4 vertices variable, Θ_v = 2π, no edge DOFs,
// λ°_e = 2·log(SCALE·|p_i p_j|) (Java uses chord length × scale, whereas the
// C++ compute_lambda0_from_mesh helper assumes unit-sphere vertices and uses
// ARC length — so we set λ° directly here to match Java exactly),
// per-vertex u(P) = 0.10·X 0.07·Y + 0.13·Z, SCALE = 0.2.
//
// To regenerate: /tmp/oracle/{tet.obj,SphereMeshOracle.java}. Values are Java %.17g.
// ─────────────────────────────────────────────────────────────────────────────
TEST(SphericalGoldenJava, FullMeshGradientAndEnergy_Tetrahedron)
{
constexpr double TWO_PI = 2.0 * 3.14159265358979323846264338328;
constexpr double SCALE = 0.2;
auto mesh = make_tetrahedron(); // same 4 vertices as /tmp/oracle/tet.obj
auto maps = setup_spherical_maps(mesh);
// λ°_e = 2·log(SCALE · chord), matching Java's prepareInvariantData(scale).
for (auto e : mesh.edges()) {
auto h = mesh.halfedge(e);
const auto& p1 = mesh.point(mesh.source(h));
const auto& p2 = mesh.point(mesh.target(h));
const double dx = p1.x() - p2.x(), dy = p1.y() - p2.y(), dz = p1.z() - p2.z();
const double chord = std::sqrt(dx*dx + dy*dy + dz*dz);
maps.lambda0[e] = 2.0 * std::log(SCALE * chord);
}
int idx = 0;
for (auto v : mesh.vertices()) {
maps.v_idx[v] = idx++;
maps.theta_v[v] = TWO_PI;
}
auto u_of = [](const Point3& p) {
return 0.10 * p.x() - 0.07 * p.y() + 0.13 * p.z();
};
std::vector<double> x(static_cast<std::size_t>(idx), 0.0);
for (auto v : mesh.vertices())
x[static_cast<std::size_t>(maps.v_idx[v])] = u_of(mesh.point(v));
auto G = spherical_gradient(mesh, x, maps);
struct GoldRow { double X, Y, Z, G; };
const GoldRow gold[4] = {
{ 1, 1, 1, 2.7671034786104927},
{ 1, -1, -1, 2.5216834054857546},
{-1, 1, -1, 1.5401761310866633},
{-1, -1, 1, 2.6518569531861100},
};
for (auto v : mesh.vertices()) {
const auto& p = mesh.point(v);
const double g = G[static_cast<std::size_t>(maps.v_idx[v])];
bool matched = false;
for (const auto& row : gold) {
if (std::abs(p.x() - row.X) < 1e-9 &&
std::abs(p.y() - row.Y) < 1e-9 &&
std::abs(p.z() - row.Z) < 1e-9) {
EXPECT_NEAR(g, row.G, 1e-12)
<< "gradient mismatch at (" << p.x() << "," << p.y()
<< "," << p.z() << ")";
matched = true;
break;
}
}
EXPECT_TRUE(matched) << "unexpected vertex position";
}
std::vector<double> x0(static_cast<std::size_t>(idx), 0.0);
const double dE = spherical_energy(mesh, x, maps)
- spherical_energy(mesh, x0, maps);
EXPECT_NEAR(dE, 0.16409141487397116, 1e-12);
}
// ─────────────────────────────────────────────────────────────────────────────
// FULL-MESH EDGE-DOF golden oracle (spherical, Finding 3) — the missing solution-
// level Java oracle for the edge-DOF gradient path. Makes two opposite edges of
// the shared tetrahedron variable (replacement parameterization: λ_e = x[e_idx],
// no u-shift) and pins BOTH the vertex gradient (Θ−Σα) AND the edge gradient
// (α_opp⁺ + α_opp⁻ θ_e, θ_e = π) bit-for-bit against the REAL upstream
// SphericalFunctional.conformalEnergyAndGradient (raw gradient, not evaluate()).
//
// This is a pure GRADIENT oracle (no ΔE): with an edge DOF, x = 0 means λ_e = 0
// ⇒ spherical arc length = π (degenerate), so the path-integral-from-origin
// energy reference is ill-defined — the gradient at a fixed non-degenerate x is
// the unambiguous Finding-3 quantity. It does NOT touch the spherical Hessian,
// so it is unaffected by the Finding-4 edge-DOF Hessian guard.
//
// To regenerate: /tmp/oracle/SphereEdgeOracle.java. Values are Java %.17g.
// ─────────────────────────────────────────────────────────────────────────────
TEST(SphericalGoldenJava, FullMeshEdgeDofGradient_Tetrahedron)
{
constexpr double TWO_PI = 2.0 * 3.14159265358979323846264338328;
constexpr double PI_C = 3.14159265358979323846264338328;
constexpr double SCALE = 0.2;
auto mesh = make_tetrahedron();
auto maps = setup_spherical_maps(mesh);
for (auto e : mesh.edges()) {
auto h = mesh.halfedge(e);
const auto& p1 = mesh.point(mesh.source(h));
const auto& p2 = mesh.point(mesh.target(h));
const double dx = p1.x()-p2.x(), dy = p1.y()-p2.y(), dz = p1.z()-p2.z();
maps.lambda0[e] = 2.0 * std::log(SCALE * std::sqrt(dx*dx + dy*dy + dz*dz));
maps.theta_e[e] = PI_C;
maps.e_idx[e] = -1;
}
int idx = 0;
for (auto v : mesh.vertices()) { maps.v_idx[v] = idx++; maps.theta_v[v] = TWO_PI; }
auto pos = [&](Vertex_index v){ return mesh.point(v); };
auto same = [](const Point3& p, double X, double Y, double Z) {
return std::abs(p.x()-X)<1e-9 && std::abs(p.y()-Y)<1e-9 && std::abs(p.z()-Z)<1e-9;
};
// Edge connects positions (aX,aY,aZ)(bX,bY,bZ) in either order?
auto connects = [&](Edge_index e, const double a[3], const double b[3]) {
auto h = mesh.halfedge(e);
const Point3& s = pos(mesh.source(h));
const Point3& t = pos(mesh.target(h));
return (same(s,a[0],a[1],a[2]) && same(t,b[0],b[1],b[2])) ||
(same(s,b[0],b[1],b[2]) && same(t,a[0],a[1],a[2]));
};
const double A[3]={1,1,1}, B[3]={1,-1,-1}, C[3]={-1,1,-1}, D[3]={-1,-1,1};
// Make edges AB and CD variable (replacement parameterization).
int edof = idx; // edge DOFs start after the 4 vertex DOFs
Edge_index eAB, eCD;
for (auto e : mesh.edges()) {
if (connects(e, A, B)) { maps.e_idx[e] = edof++; eAB = e; }
else if (connects(e, C, D)) { maps.e_idx[e] = edof++; eCD = e; }
}
ASSERT_EQ(edof, 6); // 4 vertex + 2 edge DOFs
auto u_of = [](const Point3& p){ return 0.10*p.x() - 0.07*p.y() + 0.13*p.z(); };
std::vector<double> x(6, 0.0);
for (auto v : mesh.vertices())
x[static_cast<std::size_t>(maps.v_idx[v])] = u_of(mesh.point(v));
// Edge DOF value = λ⁰_e + 0.1 (same perturbation as the Java oracle).
x[static_cast<std::size_t>(maps.e_idx[eAB])] = maps.lambda0[eAB] + 0.1;
x[static_cast<std::size_t>(maps.e_idx[eCD])] = maps.lambda0[eCD] + 0.1;
auto G = spherical_gradient(mesh, x, maps);
// Vertex gradients keyed by position.
struct VRow { double X, Y, Z, G; };
const VRow vg[4] = {
{ 1, 1, 1, 2.5036751008175546},
{ 1, -1, -1, 2.2303362601050205},
{-1, 1, -1, 1.8463710314817632},
{-1, -1, 1, 2.7499719487341570},
};
for (auto v : mesh.vertices()) {
const auto& p = mesh.point(v);
const double g = G[static_cast<std::size_t>(maps.v_idx[v])];
bool m = false;
for (auto& r : vg)
if (same(p, r.X, r.Y, r.Z)) { EXPECT_NEAR(g, r.G, 1e-12); m = true; break; }
EXPECT_TRUE(m);
}
// Edge gradients: AB and CD (Java golden values).
EXPECT_NEAR(G[static_cast<std::size_t>(maps.e_idx[eAB])], -0.35189517043413690, 1e-12);
EXPECT_NEAR(G[static_cast<std::size_t>(maps.e_idx[eCD])], -0.44101986058895950, 1e-12);
}

View File

@@ -4,10 +4,12 @@
// Port of de.varylab.discreteconformal.functional.HyperIdealUtilityTest (Java/JUnit).
#include "hyper_ideal_utility.hpp"
#include "hyper_ideal_geometry.hpp"
#include "clausen.hpp"
#include <gtest/gtest.h>
#include <cmath>
#include <complex>
using conformallab::calculateTetrahedronVolume;
using conformallab::calculateTetrahedronVolumeWithIdealVertexAtGamma;
@@ -93,3 +95,43 @@ TEST(HyperIdealUtilityTest, CompareGeneralAndIdealFormulaCase2) {
double V = calculateTetrahedronVolume(bi, bj, bk, ai, aj, ak);
EXPECT_NEAR(Ve, V, 1e-12);
}
// ─────────────────────────────────────────────────────────────────────────────
// Golden-value oracle tests — pin the C++ pure-math core bit-for-bit against the
// upstream Java reference (de.varylab.discreteconformal.functional.{Clausen,
// HyperIdealUtility}), captured by running the compiled Java library (openjdk 17)
// on these exact inputs. Unlike the FD gradient checks (which only verify
// curl-freeness, since the C++ energy is the path-integral of its own gradient),
// these lock the absolute values of the math-critical helpers against an
// independent implementation, catching any silent convention/formula drift.
//
// To regenerate: see doc/reviewer/java-port-audit.md (oracle harness recipe).
// Values are Java's Double.toString output (shortest round-trip). Tolerance is
// 1e-12 (well above the ~1e-15 inter-platform libm divergence for these ranges).
// ─────────────────────────────────────────────────────────────────────────────
TEST(HyperIdealGoldenJava, ClausenLobachevskyImLi2) {
EXPECT_NEAR(conformallab::clausen2(0.7), 0.954448086482735, 1e-12);
EXPECT_NEAR(conformallab::clausen2(2.5), 0.4335982032355327, 1e-12);
EXPECT_NEAR(conformallab::clausen2(-1.3), -0.9897032532295984, 1e-12);
EXPECT_NEAR(Lobachevsky(0.9), 0.4121734067662043, 1e-12);
EXPECT_NEAR(conformallab::ImLi2(std::complex<double>(0.3, 0.4)),
0.46136289181910894, 1e-12);
}
TEST(HyperIdealGoldenJava, ZetaFamily) {
EXPECT_NEAR(conformallab::zeta13(0.5, 0.7, 0.9), 2.663195966482385, 1e-12);
EXPECT_NEAR(conformallab::zeta14(0.4, 0.8), 1.8262295633065202, 1e-12);
EXPECT_NEAR(conformallab::zeta15(0.6), 2.216976794676588, 1e-12);
EXPECT_NEAR(conformallab::zeta(0.5, 0.7, 0.9), 1.6156519307269948, 1e-12);
}
TEST(HyperIdealGoldenJava, TetrahedronVolumes) {
// Generic non-degenerate generalized hyperbolic tetrahedron.
EXPECT_NEAR(calculateTetrahedronVolume(0.6, 0.7, 0.8, 0.5, 0.9, 0.4),
2.9074633382516435, 1e-12);
// One ideal vertex at gamma, non-symmetric angles (Java test2 inputs).
EXPECT_NEAR(calculateTetrahedronVolumeWithIdealVertexAtGamma(
0.6623267054958116, 1.437248992086214, 1.0420169560077686,
0.6896178197389236, 0.5195634857410114, 0.6304500578493993),
2.0459750326926214, 1e-12);
}