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
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>
419 lines
20 KiB
C++
419 lines
20 KiB
C++
// 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;
|
||
}
|