feat: add complex-number (phasor) arithmetic support - #8
Merged
Merged
Conversation
Mearman
marked this pull request as ready for review
September 1, 2026 13:51
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Every numeric value was real-valued until now, so a formula needing a complex quantity had to be broken up and handed out through delegate. The complex kind stores one canonical rectangular form, a real and an imaginary component, with the same optional dimensional unit a real number carries. arithmetic gains complex operands throughout: add/subtract component-wise under the identical-units rule real numbers already require, multiply/divide as real complex multiplication and division combining units dimensionally, power by repeated multiplication for a real integer exponent, and modulo as domain-error since the complex plane has no canonical remainder. A real operand meeting a complex one is promoted rather than rejected, since every real is a complex with a zero imaginary part, so one tree can mix real and complex terms freely. compare keeps its ordering operators undefined for complex operands and its kind-strictness intact; eq/neq are exact equality across both components. negate flips both components, memberOf matches on both, and a reference may declare an expected unit against a complex resolution just as it can against a real one.
The complex kind stores one canonical rectangular form, so magnitude and phase have no representation of their own to be read from or written to. complexFromPolar and complexLiteralFromPolar convert into that form when authoring a value or a literal node; complexMagnitude and complexPhase convert back out of it, the magnitude carrying the value's own unit and the phase dimensionless in radians. Keeping these as conversions at the edges is what lets every arithmetic operator work on one representation rather than branching on which form each operand happens to be stored in.
Covers what the per-node tests cannot: complex operands arriving from a resolver, combined with a real one in the same expression, and the one route from a complex value back to an ordering comparison, which is a registry function over the exported complexMagnitude helper rather than an accessor node kind in the grammar. Adds the missing complexLiteral row to the indeterminacy reference table's own per-literal coverage.
Adds a Complex values section alongside Temporal values, covering the representation decision and its reasoning, the operator-by-operator semantics including power's real-integer-exponent limit, why a real operand is promoted in arithmetic but not in a comparison, and how a tree reaches an ordering comparison over a complex quantity. Out of scope loses its complex-number bullet and gains a note on why the original sizing judgement was wrong, and Design principles gains the scope test that judgement is now written down as: a numeric extension staying within closed-form evaluation belongs here, however unlike the existing kinds it looks, while a different kind of computation stays behind delegate.
…angular on evaluation A complexLiteral node can now be written as either rectangular (re/im) or polar (magnitude/phase), told apart structurally by which fields are present rather than a form tag. Both shapes share the one kind literal, so z.discriminatedUnion can't host them as direct siblings of every other expression kind (it throws on a duplicate discriminator). ExpressionNodeSchema now folds the literal's own rect/polar union in alongside a discriminated union of everything else, via a plain z.union. The evaluator normalises whichever form was authored into the single rectangular ComputedValue immediately, reusing the existing complexFromPolar helper for the polar case. Every downstream operator (arithmetic, compare, memberOf, negate) still only ever sees that one canonical shape, so none of them needed to change.
…o rectangular Schema-level: both the rectangular and polar shapes parse on their own, and every mixed or incomplete combination (both forms at once, neither, a partial rectangular payload, one field from each form) is rejected. Evaluator-level: a polar literal evaluates to the expected rectangular ComputedValue, and a rectangular literal and a polar literal representing the same underlying number compare eq and match via memberOf, which is what actually proves the normalisation lands on the right value rather than merely producing something plausible. Integration-level: a polar-authored literal composes through arithmetic and through an ordering comparison exactly as a rectangular one already does. ExpressionNodeSchema is no longer a single flat discriminated union (see the paired feat commit), so the two schema-conversion consistency tests that read a union's kind discriminants off its own .options now recurse into a nested union instead of assuming every option is a flat object, and the one assertion that expected ExpressionNode's generated JSON Schema to carry a top-level oneOf now expects anyOf, matching what a plain z.union actually converts to.
Complex values gains a paragraph explaining that the complexLiteral node accepts either authoring form, structurally discriminated, and that ComputedValue's own complex kind is untouched by this since the evaluator normalises to rectangular immediately. The expression tree's type sketch and the literals paragraph are updated to show both complexLiteral shapes rather than only the rectangular one.
The eq and memberOf tests demonstrating that a polar-authored
complexLiteral compares equal to a rectangular one both used phase:
0, so a broken conversion that ignored phase entirely (always
returning { re: magnitude, im: 0 }) would still satisfy them.
Use phase 0.7 instead, with the rectangular side's re/im computed
from the raw trig formula rather than via the conversion helper
under test, so a phase-ignoring bug or a sin/cos swap produces a
rectangular value that actually diverges from the polar literal's
evaluated result.
Mearman
force-pushed
the
feat/complex-arithmetic
branch
from
September 1, 2026 15:13
f41e60c to
7e1ee69
Compare
|
🎉 This PR is included in version 1.2.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2
Adds
complexas a computed-value kind,complexLiteralas an expression node, and complex support acrossarithmetic,compare,memberOfandnegate. Complex/phasor arithmetic comes out of the exclusion list, and Design principles gains the scope test that call is now written down as.The representation decision
The issue flagged rectangular vs. polar as an open question. I went with one canonical rectangular form —
{ kind: "complex"; re: number; im: number; unit?: Unit }— with noformdiscriminant, and four exported conversion helpers for the polar view. Three reasons, in the order I weighted them:eq/memberOfare exact equality everywhere else in this package; supporting two forms would mean either normalising on every comparison or inventing an approximate equality for this one kind. Neither is something I wanted to introduce for a representational choice.add/subtractare component-wise in it;multiply,divide,negateand integerpowerall have standard closed forms in it. Polar's advantage (multiply as one product of magnitudes plus one sum of angles) doesn't extend to addition at all.So polar lives at the edges as conversions rather than as a second encoding:
complexFromPolar/complexLiteralFromPolargoing in,complexMagnitude/complexPhasecoming back out — magnitude as a real number in the value's own unit, phase as a dimensionless real number of radians.Semantics
add/subtractcomponent-wise, requiring identical unit maps exactly as real numbers do.multiply/divideas real complex multiplication and division, units combining by the same dimensional analysis. A zero divisor means both components zero — a purely imaginary divisor divides fine, which has its own test.powerfor a real integer exponent, as the repeated multiplication that integer exponentiation is; a negative exponent reuses the divide above rather than re-deriving the reciprocal, so a zero base inherits that operator's own division-by-zerodomain-error. Dimensionless operands required, same as realpower.moduloisdomain-error.gt/gte/lt/ltearewrong-typefor a complex operand;eq/neqare exact equality across both components, andmemberOfmatches the same way.negateflips both components.Two judgement calls beyond what the issue spelled out, both documented in the README:
A real operand is promoted in
arithmetic, not rejected. The issue's own motivation is formulas mixing real and complex terms in one tree, and scaling or offsetting a complex value by a real one is the common case — forcing every real literal to be rewritten as a complex one would defeat the point. The promotion is exact, total and canonical (every real is a complex with zero imaginary part), unlike the temporal cross-kind combinations, which had to be enumerated one by one precisely because no such embedding exists between aninstantand aduration. The result is complex whenever either operand is, even when the imaginary part comes out zero: a node's result kind follows its operand kinds, never the values flowing through it.comparestays kind-strict, so complex-vs-real iswrong-typethere. That asymmetry with arithmetic is deliberate: arithmetic produces a value, so promoting loses nothing, whereas a comparison consumes two and this design already treats a kind difference between them as a modelling error worth surfacing — the same reason aninstantis never compared against a plainnumberdespite being a millisecond count underneath.The one deliberate limitation
powerwith a non-integer real exponent, or with a complex exponent, iswrong-typerather than supported. An arbitrary complex exponent needs the complex logarithm, which is multivalued and so needs a branch-cut convention this design hasn't chosen.wrong-typerather thandomain-erroris the consistent reading of those two codes here:domain-erroris for cases where no answer exists (division by zero, an emptymax),wrong-typefor "an answer exists, but this operator doesn't accept this operand" — which is also howpower's existing dimensionless-operands requirement is already classified.Reaching an ordering comparison over a complex quantity
There's no accessor node kind in the grammar, and no built-in function set to add one to (
callresolves through a consumer-supplied registry by design). A tree that needs|z| > limitregisters a one-line function over the exported helper:test/integration/complex-arithmetic.test.tsexercises exactly that end to end, alongside complex operands arriving from a resolver and being combined with a real one in the same expression.Also worth a look
reference'sunitexpectation now accepts a complex resolution. It previously required the resolved value to be anumber, which would have left a complex-valued reference unable to declare its unit at all — a hole in the feature rather than a separate change.Testing
Built strictly red-green: every behaviour above has a test that failed first for the right reason. Locally green across
unit(364),integration(55),smoke(63) andworkers(9), pluspnpm lint,pnpm typecheck,publintandattw --pack.