diff --git a/core/include/bertini2/endgames/cauchy.hpp b/core/include/bertini2/endgames/cauchy.hpp index ec77cb7d2..a2d7ed88a 100644 --- a/core/include/bertini2/endgames/cauchy.hpp +++ b/core/include/bertini2/endgames/cauchy.hpp @@ -1060,6 +1060,34 @@ class CauchyEndgame : } + /** + \brief The FLOOR of the current Cauchy loop: the smallest dehomogenized infinity-norm over + the loop samples. + + This is the divergence signal the mean cannot give: around a closed loop the branches of a + fractional pole t^(-p/q) cancel, so the Cauchy mean stays near 0 while every SAMPLE has norm + ~r^(-p/q). The floor exceeds a bound only when the ENTIRE loop is beyond it, so a transient + single-sample spike (the cyclic-6 hazard that forbade watching samples directly) cannot lift + it. NaN samples are skipped; a loop with no finite samples returns the largest value (a + fully-poisoned loop counts as beyond any bound). + + \tparam ComplexT The complex number type of the samples to inspect. + */ + template + NumErrorT MinDehomNormOverLoopSamples() const + { + const auto& cau_samples = std::get >(cauchy_samples_); + using std::min; using std::isnan; + NumErrorT floor_of_loop = std::numeric_limits::max(); + for (const auto& s : cau_samples) + { + auto n = static_cast(this->GetSystem().InfinityNormOfDehomogenized(s)); + if (!isnan(n)) + floor_of_loop = min(floor_of_loop, n); + } + return floor_of_loop; + } + /** \brief Collects samples while tracking around the target time, until we close the loop, or exceed the limit on # of loops. @@ -1301,19 +1329,20 @@ class CauchyEndgame : return cauchy_loop_success; - // The security check watches the extrapolated ENDPOINT for divergence to - // infinity. A genuinely-infinite endpoint -- a patched projective path leaving - // the affine chart -- has an approximation whose dehomogenized norm grows to - // infinity, and it grows FASTER than any finite loop sample along the way (the - // endpoint IS the infinity; the samples are all finite), so the endpoint is the - // earliest, strongest divergence signal. Watching the loop samples instead - // over-truncates finite paths whose samples transiently spike above max_norm - // before settling (found via cyclic-6: 156 -> 153 lost, all nonsingular). The - // pole-annihilation case -- a Laurent pole whose Cauchy mean is a STATIONARY - // FINITE number, which no norm check can catch -- is handled separately by the - // pole-component operating-zone check below, so this check need only see honest - // infinity. Tracked across consecutive IN-ZONE rounds only (see below); - // initialized to 0 so the check never reads indeterminate values. + // The security check watches TWO honest divergence signals, taking the stronger: + // (1) the extrapolated ENDPOINT -- a genuinely-infinite endpoint (a patched + // projective path leaving the affine chart) has a dehomogenized norm growing to + // infinity FASTER than any finite loop sample, the earliest signal there; and + // (2) the loop FLOOR (min over loop samples) -- a fractional-pole diverger's + // branches cancel around the closed loop, so its mean/endpoint stays ~0 while + // every sample sits at ~r^(-p/q); only the floor sees it. The floor is safe + // where watching samples DIRECTLY was not (any single sample transiently spiking + // above max_norm truncated finite cyclic-6 paths, 156 -> 153): a transient spike + // lifts the max, never the min. The pole-annihilation case -- a Laurent pole + // whose Cauchy mean is a STATIONARY FINITE number -- is handled separately by + // the pole-component operating-zone check below. Tracked across consecutive + // ARMED rounds only (see below); initialized to 0 so the check never reads + // indeterminate values. RealT norm_of_dehom_prev(0), norm_of_dehom_latest(0); // Cycle-number consistency: refuse to accept a converged approximation until the cycle number @@ -1360,6 +1389,23 @@ class CauchyEndgame : auto const pole_mass = PoleComponentMass(); bool const in_operating_zone = !PoleMassSignificant(pole_mass, static_cast(latest_approx.template lpNorm())); + // B1's CycleTimeCutoff semantics (manual E.5.9): below cycle_cutoff_time the + // zone heuristics stop being consulted and the endgame behaves as if in the + // operating zone. Applied here to the SECURITY VALVE ONLY: a slowly-diverging + // path (fractional-order blowup) carries significant pole mass at every radius, + // so it is never pole-mass-in-zone and the valve never arms -- it crawls to the + // tracker's far-larger truncation threshold (measured: a t^(-1/3) diverger took + // ~117k extra steps over 3 more decades of |t| at up to 70 digits). Below the + // cutoff a genuinely convergent path's mean has long stabilized at a finite + // value, so arming the valve cannot reintroduce the cyclic-6 false truncations. + // Deliberately NOT applied to acceptance: forcing the zone there would re-open + // the junk-success hole (a Laurent pole's stationary FINITE mean would be + // accepted once below the cutoff); pole junk still runs to its honest + // non-Success terminal. + using std::abs; + bool const below_cycle_cutoff = + static_cast(abs(this->LatestTime() - target_time)) + < GetCauchySettings().cycle_cutoff_time; if (in_operating_zone && approx_error < this->FinalTolerance() @@ -1379,9 +1425,17 @@ class CauchyEndgame : // An out-of-zone round restarts the two-consecutive count. if (this->SecuritySettings().level <= 0) { - if (in_operating_zone) + if (in_operating_zone || below_cycle_cutoff) { - norm_of_dehom_latest = this->GetSystem().InfinityNormOfDehomogenized(latest_approx); + // Watch the STRONGER of two honest divergence signals: the endpoint (the + // earliest signal for a clean infinite endpoint) and the loop FLOOR (min + // over loop samples) -- the mean is structurally blind to fractional-pole + // divergers, whose branches cancel around the closed loop while every + // sample sits at ~r^(-p/q). See MinDehomNormOverLoopSamples. + using std::max; + norm_of_dehom_latest = max( + this->GetSystem().InfinityNormOfDehomogenized(latest_approx), + static_cast(MinDehomNormOverLoopSamples())); if (this->BeyondSecurityMaxNorm(norm_of_dehom_prev) && this->BeyondSecurityMaxNorm(norm_of_dehom_latest)) { @@ -1504,6 +1558,23 @@ class CauchyEndgame : auto const pole_mass = PoleComponentMassAMP(); bool const in_operating_zone = !PoleMassSignificant(pole_mass, static_cast(this->final_approximation_.template lpNorm())); + // B1's CycleTimeCutoff semantics (manual E.5.9): below cycle_cutoff_time the + // zone heuristics stop being consulted and the endgame behaves as if in the + // operating zone. Applied here to the SECURITY VALVE ONLY: a slowly-diverging + // path (fractional-order blowup) carries significant pole mass at every radius, + // so it is never pole-mass-in-zone and the valve never arms -- it crawls to the + // tracker's far-larger truncation threshold (measured: a t^(-1/3) diverger took + // ~117k extra steps over 3 more decades of |t| at up to 70 digits). Below the + // cutoff a genuinely convergent path's mean has long stabilized at a finite + // value, so arming the valve cannot reintroduce the cyclic-6 false truncations. + // Deliberately NOT applied to acceptance: forcing the zone there would re-open + // the junk-success hole (a Laurent pole's stationary FINITE mean would be + // accepted once below the cutoff); pole junk still runs to its honest + // non-Success terminal. + using std::abs; + bool const below_cycle_cutoff = + static_cast(abs(this->LatestTime() - this->AtActivePrecisionScalar(target_time))) + < GetCauchySettings().cycle_cutoff_time; if (in_operating_zone && this->approximate_error_ < this->FinalTolerance() @@ -1517,9 +1588,17 @@ class CauchyEndgame : // out-of-zone round restarts the two-consecutive count. See RunImpl. if (this->SecuritySettings().level <= 0) { - if (in_operating_zone) + if (in_operating_zone || below_cycle_cutoff) { - norm_of_dehom_latest = this->GetSystem().InfinityNormOfDehomogenized(this->final_approximation_); + // The stronger of endpoint norm and loop floor; the loop samples live in + // whichever lane is active. See RunImpl and MinDehomNormOverLoopSamples. + NumErrorT loop_floor = (this->current_endgame_precision_ == DoublePrecision()) + ? MinDehomNormOverLoopSamples() + : MinDehomNormOverLoopSamples(); + using std::max; + norm_of_dehom_latest = max( + this->GetSystem().InfinityNormOfDehomogenized(this->final_approximation_), + static_cast(loop_floor)); if (this->BeyondSecurityMaxNorm(norm_of_dehom_prev) && this->BeyondSecurityMaxNorm(norm_of_dehom_latest)) { diff --git a/core/test/endgames/amp_cauchy_test.cpp b/core/test/endgames/amp_cauchy_test.cpp index e6d39b7b9..0402ff288 100644 --- a/core/test/endgames/amp_cauchy_test.cpp +++ b/core/test/endgames/amp_cauchy_test.cpp @@ -156,5 +156,62 @@ BOOST_AUTO_TEST_CASE(tight_tolerance_forces_double_to_mpfr_migration) BOOST_AUTO_TEST_SUITE_END() // re: adaptive_numeric_type_migration +// REGRESSION: slowly-diverging paths must be truncated by the security valve, not left to +// crawl. A diverger like x = t^(-1/2) (from x^2*t - 1 = 0) carries significant pole mass +// at every radius, so it is never pole-mass-in-zone; before B1's CycleTimeCutoff semantics +// were wired in (below cycle_cutoff_time, the valve arms unconditionally), the valve never +// armed and such paths ground toward the tracker's far-larger truncation threshold +// (measured on an NID workload: ~117k extra steps over 3 decades of |t| at up to 70 digits). +BOOST_AUTO_TEST_SUITE(divergent_path_truncation) + +using namespace bertini; +using namespace bertini::endgame; + +using TrackerType = bertini::tracking::AMPTracker; +using TestedEGType = EndgameSelector::Cauchy; +using PrecisionConfig = bertini::tracking::TrackerTraits::PrecisionConfig; + +BOOST_AUTO_TEST_CASE(slow_diverger_hits_security_max_norm_below_cycle_cutoff) +{ + DefaultPrecision(DoublePrecision()); + + System sys; + auto x = node::Variable::Make("x"); + auto t = node::Variable::Make("t"); + sys.AddFunction( pow(x,2)*t - 1 ); // x(t) = t^(-1/2): an honest slow diverger + VariableGroup vars{x}; + sys.AddVariableGroup(vars); + sys.AddPathVariable(t); + + auto precision_config = PrecisionConfig(sys); + + TrackerType tracker(sys); + bertini::tracking::SteppingConfig stepping_preferences; + bertini::tracking::NewtonConfig newton_preferences; + tracker.Setup(bertini::tracking::Predictor::HeunEuler, 1e-5, 1e5, stepping_preferences, newton_preferences); + tracker.PrecisionSetup(precision_config); + + complex_mp time(real_mp("0.1"), real_mp("0.0")); + Vec sample(1); + sample << complex_mp(real_mp("3.162277660168379331998893544432719"), real_mp("0.0")); // 0.1^(-1/2) + + TestedEGType eg(tracker); + eg.SetBoundaryTime(time); + + auto code = eg.Run(sample); + BOOST_TEST_MESSAGE("diverger endgame code: " << int(code) << " |t| " << double(abs(eg.LatestTime()))); + + // truncated by the valve, not ground down to the tracker's threshold or min track time + BOOST_CHECK(code == SuccessCode::SecurityMaxNormReached); + // ...and truncated EARLY: at/below the cycle cutoff (1e-8) the valve arms; the norm + // crosses max_norm (1e4) at |t| ~ 1e-8, so death should come around that scale -- + // not after descending to ~1e-14 (ratio cutoff / old-crawl territory) + using std::abs; + BOOST_CHECK_GT(static_cast(abs(eg.LatestTime())), 1e-11); +} + +BOOST_AUTO_TEST_SUITE_END() // re: divergent_path_truncation + + BOOST_AUTO_TEST_SUITE_END() // re: