Skip to content

feat: UI quality-of-life batch (#293–#304) + solution group projection — 3.1.0.dev1 - #305

Merged
ofloveandhate merged 3 commits into
developfrom
feature/UI_quality_of_life
Jul 8, 2026
Merged

feat: UI quality-of-life batch (#293–#304) + solution group projection — 3.1.0.dev1#305
ofloveandhate merged 3 commits into
developfrom
feature/UI_quality_of_life

Conversation

@ofloveandhate

Copy link
Copy Markdown
Contributor

UI quality-of-life batch (issues #293#304) + solution group projection

These rough edges surfaced while implementing the numerical real cellular decomposition algorithm in
Python. The through-line goal: get from a polynomial system to the projection values of critical
points as real_mp, straight through numpy, without a code maze.

Per the "put reusable primitives in core" doctrine, anything with a C++ home landed in core +
bindings
(behind C++ tests); only presentation/interop sugar stayed pure-Python.

C++ core + bindings

Python layer

#298 / #301 (numpy interop) — note the overlap

Adds mp-native elementwise helpers bertini.real/imag/abs/conj/round/sum/norm/is_real (the
numpy↔eigenpy dtype boundary is unpatchable for identity-seeded reductions; documented in
known_gotchas). These likely overlap with the native ufunc coverage on feature/numpy_compat
left for the maintainer to reconcile (the helpers still stand alone for is_real / dedup / norm).

Deferred

Verification

  • C++: test_classes + test_nag_algorithms green (new regression tests for every C++ change).
  • Python: 660 passed, 4 skipped (the one initial failure was the intended i want for solutions() etc to NOT duplicate the >1 multiplicity solutions #299 default change; that
    test was updated).
  • Acceptance artifact: a polished marimo notebook (in the sibling curve_decomposition_in_python repo,
    original left untouched) runs end-to-end — projection values arrive as real_mp, plus a 3D plot of
    the real critical points.

Heads-up for review

  • Version bump 3.0.0rc1 → 3.1.0.dev1 is included (this branch is the 3.1.0.dev1 work). Drop the
    first commit if you'd rather bump separately.
  • The projective-nullvector reformulation does not track — mathematically cleaner (only critical
    points survive) but the mhom homotopy fails every path for this bilinear structure. The notebook uses
    the working affine+patch formulation and documents the projective attempt as an open question for the
    start-system / endgame work.
  • int(real_mp) segfaults (pre-existing real_mp binding bug; float/str fine) — hit
    incidentally, out of scope here, worth a follow-up (real_mp.__int__).
  • doclint not run locally (no doxygen in this env); every new public C++ entity carries a matching
    Doxygen comment, so the CI gate should pass.

Closes #293, #294, #295, #296, #297, #299, #300, #302, #304.
(#298 and #301 addressed via helpers — see the overlap note above before closing.)

🤖 Generated with Claude Code

ofloveandhate and others added 3 commits July 8, 2026 11:41
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…304 + group projection)

Reusable primitives land in core (+ bindings), gated by C++ tests:

- #294: Slice::RandomReal orthogonal path is now genuinely real (was falling
  through to the complex conjugate-orthonormal matrix); friendly random_real /
  random_complex / random_vector factories exposed in bertini.random.
- #304: IsSamePoint / IsDistinct infinity-norm tolerance util (eigen_extensions),
  bound as is_distinct_up_to for real_mp / complex_mp / real_dbl / complex_dbl points.
- #299: merge_multiplicities (default true) on the solver's SolutionsWhere and the
  finite category accessors -- dedup where the clustering lives.
- #302: MetadataForPoint / CoincidentMetadataForPoint (point -> metadata); the
  return type depends on the call (single representative, or coincident=list),
  never on the point's multiplicity.
- #297: System::functions() / copy_functions() accessors.
- #300: node.eval accepts a point array or a dict, not just kwargs (NodeEvalRaw);
  still returns complex_mp natively.
- Group projection (Cluster G): System::CoordinatesOfGroup / FIFOIndexOfGroup +
  sys.coordinates_of(point, group) -- project a user-coordinate point onto one
  variable group (projective groups returned as-is).

C++ regression tests added: seeded factory realness, IsDistinct, slice realness,
merge_multiplicities + metadata_for (mult-4 fixture), copy_functions, group projection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…293,#295,#298,#300,#301)

Pure-Python layer over the new C++ primitives, plus the presentation-only fixes:

- #293: variables(['x','y','z']) list form; add_variable_group accepts loose
  variables / a list / a single Variable / a VariableGroup; Slice.random_* gives a
  clear error (and unwraps a one-group sequence) instead of an opaque converter TypeError.
- #294 sugar: VariableGroup @ coeffs builds the single linear-combination node.
- #296: sys.clone() method (over bertini.system.clone).
- #295: bertini.sympy_bridge exposed lazily; nodes auto-sympify via the _sympy_
  protocol, so sympy.sympify(node) / sympy.Matrix(array) / sympy.det(J) just work.
- #298/#301: bertini.real/imag/abs/conj/round/sum/norm/is_real -- vectorized,
  mp-native helpers over scalar/list/array (the numpy<->eigenpy dtype boundary is
  unpatchable; documented in known_gotchas). Builtin-shadowing names kept out of *.
- #299/#302/Cluster G surface: merge_multiplicities & metadata_for reach Python via
  the bindings; group= keyword on every solution getter and to_dataframe projects
  onto a variable group (via System.coordinates_of).

Interface tests added; observer_temp_system_test updated for the new
merge-multiplicities-by-default (a singular double root now counts once).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ofloveandhate

Copy link
Copy Markdown
Contributor Author
20260708_ui_polish_architecture

@ofloveandhate

Copy link
Copy Markdown
Contributor Author

there's some competing work in here, with #306 . will deal with in a minute

@ofloveandhate
ofloveandhate merged commit 429c7f2 into develop Jul 8, 2026
16 of 17 checks passed
ofloveandhate added a commit that referenced this pull request Jul 8, 2026
…docs page reframed

Reconciles #305's interim numpy helpers with the binding-level ufunc
coverage from this branch:

- bertini.real/imag/abs/conj/sum/norm/is_real keep their API but now
  take the native C++ ufunc loops when handed an mp-dtype array
  (returning proper mp-dtype arrays that keep working with sort and
  reductions), falling back to element-wise work for lists and mixed
  input.  bertini.round keeps its decimal-digit Decimal semantics.
- The "Known gotchas" page is retired.  Its replacement,
  docs/source/numpy.rst ("Multiprecision numbers and NumPy"), documents
  the interop as the feature it now is: what works (everything), the
  float64 boundary as deliberate digit protection with the tolerance-
  comparison carve-out, the complex component accessors, and the
  boundaries by design.  The one remaining hazard -- ndarray
  .real/.imag lie on complex_mp arrays, a numpy hardwiring no binding
  can reach -- is a warning admonition, not a page of dragons.
- All cross-references updated (module docstrings, test comments).

769 tests pass (both suites merged), 169 doctests, doclint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ofloveandhate added a commit that referenced this pull request Jul 8, 2026
…bulary

`from bertini.operators import *` now gives functions that work on
EVERYTHING, dispatched per argument: a symbolic Variable/expression
builds a function-tree node; multiprecision scalars, mp-dtype numpy
arrays, lists, and plain python numbers take the numeric path through
the native precision-preserving loops.  No more remembering that arg
lives in bertini.multiprec while imag is at the top level.

- sin/cos/tan/asin/acos/atan/exp/log/sqrt: fully polymorphic
  (symbolic twin exists).
- sinh/cosh/tanh/asinh/acosh/atanh and abs/arg/real/imag/conj/round/
  sum/norm/is_real: numeric, with a clear TypeError on symbolic input.
- E/Pi/I ride along.  abs/round/sum shadow the builtins only inside
  this opt-in star-import (they fall back to builtin behavior on plain
  python input); `from bertini import *` still never shadows.
- the top-level elementary functions are rebound to the polymorphic
  versions (a strict superset: bertini.sin(x) now also accepts numbers
  and arrays), and bertini.arg + the hyperbolics join the top level.
- new _numpy_helpers.arg rides mp.arg on complex arrays (real arrays
  go through the exact real->complex cast).

Also fixes a latent #305 bug the new tests caught: _numpy_helpers'
scalar fallbacks called bare abs()/round(), which resolve to the
module's own shadowing functions at module scope -> infinite recursion
on plain python input.  Now explicitly builtins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ofloveandhate added a commit that referenced this pull request Jul 9, 2026
…e comparisons for mp dtypes (#306)

## What this PR delivers

The numpy gotchas for the `real_mp` / `complex_mp` dtypes are gone. The
scary "Known gotchas" page is retired; its replacement,
`docs/source/numpy.rst` ("Multiprecision numbers and NumPy"), documents
the interop as the feature it now is. Full decision record in
**ADR-0051**; includes the post-#305 reconciliation.

### Full ufunc coverage
`np.abs`, `np.conj`, `np.exp`/`log`/`log2`/`log10`/`expm1`/`log1p`, all
trig/hyperbolic + inverses, `power`, `sign`, `reciprocal`,
`minimum`/`maximum`/`fmin`/`fmax`, `floor`/`ceil`/`trunc`/`rint`,
`isnan`/`isinf`/`isfinite`/`signbit`, `arctan2`, `hypot`, `copysign`,
`mod`/`fmod`/`floor_divide` — previously all `TypeError: ufunc not
supported`. Every loop reads through `value_or_zero` (ADR-0006 doctrine)
and calls the same boost::multiprecision function the `multiprec` scalar
functions bind, so `np.f(a)[i] == mp.f(a[i])` exactly. numpy-semantics
corners done deliberately: `rint` is half-to-even via direct `mpfr_rint`
(boost's is half-away; registered for complex too, component-wise, so
`np.round` works on complex arrays), `mod` takes the divisor's sign,
`minimum`/`maximum` propagate nan while `fmin`/`fmax` ignore it.

### Sorting & reductions — and the bug hiding under them
`HardenCompare`/`HardenArgMinMax` fill the dtype slots for `real_mp`:
`np.sort`, `np.argsort`, `np.searchsorted`, `np.median`,
`np.argmax`/`argmin`, `np.max`/`min` (complex stays unordered by
design). `np.sum`/`np.prod`/`np.mean` work (verified numpy 2.3.2 &
2.4.6, regression-tested) — but investigating them exposed the real
hazard: **`getitem` returned `boost::ref` into the numpy buffer** (stock
eigenpy behavior), so a scalar extracted from a temporary array dangled
— `np.mean` returned a silently wrong `0`, and `s = np.sum(v)` kept past
the statement SIGABRT'd in `str()`. This was the root of the ADR-0031 /
#259 aliasing class. `getitem` now returns an **owned copy**, killing
the class at the source.

### The float64 boundary: closed for values, open for tolerance
comparisons
`double→mp` casts stay **unsafe** — floats must not pollute
polynomial-system construction. But tolerance *orderings* against a
double are registered (`np.abs(a - b) < 1e-10`, both operand orders): a
comparison yields a bool, no float value enters an mp computation, and
the compare is exact (1e-22 is not lost against a 1e-30 tolerance).
Mirrors the C++ solvers' double `ToleranceT` and the scalar
`GreatLessVisitor<T,double>` precedent. Mixed equality and arithmetic
stay blocked; `np.isclose`/`allclose` still raise (they *compute* with
float64 tolerances). `arr.astype(float)` / `astype(complex)` are the
explicit conscious truncations (`mp→complex128` cast registered unsafe;
it never had been). Registration-order note: casts must register
**before** the ufunc loops, or numpy permanently ignores them.

### Component access on complex arrays — "a crash is better than
incorrect values"
numpy's `ndarray.real`/`.imag` are C getsets gated on
`PyArray_ISCOMPLEX`, a hardwired builtin-type check with no user-dtype
hook (verified against numpy 2.4.x `getset.c`; neither the legacy API
nor NEP 42 offers one) — on mp-complex arrays they return silently wrong
values. Defended everywhere reachable:
- **`Solution` overrides `.real`/`.imag`** at the subclass level — solve
results are simply correct;
- **`bertini._numpy_guard`**, installed at import, wraps
`np.real`/`np.imag`/`np.angle` to raise a `TypeError` naming the right
tool on plain mp-complex input (pass-through otherwise; `angle` raises
for every mp-complex input since it branches on dtype);
- sanctioned array accessors `bertini.real/imag/arg` and
`mp.real/imag/arg` (en route: fixed the scalar `mp.imag`, which had
returned the **real** part since it was written — copy-paste bug);
- the raw attributes on a plain self-built ndarray are the one
unreachable spelling — documented, pinned by test, and tracked
upstream-ward in #307.

### One namespace for the whole vocabulary
`bertini.operators` is now polymorphic: `from bertini.operators import
*` gives `sin`/`cos`/`exp`/... that dispatch per argument (symbolic node
for a Variable/expression; numeric through the native
precision-preserving loops for mp scalars, numpy containers, lists, and
plain numbers), plus
`abs`/`arg`/`real`/`imag`/`conj`/`round`/`sum`/`norm`/`is_real` and
`E`/`Pi`/`I`. The top-level elementary functions are rebound to the
polymorphic versions (a strict superset), and `bertini.arg` + the
hyperbolics join the top level. No more remembering that `arg` lives in
`bertini.multiprec` while `imag` is top-level.

### #305 reconciliation
Develop (the UI QoL batch) is merged in. Its interim
`bertini.real/imag/abs/conj/sum/norm/is_real` helpers keep their API but
now ride the native ufunc loops on mp-dtype arrays, falling back to
element-wise work for lists. Two latent #305 bugs fixed: the helpers'
scalar fallbacks called bare `abs()`/`round()`, which resolve to the
module's own shadowing names → infinite recursion on plain python input;
and the docs claim that the numpy boundary "cannot be patched in the
bindings" (this PR is the patch).

## Tests / verification
- `numpy_ufuncs_test.py` (~120) + `operators_test.py` (17): exact
agreement with the scalar functions, semantics corners, precision
preservation through every loop shape (including the mixed real/complex
division path — loops re-tag via `at_precision_of`), unwritten-slot
safety per ADR-0006, sorting incl. nan-wins argmax, reductions,
tolerance comparisons, astype, guard behavior, polymorphic dispatch, and
named regressions for the dangling-scalar, `mp.imag`, and
shadowed-builtin bugs.
- Full suite **793 passed**; **169 sphinx doctests** green (the numpy
page is executable); doclint clean; end-to-end verified on a
precision-40 solve with numpy ops on the returned solutions.

## Linked issues
- Closes #298 — `.real` in numpy doesn't get the real part: Solution
overrides + guarded numpy functions +
`bertini.real/imag`/`mp.real/imag/arg` accessors; the scalar `mp.imag`
bug found under it is fixed. The unreachable plain-attribute residue is
#307.
- Closes #301 — `np.abs(complex_mp)` / `np.round(complex_mp)` and the
rest of the ufunc family now just work.
- #307 (filed from this work) tracks the numpy-side shortcoming and the
watch-items for future numpy versions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
ofloveandhate added a commit that referenced this pull request Jul 9, 2026
Bump `VERSION` from `3.1.0.dev1` to **`3.1.0rc1`** — release candidate
for 3.1.0.

`VERSION` is the single source of truth (scikit-build-core reads it
dynamically; `publish.yml`'s
`check_version` asserts a release tag matches it). This one-line change
is docs-adjacent: `VERSION`
is in the workflow `paths-ignore`, so this PR runs **no CI** and needs
no approvals — it can merge
straight in.

3.1.0.dev1 → 3.1.0rc1 gathers the 3.1.0 line so far: the UI
quality-of-life batch (#305, issues
#293#304 + solution group projection), the numpy-compat overhaul
(#306), and `ZeroDimConfig.recall`
(#308).
ofloveandhate added a commit that referenced this pull request Jul 9, 2026
The changelog had drifted badly: the last entry was **2.0.1**, while
**2.0.2**, the entire
**3.0.0** modernization, and the **3.1.0** line had all shipped — with
their notes living only
in commit messages, PRs, and GitHub Releases. This consolidates them
back into `CHANGELOG.md`,
newest-first, in the existing *Keep a Changelog* format.

### New entries
- **[3.1.0] – 2026-07-09** — NumPy interop for the mp dtypes (#306), the
Python UI
quality-of-life batch (#293#304, #305), `ZeroDimConfig.recall` (#308),
prebuilt CI deps
  (ADR-0049, #282), and docs-store Pages (ADR-0050, #291, #292).
- **[3.0.0] – 2026-07-07** — reworked from the hand-written v3.0.0
release notes (~70 PRs; full
  themed index in #238) into Added / Changed / Fixed sections.
- **[2.0.2] – 2026-05-22** — the packaging/CI maintenance entry that was
never recorded.

Older 1.0.x / 2.0.1 entries and the commented template are untouched.

### Why now (load-bearing)
`publish.yml`'s `github-release` job builds the release body from the
**top** `CHANGELOG.md`
block. That block was the stale **[2.0.1]** — so a final `v3.1.0` tag
would have published 2.0.1's
notes as the 3.1.0 release. With this merged, the extraction yields
exactly the **[3.1.0]** block
(verified locally against the workflow's extraction logic).

Docs-only (`**/*.md` → `paths-ignore`), so this runs no CI. **Merge
before tagging `v3.1.0`.**
ofloveandhate added a commit that referenced this pull request Jul 9, 2026
…ns (v3.1.0 docs deploy) (#313)

The **v3.1.0** versioned-docs deploy failed. `build_docs.yml` runs
`sphinx-build -b html -W`
(warnings-as-errors), and three docstrings wrote absolute-value / norm
notation with **bare pipes**:

```
ERROR: Undefined substitution referenced: "p_i - q_i"  — bertini.multiprec.is_distinct_up_to
ERROR: Undefined substitution referenced: "p_i - q_i"  — bertini.is_distinct_up_to  (top-level re-export)
ERROR: Undefined substitution referenced: "imag"        — bertini.operators.is_real
```

In reStructuredText `|word|` is a **substitution reference**, so Sphinx
tried to resolve undefined
substitutions `p_i - q_i` and `imag` and errored out.
`is_distinct_up_to` (#305) and `is_real` (#306)
both landed in the 3.1.0 line.

### Fix
Reword the two source strings to `abs(...)` — `max_i abs(p_i - q_i)` and
`abs(imag) < tol` — which
read cleanly in Sphinx HTML *and* plain `help()` and carry no RST
metacharacters. The other `|…|`
docstrings in the tree are already safe (wrapped in ``inline literals``
or inside code blocks) and
are left untouched.

### Not a package problem
Docstrings only — the released wheels are unaffected. **3.1.0 is already
on PyPI and the GitHub
Release is published**; only the docs-site deploy failed.

### Verified
Reproduced the exact failing command locally (`sphinx-build -b html -W
--keep-going`): now
**`build succeeded`**, zero substitution errors.

### Root-cause note (for the post-3.1.0 CI pass)
The `-b html -W` build runs **only at docs-deploy time**, not in PR CI
(PR CI runs `-b doctest`,
which passed). So this whole class of RST/docstring error is invisible
until release. Running the
`-W` html build in PR CI would have caught it — worth adding alongside
#312.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TypeError when getting the variable group of a system.

1 participant