Add bounded fully coupled second-order trial correction and frozen screen

This commit is contained in:
2026-09-18 09:41:00 -04:00
parent 2944dd5019
commit 0d87b8b919
14 changed files with 2442 additions and 35 deletions

View File

@@ -155,6 +155,7 @@ target_sources(mean_field
libmeanfield/interface/solver/enthalpy_admissibility.cppm
libmeanfield/interface/solver/gravity_subsystem.cppm
libmeanfield/interface/solver/gravity_reduction.cppm
libmeanfield/interface/solver/coupled_trial_correction.cppm
libmeanfield/interface/solver/stellar_enthalpy_strategy.cppm
libmeanfield/interface/solver/newton_numerics.cppm
libmeanfield/interface/solver/stellar_newton_strategy.cppm
@@ -376,6 +377,7 @@ add_executable(tests
tests/solver/gravity_subsystem.cpp
tests/solver/gravity_compact_applications.cpp
tests/solver/gravity_reduction.cpp
tests/solver/coupled_trial_correction.cpp
tests/solver/stellar_numerical_strategies.cpp
)

View File

@@ -0,0 +1,80 @@
# Bounded coupled second-order trial correction
Branch `research/coupled-trial-correction-20260918`, based on the completed
nonlinear obstruction investigation at `2944dd5`. The configured worktree
retains its existing name, `worktrees/preconditioner-improvement`, so that
build reuse does not disturb the preceding branch or immutable executables.
## Hypothesis
At the recorded n = 1.5, J = 0 endpoint, tenfold tighter linear accuracy did
not improve the useful trial range. The full directional derivative was
consistent with the prepared residual. Gravity-only correction reduced the
remainder norm but increased its adverse projection through hydrostatic,
EOS and surface balance. The next hypothesis is that correcting the entire
trial remainder can preserve the intended Newton response over larger steps.
For the captured base residual F0 and verified Newton direction p, preserve
L(alpha) = F0 + alpha J0 p. At a prepared candidate y, solve
J(y) c = -(F(y) - L(alpha)),
then prepare and evaluate y+c. All equations, normalization factors and
ordinary full-residual acceptance thresholds remain unchanged. A separate
auxiliary true-defect check is required; the base Newton report is never
replaced by a correction report. The locally second-order argument requires
a bounded inverse, which is not assumed to hold uniformly in this model.
## Implementation contracts
- A third, defaulted compile-time trial-path policy composes with the
established enthalpy and gravity policies. Existing two-policy recipes
retain their types and behavior. The new policy is incompatible with the
eliminated-gravity path until a corresponding reduced-manifold contract
is designed; unsupported combinations fail at compile time.
- The auxiliary solve has its own backend, a finite iteration budget and an
independent application of the complete candidate Jacobian. Its initial
options are relative tolerance 0.03, absolute tolerance 1e-14, at most160
iterations and restart160.
- Limit the correction norm to alpha||p|| by default. The initial work limit
is one actual auxiliary solve per Newton iteration, and one solve per
selected candidate. Runtime expert controls change these bounds explicitly.
- Select correction only after a prepared candidate fails the existing
acceptance predicate. Reuse the exact configured metric for selecting
between original and corrected candidates. An already acceptable step
incurs no coupled solve; an unprepared candidate cannot invoke one.
- Recheck the same selected admissibility policy and actual geometry after
correction. Retain the original candidate if the auxiliary solve fails,
the norm cap rejects it, preparation/admissibility fails, or its metric
worsens. Restoring a fallback includes fresh operator preparation, not
merely copying state vectors.
- The exported numerical core and trial transaction are shared by production
and frozen diagnostics. The physics-facing model API is unchanged.
## Initial screen
The committed `coupled_trial_screen_2026-09-18.json` records complete controls
and immutable input paths. All heavy builds and numerical cases run serially.
Evidence is written to `diagnostics/coupled_trial_2026-09-18/` in the task
workspace; individual campaign directories are immutable.
Use the previously saved late eta0.03 direction, independently reapplying the
full Jacobian at the restored base before using it. This avoids conflating a
new linear solve with the new trial map. Test both raw and gravity-corrected
starting candidates at alpha1/16,1/64,1/256,1/1024. The middle two previously
prepared but failed the merit test. The largest failed pressure preparation;
the smallest already decreased the residual and must not trigger correction.
The seed control uses its saved eta0.03 direction and alpha1 with the
gravity-corrected path. Its candidate already passes; it should preserve that
result without auxiliary coupled work. Each frozen alpha is an independent
trial with a fresh per-iteration budget, not a simulated live line search.
Record inputs/factors/layout, F0,p,J0p, the actual auxiliary RHS and J(y)c,
candidate and returned state/residual vectors, true defects, norm-cap gates,
selection/fallback decisions, restoration invariants and complete work.
Useful frozen progress is a prerequisite for a bounded nonlinear campaign.
Any accepted model must also be checked for virial balance and internal
admissibility; a lower residual alone is insufficient.
Implementation and test results are pending.

View File

@@ -0,0 +1,138 @@
[
{
"id": "late_curvature_coupled",
"arguments": [
"--index",
"1.5",
"--angular-momentum",
"0",
"--no-central",
"--max-newton",
"0",
"--coupled-trial-probe",
"--strategy",
"curvature-coupled",
"--probe-state",
"/Users/tboudreaux/.codex/.chatgpt-projects/g-p-6a6b3f2194ec8191bebcd59075897f1d/diagnostics/nonlinear_step_2026-09-18/late_v1/n1p5_late_eta003/nonlinear_step_physical_base.txt",
"--probe-direction",
"/Users/tboudreaux/.codex/.chatgpt-projects/g-p-6a6b3f2194ec8191bebcd59075897f1d/diagnostics/nonlinear_step_2026-09-18/late_v1/n1p5_late_eta003/nonlinear_step_direction_full.txt",
"--linear-tol",
"0.03",
"--restart",
"160",
"--preconditioner-variant",
"gravity-schur",
"--schur-sweeps",
"1",
"--schur-relaxation",
"0.5",
"--gravity-tol",
"1e-10",
"--gravity-iterations",
"300",
"--gravity-restart",
"80",
"--coupled-tol",
"0.03",
"--coupled-iterations",
"160",
"--coupled-restart",
"160",
"--coupled-max-ratio",
"1",
"--coupled-attempts",
"1"
]
},
{
"id": "late_raw_coupled",
"arguments": [
"--index",
"1.5",
"--angular-momentum",
"0",
"--no-central",
"--max-newton",
"0",
"--coupled-trial-probe",
"--strategy",
"coupled",
"--probe-state",
"/Users/tboudreaux/.codex/.chatgpt-projects/g-p-6a6b3f2194ec8191bebcd59075897f1d/diagnostics/nonlinear_step_2026-09-18/late_v1/n1p5_late_eta003/nonlinear_step_physical_base.txt",
"--probe-direction",
"/Users/tboudreaux/.codex/.chatgpt-projects/g-p-6a6b3f2194ec8191bebcd59075897f1d/diagnostics/nonlinear_step_2026-09-18/late_v1/n1p5_late_eta003/nonlinear_step_direction_full.txt",
"--linear-tol",
"0.03",
"--restart",
"160",
"--preconditioner-variant",
"gravity-schur",
"--schur-sweeps",
"1",
"--schur-relaxation",
"0.5",
"--gravity-tol",
"1e-10",
"--gravity-iterations",
"300",
"--gravity-restart",
"80",
"--coupled-tol",
"0.03",
"--coupled-iterations",
"160",
"--coupled-restart",
"160",
"--coupled-max-ratio",
"1",
"--coupled-attempts",
"1"
]
},
{
"id": "seed_curvature_coupled",
"arguments": [
"--index",
"1.5",
"--angular-momentum",
"0",
"--no-central",
"--max-newton",
"0",
"--coupled-trial-probe",
"--strategy",
"curvature-coupled",
"--probe-state",
"/Users/tboudreaux/.codex/.chatgpt-projects/g-p-6a6b3f2194ec8191bebcd59075897f1d/diagnostics/nonlinear_step_2026-09-18/controls_v1/n1p5_seed_eta003/nonlinear_step_physical_base.txt",
"--probe-direction",
"/Users/tboudreaux/.codex/.chatgpt-projects/g-p-6a6b3f2194ec8191bebcd59075897f1d/diagnostics/nonlinear_step_2026-09-18/controls_v1/n1p5_seed_eta003/nonlinear_step_direction_full.txt",
"--linear-tol",
"0.03",
"--restart",
"160",
"--preconditioner-variant",
"gravity-schur",
"--schur-sweeps",
"1",
"--schur-relaxation",
"0.5",
"--gravity-tol",
"1e-10",
"--gravity-iterations",
"300",
"--gravity-restart",
"80",
"--coupled-tol",
"0.03",
"--coupled-iterations",
"160",
"--coupled-restart",
"160",
"--coupled-max-ratio",
"1",
"--coupled-attempts",
"1",
"--coupled-seed-control"
]
}
]

View File

@@ -0,0 +1,471 @@
#pragma once
// Included after import mean_field. The experiment owns observations only;
// the prepared production transaction owns correction and fallback decisions.
#include "plateau_nonlinear_step_diagnostics.hpp"
#include <optional>
#include <vector>
namespace experiment::plateau_diagnostics {
namespace coupled_trial_detail {
// Expose only the production workspace operations needed by this frozen probe.
// Keeping the affine gravity gate here would let the comparison silently drift
// away from the trial path used by the nonlinear solver.
template <class State>
struct GravityWorkspace final : mean_field::solver::detail::StellarGravityWorkspace<State> {
using Base = mean_field::solver::detail::StellarGravityWorkspace<State>;
using Base::Base;
using Base::ApplyCorrection;
using Base::BeginGravityAttempt;
using Base::Refresh;
using Base::desiredResidual;
using Base::fullCorrection;
using Base::gravityCorrection;
using Base::lastAffineDisagreement;
using Base::lastPreparedDefect;
using Base::lastPreparedTargetThreshold;
using Base::lastSolve;
using Base::subsystem;
using Base::trialSolveSucceeded;
};
}
struct CoupledTrialProbeOptions final {
double outerLinearTolerance{.03};
mean_field::solver::nonlinear::GravitySolveOptions gravity{};
mean_field::solver::CoupledTrialCorrectionOptions coupled{};
bool gravityPreconditionPath{true};
bool seedControl{false};
double sufficientDecrease{1.e-4};
};
template <class State>
void ProbeCoupledTrial(State &state, const mfem::Vector &normalizedBase,
const mfem::Vector &suppliedDirection, const std::filesystem::path &output,
CoupledTrialProbeOptions options = {}) {
using namespace mean_field;
namespace d = nonlinear_step_detail;
using GravityWorkspace = coupled_trial_detail::GravityWorkspace<State>;
using GravityCorrection = solver::PreparedGravityCurvatureCorrection<typename GravityWorkspace::Subsystem>;
using Transaction = solver::detail::PreparedCoupledTrialTransaction<State>;
const auto communicator = state.Problem().GetCommunicator();
int ranks = 0;
MPI_Comm_size(communicator, &ranks);
const int size = state.Problem().StateSize();
if (ranks != 1 || size != state.Problem().EquationSize() || normalizedBase.Size() != size ||
suppliedDirection.Size() != size || !d::Finite(normalizedBase) || !d::Finite(suppliedDirection) ||
!std::isfinite(options.outerLinearTolerance) || options.outerLinearTolerance <= 0. ||
options.outerLinearTolerance >= 1. || !std::isfinite(options.sufficientDecrease) ||
options.sufficientDecrease <= 0. || options.sufficientDecrease >= 1.)
throw std::invalid_argument("Coupled-trial probes require one rank, compatible finite vectors and valid controls.");
options.gravity.Validate();
options.coupled.Validate();
const auto start = d::Clock::now();
const double nan = std::numeric_limits<double>::quiet_NaN();
const mfem::Vector base(normalizedBase), direction(suppliedDirection);
const auto &normalization = state.normalizedOperator->GetNormalization();
const mfem::Vector factors(normalization.StateFactors()), residualFactors(normalization.ResidualFactors());
const mfem::Vector savedAccepted(state.acceptedNormalizedState),
savedAcceptedResidual(state.acceptedNormalizedResidual), savedPhysical(state.AcceptedPhysicalState()),
savedCorrection(state.normalizedCorrection), savedPhysicalCorrection(state.physicalCorrection),
savedVolumeDirection(state.volumeDisplacementDirection);
const double savedMinimumJacobian = state.acceptedMinimumJacobianDeterminant;
std::filesystem::create_directories(output);
const auto file = [&](const std::string &name) { return output / ("coupled_trial_" + name); };
auto summary = d::Open(file("summary.csv"));
auto preparations = d::Open(file("preparations.csv"));
auto trials = d::Open(file("trials.csv"));
auto blocks = d::Open(file("blocks.csv"));
auto manifest = d::Open(file("layout.csv"));
summary << "metric,value\n";
preparations << "sequence,kind,sample,alpha,prepared,rejection_reason,rejection_stage,seconds,residual_norm\n";
trials << "sample,alpha,gravity_path,raw_prepared,raw_rejection_reason,raw_rejection_stage,raw_norm,raw_merit,"
"gravity_attempted,gravity_reported_gate,gravity_prepared_gate,gravity_target_defect,"
"gravity_affine_disagreement,gravity_target_threshold,before_prepared,before_usable,before_norm,"
"before_merit,before_accepted,soc_called,soc_status,soc_attempted,aux_status,aux_reported_gate,"
"aux_iterations,aux_rhs_norm,aux_reported_true_norm,aux_verified_true_norm,aux_verified_gate,"
"aux_threshold,aux_verification_applied,correction_norm,correction_cap,correction_ratio,"
"corrected_metric_observed,corrected_norm,corrected_merit,corrected_accepted,selected_prepared,"
"selected_norm,selected_merit,selected_accepted,selected_is_original,fallback_residual_difference,"
"raw_seconds,gravity_seconds,soc_seconds,soc_refresh_seconds,soc_solve_seconds,"
"soc_verification_seconds,soc_preparation_seconds,soc_preparations,soc_restorations,"
"aux_operator_applications,aux_inverse_applications,verified_action_defect_norm,"
"capture_seconds,total_sample_seconds,aux_core_status,rhs_saved,correction_saved,verified_vectors_saved\n";
blocks << "sample,alpha,stage,block,offset,size,residual_norm\n";
manifest << "kind,block,offset,size\n";
for (const auto &value : state.Problem().GetManifest().valueBlocks())
manifest << "value," << value.stableId << ',' << value.offset << ',' << value.size << '\n';
for (const auto &row : state.Problem().GetManifest().residualBlocks())
manifest << "residual," << row.stableId << ',' << row.offset << ',' << row.size << '\n';
manifest.flush();
const auto metric = [&](const char *name, auto value) { summary << name << ',' << value << '\n'; };
metric("schema_version", 1);
metric("independent_frozen_alphas", 1);
metric("accepted_commits", 0);
metric("outer_linear_tolerance", options.outerLinearTolerance);
metric("sufficient_decrease", options.sufficientDecrease);
metric("gravity_path", options.gravityPreconditionPath);
metric("seed_control", options.seedControl);
metric("coupled_maximum_correction_ratio", options.coupled.maximumCorrectionRatio);
metric("coupled_relative_tolerance", options.coupled.control.relativeTolerance);
metric("coupled_absolute_tolerance", options.coupled.control.absoluteTolerance);
metric("coupled_maximum_iterations", options.coupled.control.maximumIterations);
metric("coupled_restart", options.coupled.backend.restartLength);
metric("gravity_relative_tolerance", options.gravity.control.relativeTolerance);
metric("gravity_absolute_tolerance", options.gravity.control.absoluteTolerance);
metric("gravity_maximum_iterations", options.gravity.control.maximumIterations);
metric("gravity_restart", options.gravity.backend.restartLength);
metric("polynomial_direction_repairs", 0);
metric("sampled_enthalpy_policy", 1);
metric("coupled_attempt_budget_per_independent_alpha", 1);
metric("soc_status_not_attempted", int(solver::CoupledTrialAttemptStatus::not_attempted));
metric("soc_status_original_accepted", int(solver::CoupledTrialAttemptStatus::original_accepted));
metric("soc_status_budget_exhausted", int(solver::CoupledTrialAttemptStatus::budget_exhausted));
metric("soc_status_kept", int(solver::CoupledTrialAttemptStatus::kept));
metric("soc_status_linear_failure", int(solver::CoupledTrialAttemptStatus::linear_failure));
metric("soc_status_norm_limit", int(solver::CoupledTrialAttemptStatus::norm_limit));
metric("soc_status_non_finite", int(solver::CoupledTrialAttemptStatus::non_finite));
metric("soc_status_inadmissible", int(solver::CoupledTrialAttemptStatus::inadmissible));
metric("soc_status_not_improved", int(solver::CoupledTrialAttemptStatus::not_improved));
std::vector<double> alphas = options.seedControl ? std::vector<double>{1.}
: std::vector<double>{1. / 16., 1. / 64., 1. / 256., 1. / 1024.};
for (std::size_t sample = 0; sample < alphas.size(); ++sample)
summary << "alpha_" << sample << ',' << alphas[sample] << '\n';
mfem::Vector residual(size), baseAction(size);
bool captured = false, haveBaseAction = false, finished = false;
int preparationsCount = 0, refreshes = 0, directActions = 0;
double preparationSeconds = 0., refreshSeconds = 0., actionSeconds = 0., restorationError = 0.;
const auto prepare = [&](const mfem::Vector &candidate, const char *kind, int sample, double alpha) {
state.trialNormalizedState = candidate;
state.normalizedOperator->DenormalizeState(candidate, state.candidatePhysicalState);
const auto started = d::Clock::now();
auto result = state.PrepareTrial();
const double seconds = d::Seconds(started);
preparationSeconds += seconds;
preparations << ++preparationsCount << ',' << kind << ',' << sample << ',' << alpha << ',' << bool(result)
<< ',' << (result ? -1 : int(result.error().reason)) << ','
<< (result ? -1 : int(result.error().stage)) << ',' << seconds << ','
<< (result ? d::Norm(state.trialNormalizedResidual) : nan) << '\n';
preparations.flush();
if (result && !d::Finite(state.trialNormalizedResidual))
throw std::runtime_error("Coupled-trial preparation produced a nonfinite residual.");
return result;
};
const auto refresh = [&]() {
const auto started = d::Clock::now();
state.physicalInverse->Refresh();
refreshSeconds += d::Seconds(started);
++refreshes;
};
const auto restore = [&](const char *kind) {
if (!prepare(base, kind, -1, 0.)) throw std::runtime_error("The coupled-trial base cannot be restored.");
if (!captured) { residual = state.trialNormalizedResidual; captured = true; }
restorationError = std::max(restorationError, d::Difference(residual, state.trialNormalizedResidual));
refresh();
};
const auto action = [&](const mfem::Vector &input, mfem::Vector &result) {
const auto started = d::Clock::now();
state.normalizedOperator->Mult(input, result);
actionSeconds += d::Seconds(started);
++directActions;
if (!d::Finite(result)) throw std::runtime_error("The coupled-trial full-J action is nonfinite.");
};
const auto evaluate = [&](const mfem::Vector &value) {
return solver::nonlinear::getMetric(solver::nonlinear::NormalizedResidualMetric{}, value, communicator);
};
const auto blockRows = [&](std::size_t sample, double alpha, const char *stage, const mfem::Vector &value) {
blocks << sample << ',' << alpha << ',' << stage << ",all,0," << size << ',' << d::Norm(value) << '\n';
for (const auto &row : state.Problem().GetManifest().residualBlocks())
blocks << sample << ',' << alpha << ',' << stage << ',' << row.stableId << ',' << row.offset << ','
<< row.size << ',' << d::Norm(value, row.offset, row.size) << '\n';
blocks.flush();
};
const auto finish = [&]() {
// Output is append-only evidence. A failed cleanup must not retry a
// previously written filename or replace the original failure record.
finished = true;
const bool directionsNeverChanged = d::Identical(savedCorrection, state.normalizedCorrection) &&
d::Identical(savedPhysicalCorrection, state.physicalCorrection) &&
d::Identical(savedVolumeDirection, state.volumeDisplacementDirection);
state.normalizedCorrection = savedCorrection;
state.physicalCorrection = savedPhysicalCorrection;
state.volumeDisplacementDirection = savedVolumeDirection;
restore("final_base_restore");
d::Save(file("restored_residual.txt"), state.trialNormalizedResidual);
mfem::Vector restoredAction(size);
action(direction, restoredAction);
d::Save(file("restored_action.txt"), restoredAction);
const bool acceptedIdentical = d::Identical(savedAccepted, state.acceptedNormalizedState) &&
d::Identical(savedAcceptedResidual, state.acceptedNormalizedResidual) &&
d::Identical(savedPhysical, state.AcceptedPhysicalState()) &&
savedMinimumJacobian == state.acceptedMinimumJacobianDeterminant;
const bool factorsIdentical = d::Identical(factors, normalization.StateFactors()) &&
d::Identical(residualFactors, normalization.ResidualFactors());
const bool directionsIdentical = d::Identical(savedCorrection, state.normalizedCorrection) &&
d::Identical(savedPhysicalCorrection, state.physicalCorrection) &&
d::Identical(savedVolumeDirection, state.volumeDisplacementDirection);
metric("accepted_buffers_bitwise_identical", acceptedIdentical);
metric("normalization_factors_bitwise_identical", factorsIdentical);
metric("direction_buffers_bitwise_identical", directionsIdentical);
metric("direction_buffers_unchanged_before_restoration", directionsNeverChanged);
metric("maximum_base_restoration_error", restorationError);
metric("restored_base_action_error", haveBaseAction ? d::Difference(baseAction, restoredAction) : nan);
metric("fresh_operator_and_inverse_current", state.normalizedOperator->IsPrepared() &&
state.physicalInverse->IsCurrent() && state.normalizedInverse->IsCurrent());
metric("explicit_probe_preparations", preparationsCount);
metric("explicit_probe_preparation_seconds", preparationSeconds);
metric("explicit_probe_inverse_refreshes", refreshes);
metric("explicit_probe_inverse_refresh_seconds", refreshSeconds);
metric("explicit_probe_operator_applications", directActions);
metric("explicit_probe_operator_seconds", actionSeconds);
metric("total_seconds", d::Seconds(start));
summary.flush();
if (!acceptedIdentical || !factorsIdentical || !directionsIdentical || !directionsNeverChanged)
throw std::runtime_error("The coupled-trial probe changed accepted buffers, directions or normalization.");
};
try {
restore("initial_base_restore");
mfem::Vector physicalBase(size);
state.normalizedOperator->DenormalizeState(base, physicalBase);
action(direction, baseAction);
haveBaseAction = true;
mfem::Vector defect(baseAction);
defect += residual;
const auto baseMetric = evaluate(residual);
const double outerThreshold = options.outerLinearTolerance * baseMetric.residualNorm;
const bool outerGate = d::Norm(defect) <= outerThreshold;
d::Save(file("base_normalized.txt"), base);
d::Save(file("base_physical.txt"), physicalBase);
d::Save(file("base_residual.txt"), residual);
d::Save(file("state_factors.txt"), factors);
d::Save(file("residual_factors.txt"), residualFactors);
d::Save(file("direction.txt"), direction);
d::Save(file("base_action.txt"), baseAction);
d::Save(file("base_defect.txt"), defect);
metric("base_residual_norm", baseMetric.residualNorm);
metric("base_merit", baseMetric.merit);
metric("direction_norm", d::Norm(direction));
metric("base_merit_slope", d::Dot(residual, baseAction, 0, size));
metric("outer_verified_defect_norm", d::Norm(defect));
metric("outer_verified_relative_defect", d::Ratio(d::Norm(defect), baseMetric.residualNorm));
metric("outer_verified_threshold", outerThreshold);
metric("outer_verified_gate", outerGate);
metric("outer_gate_failure_skips_trials", !outerGate);
summary.flush();
if (!outerGate) { metric("probe_exception", 0); finish(); return; }
Transaction transaction(state, options.coupled, 1);
std::optional<GravityWorkspace> gravity;
std::optional<GravityCorrection> gravityCorrection;
if (options.gravityPreconditionPath) {
gravity.emplace(state, options.gravity);
gravityCorrection.emplace(gravity->subsystem);
metric("compact_gravity_operator", gravity->subsystem.UsesCompactOperator());
metric("compact_gravity_inverse", gravity->subsystem.UsesCompactPreconditioner());
}
for (std::size_t sample = 0; sample < alphas.size(); ++sample) {
const auto sampleStart = d::Clock::now();
const double alpha = alphas[sample];
const std::string suffix = "_a" + std::to_string(sample) + ".txt";
restore("capture_base");
const auto captureStart = d::Clock::now();
// These are independent frozen trials. Resetting here does not
// represent several attempts inside one production line search.
transaction.CaptureBase(residual, direction);
transaction.BeginTrial();
if (gravity) {
gravity->subsystem.Refresh();
gravityCorrection->CaptureBase(residual, direction);
gravity->BeginGravityAttempt();
}
const double captureSeconds = d::Seconds(captureStart);
mfem::Vector prediction(residual), rawState(base);
prediction.Add(alpha, baseAction);
rawState.Add(alpha, direction);
d::Save(file("prediction" + suffix), prediction);
d::Save(file("raw_state" + suffix), rawState);
const auto rawStart = d::Clock::now();
auto preparation = prepare(rawState, "raw", static_cast<int>(sample), alpha);
const double rawSeconds = d::Seconds(rawStart);
const bool rawPrepared = bool(preparation);
const int rawReason = rawPrepared ? -1 : int(preparation.error().reason);
const int rawStage = rawPrepared ? -1 : int(preparation.error().stage);
solver::nonlinear::MetricEvaluation rawMetric{nan, nan};
if (rawPrepared) {
rawMetric = evaluate(state.trialNormalizedResidual);
d::Save(file("raw_residual" + suffix), state.trialNormalizedResidual);
blockRows(sample, alpha, "raw", state.trialNormalizedResidual);
}
bool gravityAttempted = false, gravityReportedGate = !gravity, gravityPreparedGate = !gravity;
double gravitySeconds = 0.;
if (rawPrepared && gravity) {
const auto gravityStart = d::Clock::now();
gravity->Refresh(state);
gravityCorrection->BuildLinearPrediction(alpha, gravity->desiredResidual);
gravityAttempted = true;
gravity->lastSolve = gravityCorrection->SolveCorrection(
alpha, state.trialNormalizedResidual, gravity->gravityCorrection, options.gravity.control);
gravity->trialSolveSucceeded = gravity->lastSolve.Converged();
gravityReportedGate = gravity->trialSolveSucceeded;
if (gravityReportedGate) {
// This is the same production affine-gravity assessment,
// including its contribution-aware arithmetic allowance.
preparation = gravity->ApplyCorrection(state);
gravityPreparedGate = bool(preparation) && gravity->TrialSolveSucceeded();
d::Save(file("gravity_correction" + suffix), gravity->fullCorrection);
}
gravitySeconds = d::Seconds(gravityStart);
}
const bool beforePrepared = bool(preparation);
const bool usable = beforePrepared && gravityReportedGate && gravityPreparedGate;
mfem::Vector beforeState(state.trialNormalizedState), beforeResidual;
solver::nonlinear::MetricEvaluation beforeMetric{nan, nan};
if (beforePrepared) {
beforeResidual = state.trialNormalizedResidual;
beforeMetric = evaluate(beforeResidual);
d::Save(file("before_state" + suffix), beforeState);
d::Save(file("before_residual" + suffix), beforeResidual);
blockRows(sample, alpha, "before", beforeResidual);
}
const auto accepts = [&](const solver::nonlinear::MetricEvaluation &candidate) {
return std::isfinite(candidate.merit) && candidate.merit >= 0. &&
candidate.merit <= (1. - options.sufficientDecrease * alpha) * baseMetric.merit;
};
const auto better = [](const auto &candidate, const auto &original) {
return std::isfinite(candidate.merit) && candidate.merit >= 0. &&
std::isfinite(candidate.residualNorm) && candidate.residualNorm >= 0. &&
candidate.merit < original.merit;
};
const bool beforeAccepted = usable && accepts(beforeMetric);
auto selectedMetric = beforeMetric;
bool correctedObserved = false;
mfem::Vector observedCorrectedState, observedCorrectedResidual;
solver::nonlinear::MetricEvaluation observedCorrectedMetric{nan, nan};
const auto observeMetric = [&](const mfem::Vector &value) {
const auto result = evaluate(value);
// This callback observes the actual prepared correction before
// a possible merit-based rollback. It neither reconstructs nor
// reapplies the remedy and does not change its selection rule.
if (!d::Identical(state.trialNormalizedState, beforeState)) {
observedCorrectedState = state.trialNormalizedState;
observedCorrectedResidual = value;
observedCorrectedMetric = result;
correctedObserved = true;
}
return result;
};
const auto previousDiagnostics = transaction.GetDiagnostics();
double socSeconds = 0.;
if (usable) {
const auto socStart = d::Clock::now();
transaction.TryImprove(state, alpha, preparation, selectedMetric, observeMetric,
accepts, better, [](const mfem::Vector &) { return true; });
socSeconds = d::Seconds(socStart);
}
const auto &diagnostics = transaction.GetDiagnostics();
const auto &report = diagnostics.currentSolve;
const bool attempted = report.has_value();
const bool rhsSaved = attempted && d::Finite(transaction.GetCore().GetRightHandSide());
const bool correctionSaved = attempted && d::Finite(transaction.GetCorrection());
const bool verifiedVectorsSaved = attempted && report->verificationApplied && rhsSaved &&
d::Finite(transaction.GetCore().GetVerifiedAction()) && d::Finite(transaction.GetCore().GetVerifiedDefect());
double savedDefectNorm = nan;
if (attempted) {
if (rhsSaved) d::Save(file("soc_rhs" + suffix), transaction.GetCore().GetRightHandSide());
if (correctionSaved) d::Save(file("soc_correction" + suffix), transaction.GetCorrection());
if (verifiedVectorsSaved) {
d::Save(file("soc_action" + suffix), transaction.GetCore().GetVerifiedAction());
mfem::Vector savedDefect(transaction.GetCore().GetVerifiedAction());
savedDefect -= transaction.GetCore().GetRightHandSide();
savedDefectNorm = d::Norm(savedDefect);
d::Save(file("soc_defect" + suffix), savedDefect);
}
}
if (correctedObserved) {
d::Save(file("corrected_state" + suffix), observedCorrectedState);
d::Save(file("corrected_residual" + suffix), observedCorrectedResidual);
mfem::Vector physical(size);
state.normalizedOperator->DenormalizeState(observedCorrectedState, physical);
d::Save(file("corrected_physical" + suffix), physical);
blockRows(sample, alpha, "corrected", observedCorrectedResidual);
}
const bool selectedPrepared = bool(preparation);
const bool selectedIsOriginal = beforePrepared && d::Identical(beforeState, state.trialNormalizedState);
double fallbackDifference = nan;
if (selectedPrepared) {
selectedMetric = evaluate(state.trialNormalizedResidual);
d::Save(file("selected_state" + suffix), state.trialNormalizedState);
d::Save(file("selected_residual" + suffix), state.trialNormalizedResidual);
d::Save(file("selected_physical" + suffix), state.candidatePhysicalState);
blockRows(sample, alpha, "selected", state.trialNormalizedResidual);
if (selectedIsOriginal) fallbackDifference = d::Difference(beforeResidual, state.trialNormalizedResidual);
}
const bool selectedAccepted = usable && selectedPrepared && accepts(selectedMetric);
trials << sample << ',' << alpha << ',' << options.gravityPreconditionPath << ',' << rawPrepared << ','
<< rawReason << ',' << rawStage << ',' << rawMetric.residualNorm << ',' << rawMetric.merit << ','
<< gravityAttempted << ',' << gravityReportedGate << ',' << gravityPreparedGate << ','
<< (gravityAttempted ? gravity->lastPreparedDefect : nan) << ','
<< (gravityAttempted ? gravity->lastAffineDisagreement : nan) << ','
<< (gravityAttempted ? gravity->lastPreparedTargetThreshold : nan) << ','
<< beforePrepared << ',' << usable << ',' << beforeMetric.residualNorm << ',' << beforeMetric.merit
<< ',' << beforeAccepted << ',' << usable << ',' << int(diagnostics.status) << ',' << attempted
<< ',' << (attempted ? int(report->linearSolve.status) : -1) << ','
<< (attempted && report->linearSolve.Converged()) << ','
<< (attempted ? report->linearSolve.iterations : 0) << ','
<< (attempted ? report->linearSolve.rightHandSideNorm : nan) << ','
<< (attempted ? report->linearSolve.trueResidualNorm : nan) << ','
<< (attempted ? report->verifiedTrueResidualNorm : nan) << ','
<< (attempted && report->verifiedGate) << ','
<< (attempted ? report->convergenceThreshold : nan) << ','
<< (attempted && report->verificationApplied) << ','
<< (attempted ? report->correctionNorm : nan) << ','
<< (attempted ? report->maximumCorrectionNorm : nan) << ','
<< (attempted ? d::Ratio(report->correctionNorm, alpha * d::Norm(direction)) : nan) << ','
<< correctedObserved << ',' << observedCorrectedMetric.residualNorm << ','
<< observedCorrectedMetric.merit << ',' << (correctedObserved && accepts(observedCorrectedMetric)) << ','
<< selectedPrepared << ',' << selectedMetric.residualNorm << ',' << selectedMetric.merit << ','
<< selectedAccepted << ',' << selectedIsOriginal << ',' << fallbackDifference << ','
<< rawSeconds << ',' << gravitySeconds << ',' << socSeconds << ','
<< diagnostics.refreshSeconds - previousDiagnostics.refreshSeconds << ','
<< diagnostics.solveSeconds - previousDiagnostics.solveSeconds << ','
<< diagnostics.verificationSeconds - previousDiagnostics.verificationSeconds << ','
<< diagnostics.preparationSeconds - previousDiagnostics.preparationSeconds << ','
<< diagnostics.preparations - previousDiagnostics.preparations << ','
<< diagnostics.restorations - previousDiagnostics.restorations << ','
<< (attempted ? report->linearSolve.operatorApplications : 0) << ','
<< (attempted ? report->linearSolve.inversePreconditionerApplications : 0) << ','
<< savedDefectNorm << ',' << captureSeconds << ',' << d::Seconds(sampleStart) << ','
<< (attempted ? int(report->status) : -1) << ',' << rhsSaved << ',' << correctionSaved << ','
<< verifiedVectorsSaved << '\n';
trials.flush();
summary.flush();
}
const auto &totals = transaction.GetDiagnostics();
metric("soc_attempts", totals.attempts);
metric("soc_kept", totals.kept);
metric("soc_fallbacks", totals.fallbacks);
metric("soc_iterations", totals.iterations);
metric("soc_solve_seconds", totals.solveSeconds);
metric("soc_verification_seconds", totals.verificationSeconds);
metric("soc_preparation_seconds", totals.preparationSeconds);
metric("soc_refresh_seconds", totals.refreshSeconds);
metric("soc_preparations", totals.preparations);
metric("soc_restorations", totals.restorations);
if (gravity) {
metric("gravity_solves", gravity->subsystem.GetSolveCount());
metric("gravity_iterations", gravity->subsystem.GetIterationCount());
metric("gravity_failures", gravity->subsystem.GetFailureCount());
metric("gravity_solve_seconds", gravity->subsystem.GetSolveSeconds());
}
} catch (...) {
const auto failure = std::current_exception();
metric("probe_exception", 1);
if (!finished) finish();
std::rethrow_exception(failure);
}
metric("probe_exception", 0);
finish();
}
} // namespace experiment::plateau_diagnostics

View File

@@ -0,0 +1,185 @@
#pragma once
// Include after import mean_field. Physical replay is deliberately separate
// from timed trial selection: these quadratures do not influence acceptance.
#include "plateau_physical_diagnostics.hpp"
#include <cstring>
#include <exception>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <type_traits>
#include <vector>
namespace experiment::plateau_diagnostics {
namespace coupled_physical_detail {
inline bool Finite(const mfem::Vector &values) {
for (int i = 0; i < values.Size(); ++i)
if (!std::isfinite(values(i))) return false;
return true;
}
inline bool Identical(const mfem::Vector &a, const mfem::Vector &b) {
return a.Size() == b.Size() &&
(a.Size() == 0 || std::memcmp(a.GetData(), b.GetData(), sizeof(mfem::real_t) * a.Size()) == 0);
}
inline mfem::Vector Load(const std::filesystem::path &path, int size) {
std::ifstream input(path);
long long storedSize = -1;
if (!(input >> storedSize) || storedSize != size)
throw std::invalid_argument("Coupled physical snapshot has an incompatible size: " + path.string());
mfem::Vector values(size);
for (int i = 0; i < size; ++i)
if (!(input >> values(i)) || !std::isfinite(values(i)))
throw std::invalid_argument("Coupled physical snapshot is incomplete or nonfinite: " + path.string());
std::string trailing;
if (input >> trailing || input.bad())
throw std::invalid_argument("Coupled physical snapshot has trailing data or a read error: " + path.string());
return values;
}
// PhysicalState's structural constructor uses these historical accepted-state
// member names. This adapter supplies an explicitly prepared, uncommitted
// snapshot instead; it never aliases a writable accepted vector. Reusing that
// constructor preserves the same reduced-field scatter and Piola transforms.
template <class State>
struct PreparedSnapshotView final {
State &state;
decltype(std::declval<State &>().normalizedOperator) &normalizedOperator;
const mfem::Vector &acceptedNormalizedResidual;
explicit PreparedSnapshotView(State &source)
: state(source), normalizedOperator(source.normalizedOperator),
acceptedNormalizedResidual(source.trialNormalizedResidual) {
if (!normalizedOperator->IsPrepared() || !Finite(acceptedNormalizedResidual))
throw std::logic_error("Physical snapshot reconstruction requires a fresh prepared trial.");
}
decltype(auto) Problem() const { return state.Problem(); }
const mfem::Vector &AcceptedPhysicalState() const { return normalizedOperator->GetPhysicalState(); }
};
} // namespace coupled_physical_detail
template <class State>
void MeasureCoupledTrialSnapshots(State &state, const mean_field::fem::FEM &fem,
const std::filesystem::path &output, double n, double K) {
namespace d = coupled_physical_detail;
int ranks = 0;
MPI_Comm_size(state.Problem().GetCommunicator(), &ranks);
if (ranks != 1 || !std::isfinite(n) || n < 1. || !std::isfinite(K) || K <= 0.)
throw std::invalid_argument("Coupled physical replay requires one rank, finite n>=1 and finite K>0.");
constexpr std::string_view prefix = "coupled_trial_selected_physical_a";
constexpr std::string_view suffix = ".txt";
std::vector<std::pair<int, std::filesystem::path>> snapshots;
for (const auto &entry : std::filesystem::directory_iterator(output)) {
const std::string name = entry.path().filename().string();
if (!entry.is_regular_file() || !name.starts_with(prefix) || !name.ends_with(suffix)) continue;
const std::string digits = name.substr(prefix.size(), name.size() - prefix.size() - suffix.size());
if (digits.empty() || !std::all_of(digits.begin(), digits.end(), [](char ch) { return ch >= '0' && ch <= '9'; }))
throw std::invalid_argument("Coupled physical snapshot has an invalid sample index: " + name);
snapshots.emplace_back(std::stoi(digits), entry.path());
}
std::sort(snapshots.begin(), snapshots.end(), [](const auto &a, const auto &b) { return a.first < b.first; });
for (std::size_t i = 1; i < snapshots.size(); ++i)
if (snapshots[i - 1].first == snapshots[i].first)
throw std::invalid_argument("Coupled physical replay found duplicate sample indices.");
const auto path = output / "coupled_trial_physical.csv";
if (std::filesystem::exists(path))
throw std::invalid_argument("Coupled physical replay output already exists: " + path.string());
std::ofstream stream(path);
stream.exceptions(std::ios::failbit | std::ios::badbit);
stream << std::setprecision(17) << "sample,source,quadrature,metric,value\n";
const auto write = [&](int sample, const std::string &source, int quadrature, const std::string &metric, double value) {
stream << sample << ',' << source << ',' << quadrature << ',' << metric << ',' << value << '\n';
};
const mfem::Vector accepted(state.acceptedNormalizedState), acceptedResidual(state.acceptedNormalizedResidual),
acceptedPhysical(state.AcceptedPhysicalState()), correction(state.normalizedCorrection),
physicalCorrection(state.physicalCorrection), volumeDirection(state.volumeDisplacementDirection);
const auto &normalization = state.normalizedOperator->GetNormalization();
const mfem::Vector stateFactors(normalization.StateFactors()), residualFactors(normalization.ResidualFactors());
const double minimumJacobian = state.acceptedMinimumJacobianDeterminant;
const auto acceptedUnchanged = [&] {
return d::Identical(accepted, state.acceptedNormalizedState) &&
d::Identical(acceptedResidual, state.acceptedNormalizedResidual) &&
d::Identical(acceptedPhysical, state.AcceptedPhysicalState()) &&
minimumJacobian == state.acceptedMinimumJacobianDeterminant;
};
bool restorationAttempted = false;
const auto restore = [&] {
restorationAttempted = true;
const bool before = acceptedUnchanged();
state.RestoreAccepted();
const bool after = acceptedUnchanged();
const bool factorsUnchanged = d::Identical(stateFactors, normalization.StateFactors()) &&
d::Identical(residualFactors, normalization.ResidualFactors());
const bool directionsUnchanged = d::Identical(correction, state.normalizedCorrection) &&
d::Identical(physicalCorrection, state.physicalCorrection) &&
d::Identical(volumeDirection, state.volumeDisplacementDirection);
const bool ready = state.normalizedOperator->IsPrepared() && state.physicalInverse->IsCurrent() &&
state.normalizedInverse->IsCurrent();
write(-1, "restoration", 0, "accepted_buffers_unchanged_before_restoration", before);
write(-1, "restoration", 0, "accepted_buffers_unchanged_after_restoration", after);
write(-1, "restoration", 0, "normalization_factors_unchanged", factorsUnchanged);
write(-1, "restoration", 0, "direction_buffers_unchanged", directionsUnchanged);
write(-1, "restoration", 0, "accepted_operator_and_inverse_current", ready);
stream.flush();
if (!before || !after || !factorsUnchanged || !directionsUnchanged || !ready)
throw std::runtime_error("Coupled physical replay failed its accepted-state restoration invariant.");
};
try {
const double targetMass = state.Problem().GetStellarModel()
.template specification<mean_field::models::FixedTotalMass>().targetMass().value();
for (const auto &[sample, snapshotPath] : snapshots) {
const std::string source = snapshotPath.filename().string();
const auto saved = d::Load(snapshotPath, state.Problem().StateSize());
state.normalizedOperator->NormalizeState(saved, state.trialNormalizedState);
state.normalizedOperator->DenormalizeState(state.trialNormalizedState, state.candidatePhysicalState);
const auto preparation = state.PrepareTrial();
write(sample, source, 0, "fresh_preparation_passed", bool(preparation));
if (!preparation) {
write(sample, source, 0, "rejection_reason", int(preparation.error().reason));
write(sample, source, 0, "rejection_stage", int(preparation.error().stage));
throw std::runtime_error("Selected coupled trial is inadmissible during physical replay: " + source);
}
const auto &prepared = state.normalizedOperator->GetPhysicalState();
if (prepared.Size() != saved.Size() || !d::Finite(prepared) || !d::Finite(state.trialNormalizedResidual))
throw std::runtime_error("Selected coupled trial produced invalid prepared physical data: " + source);
long double squaredDifference = 0.;
double maximumDifference = 0., maximumScaledDifference = 0.;
for (int i = 0; i < saved.Size(); ++i) {
const double difference = std::abs(prepared(i) - saved(i));
squaredDifference += static_cast<long double>(difference) * difference;
maximumDifference = std::max(maximumDifference, difference);
maximumScaledDifference = std::max(maximumScaledDifference, difference / std::max(1., std::abs(saved(i))));
}
d::PreparedSnapshotView<State> view(state);
experiment::polytrope_validation::PhysicalState physical(view, fem);
for (const int quadrature : {14, 18}) {
auto measurements = MeasurePhysical(physical, n, K, quadrature);
measurements["saved_prepared_l2_difference"] = static_cast<double>(std::sqrt(squaredDifference));
measurements["saved_prepared_maximum_difference"] = maximumDifference;
measurements["saved_prepared_maximum_scaled_difference"] = maximumScaledDifference;
measurements["minimum_preparation_jacobian"] = preparation->minimumJacobianDeterminant;
measurements["normalized_bordered_residual"] = physical.normalizedBorderedResidualNorm;
measurements["normalized_unbordered_residual"] = physical.normalizedUnborderedResidualNorm;
measurements["normalized_border_action"] = physical.normalizedCentralBorderActionNorm;
measurements["central_border"] = physical.centralBorder;
measurements["target_mass"] = targetMass;
measurements["mass_absolute_error"] = std::abs(measurements.at("mass") - targetMass);
for (const auto &[metric, value] : measurements) write(sample, source, quadrature, metric, value);
stream.flush();
}
}
write(-1, "replay", 0, "selected_snapshots_measured", static_cast<double>(snapshots.size()));
restore();
} catch (...) {
const auto failure = std::current_exception();
if (!restorationAttempted) restore();
std::rethrow_exception(failure);
}
}
} // namespace experiment::plateau_diagnostics

View File

@@ -24,6 +24,8 @@ import mean_field;
#include "plateau_shape_curvature_diagnostics.hpp"
#include "plateau_numerical_strategy_diagnostics.hpp"
#include "plateau_nonlinear_step_diagnostics.hpp"
#include "plateau_coupled_trial_diagnostics.hpp"
#include "plateau_coupled_trial_physical.hpp"
#include "plateau_gravity_application_diagnostics.hpp"
#include "plateau_positivity_strategy_diagnostics.hpp"
#include "plateau_polynomial_certificate.hpp"
@@ -37,6 +39,9 @@ struct Options {
bool probes = false, noCentral = false;
bool strategyProbe = false, positivityProbe = false, positivityCertificate = false;
bool preconditionerProbe = false, nonlinearStepProbe = false;
bool coupledTrialProbe = false, coupledSeedControl = false;
double coupledTolerance = .03, coupledMaximumRatio = 1.;
int coupledIterations = 160, coupledRestart = 160, coupledAttempts = 1;
std::string preconditionerVariant = "independent";
int schurCorrectionSweeps = 1;
double schurRelaxation = 0.5;
@@ -103,6 +108,20 @@ Options Parse(int argc, char **argv) {
o.preconditionerProbe = true;
else if (a == "--nonlinear-step-probe")
o.nonlinearStepProbe = true;
else if (a == "--coupled-trial-probe")
o.coupledTrialProbe = true;
else if (a == "--coupled-seed-control")
o.coupledSeedControl = true;
else if (a == "--coupled-tol")
o.coupledTolerance = std::stod(v());
else if (a == "--coupled-iterations")
o.coupledIterations = std::stoi(v());
else if (a == "--coupled-restart")
o.coupledRestart = std::stoi(v());
else if (a == "--coupled-max-ratio")
o.coupledMaximumRatio = std::stod(v());
else if (a == "--coupled-attempts")
o.coupledAttempts = std::stoi(v());
else if (a == "--preconditioner-variant")
o.preconditionerVariant = v();
else if (a == "--schur-sweeps")
@@ -139,17 +158,17 @@ Options Parse(int argc, char **argv) {
throw std::invalid_argument("Invalid options");
if (o.probeRestart != 40 && o.probeRestart != 160)
throw std::invalid_argument("--probe-restart must be 40 or 160");
if ((o.pressureProbe || o.shapeCurvature || o.surfaceTrace || o.strategyProbe || o.positivityProbe) && (o.probeState.empty() || o.probeDirection.empty()))
if ((o.pressureProbe || o.shapeCurvature || o.surfaceTrace || o.strategyProbe || o.positivityProbe || o.coupledTrialProbe) && (o.probeState.empty() || o.probeDirection.empty()))
throw std::invalid_argument("Focused probes require --probe-state and --probe-direction");
if (o.positivityCertificate && o.probeState.empty())
throw std::invalid_argument("--positivity-certificate requires --probe-state");
if (o.positivityCertificate && (o.probes || o.pressureProbe || o.shapeCurvature || o.surfaceTrace ||
o.strategyProbe || o.positivityProbe || o.preconditionerProbe || o.nonlinearStepProbe || !o.probeDirection.empty() || !o.replayCandidate.empty()))
o.strategyProbe || o.positivityProbe || o.preconditionerProbe || o.nonlinearStepProbe || o.coupledTrialProbe || !o.probeDirection.empty() || !o.replayCandidate.empty()))
throw std::invalid_argument("--positivity-certificate is a certificate-only mode; omit other probe options");
if (o.preconditionerProbe) {
experiment::plateau_diagnostics::ParsePreconditionerBenchmarkVariant(o.preconditionerVariant);
if (o.maxNewton != 0 || o.probes || o.pressureProbe || o.shapeCurvature || o.surfaceTrace ||
o.strategyProbe || o.positivityProbe || o.positivityCertificate || o.nonlinearStepProbe || !o.probeDirection.empty() ||
o.strategyProbe || o.positivityProbe || o.positivityCertificate || o.nonlinearStepProbe || o.coupledTrialProbe || !o.probeDirection.empty() ||
!o.replayCandidate.empty() || !o.captureIterations.empty() || o.strategy != "baseline")
throw std::invalid_argument("--preconditioner-probe requires --max-newton 0 and no nonlinear/probe strategies");
if (!std::isfinite(o.linearTolerance) || o.linearTolerance <= 0.0)
@@ -157,25 +176,37 @@ Options Parse(int argc, char **argv) {
}
if (o.nonlinearStepProbe) {
if (o.maxNewton != 0 || o.probes || o.pressureProbe || o.shapeCurvature || o.surfaceTrace ||
o.strategyProbe || o.positivityProbe || o.positivityCertificate || o.preconditionerProbe ||
o.strategyProbe || o.positivityProbe || o.positivityCertificate || o.preconditionerProbe || o.coupledTrialProbe ||
!o.probeDirection.empty() || !o.replayCandidate.empty() || !o.captureIterations.empty() ||
o.strategy != "baseline")
throw std::invalid_argument("--nonlinear-step-probe requires --max-newton 0 and no other probes or strategy; it compares raw and curvature paths itself");
if (!std::isfinite(o.linearTolerance) || o.linearTolerance <= 0.0)
throw std::invalid_argument("A nonlinear-step probe requires a finite positive --linear-tol");
}
if (o.coupledTrialProbe) {
if (o.maxNewton != 0 || o.probes || o.pressureProbe || o.shapeCurvature || o.surfaceTrace ||
o.strategyProbe || o.positivityProbe || o.positivityCertificate || o.preconditionerProbe ||
o.nonlinearStepProbe || !o.replayCandidate.empty() || !o.captureIterations.empty() ||
o.coupledAttempts != 1 || (o.strategy != "coupled" && o.strategy != "curvature-coupled"))
throw std::invalid_argument("--coupled-trial-probe requires --max-newton 0 and strategy coupled or curvature-coupled, without other probes");
if (!std::isfinite(o.linearTolerance) || o.linearTolerance <= 0.)
throw std::invalid_argument("A coupled trial probe requires a finite positive outer --linear-tol");
} else if (o.coupledSeedControl) {
throw std::invalid_argument("--coupled-seed-control requires --coupled-trial-probe");
}
if (!o.preconditionerProbe) {
if (o.preconditionerVariant != "independent" && o.preconditionerVariant != "gravity-schur" &&
o.preconditionerVariant != "stellar-ldu" && o.preconditionerVariant != "stellar-ldu-gravity-schur")
throw std::invalid_argument("Newton preconditioner variants are independent, gravity-schur, stellar-ldu, or stellar-ldu-gravity-schur");
if (o.preconditionerVariant != "independent") {
if (o.probes || o.pressureProbe || o.shapeCurvature || o.surfaceTrace || o.strategyProbe ||
o.positivityProbe || o.positivityCertificate || (!o.probeState.empty() && !o.nonlinearStepProbe) || !o.probeDirection.empty() ||
o.positivityProbe || o.positivityCertificate || (!o.probeState.empty() && !o.nonlinearStepProbe && !o.coupledTrialProbe) ||
(!o.probeDirection.empty() && !o.coupledTrialProbe) ||
!o.replayCandidate.empty())
throw std::invalid_argument("A custom Newton preconditioner cannot be combined with frozen or certificate probes");
if (o.strategy != "baseline" && o.strategy != "positivity" && o.strategy != "curvature" &&
o.strategy != "positivity-curvature")
throw std::invalid_argument("Custom Newton preconditioners support baseline, positivity, curvature, or positivity-curvature");
o.strategy != "positivity-curvature" && o.strategy != "coupled" && o.strategy != "curvature-coupled")
throw std::invalid_argument("Unsupported custom Newton preconditioner strategy");
if (!std::isfinite(o.linearTolerance) || o.linearTolerance <= 0.0 || o.schurCorrectionSweeps < 0 ||
!std::isfinite(o.schurRelaxation) || o.schurRelaxation <= 0.0)
throw std::invalid_argument("Invalid custom Newton preconditioner controls");
@@ -184,10 +215,14 @@ Options Parse(int argc, char **argv) {
if ((!o.probeDirection.empty() || !o.replayCandidate.empty()) && o.probeState.empty())
throw std::invalid_argument("A supplied direction or candidate requires --probe-state");
if (o.strategy != "baseline" && o.strategy != "positivity" && o.strategy != "curvature" &&
o.strategy != "reduced" && o.strategy != "positivity-curvature" && o.strategy != "positivity-reduced")
o.strategy != "reduced" && o.strategy != "positivity-curvature" && o.strategy != "positivity-reduced" &&
o.strategy != "coupled" && o.strategy != "curvature-coupled")
throw std::invalid_argument("Unknown numerical strategy");
if (!std::isfinite(o.gravityTolerance) || o.gravityTolerance <= 0.0 || o.gravityIterations < 1 || o.gravityRestart < 1)
throw std::invalid_argument("Invalid gravity solve controls");
if (!std::isfinite(o.coupledTolerance) || o.coupledTolerance <= 0. || o.coupledIterations < 1 ||
o.coupledRestart < 1 || !std::isfinite(o.coupledMaximumRatio) || o.coupledMaximumRatio <= 0. || o.coupledAttempts < 1)
throw std::invalid_argument("Invalid coupled correction controls");
if (std::filesystem::exists(o.output))
throw std::runtime_error("Output directory already exists");
std::filesystem::create_directories(o.output);
@@ -453,6 +488,7 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
<< "\nM=1\nR_reference=1\nxi1=" << scales.xi << "\nmu1=" << scales.mu << "\ncentral_phase=" << !o.noCentral
<< "\nrestart=" << o.restart << "\nprobe_restart=" << o.probeRestart
<< "\nfrozen_probe_backend=" << (o.preconditionerProbe ? "direct_mfem_fgmres_preconditioner_benchmark" :
o.coupledTrialProbe ? "supplied_outer_direction_verified_with_full_J_and_auxiliary_fgmres" :
o.nonlinearStepProbe ? "prepared_fgmres_cold_with_independent_true_gate" :
o.positivityCertificate ? "polynomial_certificate_no_linear_solve" :
o.probeDirection.empty() ? "direct_mfem_fgmres_cold" : "supplied_direction_no_linear_solve")
@@ -462,6 +498,13 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
<< "\ngravity_iterations=" << o.gravityIterations << "\ngravity_restart=" << o.gravityRestart
<< "\npreconditioner_probe=" << o.preconditionerProbe
<< "\nnonlinear_step_probe=" << o.nonlinearStepProbe
<< "\ncoupled_trial_probe=" << o.coupledTrialProbe
<< "\ncoupled_seed_control=" << o.coupledSeedControl
<< "\ncoupled_tolerance=" << o.coupledTolerance
<< "\ncoupled_iterations=" << o.coupledIterations
<< "\ncoupled_restart=" << o.coupledRestart
<< "\ncoupled_maximum_ratio=" << o.coupledMaximumRatio
<< "\ncoupled_attempts_per_iteration=" << o.coupledAttempts
<< "\npreconditioner_variant=" << o.preconditionerVariant
<< "\ncontext_preconditioner_variant=" << (o.preconditionerProbe ? "independent" : o.preconditionerVariant)
<< "\ncontext_gravity_schur_sweeps="
@@ -470,6 +513,32 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
<< "\nschur_correction_sweeps=" << o.schurCorrectionSweeps << "\nschur_relaxation=" << o.schurRelaxation
<< "\ncompiler=" << __VERSION__ << "\ncompiled=" << __DATE__ << ' ' << __TIME__ << '\n';
metadata.flush();
if (o.coupledTrialProbe) {
metadata << "coupled_trial_input=physical_snapshot_and_supplied_normalized_direction\n"
<< "coupled_trial_admissibility=sampled_enthalpy_without_repair\n"
<< "coupled_trial_alphas_are_independent=1\n"
<< "probe_state=" << o.probeState << "\nprobe_direction=" << o.probeDirection << '\n';
metadata.flush();
solver::detail::StellarEquilibriumContextDiagnostics::WithState(context, [&](auto &s, const fem::FEM &f) {
const auto physical = LoadVector(o.probeState, s.AcceptedPhysicalState().Size());
mfem::Vector base;
s.normalizedOperator->NormalizeState(physical, base);
const auto direction = LoadVector(o.probeDirection, base.Size());
experiment::plateau_diagnostics::ProbeCoupledTrial(s, base, direction, o.output, {
.outerLinearTolerance = o.linearTolerance,
.gravity = {.control = {.relativeTolerance = o.gravityTolerance, .absoluteTolerance = 1e-14,
.maximumIterations = o.gravityIterations},
.backend = {.restartLength = o.gravityRestart, .printLevel = -1}},
.coupled = {.control = {.relativeTolerance = o.coupledTolerance, .absoluteTolerance = 1e-14,
.maximumIterations = o.coupledIterations},
.backend = {.restartLength = o.coupledRestart, .printLevel = -1},
.maximumCorrectionRatio = o.coupledMaximumRatio},
.gravityPreconditionPath = o.strategy == "curvature-coupled",
.seedControl = o.coupledSeedControl});
experiment::plateau_diagnostics::MeasureCoupledTrialSnapshots(s, f, o.output, o.n, scales.K);
});
return 0;
}
if (o.nonlinearStepProbe) {
metadata << "nonlinear_step_input=" << (o.probeState.empty() ? "lane_emden_seed" : "physical_snapshot")
<< "\nnonlinear_step_paths=raw,curvature\nnonlinear_step_positivity_repair=0\n";
@@ -600,6 +669,12 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
blocklog << "iteration,stage,block,normalized_norm,physical_norm,max_abs_normalized\n";
auto triallog = File(o.output / "trials.csv");
triallog << "iteration,trial,alpha,disposition,source,F,min_jacobian\n";
auto coupledLog = File(o.output / "coupled_trials.csv");
coupledLog << "iteration,trial,alpha,diagnostics_available,status,attempts_total,kept_total,fallbacks_total,"
"iterations_total,preparations_total,restorations_total,solve_seconds_total,verification_seconds_total,"
"preparation_seconds_total,refresh_seconds_total,original_merit,corrected_merit,has_solve,"
"linear_status,linear_iterations,rhs_norm,reported_true_norm,verified_true_norm,threshold,verified_gate,"
"correction_norm,maximum_correction_norm\n";
auto stateConsistency = File(o.output / "trial_state_consistency.csv");
stateConsistency << "iteration,trial,normalized_physical_roundtrip_error,relative_error\n";
mfem::Vector captureBase;
@@ -643,6 +718,23 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
std::cout << "Newton " << e.iteration << " F=" << e.residualNorm << std::endl;
},
[&](const solver::nonlinear::AfterLineSearchTrial &e) {
coupledLog << e.iteration << ',' << e.trial << ',' << e.stepLength << ',' << (e.numericalStrategies != nullptr);
if (e.numericalStrategies) {
const auto &c = e.numericalStrategies->coupledTrial;
coupledLog << ',' << static_cast<int>(c.status) << ',' << c.attempts << ',' << c.kept << ','
<< c.fallbacks << ',' << c.iterations << ',' << c.preparations << ',' << c.restorations << ','
<< c.solveSeconds << ',' << c.verificationSeconds << ',' << c.preparationSeconds << ','
<< c.refreshSeconds << ',' << c.originalMerit << ',' << c.correctedMerit << ',' << c.currentSolve.has_value();
if (c.currentSolve) {
const auto &r = *c.currentSolve;
coupledLog << ',' << static_cast<int>(r.linearSolve.status) << ',' << r.linearSolve.iterations << ','
<< r.linearSolve.rightHandSideNorm << ',' << r.linearSolve.trueResidualNorm << ','
<< r.verifiedTrueResidualNorm << ',' << r.convergenceThreshold << ',' << r.verifiedGate << ','
<< r.correctionNorm << ',' << r.maximumCorrectionNorm;
} else coupledLog << ",-1,0,nan,nan,nan,nan,0,nan,nan";
} else coupledLog << ",-1,0,0,0,0,0,0,0,0,0,0,nan,nan,0,-1,0,nan,nan,nan,nan,0,nan,nan";
coupledLog << '\n';
coupledLog.flush();
if (e.candidatePhysicalState.size() != e.candidateNormalizedState.size() ||
e.candidateNormalizedState.size() != static_cast<size_t>(stateFactors.Size()))
throw std::runtime_error("Trial observer exposed incompatible state representations");
@@ -731,6 +823,12 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
.control = {.relativeTolerance = o.gravityTolerance, .absoluteTolerance = 1e-14,
.maximumIterations = o.gravityIterations},
.backend = {.restartLength = o.gravityRestart, .printLevel = -1}};
const nl::CoupledCurvatureCorrection coupled{
.correction = {.control = {.relativeTolerance = o.coupledTolerance, .absoluteTolerance = 1e-14,
.maximumIterations = o.coupledIterations},
.backend = {.restartLength = o.coupledRestart, .printLevel = -1},
.maximumCorrectionRatio = o.coupledMaximumRatio},
.maximumAttemptsPerIteration = o.coupledAttempts};
auto run = [&](auto numerics) {
auto solve = solver::make(context, nl::Newton(options, nl::NormalizedResidualMetric{}, numerics), observer);
return solve.evaluate();
@@ -738,6 +836,8 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
auto report = [&] {
if (o.strategy == "positivity") return run(nl::NewtonNumerics{nl::PolynomialEnthalpy{}, nl::MonolithicGravity{}});
if (o.strategy == "curvature") return run(nl::NewtonNumerics{nl::SampledEnthalpy{}, nl::GravityCurvatureCorrection{gravity}});
if (o.strategy == "coupled") return run(nl::NewtonNumerics{nl::SampledEnthalpy{}, nl::MonolithicGravity{}, coupled});
if (o.strategy == "curvature-coupled") return run(nl::NewtonNumerics{nl::SampledEnthalpy{}, nl::GravityCurvatureCorrection{gravity}, coupled});
if constexpr (FullDefaultStack)
if (o.strategy == "reduced") return run(nl::NewtonNumerics{nl::SampledEnthalpy{}, nl::EliminatedGravity{gravity}});
if (o.strategy == "positivity-curvature") return run(nl::NewtonNumerics{nl::PolynomialEnthalpy{}, nl::GravityCurvatureCorrection{gravity}});
@@ -770,6 +870,17 @@ int RunContext(Context &context, const Options &o, const Scales &scales) {
metric("initial_gravity_residual", strategyDiagnostics.initialGravityResidual);
metric("completed_initial_gravity_residual", strategyDiagnostics.completedInitialGravityResidual);
metric("last_lifted_relative_true_residual", strategyDiagnostics.lastLiftedRelativeTrueResidual);
const auto &coupledDiagnostics = strategyDiagnostics.coupledTrial;
metric("coupled_attempts", coupledDiagnostics.attempts);
metric("coupled_kept", coupledDiagnostics.kept);
metric("coupled_fallbacks", coupledDiagnostics.fallbacks);
metric("coupled_iterations", coupledDiagnostics.iterations);
metric("coupled_preparations", coupledDiagnostics.preparations);
metric("coupled_restorations", coupledDiagnostics.restorations);
metric("coupled_solve_seconds", coupledDiagnostics.solveSeconds);
metric("coupled_verification_seconds", coupledDiagnostics.verificationSeconds);
metric("coupled_preparation_seconds", coupledDiagnostics.preparationSeconds);
metric("coupled_refresh_seconds", coupledDiagnostics.refreshSeconds);
converged = report.converged();
metadata << "converged=" << converged << "\naccepted_steps=" << report.completedNonlinearIterations() << '\n';
if (!converged)

View File

@@ -107,6 +107,7 @@ export import :operators.stellar_equilibrium_system;
export import :solver.enthalpy_admissibility;
export import :solver.gravity_subsystem;
export import :solver.gravity_reduction;
export import :solver.coupled_trial_correction;
export import :solver.stellar_enthalpy_strategy;
export import :solver.newton_numerics;

View File

@@ -0,0 +1,457 @@
module;
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <concepts>
#include <cstdint>
#include <limits>
#include <memory>
#include <optional>
#include <stdexcept>
#include <type_traits>
#include <utility>
#include <mfem.hpp>
#include <mpi.h>
export module mean_field:solver.coupled_trial_correction;
export import :solver.linear_backend;
export namespace mean_field::solver {
struct CoupledTrialCorrectionOptions final {
LinearSolveControl control{.relativeTolerance = .03, .absoluteTolerance = 1.e-14, .maximumIterations = 160};
linear::FGMRESOptions backend{.restartLength = 160, .printLevel = -1};
double maximumCorrectionRatio{1.0};
void Validate() const {
control.Validate();
backend.Validate();
if (control.relativeTolerance <= 0.0 || !std::isfinite(maximumCorrectionRatio) ||
maximumCorrectionRatio <= 0.0)
throw std::invalid_argument("Coupled trial correction requires positive relative tolerance and norm cap.");
}
};
enum class CoupledTrialCorrectionStatus : std::uint8_t { admissible, linear_failure, norm_limit, non_finite };
struct CoupledTrialCorrectionReport final {
CoupledTrialCorrectionStatus status{CoupledTrialCorrectionStatus::linear_failure};
LinearSolveReport linearSolve{};
double verifiedTrueResidualNorm{std::numeric_limits<double>::quiet_NaN()};
double convergenceThreshold{std::numeric_limits<double>::quiet_NaN()};
double correctionNorm{std::numeric_limits<double>::quiet_NaN()};
double maximumCorrectionNorm{std::numeric_limits<double>::quiet_NaN()};
double verificationSeconds{0.0};
bool verifiedGate{false};
bool verificationApplied{false};
[[nodiscard]] bool Admissible() const noexcept { return status == CoupledTrialCorrectionStatus::admissible; }
};
enum class CoupledTrialAttemptStatus : std::uint8_t {
not_attempted, original_accepted, budget_exhausted, kept, linear_failure, norm_limit,
non_finite, inadmissible, not_improved
};
struct CoupledTrialDiagnostics final {
std::uint64_t attempts{0}, kept{0}, fallbacks{0}, iterations{0}, preparations{0}, restorations{0};
double solveSeconds{0.0}, verificationSeconds{0.0}, preparationSeconds{0.0}, refreshSeconds{0.0};
// These fields describe the current trial; BeginTrial clears them even
// when an earlier preparation rejection prevents another auxiliary solve.
CoupledTrialAttemptStatus status{CoupledTrialAttemptStatus::not_attempted};
std::optional<CoupledTrialCorrectionReport> currentSolve{};
double originalMerit{std::numeric_limits<double>::quiet_NaN()};
double correctedMerit{std::numeric_limits<double>::quiet_NaN()};
};
}
namespace mean_field::solver::detail {
template <typename Operation>
concept CoupledGenerationAvailable = requires(const Operation &operation) {
{ operation.GetPreparationGeneration() } -> std::convertible_to<std::uint64_t>;
} || requires(const Operation &operation) {
{ operation.GetProblem().GetPreparationGeneration() } -> std::convertible_to<std::uint64_t>;
};
template <CoupledGenerationAvailable Operation>
std::uint64_t CoupledGeneration(const Operation &operation) {
if constexpr (requires { operation.GetPreparationGeneration(); }) return operation.GetPreparationGeneration();
else return operation.GetProblem().GetPreparationGeneration();
}
inline bool CoupledFinite(const mfem::Vector &values) {
for (int index = 0; index < values.Size(); ++index)
if (!std::isfinite(values(index))) return false;
return true;
}
inline bool CoupledAll(bool condition, MPI_Comm communicator) {
int local = condition, global = 0;
if (MPI_Allreduce(&local, &global, 1, MPI_INT, MPI_MIN, communicator) != MPI_SUCCESS)
throw std::runtime_error("A coupled trial correction could not reduce its validity check.");
return global != 0;
}
inline double CoupledNorm(const mfem::Vector &values, MPI_Comm communicator) {
const double local = values * values;
double global = 0.0;
if (MPI_Allreduce(&local, &global, 1, MPI_DOUBLE, MPI_SUM, communicator) != MPI_SUCCESS)
throw std::runtime_error("A coupled trial correction could not reduce its norm.");
return std::sqrt(global);
}
inline void CoupledRequire(bool condition, MPI_Comm communicator, const char *message) {
if (!CoupledAll(condition, communicator)) throw std::invalid_argument(message);
}
inline CoupledTrialCorrectionOptions PrepareCoupledOptions(CoupledTrialCorrectionOptions options, MPI_Comm communicator) {
int initialized = 0, finalized = 0;
if (MPI_Initialized(&initialized) != MPI_SUCCESS || !initialized ||
MPI_Finalized(&finalized) != MPI_SUCCESS || finalized)
throw std::logic_error("A coupled trial correction requires initialized MPI that has not been finalized.");
if (communicator == MPI_COMM_NULL) throw std::invalid_argument("A coupled trial correction requires a communicator.");
bool valid = true;
try { options.Validate(); } catch (const std::invalid_argument &) { valid = false; }
CoupledRequire(valid, communicator, "Coupled trial correction controls are invalid on at least one rank.");
const std::array<int, 3> integers{options.control.maximumIterations, options.backend.restartLength, options.backend.printLevel};
const std::array<double, 3> reals{options.control.relativeTolerance, options.control.absoluteTolerance, options.maximumCorrectionRatio};
std::array<int, 3> imin{}, imax{};
std::array<double, 3> rmin{}, rmax{};
if (MPI_Allreduce(integers.data(), imin.data(), 3, MPI_INT, MPI_MIN, communicator) != MPI_SUCCESS ||
MPI_Allreduce(integers.data(), imax.data(), 3, MPI_INT, MPI_MAX, communicator) != MPI_SUCCESS ||
MPI_Allreduce(reals.data(), rmin.data(), 3, MPI_DOUBLE, MPI_MIN, communicator) != MPI_SUCCESS ||
MPI_Allreduce(reals.data(), rmax.data(), 3, MPI_DOUBLE, MPI_MAX, communicator) != MPI_SUCCESS)
throw std::runtime_error("Coupled trial correction controls could not be compared across ranks.");
if (imin != imax || rmin != rmax)
throw std::invalid_argument("Coupled trial correction controls must agree across ranks.");
return options;
}
template <typename Operation, typename Inverse>
CoupledTrialCorrectionOptions PrepareCoupledSystemOptions(CoupledTrialCorrectionOptions options,
const Operation &operation, const Inverse &inverse, MPI_Comm communicator) {
options = PrepareCoupledOptions(options, communicator);
CoupledRequire(operation.Height() >= 0 && operation.Width() == operation.Height() &&
inverse.Height() == operation.Width() && inverse.Width() == operation.Height(), communicator,
"A coupled correction requires compatible square operator and inverse dimensions.");
const long long localSize = operation.Height();
long long globalSize = 0;
if (MPI_Allreduce(&localSize, &globalSize, 1, MPI_LONG_LONG, MPI_SUM, communicator) != MPI_SUCCESS)
throw std::runtime_error("A coupled correction could not determine its global size.");
if (globalSize == 0) throw std::invalid_argument("A coupled correction requires a globally nonempty system.");
return options;
}
inline linear::FGMRES PrepareCoupledBackend(const CoupledTrialCorrectionOptions &options, MPI_Comm communicator) {
return linear::FGMRES(PrepareCoupledOptions(options, communicator).backend);
}
}
export namespace mean_field::solver {
template <typename Operation>
concept CoupledTrialOperation = std::derived_from<Operation, mfem::Operator> &&
detail::CoupledGenerationAvailable<Operation> && requires(const Operation &operation) {
{ operation.IsPrepared() } -> std::same_as<bool>;
};
template <typename Inverse>
concept CoupledTrialInverse = std::derived_from<Inverse, mfem::Solver> && requires(const Inverse &inverse) {
{ inverse.IsCurrent() } -> std::same_as<bool>;
};
// Capture fixes the affine target at the accepted linearization. Later
// SolveCorrection borrows the freshly prepared candidate Jacobian; neither
// operation owns or mutates a model state or the outer Newton direction.
template <CoupledTrialOperation Operation, CoupledTrialInverse Inverse,
LinearBackendConfiguration Backend = linear::FGMRES>
requires LinearBackendRuntimeAvailableFor<Backend, Operation, Inverse>
class PreparedCoupledTrialCorrection final {
public:
PreparedCoupledTrialCorrection(const Operation &operation, Inverse &inverse, MPI_Comm communicator,
CoupledTrialCorrectionOptions options = {})
requires std::same_as<Backend, linear::FGMRES>
: PreparedCoupledTrialCorrection(operation, inverse, communicator, options,
detail::PrepareCoupledBackend(options, communicator)) { }
PreparedCoupledTrialCorrection(const Operation &operation, Inverse &inverse, MPI_Comm communicator,
CoupledTrialCorrectionOptions options, Backend backend)
: m_operation(std::addressof(operation)), m_inverse(std::addressof(inverse)), m_communicator(communicator),
m_options(detail::PrepareCoupledSystemOptions(options, operation, inverse, communicator)),
m_backend(prepareLinearBackend(std::move(backend), operation, inverse, communicator)),
m_baseResidual(operation.Height()), m_baseAction(operation.Height()), m_rightHandSide(operation.Height()),
m_verifiedAction(operation.Height()), m_defect(operation.Height()) {
detail::CoupledRequire(IsCurrent(), communicator, "Coupled correction preparation requires a current operator and inverse.");
}
PreparedCoupledTrialCorrection(const PreparedCoupledTrialCorrection &) = delete;
PreparedCoupledTrialCorrection &operator=(const PreparedCoupledTrialCorrection &) = delete;
PreparedCoupledTrialCorrection(PreparedCoupledTrialCorrection &&) = delete;
PreparedCoupledTrialCorrection &operator=(PreparedCoupledTrialCorrection &&) = delete;
[[nodiscard]] bool IsCurrent() const {
return m_operation->IsPrepared() && m_inverse->IsCurrent() && m_backend.IsReady() &&
m_operation->Height() == m_operation->Width() &&
m_inverse->Height() == m_operation->Width() && m_inverse->Width() == m_operation->Height() &&
m_baseResidual.Size() == m_operation->Height() && m_baseAction.Size() == m_operation->Height();
}
[[nodiscard]] bool HasBase() const noexcept { return m_hasBase; }
[[nodiscard]] std::uint64_t GetBaseGeneration() const noexcept { return m_baseGeneration; }
[[nodiscard]] double GetBaseDirectionNorm() const noexcept { return m_directionNorm; }
[[nodiscard]] MPI_Comm GetCommunicator() const noexcept { return m_communicator; }
[[nodiscard]] const mfem::Vector &GetRightHandSide() const noexcept { return m_rightHandSide; }
[[nodiscard]] const mfem::Vector &GetVerifiedAction() const noexcept { return m_verifiedAction; }
[[nodiscard]] const mfem::Vector &GetVerifiedDefect() const noexcept { return m_defect; }
[[nodiscard]] const auto &GetLastReport() const noexcept { return m_lastReport; }
[[nodiscard]] const CoupledTrialCorrectionOptions &GetOptions() const noexcept { return m_options; }
void Invalidate() noexcept { m_hasBase = false; }
void CaptureBase(const mfem::Vector &residual, const mfem::Vector &direction) {
m_hasBase = false;
detail::CoupledRequire(IsCurrent() && residual.Size() == m_operation->Height() &&
direction.Size() == m_operation->Width() && detail::CoupledFinite(residual) && detail::CoupledFinite(direction),
m_communicator, "Coupled correction capture requires a current base and finite compatible vectors.");
m_baseResidual = residual;
m_operation->Mult(direction, m_baseAction);
m_directionNorm = detail::CoupledNorm(direction, m_communicator);
detail::CoupledRequire(detail::CoupledFinite(m_baseAction) && std::isfinite(m_directionNorm), m_communicator,
"The captured coupled correction tangent is nonfinite.");
m_baseGeneration = detail::CoupledGeneration(*m_operation);
m_hasBase = true;
}
void BuildLinearPrediction(double alpha, mfem::Vector &prediction) const {
detail::CoupledRequire(m_hasBase && prediction.Size() == m_baseResidual.Size(), m_communicator,
"A coupled prediction requires a captured base and compatible output.");
RequireAlpha(alpha);
prediction = m_baseResidual;
prediction.Add(alpha, m_baseAction);
detail::CoupledRequire(detail::CoupledFinite(prediction), m_communicator, "The coupled affine target is nonfinite.");
}
[[nodiscard]] CoupledTrialCorrectionReport SolveCorrection(double alpha, const mfem::Vector &trialResidual,
mfem::Vector &correction) {
detail::CoupledRequire(m_hasBase && IsCurrent() && detail::CoupledGeneration(*m_operation) >= m_baseGeneration &&
trialResidual.Size() == m_operation->Height() && correction.Size() == m_operation->Width(),
m_communicator, "Coupled correction requires a captured base, current candidate and compatible vectors.");
RequireAlpha(alpha);
CoupledTrialCorrectionReport report;
const auto finish = [&]() { m_lastReport = report; return report; };
correction = 0.0;
m_verifiedAction = std::numeric_limits<double>::quiet_NaN();
m_defect = std::numeric_limits<double>::quiet_NaN();
report.maximumCorrectionNorm = m_options.maximumCorrectionRatio * alpha * m_directionNorm;
BuildLinearPrediction(alpha, m_rightHandSide);
m_rightHandSide -= trialResidual;
if (!detail::CoupledAll(detail::CoupledFinite(m_rightHandSide) && std::isfinite(report.maximumCorrectionNorm), m_communicator)) {
report.status = CoupledTrialCorrectionStatus::non_finite;
return finish();
}
const double rhsNorm = detail::CoupledNorm(m_rightHandSide, m_communicator);
const double relativeThreshold = m_options.control.relativeTolerance * rhsNorm;
if (!std::isfinite(rhsNorm) || !std::isfinite(relativeThreshold)) {
report.status = CoupledTrialCorrectionStatus::non_finite;
return finish();
}
report.convergenceThreshold = m_options.control.ConvergenceThreshold(rhsNorm);
report.linearSolve = m_backend.Solve(m_rightHandSide, correction, m_options.control);
if (!detail::CoupledAll(correction.Size() == m_operation->Width() && detail::CoupledFinite(correction), m_communicator)) {
report.status = CoupledTrialCorrectionStatus::non_finite;
return finish();
}
const auto start = std::chrono::steady_clock::now();
m_operation->Mult(correction, m_verifiedAction);
report.verificationApplied = true;
m_defect = m_verifiedAction;
m_defect -= m_rightHandSide;
const bool finite = detail::CoupledAll(detail::CoupledFinite(m_defect), m_communicator);
report.verifiedTrueResidualNorm = finite ? detail::CoupledNorm(m_defect, m_communicator) :
std::numeric_limits<double>::quiet_NaN();
report.correctionNorm = detail::CoupledNorm(correction, m_communicator);
report.verificationSeconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
report.verifiedGate = std::isfinite(report.convergenceThreshold) && report.convergenceThreshold >= 0.0 &&
std::isfinite(report.verifiedTrueResidualNorm) &&
report.verifiedTrueResidualNorm <= report.convergenceThreshold;
const bool solved = detail::CoupledAll(report.linearSolve.Converged(), m_communicator);
if (!finite || !std::isfinite(report.verifiedTrueResidualNorm) || !std::isfinite(report.correctionNorm))
report.status = CoupledTrialCorrectionStatus::non_finite;
else if (!solved || !report.verifiedGate) report.status = CoupledTrialCorrectionStatus::linear_failure;
else if (report.correctionNorm > report.maximumCorrectionNorm) report.status = CoupledTrialCorrectionStatus::norm_limit;
else report.status = CoupledTrialCorrectionStatus::admissible;
return finish();
}
private:
void RequireAlpha(double alpha) const {
detail::CoupledRequire(std::isfinite(alpha) && alpha >= 0.0, m_communicator,
"Coupled correction requires a finite nonnegative step length.");
double minimum = 0.0, maximum = 0.0;
if (MPI_Allreduce(&alpha, &minimum, 1, MPI_DOUBLE, MPI_MIN, m_communicator) != MPI_SUCCESS ||
MPI_Allreduce(&alpha, &maximum, 1, MPI_DOUBLE, MPI_MAX, m_communicator) != MPI_SUCCESS)
throw std::runtime_error("A coupled correction could not compare step lengths.");
if (minimum != maximum) throw std::invalid_argument("Coupled correction step lengths must agree across ranks.");
}
const Operation *m_operation;
Inverse *m_inverse;
MPI_Comm m_communicator;
CoupledTrialCorrectionOptions m_options;
PreparedLinearBackendType<Backend, Operation, Inverse> m_backend;
mfem::Vector m_baseResidual, m_baseAction, m_rightHandSide, m_verifiedAction, m_defect;
double m_directionNorm{0.0};
std::uint64_t m_baseGeneration{0};
bool m_hasBase{false};
std::optional<CoupledTrialCorrectionReport> m_lastReport{};
};
}
// The state protocol is deliberately structural: production and focused probes
// execute the same transaction while their typed callbacks choose the metric
// and enthalpy policy. This helper never reads or writes accepted-state buffers.
export namespace mean_field::solver::detail {
template <typename State> class PreparedCoupledTrialTransaction final {
using Operation = std::remove_cvref_t<decltype(*std::declval<State>().normalizedOperator)>;
using Inverse = std::remove_cvref_t<decltype(*std::declval<State>().normalizedInverse)>;
using Clock = std::chrono::steady_clock;
public:
PreparedCoupledTrialTransaction(State &state, const CoupledTrialCorrectionOptions &options,
int maximumAttemptsPerIteration = 1)
: m_core(*state.normalizedOperator, *state.normalizedInverse, state.Problem().GetCommunicator(), options),
m_maximumAttempts(maximumAttemptsPerIteration), m_originalState(state.normalizedOperator->Width()),
m_originalResidual(state.normalizedOperator->Height()), m_originalPhysical(state.normalizedOperator->Width()),
m_correction(state.normalizedOperator->Width()) {
CoupledRequire(maximumAttemptsPerIteration > 0, m_core.GetCommunicator(), "A coupled trial attempt budget must be positive.");
int minimum = 0, maximum = 0;
if (MPI_Allreduce(&maximumAttemptsPerIteration, &minimum, 1, MPI_INT, MPI_MIN, m_core.GetCommunicator()) != MPI_SUCCESS ||
MPI_Allreduce(&maximumAttemptsPerIteration, &maximum, 1, MPI_INT, MPI_MAX, m_core.GetCommunicator()) != MPI_SUCCESS)
throw std::runtime_error("Coupled trial attempt budgets could not be compared.");
if (minimum != maximum) throw std::invalid_argument("Coupled trial attempt budgets must agree across ranks.");
}
void CaptureBase(const mfem::Vector &residual, const mfem::Vector &direction) {
m_core.CaptureBase(residual, direction);
m_attemptsThisIteration = 0;
}
void BeginTrial() noexcept {
m_diagnostics.status = CoupledTrialAttemptStatus::not_attempted;
m_diagnostics.currentSolve.reset();
m_diagnostics.originalMerit = m_diagnostics.correctedMerit = std::numeric_limits<double>::quiet_NaN();
}
[[nodiscard]] const CoupledTrialDiagnostics &GetDiagnostics() const noexcept { return m_diagnostics; }
[[nodiscard]] const auto &GetCore() const noexcept { return m_core; }
[[nodiscard]] const mfem::Vector &GetCorrection() const noexcept { return m_correction; }
template <typename Preparation, typename Metric, typename Evaluate, typename Accepts, typename Better, typename CheckState>
void TryImprove(State &state, double alpha, Preparation &preparation, Metric &metric,
Evaluate &&evaluate, Accepts &&accepts, Better &&better, CheckState &&checkState) {
const auto communicator = m_core.GetCommunicator();
CoupledRequire(preparation.has_value() && state.normalizedOperator->IsPrepared() &&
CoupledFinite(state.trialNormalizedState) && CoupledFinite(state.trialNormalizedResidual) &&
std::isfinite(metric.merit), communicator,
"Coupled correction requires a successfully prepared original trial.");
m_diagnostics.originalMerit = metric.merit;
if (CoupledAll(accepts(metric), communicator)) {
m_diagnostics.status = CoupledTrialAttemptStatus::original_accepted;
return;
}
if (m_attemptsThisIteration >= m_maximumAttempts) {
m_diagnostics.status = CoupledTrialAttemptStatus::budget_exhausted;
return;
}
m_originalState = state.trialNormalizedState;
m_originalResidual = state.trialNormalizedResidual;
m_originalPhysical = state.candidatePhysicalState;
Refresh(state);
++m_attemptsThisIteration;
++m_diagnostics.attempts;
m_diagnostics.currentSolve = m_core.SolveCorrection(alpha, m_originalResidual, m_correction);
const auto &report = *m_diagnostics.currentSolve;
m_diagnostics.iterations += static_cast<std::uint64_t>(report.linearSolve.iterations);
m_diagnostics.solveSeconds += report.linearSolve.solveSeconds;
m_diagnostics.verificationSeconds += report.verificationSeconds;
if (!report.Admissible()) {
++m_diagnostics.fallbacks;
switch (report.status) {
case CoupledTrialCorrectionStatus::linear_failure: m_diagnostics.status = CoupledTrialAttemptStatus::linear_failure; break;
case CoupledTrialCorrectionStatus::norm_limit: m_diagnostics.status = CoupledTrialAttemptStatus::norm_limit; break;
default: m_diagnostics.status = CoupledTrialAttemptStatus::non_finite; break;
}
// No candidate mutation has occurred; its prepared operator and
// freshly refreshed inverse still describe the original trial.
return;
}
const Metric originalMetric = metric;
bool restorationStarted = false;
auto restore = [&]() {
restorationStarted = true;
state.trialNormalizedState = m_originalState;
state.candidatePhysicalState = m_originalPhysical;
auto restored = Prepare(state);
++m_diagnostics.restorations;
if (!CoupledAll(restored.has_value() && CoupledFinite(state.trialNormalizedResidual), communicator))
throw std::runtime_error("A coupled trial fallback could not reprepare its original candidate.");
preparation = std::move(restored);
Refresh(state);
metric = evaluate(state.trialNormalizedResidual);
++m_diagnostics.fallbacks;
};
try {
state.trialNormalizedState += m_correction;
state.normalizedOperator->DenormalizeState(state.trialNormalizedState, state.candidatePhysicalState);
if (!CoupledAll(CoupledFinite(state.trialNormalizedState) && CoupledFinite(state.candidatePhysicalState), communicator)) {
m_diagnostics.status = CoupledTrialAttemptStatus::non_finite;
restore();
return;
}
if (!CoupledAll(checkState(state.trialNormalizedState), communicator)) {
m_diagnostics.status = CoupledTrialAttemptStatus::inadmissible;
restore();
return;
}
auto corrected = Prepare(state);
if (!CoupledAll(corrected.has_value(), communicator)) {
m_diagnostics.status = CoupledTrialAttemptStatus::inadmissible;
restore();
return;
}
if (!CoupledAll(CoupledFinite(state.trialNormalizedResidual), communicator)) {
m_diagnostics.status = CoupledTrialAttemptStatus::non_finite;
restore();
return;
}
const auto correctedMetric = evaluate(state.trialNormalizedResidual);
m_diagnostics.correctedMerit = correctedMetric.merit;
if (!CoupledAll(better(correctedMetric, originalMetric), communicator)) {
m_diagnostics.status = CoupledTrialAttemptStatus::not_improved;
restore();
return;
}
preparation = std::move(corrected);
metric = correctedMetric;
m_diagnostics.status = CoupledTrialAttemptStatus::kept;
++m_diagnostics.kept;
// This is a nonlinear candidate, not an affine gravity completion.
// Its fresh full residual is judged by the caller's normal metric.
// As for an ordinary prepared trial, the caller refreshes the
// inverse only if it commits or needs another auxiliary solve.
} catch (...) {
// Numerical rejection is handled above. For collectively
// propagated exceptions, restore the original prepared trial
// before rethrowing. A failed restoration itself propagates to
// the owning Newton context, which restores the accepted base.
if (!restorationStarted) restore();
throw;
}
}
private:
void Refresh(State &state) {
const auto start = Clock::now();
state.physicalInverse->Refresh();
m_diagnostics.refreshSeconds += std::chrono::duration<double>(Clock::now() - start).count();
}
auto Prepare(State &state) {
const auto start = Clock::now();
auto result = state.PrepareTrial();
m_diagnostics.preparationSeconds += std::chrono::duration<double>(Clock::now() - start).count();
++m_diagnostics.preparations;
return result;
}
PreparedCoupledTrialCorrection<Operation, Inverse> m_core;
int m_maximumAttempts, m_attemptsThisIteration{0};
mfem::Vector m_originalState, m_originalResidual, m_originalPhysical, m_correction;
CoupledTrialDiagnostics m_diagnostics{};
};
}

View File

@@ -268,6 +268,9 @@ export namespace mean_field::solver::nonlinear {
// A curved trial path cannot recover its Newton tangent by dividing
// the candidate displacement by alpha. Observers borrow it directly.
std::span<const mfem::real_t> normalizedDirection{};
// Auxiliary trial solves must remain distinct from the Newton report.
// Like the state spans, this diagnostic view is borrowed for the callback.
const NumericalStrategyDiagnostics *numericalStrategies{nullptr};
};
struct AfterIteration final {

View File

@@ -5,6 +5,7 @@ module;
#include <cstdint>
#include <limits>
#include <span>
#include <stdexcept>
#include <type_traits>
#include <utility>
@@ -12,6 +13,7 @@ export module mean_field:solver.newton_numerics;
export import :solver.enthalpy_admissibility;
export import :solver.linear_backend;
export import :solver.coupled_trial_correction;
export namespace mean_field::solver::nonlinear {
struct SampledEnthalpy final { };
@@ -39,6 +41,18 @@ export namespace mean_field::solver::nonlinear {
GravitySolveOptions gravity{};
};
struct UncorrectedTrial final { };
struct CoupledCurvatureCorrection final {
CoupledTrialCorrectionOptions correction{};
int maximumAttemptsPerIteration{1};
void Validate() const {
correction.Validate();
if (maximumAttemptsPerIteration <= 0)
throw std::invalid_argument("A coupled trial policy requires a positive attempt budget.");
}
};
template <typename Candidate>
concept EnthalpyNumerics = std::same_as<Candidate, SampledEnthalpy> ||
std::same_as<Candidate, PolynomialEnthalpy>;
@@ -48,23 +62,33 @@ export namespace mean_field::solver::nonlinear {
std::same_as<Candidate, GravityCurvatureCorrection> ||
std::same_as<Candidate, EliminatedGravity>;
template <typename Candidate>
concept TrialPathNumerics = std::same_as<Candidate, UncorrectedTrial> ||
std::same_as<Candidate, CoupledCurvatureCorrection>;
// Types select algorithms; tolerances and the mesh-dependent work sizes
// remain runtime data. Adding a strategy cannot silently change a model's
// equation set or impose an additional specification such as rho_c.
template <EnthalpyNumerics Enthalpy = SampledEnthalpy, GravityNumerics Gravity = MonolithicGravity>
template <EnthalpyNumerics Enthalpy = SampledEnthalpy, GravityNumerics Gravity = MonolithicGravity,
TrialPathNumerics Trial = UncorrectedTrial>
requires (!std::same_as<Gravity, EliminatedGravity> || std::same_as<Trial, UncorrectedTrial>)
struct NewtonNumerics final {
using EnthalpyType = Enthalpy;
using GravityType = Gravity;
using TrialType = Trial;
[[no_unique_address]] Enthalpy enthalpy{};
[[no_unique_address]] Gravity gravity{};
[[no_unique_address]] Trial trial{};
};
template <typename Enthalpy, typename Gravity>
NewtonNumerics(Enthalpy, Gravity) -> NewtonNumerics<Enthalpy, Gravity>;
template <typename Enthalpy, typename Gravity, typename Trial>
NewtonNumerics(Enthalpy, Gravity, Trial) -> NewtonNumerics<Enthalpy, Gravity, Trial>;
template <typename> struct IsNewtonNumerics : std::false_type { };
template <typename Enthalpy, typename Gravity>
struct IsNewtonNumerics<NewtonNumerics<Enthalpy, Gravity>> : std::true_type { };
template <typename Enthalpy, typename Gravity, typename Trial>
struct IsNewtonNumerics<NewtonNumerics<Enthalpy, Gravity, Trial>> : std::true_type { };
template <typename Candidate>
concept NewtonNumericalConfiguration = IsNewtonNumerics<std::remove_cvref_t<Candidate>>::value;
@@ -102,6 +126,9 @@ export namespace mean_field::solver::nonlinear {
double initialGravityResidual{0.0};
double completedInitialGravityResidual{0.0};
double lastLiftedRelativeTrueResidual{0.0};
// Gravity diagnostics refer to the preceding gravity-only preparation
// when this transaction subsequently keeps a fully coupled candidate.
CoupledTrialDiagnostics coupledTrial{};
};
}

View File

@@ -1359,6 +1359,7 @@ export namespace mean_field::solver {
const Clock::time_point lineSearchStart = Clock::now();
try {
for (int trial = 0; trial < options.backtracking.maximumTrials; ++trial) {
strategy.BeginTrial(diagnostics.numericalStrategies);
++lineSearchTrials;
++diagnostics.totalLineSearchTrials;
nonlinear::LineSearchTrialDisposition trialDisposition =
@@ -1497,6 +1498,41 @@ export namespace mean_field::solver {
metricSeconds = std::chrono::duration<double>(Clock::now() - metricStart).count();
timings.metricEvaluationSeconds += metricSeconds;
diagnostics.totalMetricEvaluationSeconds += metricSeconds;
const auto acceptsTrialMetric = [&](const nonlinear::MetricEvaluation &candidateMetric) {
if constexpr (Strategy::altersDirection) {
return candidateMetric.merit <= acceptedMetric.merit +
options.backtracking.sufficientDecrease * stepLength *
diagnostics.numericalStrategies.lastMeritDirectionalDerivative;
} else {
return AcceptStep(acceptedMetric, candidateMetric, stepLength, options.backtracking);
}
};
if constexpr (Strategy::correctsTrial) {
if (detail::AllStellarRanksSatisfy(ValidMetric(trialMetric), communicator)) {
const Clock::time_point correctionStart = Clock::now();
const double previousMetricSeconds = metricSeconds;
strategy.TryImproveTrial(state, stepLength, preparationResult, trialMetric,
diagnostics.numericalStrategies,
[&](const mfem::Vector &residual) {
const Clock::time_point start = Clock::now();
auto metric = EvaluateMetric(residual);
const double seconds = std::chrono::duration<double>(Clock::now() - start).count();
metricSeconds += seconds;
timings.metricEvaluationSeconds += seconds;
diagnostics.totalMetricEvaluationSeconds += seconds;
return metric;
}, acceptsTrialMetric,
[&](const nonlinear::MetricEvaluation &candidate, const nonlinear::MetricEvaluation &original) {
return ValidMetric(candidate) && candidate.merit < original.merit;
});
const double seconds = std::chrono::duration<double>(Clock::now() - correctionStart).count() -
(metricSeconds - previousMetricSeconds);
preparationSeconds += seconds;
timings.trialPreparationSeconds += seconds;
diagnostics.totalTrialPreparationSeconds += seconds;
minimumJacobianDeterminant = preparationResult->minimumJacobianDeterminant;
}
}
observedTrialMetric = trialMetric;
if (!detail::AllStellarRanksSatisfy(ValidMetric(trialMetric), communicator)) {
rejectionReason = StellarEquilibriumFailureReason::non_finite_residual;
@@ -1508,15 +1544,7 @@ export namespace mean_field::solver {
++diagnostics.nonFiniteLineSearchTrials;
} else if (
detail::AllStellarRanksSatisfy(
[&] {
if constexpr (Strategy::altersDirection) {
return trialMetric.merit <= acceptedMetric.merit +
options.backtracking.sufficientDecrease * stepLength *
diagnostics.numericalStrategies.lastMeritDirectionalDerivative;
} else {
return AcceptStep(acceptedMetric, trialMetric, stepLength, options.backtracking);
}
}(),
acceptsTrialMetric(trialMetric),
communicator
)
) {
@@ -1559,7 +1587,8 @@ export namespace mean_field::solver {
timings.observerSeconds += NotifyLineSearchTrial(
iteration, trial, stepLength, trialDisposition, rejectionSource, observedTrialMetric,
minimumJacobianDeterminant, preparationSeconds, metricSeconds, residualAvailable, state
minimumJacobianDeterminant, preparationSeconds, metricSeconds, residualAvailable, state,
diagnostics.numericalStrategies
);
if (accepted) {
@@ -1767,7 +1796,8 @@ export namespace mean_field::solver {
const double preparationSeconds,
const double metricSeconds,
const bool residualAvailable,
const State &state
const State &state,
const nonlinear::NumericalStrategyDiagnostics &numericalStrategies
) {
if constexpr (nonlinear::detail::ObservesLineSearchTrial<ObserverType>) {
const nonlinear::AfterLineSearchTrial event{
@@ -1786,7 +1816,8 @@ export namespace mean_field::solver {
.candidateNormalizedResidual = residualAvailable
? detail::ReadOnlySpan(state.trialNormalizedResidual)
: std::span<const mfem::real_t>{},
.normalizedDirection = detail::ReadOnlySpan(state.normalizedCorrection)
.normalizedDirection = detail::ReadOnlySpan(state.normalizedCorrection),
.numericalStrategies = std::addressof(numericalStrategies)
};
const Clock::time_point observerStart = Clock::now();
nonlinear::detail::InvokeAfterLineSearchTrial(m_observer, event);

View File

@@ -43,8 +43,9 @@ namespace mean_field::solver::detail {
void RequireCollectivelyIdenticalNumerics(const Recipe &recipe, const MPI_Comm communicator) {
using Enthalpy = typename Recipe::EnthalpyType;
using Gravity = typename Recipe::GravityType;
std::array<int, 8> integers{};
std::array<double, 2> reals{};
using Trial = typename Recipe::TrialType;
std::array<int, 13> integers{};
std::array<double, 5> reals{};
integers[0] = std::same_as<Enthalpy, nonlinear::PolynomialEnthalpy>;
integers[1] = std::same_as<Gravity, nonlinear::GravityCurvatureCorrection> ? 1 :
std::same_as<Gravity, nonlinear::EliminatedGravity> ? 2 : 0;
@@ -57,15 +58,27 @@ namespace mean_field::solver::detail {
integers[4] = options.control.maximumIterations;
integers[5] = options.backend.restartLength;
integers[6] = options.backend.printLevel;
reals = {options.control.relativeTolerance, options.control.absoluteTolerance};
reals[0] = options.control.relativeTolerance;
reals[1] = options.control.absoluteTolerance;
}
integers[7] = std::isfinite(reals[0]) && std::isfinite(reals[1]);
std::array<int, 8> minimumIntegers{}, maximumIntegers{};
std::array<double, 2> minimumReals{}, maximumReals{};
if (MPI_Allreduce(integers.data(), minimumIntegers.data(), 8, MPI_INT, MPI_MIN, communicator) != MPI_SUCCESS ||
MPI_Allreduce(integers.data(), maximumIntegers.data(), 8, MPI_INT, MPI_MAX, communicator) != MPI_SUCCESS ||
MPI_Allreduce(reals.data(), minimumReals.data(), 2, MPI_DOUBLE, MPI_MIN, communicator) != MPI_SUCCESS ||
MPI_Allreduce(reals.data(), maximumReals.data(), 2, MPI_DOUBLE, MPI_MAX, communicator) != MPI_SUCCESS) {
integers[8] = std::same_as<Trial, nonlinear::CoupledCurvatureCorrection>;
if constexpr (std::same_as<Trial, nonlinear::CoupledCurvatureCorrection>) {
const auto &options = recipe.trial.correction;
integers[9] = options.control.maximumIterations;
integers[10] = options.backend.restartLength;
integers[11] = options.backend.printLevel;
integers[12] = recipe.trial.maximumAttemptsPerIteration;
reals[2] = options.control.relativeTolerance;
reals[3] = options.control.absoluteTolerance;
reals[4] = options.maximumCorrectionRatio;
}
integers[7] = std::all_of(reals.begin(), reals.end(), [](double value) { return std::isfinite(value); });
std::array<int, 13> minimumIntegers{}, maximumIntegers{};
std::array<double, 5> minimumReals{}, maximumReals{};
if (MPI_Allreduce(integers.data(), minimumIntegers.data(), 13, MPI_INT, MPI_MIN, communicator) != MPI_SUCCESS ||
MPI_Allreduce(integers.data(), maximumIntegers.data(), 13, MPI_INT, MPI_MAX, communicator) != MPI_SUCCESS ||
MPI_Allreduce(reals.data(), minimumReals.data(), 5, MPI_DOUBLE, MPI_MIN, communicator) != MPI_SUCCESS ||
MPI_Allreduce(reals.data(), maximumReals.data(), 5, MPI_DOUBLE, MPI_MAX, communicator) != MPI_SUCCESS) {
throw std::runtime_error("Newton could not compare numerical strategies across ranks.");
}
if (minimumIntegers != maximumIntegers || minimumReals != maximumReals ||
@@ -73,6 +86,7 @@ namespace mean_field::solver::detail {
throw std::invalid_argument("Newton numerical strategies and controls must agree on every rank.");
}
if constexpr (!std::same_as<Gravity, nonlinear::MonolithicGravity>) { recipe.gravity.gravity.Validate(); }
if constexpr (std::same_as<Trial, nonlinear::CoupledCurvatureCorrection>) { recipe.trial.Validate(); }
}
template <typename State, typename Policy> class PreparedGravityStrategy;
@@ -91,7 +105,9 @@ namespace mean_field::solver::detail {
void CopyDiagnostics(nonlinear::NumericalStrategyDiagnostics &) const { }
};
template <typename State>
// Export the prepared workspace so bounded diagnostics can exercise this
// same gravity completion and arithmetic gate without reproducing them.
export template <typename State>
class StellarGravityWorkspace {
public:
using Operation = std::remove_cvref_t<decltype(*std::declval<State>().normalizedOperator)>;
@@ -406,15 +422,34 @@ namespace mean_field::solver::detail {
PreparedStellarEnthalpyStrategy<State> m_prepared;
};
template <typename State, typename Policy> struct PreparedTrialPath;
template <typename State> struct PreparedTrialPath<State, nonlinear::UncorrectedTrial> {
PreparedTrialPath(State &, const nonlinear::UncorrectedTrial &) { }
};
template <typename State> struct PreparedTrialPath<State, nonlinear::CoupledCurvatureCorrection> {
PreparedTrialPath(State &state, const nonlinear::CoupledCurvatureCorrection &policy)
: transaction(state, policy.correction, policy.maximumAttemptsPerIteration) { }
PreparedCoupledTrialTransaction<State> transaction;
};
template <typename State, nonlinear::NewtonNumericalConfiguration Recipe>
class PreparedNewtonStrategy final : public NewtonStrategyStorage {
public:
using Enthalpy = typename Recipe::EnthalpyType;
using Gravity = typename Recipe::GravityType;
using Trial = typename Recipe::TrialType;
static constexpr bool altersDirection = std::same_as<Enthalpy, nonlinear::PolynomialEnthalpy>;
static constexpr bool correctsTrial = std::same_as<Trial, nonlinear::CoupledCurvatureCorrection>;
PreparedNewtonStrategy(State &state, const Recipe &recipe)
: m_enthalpy(state, recipe.enthalpy), m_gravity(state, recipe.gravity) { }
: m_enthalpy(state, recipe.enthalpy), m_gravity(state, recipe.gravity), m_trial(state, recipe.trial) { }
void BeginTrial(nonlinear::NumericalStrategyDiagnostics &diagnostics) {
if constexpr (correctsTrial) {
m_trial.transaction.BeginTrial();
diagnostics.coupledTrial = m_trial.transaction.GetDiagnostics();
}
}
bool CheckState(const mfem::Vector &state, nonlinear::NumericalStrategyDiagnostics &diagnostics) {
return m_enthalpy.Check(state, diagnostics);
@@ -451,6 +486,12 @@ namespace mean_field::solver::detail {
if (!std::isfinite(diagnostics.lastMeritDirectionalDerivative) ||
diagnostics.lastMeritDirectionalDerivative >= 0.0) { return false; }
}
if constexpr (correctsTrial) {
// Repair has finished and the accepted Jacobian is still current.
// Every later trial, including a gravity-corrected one, uses this
// same full affine target and the original-direction norm cap.
m_trial.transaction.CaptureBase(state.acceptedNormalizedResidual, state.normalizedCorrection);
}
return true;
}
auto PrepareTrial(State &state, const double alpha, nonlinear::NumericalStrategyDiagnostics &diagnostics) {
@@ -460,9 +501,19 @@ namespace mean_field::solver::detail {
}
bool TrialSolveSucceeded() const noexcept { return m_gravity.TrialSolveSucceeded(); }
bool DirectionSolveFailed() const noexcept { return m_directionSolveFailed; }
template <typename Preparation, typename Metric, typename Evaluate, typename Accepts, typename Better>
void TryImproveTrial(State &state, double alpha, Preparation &preparation, Metric &metric,
nonlinear::NumericalStrategyDiagnostics &diagnostics,
Evaluate &&evaluate, Accepts &&accepts, Better &&better) requires(correctsTrial) {
m_trial.transaction.TryImprove(state, alpha, preparation, metric,
std::forward<Evaluate>(evaluate), std::forward<Accepts>(accepts), std::forward<Better>(better),
[&](const mfem::Vector &candidate) { return m_enthalpy.Check(candidate, diagnostics); });
diagnostics.coupledTrial = m_trial.transaction.GetDiagnostics();
}
private:
bool m_directionSolveFailed{false};
PreparedEnthalpyStrategy<State, Enthalpy> m_enthalpy;
PreparedGravityStrategy<State, Gravity> m_gravity;
[[no_unique_address]] PreparedTrialPath<State, Trial> m_trial;
};
}

View File

@@ -0,0 +1,791 @@
#include <algorithm>
#include <cmath>
#include <concepts>
#include <cstdint>
#include <expected>
#include <limits>
#include <memory>
#include <stdexcept>
#include <utility>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <mfem.hpp>
#include <mpi.h>
import mean_field;
namespace coupled_trial_correction_test {
using namespace mean_field;
using namespace mean_field::solver;
class DenseOperation final : public mfem::Operator {
public:
explicit DenseOperation(mfem::DenseMatrix matrix)
: mfem::Operator(matrix.Height(), matrix.Width()), m_matrix(std::move(matrix)) { }
void Mult(const mfem::Vector &input, mfem::Vector &output) const override {
if (!prepared) throw std::logic_error("The test operator is not prepared.");
output.SetSize(Height());
m_matrix.Mult(input, output);
++applications;
}
void DenormalizeState(const mfem::Vector &normalized, mfem::Vector &physical) const { physical = normalized; }
void Prepare(mfem::DenseMatrix matrix) {
m_matrix = std::move(matrix);
prepared = true;
++generation;
}
[[nodiscard]] bool IsPrepared() const noexcept { return prepared; }
[[nodiscard]] std::uint64_t GetPreparationGeneration() const noexcept { return generation; }
[[nodiscard]] const DenseOperation &GetProblem() const noexcept { return *this; }
[[nodiscard]] const mfem::DenseMatrix &Matrix() const noexcept { return m_matrix; }
bool prepared{true};
mutable int applications{0};
std::uint64_t generation{1};
private:
mfem::DenseMatrix m_matrix;
};
class ExactInverse final : public mfem::Solver {
public:
static constexpr auto applicationContract = preconditioning::ApplicationContract::stationary_linear;
explicit ExactInverse(const DenseOperation &operation)
: mfem::Solver(operation.Width()), m_operation(operation) { Refresh(); }
void SetOperator(const mfem::Operator &) override { }
void Mult(const mfem::Vector &input, mfem::Vector &output) const override {
if (!IsCurrent()) throw std::logic_error("The test inverse is stale.");
output.SetSize(Height());
if (Height() > 0) {
mfem::DenseMatrixInverse inverse(m_operation.Matrix());
inverse.Mult(input, output);
if (Height() > 1) output(1) += extraSecondComponent * input(0);
}
++applications;
}
void Refresh() { m_generation = m_operation.GetPreparationGeneration(); }
[[nodiscard]] bool IsCurrent() const noexcept {
return m_operation.IsPrepared() && m_generation == m_operation.GetPreparationGeneration();
}
mutable int applications{0};
double extraSecondComponent{0.0};
private:
const DenseOperation &m_operation;
std::uint64_t m_generation{};
};
[[nodiscard]] mfem::DenseMatrix identity(const int size) {
mfem::DenseMatrix matrix(size);
matrix = 0.0;
for (int i = 0; i < size; ++i) matrix(i, i) = 1.0;
return matrix;
}
[[nodiscard]] mfem::Vector coupledResidual(const mfem::Vector &state) {
mfem::Vector result(3);
result(0) = 1.0 + state(0) + 10.0 * state(1) * state(2);
result(1) = 1.0 + state(1);
result(2) = 1.0 + state(2);
return result;
}
[[nodiscard]] mfem::DenseMatrix coupledJacobian(const mfem::Vector &state) {
auto matrix = identity(3);
matrix(0, 1) = 10.0 * state(2);
matrix(0, 2) = 10.0 * state(1);
return matrix;
}
[[nodiscard]] CoupledTrialCorrectionOptions exactOptions() {
return {.control = {.relativeTolerance = 1.e-12, .absoluteTolerance = 1.e-14, .maximumIterations = 6},
.backend = {.restartLength = 4, .printLevel = -1}, .maximumCorrectionRatio = 4.0};
}
enum class BackendBehavior { exact, inaccurate, failed_status, non_finite };
struct BackendProbe {
BackendBehavior behavior{BackendBehavior::exact};
int calls{0};
bool allInitialGuessesZero{true};
};
struct TestBackend final : LinearBackendConfigurationTag {
static constexpr auto supportedPreconditionerContract = preconditioning::ApplicationContract::flexible;
std::shared_ptr<BackendProbe> probe;
explicit TestBackend(std::shared_ptr<BackendProbe> value) : probe(std::move(value)) { }
};
template <typename Operation, typename Inverse>
class PreparedTestBackend final {
public:
PreparedTestBackend(const Operation &operation, Inverse &inverse, const MPI_Comm communicator,
std::shared_ptr<BackendProbe> probe)
: m_operation(operation), m_inverse(inverse), m_communicator(communicator), m_probe(std::move(probe)) { }
[[nodiscard]] const Operation &GetOperator() const noexcept { return m_operation; }
[[nodiscard]] const Inverse &GetPreconditioner() const noexcept { return m_inverse; }
[[nodiscard]] MPI_Comm GetCommunicator() const noexcept { return m_communicator; }
[[nodiscard]] bool IsReady() const noexcept { return m_communicator != MPI_COMM_NULL; }
[[nodiscard]] int RightHandSideSize() const noexcept { return m_operation.Height(); }
[[nodiscard]] int CorrectionSize() const noexcept { return m_operation.Width(); }
[[nodiscard]] LinearSolveReport Solve(const mfem::Vector &rhs, mfem::Vector &correction,
const LinearSolveControl &control) {
++m_probe->calls;
for (int i = 0; i < correction.Size(); ++i)
m_probe->allInitialGuessesZero &= correction(i) == 0.0;
m_inverse.Mult(rhs, correction);
if (m_probe->behavior == BackendBehavior::inaccurate) correction *= 0.5;
if (m_probe->behavior == BackendBehavior::non_finite && correction.Size() > 0)
correction(0) = std::numeric_limits<double>::quiet_NaN();
double local = rhs * rhs, global = 0.0;
if (MPI_Allreduce(&local, &global, 1, MPI_DOUBLE, MPI_SUM, m_communicator) != MPI_SUCCESS)
throw std::runtime_error("The test backend could not reduce its norm.");
// A backend claiming exact convergence is deliberately insufficient
// evidence: the correction owner must verify the actual J action.
return {.status = m_probe->behavior == BackendBehavior::failed_status ?
LinearSolveStatus::maximum_iterations : LinearSolveStatus::converged,
.control = control, .iterations = 1, .rightHandSideNorm = std::sqrt(global),
.trueResidualNorm = 0.0, .relativeTrueResidualNorm = 0.0};
}
private:
const Operation &m_operation;
Inverse &m_inverse;
MPI_Comm m_communicator;
std::shared_ptr<BackendProbe> m_probe;
};
template <typename Operation, typename Inverse>
[[nodiscard]] auto prepareLinearBackend(TestBackend recipe, const Operation &operation, Inverse &inverse,
const MPI_Comm communicator) {
return PreparedTestBackend<Operation, Inverse>(operation, inverse, communicator, std::move(recipe.probe));
}
static_assert(CoupledTrialOperation<DenseOperation>);
static_assert(CoupledTrialInverse<ExactInverse>);
static_assert(!CoupledTrialOperation<mfem::Operator>);
static_assert(!CoupledTrialInverse<mfem::Solver>);
static_assert(LinearBackendRuntimeAvailableFor<TestBackend, DenseOperation, ExactInverse>);
using ScriptedCorrection = PreparedCoupledTrialCorrection<DenseOperation, ExactInverse, TestBackend>;
} // namespace coupled_trial_correction_test
TEST_CASE("A coupled trial correction preserves the tangent and removes mixed nonlinear curvature",
"[solver][coupled_trial_correction]") {
using namespace coupled_trial_correction_test;
using Catch::Approx;
mfem::Vector base(3), direction(3), correction(3), prediction(3);
base = 0.0;
direction = -1.0;
const auto baseResidual = coupledResidual(base);
DenseOperation operation(coupledJacobian(base));
ExactInverse inverse(operation);
PreparedCoupledTrialCorrection prepared(operation, inverse, MPI_COMM_SELF, exactOptions());
prepared.CaptureBase(baseResidual, direction);
const auto capturedGeneration = prepared.GetBaseGeneration();
CHECK(prepared.GetBaseDirectionNorm() == Approx(std::sqrt(3.0)));
double previousScaledDisplacement = 0.0;
for (const double alpha : {.5, .25, .125, .0625}) {
CAPTURE(alpha);
mfem::Vector trial(direction);
trial *= alpha;
const auto rawResidual = coupledResidual(trial);
operation.Prepare(coupledJacobian(trial));
inverse.Refresh();
correction = 17.0;
const auto report = prepared.SolveCorrection(alpha, rawResidual, correction);
REQUIRE(report.Admissible());
REQUIRE(report.verifiedGate);
CHECK(report.linearSolve.Converged());
CHECK(report.linearSolve.iterations <= exactOptions().control.maximumIterations);
CHECK(report.verifiedTrueResidualNorm <= report.convergenceThreshold);
CHECK(correction(0) == Approx(-10.0 * alpha * alpha).margin(1.e-13));
CHECK(std::abs(correction(1)) < 1.e-13);
CHECK(std::abs(correction(2)) < 1.e-13);
trial += correction;
const auto correctedResidual = coupledResidual(trial);
prepared.BuildLinearPrediction(alpha, prediction);
mfem::Vector remainder(correctedResidual);
remainder -= prediction;
CHECK(remainder.Norml2() < 1.e-12);
CHECK(correctedResidual.Norml2() < rawResidual.Norml2());
if (alpha == .5) {
CHECK(rawResidual.Norml2() > baseResidual.Norml2());
CHECK(correctedResidual.Norml2() == Approx(.5 * baseResidual.Norml2()));
}
trial /= alpha;
trial -= direction;
const double scaledDisplacement = trial.Norml2();
CHECK(scaledDisplacement == Approx(10.0 * alpha));
if (previousScaledDisplacement > 0.0)
CHECK(scaledDisplacement == Approx(previousScaledDisplacement / 2.0));
previousScaledDisplacement = scaledDisplacement;
CHECK(prepared.GetBaseGeneration() == capturedGeneration);
for (int i = 0; i < 3; ++i) CHECK(direction(i) == -1.0);
}
}
TEST_CASE("Coupled correction captures the base action while solving with the current trial Jacobian",
"[solver][coupled_trial_correction][lifetime]") {
using namespace coupled_trial_correction_test;
using Catch::Approx;
DenseOperation operation(identity(2));
ExactInverse inverse(operation);
auto probe = std::make_shared<BackendProbe>();
ScriptedCorrection prepared(operation, inverse, MPI_COMM_SELF, exactOptions(), TestBackend(probe));
mfem::Vector f(2), p(2), prediction(2), correction(2), trialResidual(2);
f = 1.0;
p = -1.0;
prepared.CaptureBase(f, p);
const auto generation = prepared.GetBaseGeneration();
auto matrix = identity(2);
matrix(0, 0) = 2.0;
matrix(1, 1) = 4.0;
operation.Prepare(matrix);
const int applications = operation.applications;
prepared.BuildLinearPrediction(.5, prediction);
CHECK(operation.applications == applications);
CHECK(prediction(0) == .5);
CHECK(prediction(1) == .5);
trialResidual = prediction;
trialResidual(0) += .2;
trialResidual(1) += .4;
REQUIRE_THROWS_AS(prepared.SolveCorrection(.5, trialResidual, correction), std::invalid_argument);
inverse.Refresh();
const auto report = prepared.SolveCorrection(.5, trialResidual, correction);
REQUIRE(report.Admissible());
CHECK(correction(0) == Approx(-.1));
CHECK(correction(1) == Approx(-.1));
CHECK(prepared.GetBaseGeneration() == generation);
CHECK(probe->allInitialGuessesZero);
prepared.Invalidate();
CHECK_FALSE(prepared.HasBase());
CHECK_THROWS_AS(prepared.BuildLinearPrediction(.5, prediction), std::invalid_argument);
}
TEST_CASE("Coupled correction independently rejects a false auxiliary convergence report",
"[solver][coupled_trial_correction][failure]") {
using namespace coupled_trial_correction_test;
DenseOperation operation(identity(2));
ExactInverse inverse(operation);
auto probe = std::make_shared<BackendProbe>();
ScriptedCorrection prepared(operation, inverse, MPI_COMM_SELF, exactOptions(), TestBackend(probe));
mfem::Vector f(2), p(2), trial(2), correction(2);
f = 1.0;
p = -1.0;
trial = .75;
prepared.CaptureBase(f, p);
SECTION("The returned correction fails the independently computed true gate") {
probe->behavior = BackendBehavior::inaccurate;
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK_FALSE(report.Admissible());
CHECK(report.status == CoupledTrialCorrectionStatus::linear_failure);
CHECK(report.linearSolve.Converged());
CHECK_FALSE(report.verifiedGate);
CHECK(report.verifiedTrueResidualNorm > report.convergenceThreshold);
}
SECTION("An accurate correction does not erase backend nonconvergence") {
probe->behavior = BackendBehavior::failed_status;
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK_FALSE(report.Admissible());
CHECK(report.status == CoupledTrialCorrectionStatus::linear_failure);
CHECK_FALSE(report.linearSolve.Converged());
}
SECTION("A nonfinite returned correction is never admissible") {
probe->behavior = BackendBehavior::non_finite;
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK_FALSE(report.Admissible());
CHECK(report.status == CoupledTrialCorrectionStatus::non_finite);
CHECK_FALSE(report.verifiedGate);
}
CHECK(probe->calls == 1);
CHECK(probe->allInitialGuessesZero);
for (int i = 0; i < 2; ++i) {
CHECK(f(i) == 1.0);
CHECK(p(i) == -1.0);
CHECK(trial(i) == .75);
}
}
TEST_CASE("Coupled correction bounds work and correction size without changing the Newton direction",
"[solver][coupled_trial_correction][safeguard]") {
using namespace coupled_trial_correction_test;
using Catch::Approx;
DenseOperation operation(identity(2));
ExactInverse inverse(operation);
auto options = exactOptions();
options.maximumCorrectionRatio = .1;
auto probe = std::make_shared<BackendProbe>();
ScriptedCorrection prepared(operation, inverse, MPI_COMM_SELF, options, TestBackend(probe));
mfem::Vector f(2), p(2), trial(2), correction(2);
f = 1.0;
p = -1.0;
trial = .9;
prepared.CaptureBase(f, p);
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK(report.status == CoupledTrialCorrectionStatus::norm_limit);
CHECK_FALSE(report.Admissible());
CHECK(report.verifiedGate);
CHECK(report.maximumCorrectionNorm == Approx(.1 * .5 * std::sqrt(2.0)));
CHECK(report.correctionNorm > report.maximumCorrectionNorm);
CHECK(report.linearSolve.iterations <= options.control.maximumIterations);
CHECK(probe->calls == 1);
CHECK(probe->allInitialGuessesZero);
CHECK(p(0) == -1.0);
CHECK(p(1) == -1.0);
}
TEST_CASE("Zero coupled remainder does not manufacture an auxiliary displacement",
"[solver][coupled_trial_correction][safeguard]") {
using namespace coupled_trial_correction_test;
DenseOperation operation(identity(2));
ExactInverse inverse(operation);
auto probe = std::make_shared<BackendProbe>();
ScriptedCorrection prepared(operation, inverse, MPI_COMM_SELF, exactOptions(), TestBackend(probe));
mfem::Vector f(2), p(2), trial(2), correction(2);
f = 1.0;
p = -1.0;
trial = .5;
correction = 900.0;
prepared.CaptureBase(f, p);
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK(report.Admissible());
CHECK(report.verifiedGate);
CHECK(correction.Norml2() == 0.0);
CHECK(report.correctionNorm == 0.0);
CHECK(report.verifiedTrueResidualNorm == 0.0);
CHECK(probe->calls <= 1);
CHECK(probe->allInitialGuessesZero);
}
TEST_CASE("Coupled correction rejects invalid controls before numerical work",
"[solver][coupled_trial_correction][configuration]") {
using namespace coupled_trial_correction_test;
auto options = exactOptions();
SECTION("A nonfinite cap cannot define a bounded correction") {
options.maximumCorrectionRatio = std::numeric_limits<double>::infinity();
}
SECTION("A negative cap has no feasible correction size") { options.maximumCorrectionRatio = -1.0; }
SECTION("A zero cap cannot enable the correction strategy") { options.maximumCorrectionRatio = 0.0; }
SECTION("The auxiliary iteration budget must be finite and positive") { options.control.maximumIterations = 0; }
SECTION("The auxiliary tolerance cannot be NaN") {
options.control.relativeTolerance = std::numeric_limits<double>::quiet_NaN();
}
CHECK_THROWS_AS(options.Validate(), std::invalid_argument);
}
TEST_CASE("Coupled correction decisions remain collective with an empty peer rank",
"[solver][coupled_trial_correction][mpi]") {
using namespace coupled_trial_correction_test;
using Catch::Approx;
int rank = 0;
REQUIRE(MPI_Comm_rank(MPI_COMM_WORLD, &rank) == MPI_SUCCESS);
// Only one rank owns this tiny synthetic problem. A distributed stop must
// not rely on every rank having a local entry to inspect.
const int localSize = rank == 0 ? 2 : 0;
DenseOperation operation(identity(localSize));
ExactInverse inverse(operation);
auto probe = std::make_shared<BackendProbe>();
ScriptedCorrection prepared(operation, inverse, MPI_COMM_WORLD, exactOptions(), TestBackend(probe));
mfem::Vector f(localSize), p(localSize), trial(localSize), correction(localSize);
f = 1.0;
p = -1.0;
trial = .75;
prepared.CaptureBase(f, p);
SECTION("Finite global norms include the owning rank exactly once") {
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK(report.Admissible());
CHECK(report.correctionNorm == Approx(std::sqrt(2.0) * .25));
CHECK(probe->calls == 1);
}
SECTION("One nonfinite trial prevents the backend call on every rank") {
if (rank == 0) trial(0) = std::numeric_limits<double>::quiet_NaN();
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK(report.status == CoupledTrialCorrectionStatus::non_finite);
CHECK_FALSE(report.Admissible());
CHECK(probe->calls == 0);
}
SECTION("One nonfinite correction fails the independently checked global gate") {
probe->behavior = BackendBehavior::non_finite;
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK(report.status == CoupledTrialCorrectionStatus::non_finite);
CHECK_FALSE(report.Admissible());
CHECK_FALSE(report.verifiedGate);
CHECK(probe->calls == 1);
}
}
namespace coupled_trial_correction_test {
struct ToyProblem final {
MPI_Comm communicator{MPI_COMM_SELF};
[[nodiscard]] MPI_Comm GetCommunicator() const noexcept { return communicator; }
};
struct ToyState final {
DenseOperation operation{identity(3)};
ExactInverse inverse{operation};
DenseOperation *normalizedOperator{&operation};
ExactInverse *normalizedInverse{&inverse};
ExactInverse *physicalInverse{&inverse};
mfem::Vector trialNormalizedState = mfem::Vector(3);
mfem::Vector trialNormalizedResidual = mfem::Vector(3);
mfem::Vector candidatePhysicalState = mfem::Vector(3);
mfem::Vector acceptedState = mfem::Vector(3);
mfem::Vector acceptedResidual = mfem::Vector(3);
mfem::Vector normalizedCorrection = mfem::Vector(3);
LinearSolveReport outerReport{.status = LinearSolveStatus::converged, .iterations = 11,
.trueResidualNorm = .0123};
ToyProblem problem{};
int preparations{0};
bool quarticBarrier{false}, rejectCorrectedPreparation{false}, nonfiniteCorrectedResidual{false};
bool throwCorrectedPreparation{false};
ToyState() {
trialNormalizedState = 0.0;
candidatePhysicalState = 0.0;
trialNormalizedResidual = 1.0;
acceptedState = 0.0;
acceptedResidual = 1.0;
normalizedCorrection = -1.0;
}
[[nodiscard]] ToyProblem &Problem() noexcept { return problem; }
[[nodiscard]] const ToyProblem &Problem() const noexcept { return problem; }
[[nodiscard]] std::expected<void, int> PrepareTrial() {
++preparations;
candidatePhysicalState = trialNormalizedState;
if (throwCorrectedPreparation && trialNormalizedState(0) < -2.0) {
operation.prepared = false;
throw std::runtime_error("Synthetic corrected preparation failure.");
}
if (rejectCorrectedPreparation && trialNormalizedState(0) < -2.0) {
operation.prepared = false;
return std::unexpected(7);
}
trialNormalizedResidual = coupledResidual(trialNormalizedState);
auto matrix = coupledJacobian(trialNormalizedState);
if (quarticBarrier) {
// The extra term and its derivative vanish at the base and at
// alpha=.5. It makes the solved correction harmful without
// supplying the transaction an inconsistent trial Jacobian.
const double x = trialNormalizedState(0);
trialNormalizedResidual(0) += x*x*(x+.5)*(x+.5);
matrix(0, 0) += 2.0*x*(x+.5)*(2.0*x+.5);
}
operation.Prepare(matrix);
if (nonfiniteCorrectedResidual && trialNormalizedState(0) < -2.0)
trialNormalizedResidual(0) = std::numeric_limits<double>::quiet_NaN();
return {};
}
[[nodiscard]] std::expected<void, int> PrepareAlpha(const double alpha) {
trialNormalizedState = normalizedCorrection;
trialNormalizedState *= alpha;
return PrepareTrial();
}
};
[[nodiscard]] nonlinear::MetricEvaluation toyMetric(const mfem::Vector &residual) {
const double norm = residual.Norml2();
return {.residualNorm = norm, .merit = .5*norm*norm};
}
[[nodiscard]] bool toyAccepts(const nonlinear::MetricEvaluation &metric) {
return std::isfinite(metric.merit) && metric.merit < 1.5;
}
[[nodiscard]] bool toyImproves(const nonlinear::MetricEvaluation &candidate,
const nonlinear::MetricEvaluation &original) {
return std::isfinite(candidate.merit) && candidate.merit < original.merit;
}
[[nodiscard]] bool admissibleToy(const mfem::Vector &) { return true; }
void checkOuterData(const ToyState &state) {
for (int i = 0; i < 3; ++i) {
CHECK(state.acceptedState(i) == 0.0);
CHECK(state.acceptedResidual(i) == 1.0);
CHECK(state.normalizedCorrection(i) == -1.0);
}
CHECK(state.outerReport.status == LinearSolveStatus::converged);
CHECK(state.outerReport.iterations == 11);
CHECK(state.outerReport.trueResidualNorm == .0123);
}
void checkRestoredTrial(const ToyState &state, const mfem::Vector &originalState,
const mfem::Vector &originalResidual, const mfem::Vector &originalAction) {
for (int i = 0; i < 3; ++i) {
CHECK(state.trialNormalizedState(i) == originalState(i));
CHECK(state.candidatePhysicalState(i) == originalState(i));
CHECK(state.trialNormalizedResidual(i) == originalResidual(i));
}
CHECK(state.operation.IsPrepared());
CHECK(state.inverse.IsCurrent());
mfem::Vector action(3);
state.operation.Mult(state.normalizedCorrection, action);
for (int i = 0; i < 3; ++i) CHECK(action(i) == originalAction(i));
checkOuterData(state);
}
}
TEST_CASE("The production coupled transaction keeps an improved full residual and leaves outer data alone",
"[solver][coupled_trial_correction][transaction]") {
using namespace coupled_trial_correction_test;
using Catch::Approx;
ToyState state;
solver::detail::PreparedCoupledTrialTransaction transaction(state, exactOptions());
transaction.CaptureBase(state.acceptedResidual, state.normalizedCorrection);
auto preparation = state.PrepareAlpha(.5);
REQUIRE(preparation.has_value());
auto metric = toyMetric(state.trialNormalizedResidual);
transaction.BeginTrial();
transaction.TryImprove(state, .5, preparation, metric, toyMetric, toyAccepts, toyImproves, admissibleToy);
const auto &diagnostics = transaction.GetDiagnostics();
CHECK(diagnostics.status == CoupledTrialAttemptStatus::kept);
REQUIRE(diagnostics.currentSolve.has_value());
CHECK(diagnostics.currentSolve->Admissible());
CHECK(diagnostics.currentSolve->verifiedGate);
CHECK(diagnostics.attempts == 1);
CHECK(diagnostics.kept == 1);
CHECK(diagnostics.fallbacks == 0);
CHECK(diagnostics.preparations == 1);
CHECK(diagnostics.restorations == 0);
CHECK(metric.merit == Approx(.375));
CHECK(metric.merit < diagnostics.originalMerit);
REQUIRE(preparation.has_value());
for (int i = 0; i < 3; ++i) CHECK(state.trialNormalizedResidual(i) == Approx(.5));
checkOuterData(state);
transaction.BeginTrial();
CHECK(transaction.GetDiagnostics().status == CoupledTrialAttemptStatus::not_attempted);
CHECK_FALSE(transaction.GetDiagnostics().currentSolve.has_value());
CHECK(std::isnan(transaction.GetDiagnostics().originalMerit));
CHECK(transaction.GetDiagnostics().attempts == 1);
}
TEST_CASE("The production coupled transaction restores rejected corrections and their prepared operators",
"[solver][coupled_trial_correction][transaction][rollback]") {
using namespace coupled_trial_correction_test;
ToyState state;
bool rejectAdmissibility = false;
CoupledTrialAttemptStatus expected = CoupledTrialAttemptStatus::inadmissible;
SECTION("Corrected physical preparation rejects the candidate") { state.rejectCorrectedPreparation = true; }
SECTION("Corrected polynomial admissibility rejects before preparation") { rejectAdmissibility = true; }
SECTION("A consistent nonlinear map makes the solved correction worse") {
state.quarticBarrier = true;
expected = CoupledTrialAttemptStatus::not_improved;
}
SECTION("A successfully prepared nonfinite residual is unusable") {
state.nonfiniteCorrectedResidual = true;
expected = CoupledTrialAttemptStatus::non_finite;
}
solver::detail::PreparedCoupledTrialTransaction transaction(state, exactOptions());
transaction.CaptureBase(state.acceptedResidual, state.normalizedCorrection);
auto preparation = state.PrepareAlpha(.5);
REQUIRE(preparation.has_value());
const mfem::Vector originalState(state.trialNormalizedState), originalResidual(state.trialNormalizedResidual);
mfem::Vector originalAction(3);
state.operation.Mult(state.normalizedCorrection, originalAction);
auto metric = toyMetric(originalResidual);
const double originalMerit = metric.merit;
transaction.BeginTrial();
transaction.TryImprove(state, .5, preparation, metric, toyMetric, toyAccepts, toyImproves,
[rejectAdmissibility](const mfem::Vector &candidate) { return !rejectAdmissibility || candidate(0) >= -2.0; });
const auto &diagnostics = transaction.GetDiagnostics();
CHECK(diagnostics.status == expected);
REQUIRE(diagnostics.currentSolve.has_value());
CHECK(diagnostics.currentSolve->Admissible());
CHECK(diagnostics.attempts == 1);
CHECK(diagnostics.kept == 0);
CHECK(diagnostics.fallbacks == 1);
CHECK(diagnostics.restorations == 1);
CHECK(diagnostics.preparations == (rejectAdmissibility ? 1 : 2));
CHECK(metric.merit == originalMerit);
if (expected == CoupledTrialAttemptStatus::not_improved)
CHECK(diagnostics.correctedMerit > diagnostics.originalMerit);
REQUIRE(preparation.has_value());
checkRestoredTrial(state, originalState, originalResidual, originalAction);
}
TEST_CASE("The coupled transaction skips acceptable trials and bounds attempts per captured direction",
"[solver][coupled_trial_correction][transaction][budget]") {
using namespace coupled_trial_correction_test;
ToyState state;
state.quarticBarrier = true;
solver::detail::PreparedCoupledTrialTransaction transaction(state, exactOptions(), 1);
transaction.CaptureBase(state.acceptedResidual, state.normalizedCorrection);
auto preparation = state.PrepareAlpha(.05);
auto metric = toyMetric(state.trialNormalizedResidual);
REQUIRE(toyAccepts(metric));
const int before = state.preparations;
transaction.BeginTrial();
transaction.TryImprove(state, .05, preparation, metric, toyMetric, toyAccepts, toyImproves, admissibleToy);
CHECK(transaction.GetDiagnostics().status == CoupledTrialAttemptStatus::original_accepted);
CHECK(transaction.GetDiagnostics().attempts == 0);
CHECK(state.preparations == before);
CHECK_FALSE(transaction.GetDiagnostics().currentSolve.has_value());
preparation = state.PrepareAlpha(.5);
metric = toyMetric(state.trialNormalizedResidual);
transaction.BeginTrial();
transaction.TryImprove(state, .5, preparation, metric, toyMetric, toyAccepts, toyImproves, admissibleToy);
CHECK(transaction.GetDiagnostics().status == CoupledTrialAttemptStatus::not_improved);
CHECK(transaction.GetDiagnostics().attempts == 1);
const auto iterations = transaction.GetDiagnostics().iterations;
transaction.BeginTrial();
transaction.TryImprove(state, .5, preparation, metric, toyMetric, toyAccepts, toyImproves, admissibleToy);
CHECK(transaction.GetDiagnostics().status == CoupledTrialAttemptStatus::budget_exhausted);
CHECK(transaction.GetDiagnostics().attempts == 1);
CHECK(transaction.GetDiagnostics().iterations == iterations);
CHECK_FALSE(transaction.GetDiagnostics().currentSolve.has_value());
checkOuterData(state);
}
TEST_CASE("A rank-local corrected admissibility rejection restores the original trial everywhere",
"[solver][coupled_trial_correction][transaction][rollback][mpi]") {
using namespace coupled_trial_correction_test;
int rank = 0;
REQUIRE(MPI_Comm_rank(MPI_COMM_WORLD, &rank) == MPI_SUCCESS);
ToyState state;
state.problem.communicator = MPI_COMM_WORLD;
solver::detail::PreparedCoupledTrialTransaction transaction(state, exactOptions());
transaction.CaptureBase(state.acceptedResidual, state.normalizedCorrection);
auto preparation = state.PrepareAlpha(.5);
const mfem::Vector originalState(state.trialNormalizedState), originalResidual(state.trialNormalizedResidual);
mfem::Vector originalAction(3);
state.operation.Mult(state.normalizedCorrection, originalAction);
auto metric = toyMetric(originalResidual);
transaction.BeginTrial();
transaction.TryImprove(state, .5, preparation, metric, toyMetric, toyAccepts, toyImproves,
[rank](const mfem::Vector &) { return rank != 0; });
CHECK(transaction.GetDiagnostics().status == CoupledTrialAttemptStatus::inadmissible);
CHECK(transaction.GetDiagnostics().restorations == 1);
checkRestoredTrial(state, originalState, originalResidual, originalAction);
}
TEST_CASE("A coupled auxiliary gate or cap failure preserves the already prepared original candidate",
"[solver][coupled_trial_correction][transaction][failure]") {
using namespace coupled_trial_correction_test;
ToyState state;
auto options = exactOptions();
auto expected = CoupledTrialAttemptStatus::norm_limit;
SECTION("A correction outside the cap is rejected without mutation") {
options.maximumCorrectionRatio = .1;
}
SECTION("A bounded unsuccessful Krylov solve is rejected without mutation") {
// This stationary perturbation prevents one Krylov vector from spanning
// the exact correction. The failure comes from a real verified solve.
state.inverse.extraSecondComponent = .1;
options.control.maximumIterations = 1;
options.backend.restartLength = 1;
expected = CoupledTrialAttemptStatus::linear_failure;
}
solver::detail::PreparedCoupledTrialTransaction transaction(state, options);
transaction.CaptureBase(state.acceptedResidual, state.normalizedCorrection);
auto preparation = state.PrepareAlpha(.5);
const mfem::Vector originalState(state.trialNormalizedState), originalResidual(state.trialNormalizedResidual);
mfem::Vector originalAction(3);
state.operation.Mult(state.normalizedCorrection, originalAction);
auto metric = toyMetric(originalResidual);
transaction.BeginTrial();
transaction.TryImprove(state, .5, preparation, metric, toyMetric, toyAccepts, toyImproves, admissibleToy);
const auto &diagnostics = transaction.GetDiagnostics();
CHECK(diagnostics.status == expected);
REQUIRE(diagnostics.currentSolve.has_value());
CHECK_FALSE(diagnostics.currentSolve->Admissible());
CHECK(diagnostics.attempts == 1);
CHECK(diagnostics.fallbacks == 1);
CHECK(diagnostics.preparations == 0);
CHECK(diagnostics.restorations == 0);
CHECK(diagnostics.iterations <= static_cast<std::uint64_t>(options.control.maximumIterations));
if (expected == CoupledTrialAttemptStatus::linear_failure)
CHECK_FALSE(diagnostics.currentSolve->verifiedGate);
checkRestoredTrial(state, originalState, originalResidual, originalAction);
}
TEST_CASE("Coupled correction options and step lengths must agree across ranks",
"[solver][coupled_trial_correction][configuration][mpi]") {
using namespace coupled_trial_correction_test;
int rank = 0, ranks = 0;
REQUIRE(MPI_Comm_rank(MPI_COMM_WORLD, &rank) == MPI_SUCCESS);
REQUIRE(MPI_Comm_size(MPI_COMM_WORLD, &ranks) == MPI_SUCCESS);
DenseOperation operation(identity(2));
ExactInverse inverse(operation);
auto options = exactOptions();
SECTION("A rank-local invalid cap fails collectively") {
if (rank == 0) options.maximumCorrectionRatio = std::numeric_limits<double>::quiet_NaN();
const auto create = [&] { return PreparedCoupledTrialCorrection(operation, inverse, MPI_COMM_WORLD, options); };
CHECK_THROWS_AS(create(), std::invalid_argument);
}
SECTION("Different valid caps fail collectively") {
if (ranks < 2) return;
options.maximumCorrectionRatio = rank == 0 ? 1.0 : 2.0;
const auto create = [&] { return PreparedCoupledTrialCorrection(operation, inverse, MPI_COMM_WORLD, options); };
CHECK_THROWS_AS(create(), std::invalid_argument);
}
SECTION("Different step lengths cannot form one global trial") {
if (ranks < 2) return;
PreparedCoupledTrialCorrection prepared(operation, inverse, MPI_COMM_WORLD, options);
mfem::Vector f(2), p(2), prediction(2);
f = 1.0;
p = -1.0;
prepared.CaptureBase(f, p);
CHECK_THROWS_AS(prepared.BuildLinearPrediction(rank == 0 ? .5 : .25, prediction), std::invalid_argument);
}
}
TEST_CASE("Finite entries with an overflowing auxiliary norm never authorize a correction",
"[solver][coupled_trial_correction][failure]") {
using namespace coupled_trial_correction_test;
DenseOperation operation(identity(2));
ExactInverse inverse(operation);
auto probe = std::make_shared<BackendProbe>();
ScriptedCorrection prepared(operation, inverse, MPI_COMM_SELF, exactOptions(), TestBackend(probe));
mfem::Vector f(2), p(2), trial(2), correction(2);
f = 1.0;
p = -1.0;
trial = 1.e200;
prepared.CaptureBase(f, p);
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK(report.status == CoupledTrialCorrectionStatus::non_finite);
CHECK_FALSE(report.Admissible());
CHECK_FALSE(report.verifiedGate);
CHECK(probe->calls == 0);
}
TEST_CASE("Exceptions after coupled mutation restore the original prepared candidate before escaping",
"[solver][coupled_trial_correction][transaction][rollback][exception]") {
using namespace coupled_trial_correction_test;
ToyState state;
enum class FailureSite { admissibility, preparation, metric, comparison };
FailureSite site = FailureSite::admissibility;
SECTION("The admissibility callback throws") { site = FailureSite::admissibility; }
SECTION("Physical preparation invalidates its operator and throws") {
site = FailureSite::preparation;
state.throwCorrectedPreparation = true;
}
SECTION("Evaluation of the corrected metric throws") { site = FailureSite::metric; }
SECTION("Comparison of the corrected full merit throws") { site = FailureSite::comparison; }
solver::detail::PreparedCoupledTrialTransaction transaction(state, exactOptions());
transaction.CaptureBase(state.acceptedResidual, state.normalizedCorrection);
auto preparation = state.PrepareAlpha(.5);
const mfem::Vector originalState(state.trialNormalizedState), originalResidual(state.trialNormalizedResidual);
mfem::Vector originalAction(3);
state.operation.Mult(state.normalizedCorrection, originalAction);
auto metric = toyMetric(originalResidual);
const double originalMerit = metric.merit;
int evaluations = 0;
transaction.BeginTrial();
const auto attempt = [&] {
transaction.TryImprove(state, .5, preparation, metric,
[&](const mfem::Vector &residual) {
if (++evaluations == 1 && site == FailureSite::metric)
throw std::runtime_error("Synthetic corrected metric failure.");
return toyMetric(residual);
}, toyAccepts,
[&](const auto &candidate, const auto &original) {
if (site == FailureSite::comparison)
throw std::runtime_error("Synthetic corrected comparison failure.");
return toyImproves(candidate, original);
}, [&](const mfem::Vector &) {
if (site == FailureSite::admissibility)
throw std::runtime_error("Synthetic corrected admissibility failure.");
return true;
});
};
CHECK_THROWS_AS(attempt(), std::runtime_error);
CHECK(transaction.GetDiagnostics().kept == 0);
CHECK(transaction.GetDiagnostics().restorations == 1);
CHECK(metric.merit == originalMerit);
REQUIRE(preparation.has_value());
checkRestoredTrial(state, originalState, originalResidual, originalAction);
}

View File

@@ -21,6 +21,7 @@ namespace stellar_numerical_strategy_test {
struct UnregisteredEnthalpy { };
struct UnregisteredGravity { };
struct UnregisteredTrial { };
template <typename Enthalpy, typename Gravity>
concept MakesNumerics = requires { typename nonlinear::NewtonNumerics<Enthalpy, Gravity>; };
static_assert(!MakesNumerics<UnregisteredEnthalpy, nonlinear::MonolithicGravity>);
@@ -32,6 +33,21 @@ namespace stellar_numerical_strategy_test {
nonlinear::NormalizedResidualMetric{})), nonlinear::Newton<>>);
static_assert(MakesNumerics<nonlinear::PolynomialEnthalpy, nonlinear::GravityCurvatureCorrection>);
static_assert(MakesNumerics<nonlinear::PolynomialEnthalpy, nonlinear::EliminatedGravity>);
template <typename Enthalpy, typename Gravity, typename Trial>
concept MakesTrialNumerics = requires { typename nonlinear::NewtonNumerics<Enthalpy, Gravity, Trial>; };
static_assert(std::same_as<typename nonlinear::NewtonNumerics<>::TrialType, nonlinear::UncorrectedTrial>);
static_assert(std::same_as<decltype(nonlinear::NewtonNumerics{nonlinear::SampledEnthalpy{},
nonlinear::MonolithicGravity{}}), nonlinear::NewtonNumerics<>>);
static_assert(MakesTrialNumerics<nonlinear::SampledEnthalpy, nonlinear::MonolithicGravity,
nonlinear::CoupledCurvatureCorrection>);
static_assert(MakesTrialNumerics<nonlinear::PolynomialEnthalpy, nonlinear::GravityCurvatureCorrection,
nonlinear::CoupledCurvatureCorrection>);
static_assert(!MakesTrialNumerics<nonlinear::SampledEnthalpy, nonlinear::EliminatedGravity,
nonlinear::CoupledCurvatureCorrection>);
static_assert(!MakesTrialNumerics<nonlinear::PolynomialEnthalpy, nonlinear::EliminatedGravity,
nonlinear::CoupledCurvatureCorrection>);
static_assert(!MakesTrialNumerics<nonlinear::SampledEnthalpy, nonlinear::MonolithicGravity, UnregisteredTrial>);
[[nodiscard]] double matchingCentralDensity() {
return std::numbers::pi_v<double> * utils::MASS / (4.0 * utils::RADIUS * utils::RADIUS * utils::RADIUS);
@@ -284,4 +300,47 @@ TEST_CASE("Polynomial admissibility is invoked by each production gravity strate
check(nonlinear::EliminatedGravity{permissiveGravity()});
REQUIRE(context.isReady());
checkPreserved(original, snapshot(context));
{
const auto report = evaluate(context, nonlinear::NewtonNumerics{polynomial,
nonlinear::MonolithicGravity{}, nonlinear::CoupledCurvatureCorrection{}});
CHECK_FALSE(report.converged());
CHECK(report.diagnostics().numericalStrategies.polynomialChecks > 0);
CHECK(report.diagnostics().acceptedNonlinearIterations == 0);
CHECK(report.lastAcceptedCheckpointView().valid());
}
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][rollback]") {
using namespace stellar_numerical_strategy_test;
auto context = makeContext<false>();
const auto original = snapshot(context);
const auto check = [&](auto gravity) {
const nonlinear::CoupledCurvatureCorrection 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;
REQUIRE(auxiliary.currentSolve.has_value());
CHECK(auxiliary.attempts == 1);
CHECK(auxiliary.kept == 0);
CHECK(auxiliary.fallbacks == 1);
CHECK(auxiliary.currentSolve->linearSolve.control.relativeTolerance == correction.correction.control.relativeTolerance);
CHECK(auxiliary.currentSolve->linearSolve.control.absoluteTolerance == correction.correction.control.absoluteTolerance);
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));
}