Files
ConformalLabpp/code/tests/cgal/test_hyper_ideal_functional.cpp
Tarik Moussa 9afefcbb7b fix(hyper-ideal): guard face_energy() against unsupported multi-ideal faces
Finding-A from doc/reviewer/external-audit-2026-05-30.md.

Root cause: both the Java reference (HyperIdealFunctional.java:222-231)
and the C++ port silently applied the one-ideal-vertex volume formula
to the first ideal vertex found in a face, ignoring any additional ideal
vertices.  For two or three ideal vertices this produces a wrong energy
value with no diagnostic.

Fix: add an ideal_count guard at the top of face_energy() that throws
std::logic_error for ideal_count >= 2.  The one-ideal (Kolpakov-Mednykh)
and zero-ideal (Meyerhoff/Ushijima) paths are unchanged and correct.

Three new GTests cover the three guard cases:
  MultiIdealGuard_TwoIdealVertices_Throws        (two ideal  → throw)
  MultiIdealGuard_AllThreeIdealVertices_Throws   (all ideal  → throw)
  MultiIdealGuard_ExactlyOneIdeal_DoesNotThrow   (one ideal  → no throw)

262/262 CGAL tests pass, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 01:17:27 +02:00

291 lines
14 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_hyper_ideal_functional.cpp
//
// Phase 3b — HyperIdealFunctional ported to ConformalMesh.
//
// Corresponds to de.varylab.discreteconformal.functional.HyperIdealFunctionalTest.
//
// The Java tests use CoHDS + HyperIdealGenerator to build complex meshes and
// then verify correctness via a finite-difference gradient check (FunctionalTest).
// Here we apply the same gradient-check strategy on simple hand-crafted meshes
// (triangle, tetrahedron) to validate the port independently of the generator.
//
// Test map (Java → C++)
// ──────────────────────
// testHessian (Ignored) → GradientCheck_Hessian (SKIPPED, not implemented)
// testGradientWithHyperIdeal… → GradientCheck_AllHyperIdealTriangle (ported)
// testGradientInExtendedDomain → GradientCheck_ExtendedDomain (ported)
// testGradientWithHyperelliptic → GradientCheck_TetrahedronAllVariable (ported)
// testFunctionalAtNaNValue → EnergyFiniteAtTestPoint (ported)
#include "conformal_mesh.hpp"
#include "mesh_builder.hpp"
#include "hyper_ideal_functional.hpp"
#include "hyper_ideal_hessian.hpp"
#include <gtest/gtest.h>
#include <Eigen/Dense>
#include <cmath>
#include <vector>
using namespace conformallab;
// ════════════════════════════════════════════════════════════════════════════
// Helpers
// ════════════════════════════════════════════════════════════════════════════
// Build a mesh with all vertices and edges variable, and set reasonable
// DOF values: b_i = b_val, a_e = a_val.
static std::vector<double> make_x_all_variable(
ConformalMesh& mesh, HyperIdealMaps& maps,
double b_val, double a_val)
{
int n = assign_all_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n));
// Vertices first, then edges (matching assign_all_dof_indices order)
for (auto v : mesh.vertices())
x[static_cast<std::size_t>(maps.v_idx[v])] = b_val;
for (auto e : mesh.edges())
x[static_cast<std::size_t>(maps.e_idx[e])] = a_val;
return x;
}
// ════════════════════════════════════════════════════════════════════════════
// @Ignore in Java: no Hessian implemented
// ════════════════════════════════════════════════════════════════════════════
TEST(HyperIdealFunctional, HessianSymmetryCheck)
{
// Hessian is now implemented (numerical FD). Verify it is symmetric.
auto mesh = make_triangle();
auto maps = setup_hyper_ideal_maps(mesh);
int n = assign_all_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), 0.5);
auto H = hyper_ideal_hessian_sym(mesh, x, maps);
Eigen::MatrixXd Hd(H);
EXPECT_NEAR((Hd - Hd.transpose()).norm(), 0.0, 1e-8)
<< "HyperIdeal Hessian must be symmetric";
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: single triangle, all vertices hyper-ideal
//
// Mirrors testGradientWithHyperIdealAndIdealPoints (simplified to single face).
// DOFs: 3 vertex b-values + 3 edge a-values = 6 total.
// Point: b_i = 1.0, a_e = 0.5.
// ════════════════════════════════════════════════════════════════════════════
TEST(HyperIdealFunctional, GradientCheck_AllHyperIdealTriangle)
{
auto mesh = make_triangle();
auto maps = setup_hyper_ideal_maps(mesh);
auto x = make_x_all_variable(mesh, maps, /*b=*/1.0, /*a=*/0.5);
EXPECT_TRUE(gradient_check(mesh, x, maps))
<< "Finite-difference gradient check failed on all-hyper-ideal triangle";
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check in the "extended domain"
//
// Mirrors testGradientInTheExtendedDomain: larger DOF values
// (Java: x_i = 1.2 + |rnd|, so typically > 1.2).
// ════════════════════════════════════════════════════════════════════════════
TEST(HyperIdealFunctional, GradientCheck_ExtendedDomain)
{
auto mesh = make_triangle();
auto maps = setup_hyper_ideal_maps(mesh);
auto x = make_x_all_variable(mesh, maps, /*b=*/2.0, /*a=*/1.5);
EXPECT_TRUE(gradient_check(mesh, x, maps))
<< "Finite-difference gradient check failed in extended domain";
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: tetrahedron (4 faces), all vertices and edges variable
//
// Mirrors testGradientWithHyperellipticCurve — larger mesh, closed surface.
// DOFs: 4 vertex b-values + 6 edge a-values = 10 total.
// ════════════════════════════════════════════════════════════════════════════
TEST(HyperIdealFunctional, GradientCheck_TetrahedronAllVariable)
{
auto mesh = make_tetrahedron();
auto maps = setup_hyper_ideal_maps(mesh);
auto x = make_x_all_variable(mesh, maps, /*b=*/1.0, /*a=*/0.5);
EXPECT_TRUE(gradient_check(mesh, x, maps))
<< "Finite-difference gradient check failed on all-variable tetrahedron";
}
// ════════════════════════════════════════════════════════════════════════════
// Energy is finite (not NaN / Inf)
//
// Mirrors testFunctionalAtNaNValue: evaluates at a test point and checks
// the energy is a real number. Uses a quad-strip to exercise the interior
// edge path (adjacent faces sharing one non-border edge).
// ════════════════════════════════════════════════════════════════════════════
TEST(HyperIdealFunctional, EnergyFiniteAtTestPoint)
{
auto mesh = make_quad_strip();
auto maps = setup_hyper_ideal_maps(mesh);
int n = assign_all_dof_indices(mesh, maps);
// Values taken from the Java testFunctionalAtNaNValue spirit:
// large-ish but positive DOF values that could expose degenerate paths.
std::vector<double> x(static_cast<std::size_t>(n), 1.5);
auto res = evaluate_hyper_ideal(mesh, x, maps, true, false);
EXPECT_FALSE(std::isnan(res.energy)) << "Energy must not be NaN";
EXPECT_FALSE(std::isinf(res.energy)) << "Energy must not be Inf";
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: mixed vertices (some ideal = pinned, some hyper-ideal)
//
// One vertex is ideal (v_idx = -1, b = 0), the other two are hyper-ideal.
// This exercises the lij / αij branches for the ideal-vertex case.
// ════════════════════════════════════════════════════════════════════════════
TEST(HyperIdealFunctional, GradientCheck_MixedIdealHyperIdeal)
{
auto mesh = make_triangle();
auto maps = setup_hyper_ideal_maps(mesh);
// Make v0 ideal (pinned), v1 and v2 hyper-ideal.
auto vit = mesh.vertices().begin();
Vertex_index v0 = *vit++;
Vertex_index v1 = *vit++;
Vertex_index v2 = *vit;
maps.v_idx[v0] = -1; // ideal: b_0 = 0 (fixed)
maps.v_idx[v1] = 0;
maps.v_idx[v2] = 1;
// All edges variable: indices 2, 3, 4
int eidx = 2;
for (auto e : mesh.edges()) maps.e_idx[e] = eidx++;
// DOF vector: [b1, b2, a_e0, a_e1, a_e2]
std::vector<double> x = {1.0, 1.0, 0.5, 0.5, 0.5};
EXPECT_TRUE(gradient_check(mesh, x, maps))
<< "Finite-difference gradient check failed for mixed ideal / hyper-ideal";
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: fan mesh (n=6), all variable
//
// Exercises the full halfedge-around-vertex traversal for a high-valence
// interior vertex.
// ════════════════════════════════════════════════════════════════════════════
TEST(HyperIdealFunctional, GradientCheck_Fan6AllVariable)
{
auto mesh = make_fan(6);
auto maps = setup_hyper_ideal_maps(mesh);
auto x = make_x_all_variable(mesh, maps, /*b=*/1.0, /*a=*/0.5);
EXPECT_TRUE(gradient_check(mesh, x, maps))
<< "Finite-difference gradient check failed on fan-6 mesh";
}
// ════════════════════════════════════════════════════════════════════════════
// Guard test: face_energy() must throw for 2+ ideal vertices in one face.
//
// This tests Finding-A from doc/reviewer/external-audit-2026-05-30.md.
//
// The Java reference (HyperIdealFunctional.java lines 222-231) silently applies
// the one-ideal-vertex volume formula to the first ideal vertex found, ignoring
// any additional ideal vertices in the same face. That is mathematically wrong
// for two-ideal / three-ideal faces. The C++ port detects this at runtime and
// throws std::logic_error instead of silently producing a wrong energy value.
//
// Tests cover:
// (a) Two ideal vertices in the same face (v1+v2 ideal, v3 hyper-ideal)
// (b) All three vertices ideal
// (c) Exactly one ideal vertex — must NOT throw (valid configuration)
// ════════════════════════════════════════════════════════════════════════════
TEST(HyperIdealFunctional, MultiIdealGuard_TwoIdealVertices_Throws)
{
// Triangle mesh: 3 vertices, 3 edges, 1 face (open mesh, single face).
auto mesh = make_triangle();
auto maps = setup_hyper_ideal_maps(mesh);
// Assign edge DOFs to all three edges.
int eidx = 0;
for (auto e : mesh.edges()) maps.e_idx[e] = eidx++;
// Pin v1 and v2 (ideal), make only v3 hyper-ideal.
auto vit = mesh.vertices().begin();
Vertex_index v1 = *vit++;
Vertex_index v2 = *vit++;
// v3 remains pinned (default v_idx = -1, i.e. ideal too — see below).
maps.v_idx[v1] = -1; // ideal
maps.v_idx[v2] = -1; // ideal
maps.v_idx[*vit] = 3; // hyper-ideal: DOF index 3 (after 3 edge DOFs)
// DOF vector: [a_e0, a_e1, a_e2, b_v3]
std::vector<double> x = {0.5, 0.5, 0.5, 1.0};
// evaluate_hyper_ideal calls face_energy() which must detect 2 ideal vertices
// and throw std::logic_error.
EXPECT_THROW(
evaluate_hyper_ideal(mesh, x, maps, /*energy=*/true, /*gradient=*/false),
std::logic_error)
<< "Expected std::logic_error for face with two ideal vertices";
}
TEST(HyperIdealFunctional, MultiIdealGuard_AllThreeIdealVertices_Throws)
{
auto mesh = make_triangle();
auto maps = setup_hyper_ideal_maps(mesh);
// Only edge DOFs — all vertices remain ideal (default v_idx = -1).
int eidx = 0;
for (auto e : mesh.edges()) maps.e_idx[e] = eidx++;
// DOF vector: [a_e0, a_e1, a_e2]
std::vector<double> x = {0.5, 0.5, 0.5};
EXPECT_THROW(
evaluate_hyper_ideal(mesh, x, maps, /*energy=*/true, /*gradient=*/false),
std::logic_error)
<< "Expected std::logic_error for face with all three ideal vertices";
}
TEST(HyperIdealFunctional, MultiIdealGuard_ExactlyOneIdeal_DoesNotThrow)
{
// Exactly one ideal vertex per face must NOT throw — it is the supported
// one-ideal-vertex configuration (Kolpakov-Mednykh formula).
auto mesh = make_triangle();
auto maps = setup_hyper_ideal_maps(mesh);
// All edges variable.
int eidx = 0;
for (auto e : mesh.edges()) maps.e_idx[e] = eidx++;
// Pin only v1 (ideal); v2 and v3 are hyper-ideal.
auto vit = mesh.vertices().begin();
maps.v_idx[*vit] = -1; ++vit; // v1: ideal
maps.v_idx[*vit] = 3; ++vit; // v2: hyper-ideal, DOF 3
maps.v_idx[*vit] = 4; // v3: hyper-ideal, DOF 4
// DOF vector: [a_e0, a_e1, a_e2, b_v2, b_v3]
std::vector<double> x = {0.5, 0.5, 0.5, 1.0, 1.0};
EXPECT_NO_THROW(
evaluate_hyper_ideal(mesh, x, maps, /*energy=*/true, /*gradient=*/false))
<< "Unexpected throw for valid one-ideal-vertex configuration";
}