From 123f28c27297d1c45d115b82fedb84c6f7dd8099 Mon Sep 17 00:00:00 2001 From: silviana amethyst Date: Wed, 7 Oct 2026 19:19:23 +0200 Subject: [PATCH] Given start points join the records ask, so a solve from different starts is never recalled with another solve's answers A HomotopySolver solve named its homotopy, target, settings and seed in its records ask, but not the start points it was given. With recording on (the default), a second solve of the same homotopy from different start points was the same ask and was answered from the record of the first: it returned the first solve's endpoints. StartSystem::GivenStartIdentity() is empty for a start system whose points follow from its target, and start_system::User returns a SHA-256 of its points' exact values (IEEE bits for doubles; precision and every stored digit for multiprecision, versioned b2start/1). RecordsAsk adds it as the ask's start when the caller has not supplied one, so no entry point can leave it out. Tests: different given starts are different asks and reach their own endpoints; the same starts are still recalled; the identity tells apart points that differ by 2^-40; and the same through Python's HomotopySolver. Closes #498 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++ .../nag_algorithms/zero_dim_solve.hpp | 10 +- core/include/bertini2/system/start/user.hpp | 8 ++ core/include/bertini2/system/start_base.hpp | 15 +++ core/src/system/start/user.cpp | 72 ++++++++++++++ core/test/nag_algorithms/zero_dim.cpp | 99 +++++++++++++++++++ python/test/records/records_test.py | 28 ++++++ 7 files changed, 240 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e94dfbafe..256512fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,16 @@ _______________________________________________________________________________ --> +_______________________________________________________________________________ + +## [4.0.1] - unreleased + +Bug fixes for 4.0. + +### Fixed + +- A `HomotopySolver` solve's records ask now includes its start points (#498). It named the homotopy, target, settings and seed but not the given start points, so with recording on (the default) a second solve of the same homotopy from different start points was the same ask, and was answered from the record of the first: it silently returned the first solve's endpoints. The start system now reports the identity of start points it was given (`StartSystem::GivenStartIdentity`, a digest of their exact values), and the solve adds it to its ask. A solve from given start points therefore has a different run id than in 4.0.0, so records written by 4.0.0 for such solves are asked again rather than recalled. + _______________________________________________________________________________ ## [4.0.0] - 2026-10-06 diff --git a/core/include/bertini2/nag_algorithms/zero_dim_solve.hpp b/core/include/bertini2/nag_algorithms/zero_dim_solve.hpp index 07dc53f84..5b07f68cd 100644 --- a/core/include/bertini2/nag_algorithms/zero_dim_solve.hpp +++ b/core/include/bertini2/nag_algorithms/zero_dim_solve.hpp @@ -2716,8 +2716,14 @@ run the endgame, classify the endpoints, report. See the forward-declare doc ab // redundant with (target, seed) but harmless; for USER homotopies (the // engine driven directly) it is the only thing telling asks apart ask["homotopy"] = Homotopy().ContentDigest().Hex(); - if (!records_start_identity_.empty()) - ask["start"] = records_start_identity_; // external start data is identity + // external start data is identity: the caller's (a chain or a given), else the + // start system's own when it holds given points. Without the second, two solves + // of one homotopy from different given points were one ask, and the second was + // answered with the first's endpoints (bertiniteam/b2#498) + auto const start_identity = !records_start_identity_.empty() + ? records_start_identity_ : StartSystem().GivenStartIdentity(); + if (!start_identity.empty()) + ask["start"] = start_identity; ask["config"] = settings.Hex(); ask["seed"] = static_cast(GetGlobalSeed()); return ask; diff --git a/core/include/bertini2/system/start/user.hpp b/core/include/bertini2/system/start/user.hpp index 065354bbf..8772b8675 100644 --- a/core/include/bertini2/system/start/user.hpp +++ b/core/include/bertini2/system/start/user.hpp @@ -80,6 +80,14 @@ namespace bertini */ unsigned long long NumStartPoints() const override; + /** + \brief A digest of the given start points: their exact values in order, at the + precision they were given in. + + \return The SHA-256 digest, as hex, of the points' exact encoding. + */ + std::string GivenStartIdentity() const override; + User& operator*=(Nd const& n) = delete; User& operator+=(System const& sys) = delete; diff --git a/core/include/bertini2/system/start_base.hpp b/core/include/bertini2/system/start_base.hpp index 72937d4ed..b0e2581ee 100644 --- a/core/include/bertini2/system/start_base.hpp +++ b/core/include/bertini2/system/start_base.hpp @@ -32,6 +32,7 @@ #include #include +#include namespace bertini @@ -98,6 +99,20 @@ namespace bertini return GenerateStartPoint(T(),index); } + /** + \brief The identity of start points this system was GIVEN, as a digest; empty for a + start system whose points follow from its target system. + + A solve's records ask names its target, homotopy, settings and seed. Start points + derived from the target are determined by those; given start points are not, so a + start system that holds given points reports their identity here and the solve adds it + to its ask. The same homotopy from different given points is then a different ask, + and the same points are still recalled. + + \return A digest of the given start points, or the empty string. + */ + virtual std::string GivenStartIdentity() const { return {}; } + virtual ~StartSystem() = default; private: diff --git a/core/src/system/start/user.cpp b/core/src/system/start/user.cpp index 5381564f7..a0ef2a196 100644 --- a/core/src/system/start/user.cpp +++ b/core/src/system/start/user.cpp @@ -20,11 +20,39 @@ // additional terms in the b2/licenses/ directory. #include "bertini2/system/start/user.hpp" +#include "bertini2/detail/sha256.hpp" + +#include +#include +#include +#include BOOST_CLASS_EXPORT(bertini::start_system::User); +namespace { + + // a double as its exact IEEE-754 bit pattern, the convention of every b2 digest preimage + void EmitExact(std::ostream& out, double v) + { + std::uint64_t bits; + static_assert(sizeof bits == sizeof v, "a double is 64 bits"); + std::memcpy(&bits, &v, sizeof bits); + out << "d64:" << std::hex << std::setw(16) << std::setfill('0') << bits << std::dec; + } + + // an mpfr value at its own precision with every stored digit, as the system canonical + // encoding writes its multiprecision constants + template + void EmitExactMp(std::ostream& out, RealT const& v) + { + out << v.str(0, std::ios::scientific); + } + +} // namespace + + namespace bertini { namespace start_system { @@ -50,6 +78,50 @@ namespace bertini { } + std::string User::GivenStartIdentity() const + { + // the exact start data, in order: version, kind, point count, then each point's + // length and coordinates. Versioned so a later change of encoding is a new identity. + std::ostringstream text; + text << "b2start/1 "; + if (solns_in_dbl_) + { + auto const& pts = std::get>(solns_); + text << "dbl " << pts.size() << '\n'; + for (auto const& p : pts) + { + text << p.size(); + for (Eigen::Index ii = 0; ii < p.size(); ++ii) + { + text << ' '; + EmitExact(text, p(ii).real()); + text << ' '; + EmitExact(text, p(ii).imag()); + } + text << '\n'; + } + } + else + { + auto const& pts = std::get>(solns_); + text << "mp " << pts.size() << '\n'; + for (auto const& p : pts) + { + text << p.size(); + for (Eigen::Index ii = 0; ii < p.size(); ++ii) + { + text << ' ' << p(ii).precision() << ' '; + EmitExactMp(text, p(ii).real()); + text << ' '; + EmitExactMp(text, p(ii).imag()); + } + text << '\n'; + } + } + return detail::Sha256(text.str()).Hex(); + } + + Vec User::GenerateStartPoint(complex_dbl,unsigned long long index) const { diff --git a/core/test/nag_algorithms/zero_dim.cpp b/core/test/nag_algorithms/zero_dim.cpp index 75227df9a..a1a5c8a2b 100644 --- a/core/test/nag_algorithms/zero_dim.cpp +++ b/core/test/nag_algorithms/zero_dim.cpp @@ -1584,6 +1584,105 @@ BOOST_AUTO_TEST_CASE(a_recalled_path_remembers_that_its_crossing_was_never_resol } +namespace { + +// One path of the parameter homotopy H(x,t) = x^2 - (9 - 5t) -- t = 1 roots +/-2, t = 0 roots +// +/-3 -- from a GIVEN start point, recording into `dir`. +struct GivenStartSolve +{ + using TrackerT = bertini::tracking::DoublePrecisionTracker; + using SolverT = bertini::algorithm::HomotopySolver::PSEG, bertini::System>; + + bertini::System H, target; + bertini::SampCont start_points; + std::unique_ptr start; + std::unique_ptr solver; + + GivenStartSolve(bertini::complex_mp const& from, std::filesystem::path const& dir) + { + using namespace bertini; + auto x = Variable::Make("x"); + auto t = Variable::Make("t"); + H.AddVariableGroup(VariableGroup{x}); + H.AddFunction(x*x - (9 - 5*t)); + H.AddPathVariable(t); + target.AddVariableGroup(VariableGroup{x}); + target.AddFunction(x*x - 9); + + Vec p(1); p(0) = from; + start_points.push_back(p); + start = std::make_unique(target, start_points); + solver = std::make_unique(target, *start, H); + solver->DefaultSetup(); + solver->RecordTo(std::make_shared(dir)); + solver->Solve(); + } + + bertini::complex_dbl Endpoint() const + { + return bertini::complex_dbl(solver->SolutionsUserCoords().at(0)(0)); + } +}; + +} // namespace + + +// The same homotopy tracked from DIFFERENT given start points is a different computation, so a +// records directory holding the first must not answer the second with the first's endpoints. +// Before the start system's own identity joined the ask, the second solve was the same ask and was +// recalled: tracked from -2 it came back at +3. +BOOST_AUTO_TEST_CASE(different_given_start_points_are_different_asks) +{ + auto const dir = FreshCrossingRecords("given_starts"); + GivenStartSolve from_plus(bertini::complex_mp(2), dir); + GivenStartSolve from_minus(bertini::complex_mp(-2), dir); + + BOOST_CHECK_NE(from_plus.solver->RecordsRunId(), from_minus.solver->RecordsRunId()); + BOOST_CHECK_EQUAL(from_minus.solver->NumPathsRecalled(), 0u); + BOOST_CHECK_SMALL(std::abs(from_plus.Endpoint() - bertini::complex_dbl( 3, 0)), 1e-8); + BOOST_CHECK_SMALL(std::abs(from_minus.Endpoint() - bertini::complex_dbl(-3, 0)), 1e-8); +} + + +// ... and the same given start points are still the same ask, answered from the record. +BOOST_AUTO_TEST_CASE(the_same_given_start_points_are_still_recalled) +{ + auto const dir = FreshCrossingRecords("given_starts_again"); + GivenStartSolve first(bertini::complex_mp(2), dir); + GivenStartSolve again(bertini::complex_mp(2), dir); + + BOOST_CHECK_EQUAL(again.solver->RecordsRunId(), first.solver->RecordsRunId()); + BOOST_CHECK_EQUAL(again.solver->NumPathsRecalled(), 1u); + BOOST_CHECK_SMALL(std::abs(again.Endpoint() - first.Endpoint()), 1e-14); +} + + +// The identity is of the points' exact values: two start points that differ by a hair are +// different start data, and the identity does not depend on anything but the points. +BOOST_AUTO_TEST_CASE(given_start_identity_is_exact) +{ + using namespace bertini; + System target; + auto x = Variable::Make("x"); + target.AddVariableGroup(VariableGroup{x}); + target.AddFunction(x*x - 9); + + auto identity_of = [&](complex_mp const& v) { + SampCont pts; + Vec p(1); p(0) = v; + pts.push_back(p); + return start_system::User(target, pts).GivenStartIdentity(); + }; + // 2 and 2 + 2^-40: a small difference, exactly representable at any working precision + complex_mp a(2), b(complex_dbl(2.0 + std::ldexp(1.0, -40), 0.0)); + + BOOST_CHECK(!identity_of(a).empty()); + BOOST_CHECK_EQUAL(identity_of(a), identity_of(complex_mp(2))); + BOOST_CHECK_NE(identity_of(a), identity_of(b)); +} + + // A run whose check passed says so in its own record: absence of a midpath record has to keep // meaning "the check did not run", or a solve cut short reads as a clean bill of health. BOOST_AUTO_TEST_CASE(a_clean_solve_records_a_crossing_check_that_passed) diff --git a/python/test/records/records_test.py b/python/test/records/records_test.py index 379ca1572..c30b687b4 100644 --- a/python/test/records/records_test.py +++ b/python/test/records/records_test.py @@ -67,6 +67,34 @@ def test_homotopy_solver_also_produces_a_zerodim_result(): assert len(hr.answer.finite) == 2 and len(hr.answer.real) == 2 # +-sqrt(3) +def test_homotopy_solver_from_different_starts_is_never_recalled(tmp_path): + """The same homotopy from DIFFERENT start points is a different solve: recording the first + must not answer the second with the first's endpoints, and the same start points still + recall. H(x, t) = x^2 - (9 - 5t) carries +2 to +3 and -2 to -3.""" + import bertini.nag_algorithm as nag + x, t = Variable('x'), Variable('t') + H = System(); H.add_variable_group(VariableGroup([x])); H.add_function(x * x - (9 - 5 * t)) + H.add_path_variable(t) + tgt = System(); tgt.add_variable_group(VariableGroup([x])); tgt.add_function(x * x - 9) + d = str(tmp_path / 'records') + + def from_(value): + # one seed for every solve, so the start points are all that differs between asks + pb.random.set_random_seed(11) + solver = nag.HomotopySolver(H, [np.array([pb.multiprec.complex_mp(value)])], tgt) + solver.record_to(d) + result = solver.solve() + return complex(result[0][0]), result.num_recalled, result.run_id + + plus, recalled_plus, run_plus = from_(2) + minus, recalled_minus, run_minus = from_(-2) + assert recalled_minus == 0 and run_minus != run_plus + assert abs(plus - 3) < 1e-8 and abs(minus + 3) < 1e-8 + + again, recalled_again, run_again = from_(2) + assert recalled_again == 1 and run_again == run_plus and abs(again - plus) < 1e-14 + + def test_zerodim_result_categories_for_a_multiple_root(): # {x^2, y^2}: one distinct finite solution (0,0), singular (multiplicity 4) x, y = Variable('x'), Variable('y')