From b0e308906319303cb5147db6e83460da9b2cba78 Mon Sep 17 00:00:00 2001 From: silviana amethyst Date: Thu, 16 Jul 2026 09:33:30 +0000 Subject: [PATCH] fix(cauchy): slow divergers truncate honestly -- valve arms below cycle_cutoff_time and watches the loop floor Two composing pieces, each necessary, neither sufficient alone: 1. Arm the security valve below cycle_cutoff_time (B1 CycleTimeCutoff semantics, manual E.5.9). 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 armed -- it crawled 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). Valve ONLY, deliberately not acceptance: forcing the zone there would re-open the junk-success hole (a Laurent pole's stationary finite mean would be accepted below the cutoff). 2. The valve watches the STRONGER of the endpoint norm and the loop FLOOR (min dehomogenized norm over the Cauchy loop samples). Arming alone was proven insufficient by the regression test: the mean is structurally blind to fractional-pole divergers -- around a closed loop the branches of t^(-p/q) cancel, so the mean sits near 0 while every sample is at ~r^(-p/q). The floor exceeds max_norm only when the ENTIRE loop is beyond it, so a transient single-sample spike (the cyclic-6 hazard that forbade watching samples directly, 156 -> 153) cannot lift it. Named regression test: the x^2*t - 1 diverger (x = t^(-1/2), exactly the branch-cancellation case) returns SecurityMaxNormReached promptly instead of dying at the tracker's threshold ~12 decades of |t| later. Co-Authored-By: Claude Fable 5 --- core/include/bertini2/endgames/cauchy.hpp | 113 ++++++++++++++++++---- core/test/endgames/amp_cauchy_test.cpp | 57 +++++++++++ 2 files changed, 153 insertions(+), 17 deletions(-) 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: