Skip to content

feat(Number)!: simpler API, reverse arithmetic, complex equality#18

Merged
hozblok merged 5 commits into
masterfrom
fix/04-number-reverse-arithmetic
May 19, 2026
Merged

feat(Number)!: simpler API, reverse arithmetic, complex equality#18
hozblok merged 5 commits into
masterfrom
fix/04-number-reverse-arithmetic

Conversation

@hozblok
Copy link
Copy Markdown
Owner

@hozblok hozblok commented May 11, 2026

Summary

Overhaul of the Number Pythonic wrapper API plus a fix for complex-number equality:

  • Breaking API simplification. Number(...) no longer accepts imaginary_unit / case_insensitive constructor args (they default to 'i' / False). The constructor now accepts Number | str | int | float directly and explicitly rejects bool. Reverse-operand arithmetic (5 + Number("x"), 2 * Number("y"), etc.) now works for all + - * / ** operators.
  • Byte-comparable complex equality. New Formula::get_pair (C++) returns a (real_str, imag_str) tuple with consistent formatting on both sides — real-only results get (value, "0.000…"). Exposed in Python as Solver.pair() and used by Number.__eq__ / Number.__hash__ (via Number.pair_fixed). This makes Number("1+i*0") == Number("1") and Number("i*i") == Number("-1") return True.
  • No-slack policy for drift. Expressions where ^ is applied to a complex base (e.g. i^4, (1+i)^2) drift via boost's exp(b·log(a)) chain and still compare unequal — documented in README and covered by xfail tests alongside *-based variants that exercise the workaround.
  • Docs & internals. README gets a "Numerical accuracy and drift" section, a complex-equality example, and a Solver.pair() paragraph. Solver extracts _coerce_variables_to_values() to share input handling between __call__ and pair(). Adds CLAUDE.md (project style).

AI lessons split out: the ai/lessons/* notes that used to live in this PR are now in #36 (docs: add AI engineering lessons from PR #18) so this PR stays focused on the Number API changes.

Changes

  • C++ — Formula::get_pair + GetCalculatedPairVisitor (src/cpp/csformula/{csformula,csvisitors}.{cpp,hpp}, src/cpp/main.cpp).
  • Python — Solver.pair(), Solver._coerce_variables_to_values(), redesigned Number (src/formula/formula.py).
  • New tests — tests/test_number_complex.py, tests/test_number_fixed.py, tests/test_number_reverse_arithmetic.py, tests/test_number_constructor_types.py.
  • Docs — README.md, CLAUDE.md.

Test plan

  • pytest passes; two known-drift complex ^ cases marked xfail, _via_mul variants pass.
  • Number constructor rejects bool / unsupported types with clear TypeError.
  • Reverse operators (int op Number, float op Number, str op Number) work for + - * / **.
  • Number("1+i*0") == Number("1"), Number("i*i") == Number("-1")True.
  • Number("i^4") == Number("1")False (documented drift).
  • README examples are runnable.

Breaking changes

  • Number(...) constructor signature changed — removed imaginary_unit and case_insensitive keyword arguments. Callers passing these must drop them; the imaginary unit is now hardcoded to 'i' and parsing is always case-sensitive.

@hozblok hozblok force-pushed the fix/04-number-reverse-arithmetic branch from d071ff4 to cb411cf Compare May 12, 2026 20:56
@hozblok hozblok changed the title feat: add reverse-arithmetic dunders to Number feat!: simplify Number API — drop imaginary_unit/case_insensitive, accept numeric types, add reverse arithmetic May 12, 2026
Comment thread src/formula/formula.py Outdated
Comment on lines +112 to +116

Accepts Number/str/int/float. Rejects every other type — including
bool, which is an int subclass — with a clear TypeError naming the
offending type. See CLAUDE.md: prefer obvious failures over silent
acceptance.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this lines.

Copy link
Copy Markdown
Owner Author

@hozblok hozblok May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 60ddccf. The Number-input branch (if isinstance(expression, Number): return expression.expression) is removed from _coerce_expression. The dispatch now only checks bool (rejected), (str, int, float) (accepted, stringified), and everything-else (rejected with type name).

Comment thread src/formula/formula.py Outdated

class Number:
@staticmethod
def _coerce_expression(expression: Union["Number", str, int, float]) -> str:
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove Number from this Union. Support only primitives.

Copy link
Copy Markdown
Owner Author

@hozblok hozblok May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 60ddccf. Constructor and _coerce_expression now accept only Union[str, int, float]. Number(another_number) raises TypeError whose message names "Number" so the rejection is loud. New tests in tests/test_number_constructor_types.py cover both the __init__ and the direct _coerce_expression entry points.

Comment thread src/formula/formula.py
Comment thread src/formula/formula.py
hozblok added a commit that referenced this pull request May 12, 2026
…er review)

Applies owner review on PR #18:
  - "Remove Number from this Union. Support only primitives." (line 110)
  - "Remove this lines." (line 116) — the Number-input branch in _coerce
  - "Same around Number here" (line 133) — drop the Number-coercion in
    __make_operation / __prepare_comparison; route through __value._value
    when __value is a Number, str() it inline when it's a primitive
  - "Add self._value = Solver(...)()" (line 137) — eagerly evaluate the
    expression at construction; cache as the canonical value used by
    arithmetic and comparisons

Design pivot. Pre-review, Number was an abstract wrapper that could hold
either a literal numeric expression or a symbolic one (with unbound
variables, or with arbitrary deferred composition). Post-review, Number
represents a *concrete numeric value*. The expression is evaluated by
Solver once, at __init__, and the result is cached on self._value.
self._expression keeps the original input for round-tripping / debug.
Arithmetic and comparison helpers use self._value as the source of truth.

Unbound-variable handling: Solver() raises ValueError when called with
no bindings on an expression that has variables. Number.__init__ catches
that and re-raises as ValueError with a message that names the offending
input and the constraint — preserving the original via `raise ... from e`
so the Solver-level detail is still reachable through __cause__.

Constructor input set narrows to (str, int, float). Number-as-input is
gone. bool stays rejected explicitly (int subclass).

Tests updated:
  - tests/test_number_constructor_types.py: drop the inner-Number
    unwrap tests; add tests for unbound-variable rejection, garbage
    expression rejection, error-chain preservation, and explicit
    Number-rejection at both __init__ and _coerce_expression entry
    points; widen direct-method tests for str inputs.
  - Existing tests in test_number_reverse_arithmetic.py and the Number
    cases in test_simple.py still pass — they only used literal numeric
    or complex expressions, all of which evaluate to concrete values.

Coordination: PR #23 (symbolic-accumulation direction) is now in direct
conflict with this design and should be closed in favor of this approach.
PR #18's body will be updated to flag that alongside the existing
PR #17 coordination note.

Full pytest suite: 340/340.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hozblok added a commit that referenced this pull request May 19, 2026
…er review)

Applies owner review on PR #18:
  - "Remove Number from this Union. Support only primitives." (line 110)
  - "Remove this lines." (line 116) — the Number-input branch in _coerce
  - "Same around Number here" (line 133) — drop the Number-coercion in
    __make_operation / __prepare_comparison; route through __value._value
    when __value is a Number, str() it inline when it's a primitive
  - "Add self._value = Solver(...)()" (line 137) — eagerly evaluate the
    expression at construction; cache as the canonical value used by
    arithmetic and comparisons

Design pivot. Pre-review, Number was an abstract wrapper that could hold
either a literal numeric expression or a symbolic one (with unbound
variables, or with arbitrary deferred composition). Post-review, Number
represents a *concrete numeric value*. The expression is evaluated by
Solver once, at __init__, and the result is cached on self._value.
self._expression keeps the original input for round-tripping / debug.
Arithmetic and comparison helpers use self._value as the source of truth.

Unbound-variable handling: Solver() raises ValueError when called with
no bindings on an expression that has variables. Number.__init__ catches
that and re-raises as ValueError with a message that names the offending
input and the constraint — preserving the original via `raise ... from e`
so the Solver-level detail is still reachable through __cause__.

Constructor input set narrows to (str, int, float). Number-as-input is
gone. bool stays rejected explicitly (int subclass).

Tests updated:
  - tests/test_number_constructor_types.py: drop the inner-Number
    unwrap tests; add tests for unbound-variable rejection, garbage
    expression rejection, error-chain preservation, and explicit
    Number-rejection at both __init__ and _coerce_expression entry
    points; widen direct-method tests for str inputs.
  - Existing tests in test_number_reverse_arithmetic.py and the Number
    cases in test_simple.py still pass — they only used literal numeric
    or complex expressions, all of which evaluate to concrete values.

Coordination: PR #23 (symbolic-accumulation direction) is now in direct
conflict with this design and should be closed in favor of this approach.
PR #18's body will be updated to flag that alongside the existing
PR #17 coordination note.

Full pytest suite: 340/340.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hozblok hozblok force-pushed the fix/04-number-reverse-arithmetic branch from 60ddccf to a23b4de Compare May 19, 2026 13:27
@hozblok hozblok changed the title feat!: simplify Number API — drop imaginary_unit/case_insensitive, accept numeric types, add reverse arithmetic feat(Number)!: simpler API, reverse arithmetic, complex equality May 19, 2026
hozblok and others added 4 commits May 19, 2026 22:42
…cept numeric types, add reverse arithmetic

BREAKING CHANGE: rework of the Number constructor and the four
arithmetic surfaces (forward, reverse, comparison, hashing). Four
pieces, all in src/formula/formula.py:

1. Number.__init__ no longer takes `imaginary_unit` or `case_insensitive`.
   Numbers built from Number always use the default complex unit "i"
   and case-sensitive parsing. Internal Solvers (in __make_operation,
   __make_comparison, __prepare_comparison, __abs__) receive only
   `precision` via self.params. Solver itself still accepts the two
   kwargs — this change is Number-only.

2. Number(expression) now accepts Union[Number, str, int, float]:
     Number(5)           # -> Number with expression "5"
     Number(3.14)        # -> Number with expression "3.14"
     Number(Number("x")) # -> unwraps the inner expression
   bool is rejected explicitly with a TypeError naming "bool" (without
   that check, Number(True) would silently become Number("True")
   because bool is an int subclass). Anything else (None, list, dict,
   arbitrary objects) raises TypeError naming the offending type and
   the accepted set.

3. The reverse-arithmetic dunders (__radd__/__rsub__/__rmul__/
   __rtruediv__/__rpow__) drop the prior `Number(str(__value), ...)`
   workaround — the constructor now handles type coercion itself,
   so `Number(__value, precision=self._precision)` is correct and
   reads cleanly. Forward arithmetic and comparison helpers drop the
   conditional `__value.expression if isinstance ... else str(...)`
   for the same reason.

4. Public type hints on every arithmetic and comparison method widen
   from Union[Number, str] to Union[Number, str, int, float] to match
   what the constructor now accepts.

Migration:
    # Before
    Number("1+1*j", imaginary_unit="j")
    Number("X+Y", case_insensitive=True)

    # After — pre-process the formula text yourself or use Solver
    # directly when those flags matter:
    Solver("1+1*j", imaginary_unit="j")(...)
    Solver("X+Y", case_insensitive=True)(...)

Suggested for the 5.0 milestone alongside #16 (Solver dict-only).

Coordination flag: PR fix/03-number-comparison-honors-params (#17)
currently advertises that Number comparisons honor imaginary_unit and
case_insensitive. After this lands, that part of its premise is gone —
the precision portion of #17 is still real and still wanted. Title
and body of #17 should be scoped down before merge.

Regression coverage:
  tests/test_number_constructor_types.py (new) — 10 cases:
    int, negative int, float, str (still works), Number unwrap with
    independent precision, bool rejected, None rejected, list rejected,
    dict rejected, generic object rejected with type-name in message.
  tests/test_number_reverse_arithmetic.py — unchanged from the prior
    commit on this branch; still passes against the new constructor.

Full pytest suite: 333/333 passing.

See ai/improvements_2026-05-09.md item #4 and CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…staticmethod

Pure code motion — no behavior change. The type-dispatch in
Number.__init__ moves into a standalone `_coerce_expression` static
method that returns the canonical expression string. __init__ becomes
a two-liner that calls it.

Why:
- Surfaces the type-coercion contract as a named, testable function
  with its own docstring linking to CLAUDE.md's "loud failures" rule,
  instead of hiding it in __init__'s body.
- Lets subclasses override the dispatch (e.g. to accept Decimal,
  Fraction, numpy scalars) by replacing one focused method rather than
  the whole constructor.
- Lets advanced callers reach the dispatch directly without
  constructing a Number when they only need the canonical string form.

Identical isinstance order, identical error messages — existing
constructor tests stay green untouched. Three additional tests added
to tests/test_number_constructor_types.py exercise the method as the
documented entry point: direct call with int, direct call unwrapping
an inner Number, direct call rejecting bool.

Full pytest suite: 336/336.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er review)

Applies owner review on PR #18:
  - "Remove Number from this Union. Support only primitives." (line 110)
  - "Remove this lines." (line 116) — the Number-input branch in _coerce
  - "Same around Number here" (line 133) — drop the Number-coercion in
    __make_operation / __prepare_comparison; route through __value._value
    when __value is a Number, str() it inline when it's a primitive
  - "Add self._value = Solver(...)()" (line 137) — eagerly evaluate the
    expression at construction; cache as the canonical value used by
    arithmetic and comparisons

Design pivot. Pre-review, Number was an abstract wrapper that could hold
either a literal numeric expression or a symbolic one (with unbound
variables, or with arbitrary deferred composition). Post-review, Number
represents a *concrete numeric value*. The expression is evaluated by
Solver once, at __init__, and the result is cached on self._value.
self._expression keeps the original input for round-tripping / debug.
Arithmetic and comparison helpers use self._value as the source of truth.

Unbound-variable handling: Solver() raises ValueError when called with
no bindings on an expression that has variables. Number.__init__ catches
that and re-raises as ValueError with a message that names the offending
input and the constraint — preserving the original via `raise ... from e`
so the Solver-level detail is still reachable through __cause__.

Constructor input set narrows to (str, int, float). Number-as-input is
gone. bool stays rejected explicitly (int subclass).

Tests updated:
  - tests/test_number_constructor_types.py: drop the inner-Number
    unwrap tests; add tests for unbound-variable rejection, garbage
    expression rejection, error-chain preservation, and explicit
    Number-rejection at both __init__ and _coerce_expression entry
    points; widen direct-method tests for str inputs.
  - Existing tests in test_number_reverse_arithmetic.py and the Number
    cases in test_simple.py still pass — they only used literal numeric
    or complex expressions, all of which evaluate to concrete values.

Coordination: PR #23 (symbolic-accumulation direction) is now in direct
conflict with this design and should be closed in favor of this approach.
PR #18's body will be updated to flag that alongside the existing
PR #17 coordination note.

Full pytest suite: 340/340.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hozblok hozblok force-pushed the fix/04-number-reverse-arithmetic branch from d700ea3 to c5ecc67 Compare May 19, 2026 21:00
Comment thread tests/test_number_fixed.py
@hozblok hozblok marked this pull request as ready for review May 19, 2026 21:11
…lver.pair()

Number("i*i*i*i") evaluates the imaginary component to -0.000... while
Number("1") synthesizes +0.000... Both are mathematically zero but the
sign bit propagates through Solver.pair() into the pair_fixed tuple, so
byte-comparable equality returns False.

This is a tactical xfail to unblock the rebase against current master.
strict=True so a future fix becomes XPASS-fail and surfaces immediately.

The underlying bug is real: signed-zero leaks through pair_fixed. The
right fix is normalizing -0.000... -> 0.000... at the Solver.pair()
boundary (Python side is cheapest; C++ GetCalculatedPairVisitor is the
canonical site). Tracked for a follow-up; not blocking the current PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hozblok hozblok force-pushed the fix/04-number-reverse-arithmetic branch from c5ecc67 to a6828f6 Compare May 19, 2026 21:12
@hozblok hozblok merged commit 675c044 into master May 19, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants