#include #include #include #include #include #include #include #include #include #include #include #include import mean_field; import test_helpers; #include "../../experiments/continuation_snapshot.hpp" namespace stellar_numerical_strategy_test { using namespace mean_field; namespace nonlinear = solver::nonlinear; struct UnregisteredEnthalpy { }; struct UnregisteredGravity { }; struct UnregisteredTrial { }; template concept MakesNumerics = requires { typename nonlinear::NewtonNumerics; }; static_assert(!MakesNumerics); static_assert(!MakesNumerics); static_assert(!nonlinear::NewtonNumericalConfiguration); static_assert(std::same_as>); static_assert(std::same_as>); static_assert(std::same_as>); static_assert(MakesNumerics); static_assert(MakesNumerics); template concept MakesTrialNumerics = requires { typename nonlinear::NewtonNumerics; }; static_assert(std::same_as::TrialType, nonlinear::UncorrectedTrial>); static_assert(std::same_as>); static_assert(MakesTrialNumerics); static_assert(MakesTrialNumerics); static_assert(!MakesTrialNumerics); static_assert(!MakesTrialNumerics); static_assert(!MakesTrialNumerics); static_assert(nonlinear::CoupledTrialNumerics); static_assert(nonlinear::CoupledTrialNumerics); static_assert(!nonlinear::CoupledTrialNumerics); static_assert(!nonlinear::TrialPathNumerics); static_assert(MakesTrialNumerics); static_assert(MakesTrialNumerics); static_assert(!MakesTrialNumerics); static_assert(!MakesTrialNumerics); static_assert(std::same_as); [[nodiscard]] double matchingCentralDensity() { return std::numbers::pi_v * utils::MASS / (4.0 * utils::RADIUS * utils::RADIUS * utils::RADIUS); } template [[nodiscard]] auto makeModel() { const auto equationOfState = eos::Polytrope({.n = 1.0, .K = 2.0 * utils::G * utils::RADIUS * utils::RADIUS / std::numbers::pi_v}); const auto pressureSurface = surface::Isobaric({.Psurf = dimensions::PressureValue{0.0}}); const auto mass = integral::FixedTotalMass({.Mtotal = dimensions::MassValue{utils::MASS}}); const auto angularMomentum = integral::FixedAngularMomentum({.Jtotal = dimensions::AngularMomentumValue{0.05}}); if constexpr (CentralConstraint) return model::StellarModel(equationOfState, pressureSurface, mass, angularMomentum, constraint::FixedCentralDensity({.RhoC = dimensions::DensityValue{matchingCentralDensity()}})); else return model::StellarModel(equationOfState, pressureSurface, mass, angularMomentum); } template [[nodiscard]] auto makeContext() { const auto args = test_utils::setup_args(); auto finiteElements = fem::setup_fem(args.mesh_file, args, 0); REQUIRE(finiteElements.okay()); return solver::makeContext( makeModel(), equilibrium::StellarDiscretization{std::move(finiteElements)}, preconditioning::makePreconditioner(), solver::linear::FGMRES({.restartLength = 2, .printLevel = -1}), seed::LaneEmden({.centralDensity = dimensions::DensityValue{matchingCentralDensity()}})); } struct Snapshot { mfem::Vector physical; mfem::Vector normalized; mfem::Vector residual; std::vector specifications; int retainedSize{}; int gravitySize{}; }; template [[nodiscard]] Snapshot snapshot(Context &context) { Snapshot snapshot; solver::detail::StellarEquilibriumContextDiagnostics::WithState(context, [&](auto &state, const auto &) { using Form = typename std::remove_cvref_t::FormType; snapshot.physical = state.AcceptedPhysicalState(); snapshot.normalized = state.acceptedNormalizedState; snapshot.residual = state.acceptedNormalizedResidual; const auto &manifest = state.Problem().GetManifest(); for (const auto &descriptor : manifest.constraints()) snapshot.specifications.emplace_back(descriptor.stableId); const auto partition = solver::MakeGravityBlockPartition
(manifest.layout()); snapshot.retainedSize = partition.RetainedValueSize(); snapshot.gravitySize = partition.GravityValueSize(); CHECK(partition.RetainedResidualSize() == partition.RetainedValueSize()); CHECK(partition.RootValueSize() == state.Problem().StateSize()); CHECK(partition.GravityResidualSize() == partition.GravityValueSize()); CHECK(state.physicalInverse->IsCurrent()); CHECK(state.normalizedInverse->IsCurrent()); }); return snapshot; } [[nodiscard]] double relativeDifference(const mfem::Vector &left, const mfem::Vector &right) { REQUIRE(left.Size() == right.Size()); mfem::Vector difference(left); difference -= right; return difference.Norml2() / std::max(1.0, left.Norml2()); } void checkPreserved(const Snapshot &before, const Snapshot &after) { CHECK(before.specifications == after.specifications); CHECK(before.retainedSize == after.retainedSize); CHECK(before.gravitySize == after.gravitySize); CHECK(relativeDifference(before.physical, after.physical) < 1.e-13); CHECK(relativeDifference(before.normalized, after.normalized) < 1.e-13); CHECK(relativeDifference(before.residual, after.residual) < 1.e-13); } template void checkGravityApplications(Context &context) { solver::detail::StellarEquilibriumContextDiagnostics::WithState(context, [&](auto &state, const auto &) { using Form = typename std::remove_cvref_t::FormType; const auto partition = solver::MakeGravityBlockPartition(state.Problem().GetManifest().layout()); solver::PreparedGravitySubsystem compact(*state.normalizedOperator, *state.normalizedInverse, partition, state.Problem().GetCommunicator()); solver::PreparedGravitySubsystem reference(*state.normalizedOperator, *state.normalizedInverse, partition, state.Problem().GetCommunicator(), solver::linear::FGMRES{}, solver::RootRestrictedGravityApplications{}); REQUIRE(compact.UsesCompactOperator()); REQUIRE(compact.UsesCompactPreconditioner()); mfem::Vector direction(partition.GravityValueSize()), actual(direction.Size()), expected(direction.Size()); // Dense directions expose ordering/scaling mistakes which a few // isolated basis vectors may miss in a distributed field block. for (int trial = 0; trial < 3; ++trial) { for (int i = 0; i < direction.Size(); ++i) direction(i) = std::sin(.37*(i+1)*(trial+1)) + .4*std::cos(.11*(i+1)*(trial+2)); compact.GetOperator().Mult(direction, actual); reference.GetOperator().Mult(direction, expected); CHECK(relativeDifference(expected, actual) < 2.e-12); compact.GetPreconditioner().Mult(direction, actual); reference.GetPreconditioner().Mult(direction, expected); CHECK(relativeDifference(expected, actual) < 2.e-12); } }); } [[nodiscard]] nonlinear::NewtonOptions oneAttempt() { // A relative threshold of exactly ||b|| need not stop MFEM before // its first iteration. Use a separated absolute threshold so this // wiring test deliberately retains its zero initial correction. return {.relativeTolerance = 0.0, .absoluteTolerance = 0.0, .maximumIterations = 1, .linearSolve = {.relativeTolerance = 0.0, .absoluteTolerance = 1.e100, .maximumIterations = 1}, .backtracking = {.maximumTrials = 1, .geometryPreflight = false}}; } [[nodiscard]] nonlinear::GravitySolveOptions permissiveGravity() { // Accepting a zero correction exercises wiring and rollback cheaply; // the ordinary residual metric still rejects the unchanged trial. // These controls deliberately establish no physical accuracy claim. return {.control = {.relativeTolerance = 1.0, .absoluteTolerance = 1.e100, .maximumIterations = 1}, .backend = {.restartLength = 2, .printLevel = -1}}; } template [[nodiscard]] auto evaluate(Context &context, Numerics numerics) { auto instance = solver::make(context, nonlinear::Newton(oneAttempt(), nonlinear::NormalizedResidualMetric{}, std::move(numerics))); return instance.evaluate(); } } // namespace stellar_numerical_strategy_test TEST_CASE("Continuation snapshot transfers named coordinates and explicitly handles the central border", "[solver][continuation_initial_state]") { CHECK(experiment::continuation::RunSnapshotCoordinateSelfChecks() == 11); } TEST_CASE("Explicit physical restart prepares a new initial guess and rolls back invalid input", "[solver][continuation_initial_state]") { using namespace stellar_numerical_strategy_test; auto context = makeContext(); using Diagnostics = solver::detail::StellarEquilibriumContextDiagnostics; const auto original = snapshot(context); mfem::Vector changed(original.physical); int enthalpyOffset = -1; Diagnostics::WithState(context, [&](auto &state, const auto &) { for (const auto &block : state.Problem().GetManifest().valueBlocks()) { if (block.stableId == "density") for (int i = block.offset; i < block.offset + block.size; ++i) changed(i) *= 1.001; if (block.stableId == "specific_enthalpy") enthalpyOffset = block.offset; } }); REQUIRE(relativeDifference(original.physical, changed) > 0.); REQUIRE_NOTHROW(Diagnostics::InitializePhysicalState(context, changed)); const auto installed = snapshot(context); CHECK(relativeDifference(changed, installed.physical) < 1e-14); CHECK(relativeDifference(original.residual, installed.residual) > 1e-12); Diagnostics::WithState(context, [&](auto &state, const auto &) { CHECK(state.normalizedCorrection.Norml2() == 0.); CHECK(state.physicalInverse->IsCurrent()); CHECK(state.normalizedInverse->IsCurrent()); }); mfem::Vector wrongSize(changed.Size() - 1); CHECK_THROWS_AS(Diagnostics::InitializePhysicalState(context, wrongSize), std::invalid_argument); mfem::Vector nonfinite(changed); nonfinite(0) = std::numeric_limits::quiet_NaN(); CHECK_THROWS_AS(Diagnostics::InitializePhysicalState(context, nonfinite), std::invalid_argument); REQUIRE(enthalpyOffset >= 0); mfem::Vector inadmissible(changed); inadmissible(enthalpyOffset) = -1e10; CHECK_THROWS_AS(Diagnostics::InitializePhysicalState(context, inadmissible), std::invalid_argument); checkPreserved(installed, snapshot(context)); { auto active = solver::make(context, nonlinear::Newton(oneAttempt())); CHECK_THROWS_AS(Diagnostics::InitializePhysicalState(context, changed), std::logic_error); } REQUIRE_NOTHROW(Diagnostics::InitializePhysicalState(context, original.physical)); checkPreserved(original, snapshot(context)); } TEST_CASE("Typed numerical policies preserve the existing Newton configuration", "[solver][numerical_strategies]") { using namespace stellar_numerical_strategy_test; const nonlinear::Newton original; CHECK(original.options().maximumIterations == 50); const auto selected = nonlinear::Newton(nonlinear::NewtonOptions{}, nonlinear::NormalizedResidualMetric{}, nonlinear::NewtonNumerics{nonlinear::SampledEnthalpy{}, nonlinear::GravityCurvatureCorrection{}}); CHECK(selected.numerics().gravity.gravity.control.relativeTolerance == 1.e-10); const nonlinear::BacktrackedCoupledCurvatureCorrection damped; CHECK(damped.search.maximumSteps == 4); CHECK(damped.search.reduction == .5); CHECK(damped.attempt.maximumExcessRatio == 1.0); CHECK(damped.maximumAttemptsPerIteration == 1); } TEST_CASE("Production gravity strategies retain optional model borders and restore rejected trials", "[solver][numerical_strategies][gravity][rollback]") { using namespace stellar_numerical_strategy_test; const auto check = []() { CAPTURE(CentralConstraint); auto context = makeContext(); const auto original = snapshot(context); checkGravityApplications(context); checkPreserved(original, snapshot(context)); REQUIRE(original.gravitySize > 0); REQUIRE(original.retainedSize > 0); // The fixed mass and angular momentum are always retained. Central // density adds one specification only when the model requests it. CHECK(original.specifications.size() == (CentralConstraint ? 4 : 3)); { const auto report = evaluate(context, nonlinear::NewtonNumerics{nonlinear::SampledEnthalpy{}, nonlinear::GravityCurvatureCorrection{permissiveGravity()}}); CHECK_FALSE(report.converged()); CHECK(report.diagnostics().attemptedNonlinearIterations == 1); CHECK(report.diagnostics().acceptedNonlinearIterations == 0); REQUIRE(report.diagnostics().lastLinearSolve.has_value()); CHECK(report.diagnostics().lastLinearSolve->iterations == 0); CHECK(report.diagnostics().numericalStrategies.gravitySolves > 0); CHECK(report.diagnostics().numericalStrategies.gravityFailures == 0); CHECK(std::isfinite(report.diagnostics().numericalStrategies.lastGravityCorrectionNorm)); CHECK(std::isfinite(report.diagnostics().numericalStrategies.lastGravityPreparedDefect)); CHECK(std::isfinite(report.diagnostics().numericalStrategies.lastGravityAffineDisagreement)); CHECK(std::isfinite(report.diagnostics().numericalStrategies.lastGravityArithmeticAllowance)); CHECK(std::isfinite(report.diagnostics().numericalStrategies.lastGravityPreparedTargetThreshold)); CHECK(report.lastAcceptedCheckpointView().valid()); } REQUIRE(context.isReady()); CHECK_FALSE(context.hasActiveSolver()); checkPreserved(original, snapshot(context)); { const auto report = evaluate(context, nonlinear::NewtonNumerics{nonlinear::SampledEnthalpy{}, nonlinear::EliminatedGravity{permissiveGravity()}}); CHECK_FALSE(report.converged()); CHECK(report.diagnostics().attemptedNonlinearIterations == 1); CHECK(report.diagnostics().acceptedNonlinearIterations == 0); REQUIRE(report.diagnostics().lastLinearSolve.has_value()); CHECK(report.diagnostics().lastLinearSolve->iterations == 0); CHECK(report.diagnostics().numericalStrategies.gravitySolves > 0); CHECK(report.diagnostics().numericalStrategies.gravityFailures == 0); CHECK(std::isfinite(report.diagnostics().numericalStrategies.lastLiftedRelativeTrueResidual)); CHECK(report.lastAcceptedCheckpointView().valid()); } REQUIRE(context.isReady()); checkPreserved(original, snapshot(context)); }; check.template operator()(); check.template operator()(); } TEST_CASE("A failed initial gravity completion preserves the accepted stellar checkpoint", "[solver][numerical_strategies][gravity][failure]") { using namespace stellar_numerical_strategy_test; auto context = makeContext(); const auto original = snapshot(context); nonlinear::GravitySolveOptions strict{ .control = {.relativeTolerance = 0.0, .absoluteTolerance = 0.0, .maximumIterations = 1}, .backend = {.restartLength = 1, .printLevel = -1}}; { const auto report = evaluate(context, nonlinear::NewtonNumerics{nonlinear::SampledEnthalpy{}, nonlinear::EliminatedGravity{strict}}); REQUIRE_FALSE(report.converged()); CHECK(report.failure().reason == mean_field::solver::StellarEquilibriumFailureReason::linear_solve_failure); CHECK(report.diagnostics().numericalStrategies.initialGravityResidual > 0.0); CHECK(report.diagnostics().numericalStrategies.gravitySolves == 1); CHECK(report.diagnostics().numericalStrategies.gravityFailures == 1); const auto &auxiliary = report.diagnostics().numericalStrategies; // A completed but unsuccessful inner solve has a meaningful true // defect. No correction/prepared assessment occurred in this attempt. CHECK(std::isfinite(auxiliary.lastGravityTrueResidual)); CHECK(std::isnan(auxiliary.lastGravityCorrectionNorm)); CHECK(std::isnan(auxiliary.lastGravityPreparedDefect)); CHECK(std::isnan(auxiliary.lastGravityAffineDisagreement)); CHECK(std::isnan(auxiliary.lastGravityArithmeticAllowance)); CHECK(std::isnan(auxiliary.lastGravityPreparedTargetThreshold)); CHECK(report.diagnostics().acceptedNonlinearIterations == 0); CHECK(report.lastAcceptedCheckpointView().valid()); } REQUIRE(context.isReady()); CHECK_FALSE(context.hasActiveSolver()); checkPreserved(original, snapshot(context)); } TEST_CASE("Polynomial admissibility is invoked by each production gravity strategy", "[solver][numerical_strategies][enthalpy][rollback]") { using namespace stellar_numerical_strategy_test; auto context = makeContext(); const auto original = snapshot(context); // A bounded certification budget must fail closed if the seed is not // certifiable. If it is certifiable, the zero Newton direction cannot // satisfy the required strict merit descent. Either path preserves it. const nonlinear::PolynomialEnthalpy polynomial{{.maximumSubdivisionDepth = 4, .maximumSubcells = 64}}; const auto check = [&](auto gravity) { const auto report = evaluate(context, nonlinear::NewtonNumerics{polynomial, gravity}); CHECK_FALSE(report.converged()); CHECK(report.diagnostics().numericalStrategies.polynomialChecks > 0); CHECK(report.diagnostics().acceptedNonlinearIterations == 0); CHECK(report.lastAcceptedCheckpointView().valid()); }; check(nonlinear::MonolithicGravity{}); REQUIRE(context.isReady()); checkPreserved(original, snapshot(context)); check(nonlinear::GravityCurvatureCorrection{permissiveGravity()}); REQUIRE(context.isReady()); checkPreserved(original, snapshot(context)); check(nonlinear::EliminatedGravity{permissiveGravity()}); REQUIRE(context.isReady()); checkPreserved(original, snapshot(context)); const auto checkTrial = [&](auto correction) { const auto report = evaluate(context, nonlinear::NewtonNumerics{polynomial, nonlinear::MonolithicGravity{}, correction}); CHECK_FALSE(report.converged()); CHECK(report.diagnostics().numericalStrategies.polynomialChecks > 0); CHECK(report.diagnostics().acceptedNonlinearIterations == 0); CHECK(report.lastAcceptedCheckpointView().valid()); }; checkTrial(nonlinear::CoupledCurvatureCorrection{}); REQUIRE(context.isReady()); checkPreserved(original, snapshot(context)); checkTrial(nonlinear::BacktrackedCoupledCurvatureCorrection{}); REQUIRE(context.isReady()); checkPreserved(original, snapshot(context)); } TEST_CASE("Coupled trial selection keeps the outer linear report and rejected stellar checkpoint", "[solver][numerical_strategies][coupled_trial_correction][backtracked][rollback]") { using namespace stellar_numerical_strategy_test; auto context = makeContext(); const auto original = snapshot(context); const auto check = [&](auto gravity, auto correction) { const auto report = evaluate(context, nonlinear::NewtonNumerics{ nonlinear::SampledEnthalpy{}, gravity, correction}); CHECK_FALSE(report.converged()); CHECK(report.diagnostics().acceptedNonlinearIterations == 0); REQUIRE(report.diagnostics().lastLinearSolve.has_value()); const auto &outer = *report.diagnostics().lastLinearSolve; CHECK(outer.iterations == 0); CHECK(outer.control.absoluteTolerance == 1.e100); CHECK(report.diagnostics().numericalStrategies.lastLinearReportSpace == nonlinear::LinearReportSpace::full_system); const auto &auxiliary = report.diagnostics().numericalStrategies.coupledTrial; CHECK(auxiliary.kept == 0); if constexpr (std::same_as) { REQUIRE(auxiliary.currentSolve.has_value()); CHECK(auxiliary.attempts == 1); CHECK(auxiliary.fallbacks == 1); CHECK(auxiliary.currentSolve->linearSolve.control.relativeTolerance == correction.correction.control.relativeTolerance); CHECK(auxiliary.currentSolve->linearSolve.control.absoluteTolerance == correction.correction.control.absoluteTolerance); } else { // The captured zero outer correction predicts no decrease. The // optional selector must leave the auxiliary budget untouched. CHECK(auxiliary.status == solver::CoupledTrialAttemptStatus::prediction_deferred); CHECK(auxiliary.deferredAttempts == 1); CHECK(auxiliary.attempts == 0); CHECK_FALSE(auxiliary.currentSolve.has_value()); CHECK(auxiliary.candidateCount == 0); CHECK(auxiliary.candidates.empty()); CHECK(std::isnan(auxiliary.selectedBeta)); } CHECK(report.lastAcceptedCheckpointView().valid()); }; check(nonlinear::MonolithicGravity{}, nonlinear::CoupledCurvatureCorrection{}); REQUIRE(context.isReady()); checkPreserved(original, snapshot(context)); check(nonlinear::GravityCurvatureCorrection{permissiveGravity()}, nonlinear::CoupledCurvatureCorrection{}); REQUIRE(context.isReady()); checkPreserved(original, snapshot(context)); check(nonlinear::MonolithicGravity{}, nonlinear::BacktrackedCoupledCurvatureCorrection{}); REQUIRE(context.isReady()); checkPreserved(original, snapshot(context)); check(nonlinear::GravityCurvatureCorrection{permissiveGravity()}, nonlinear::BacktrackedCoupledCurvatureCorrection{}); REQUIRE(context.isReady()); checkPreserved(original, snapshot(context)); }