Skip to content

MakeMovingHomotopy: 'identical moving row' guard compares 6-digit renderings, so distinct rows are falsely refused #391

Description

@ofloveandhate

Summary

MakeMovingHomotopy decides whether two moving rows are "identical" by comparing the
strings that operator<< produces for them. That stream uses the default ostream
precision — 6 significant digits — so two genuinely different linear forms that agree
to 6 digits are declared identical and the homotopy is refused:

MakeMovingHomotopy: moving row 0 (`0.735966*x+(-2408.97)+0.593277*y+(-0.890232)*z`)
is identical in start_moving and end_moving, so it does not move; put non-moving
equations in `fixed` instead.

The threshold is relative: ~1e-6 of the constant's magnitude. At coefficients of order
1 it is 1e-6 and invisible; at order 2400 it is ~2.4e-3, which is easily inside the range
of legitimately distinct slice values. So this only bites at larger coordinate scales,
which is what makes it look sporadic.

This is a false positive in a guard that aborts a valid computation.

Reproducer

import bertini, bertini.nag_algorithm as na

bertini.default_precision(30)
x, y, z = bertini.variables(list('xyz'))

fixed = bertini.System(); fixed.add_variable_group([x, y, z])
fixed.add_functions([x**2 - y**2*z])

def lin(const):
    S = bertini.System(); S.add_variable_group([x, y, z])
    S.add_functions([bertini.coefficient('0.735966')*x
                     + bertini.coefficient('0.593277')*y
                     - bertini.coefficient('0.890232')*z
                     - bertini.coefficient(const)])
    return S

for d in ('1e-12', '1e-8', '1e-5', '1e-3', '1e-2', '1e-1'):
    v2 = repr(2408.97 + float(d))
    try:
        na.moving_homotopy(fixed, lin('2408.97'), lin(v2))
        print(f"delta={d:>6}  moved OK")
    except RuntimeError as e:
        print(f"delta={d:>6}  REFUSED: {'identical' in str(e)}")

Output:

delta= 1e-12  REFUSED: True
delta=  1e-8  REFUSED: True
delta=  1e-5  REFUSED: True
delta=  1e-3  REFUSED: True      <-- genuinely different rows, 1e-3 apart
delta=  1e-2  moved OK
delta=  1e-1  moved OK

A delta of 1e-3 at a constant of 2408.97 is a perfectly meaningful separation — those
are two different slices — and it is refused.

Cause

core/src/system/system.cpp (~1840):

auto function_strings = [](System const& s) {
    std::vector<std::string> out;
    for (auto const& f : s.NaturalFunctionsAsNodes())
    {
        std::ostringstream ss;
        ss << f;                       // default precision: 6 significant digits
        out.push_back(ss.str());
    }
    return out;
};
...
for (size_t i = 0; i < start_funcs.size(); ++i)
    if (start_funcs[i] == end_funcs[i])
        throw std::runtime_error("MakeMovingHomotopy: moving row ... is identical ...");

The same function_strings output also backs the check just above it (the "function appears
in both the fixed system and the moving rows" throw), so both guards share the defect: a
fixed function that merely renders like a moving one would also throw spuriously.

Why this is the wrong tool, by the project's own rule

docs/adr/0042 and the repo's own guidance are explicit that the canonical encodings are
digest preimages, never presentation, and that human-readable renders are derived views
free to change. operator<< is presentation. Using it to make an identity decision is
exactly the inversion that rule forbids — and it silently couples a correctness guard to a
formatting default.

The exact-value encoder this needs already exists in tree:

// core/include/bertini2/function_tree/canonical_encoding.hpp
std::string CanonicalEncoding(std::shared_ptr<Node> const& root);

Suggested fix

  1. Compare with CanonicalEncoding(f) instead of ss << f in function_strings — exact
    values, no formatting dependence. Keep operator<< for the message text, which is
    presentation and appropriate there.
  2. Apply to both guards (the fixed-vs-moving check and the start-vs-end check).
  3. Consider whether string identity is the right predicate at all: two structurally different
    but mathematically identical rows still pass. Evaluating start - end at a random point
    would be a stronger test. At minimum, exact encoding removes the false positives.
  4. A regression test with two rows differing at the 1e-3 level on a ~1e3-magnitude constant
    would have caught this; the scale dependence is the part that makes it easy to miss.

Impact — measured on the real failure, not just the synthetic case

Hit in a surface cell decomposition (whitney x^2 - y^2 z, a component whose bounding
sphere has radius ~1275, so projection values run to a few thousand). A slice curve's fiber
move between two adjacent critical values was refused, aborting the whole decomposition.

I caught the refusal and evaluated start_moving and end_moving at two independent random
points to check whether the rows really were identical:

trial0 row0:  start = -2404.4890203631344
              end   = -2404.4890010320519
              DIFFERENCE = -1.933108e-05   (relative 8.04e-09)

trial1 row0:  start = -2406.9979659498613
              end   = -2406.9979466187788
              DIFFERENCE = -1.933108e-05   (relative 8.03e-09)

rendered start: 0.735966*x+(-2408.97)+0.593277*y+(-0.890232)*z
rendered end  : 0.735966*x+(-2408.97)+0.593277*y+(-0.890232)*z

The two rows differ by a consistent 1.93e-05 and render identically. The caller was moving
between two distinct slices; the guard asserted otherwise and killed the run.

Environment

  • b2 VERSION 3.5.0.dev0, branch feature/eval-precision-tolerant @ c24bdc2e
  • installed dist bertini2 2.0.2
  • Python 3.14.5, Linux aarch64

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions