Skip to content

feat: add complex-number (phasor) arithmetic support - #8

Merged
Mearman merged 8 commits into
mainfrom
feat/complex-arithmetic
Sep 1, 2026
Merged

Mearman merged 8 commits into
mainfrom
feat/complex-arithmetic

Conversation

@Mearman

@Mearman Mearman commented Sep 1, 2026

Copy link
Copy Markdown
Member

Closes #2

Adds complex as a computed-value kind, complexLiteral as an expression node, and complex support across arithmetic, compare, memberOf and negate. 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 no form discriminant, and four exported conversion helpers for the polar view. Three reasons, in the order I weighted them:

  1. A second form makes equality ambiguous. Polar doesn't encode a value uniquely: phase is only defined modulo a full turn, and a zero-magnitude value has no meaningful phase at all, so the same number has unboundedly many polar encodings. eq/memberOf are 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.
  2. A discriminant would double the branching in every operator (quadruple it for a binary one) while changing no value — every operator would convert to rectangular internally anyway, because that's where the closed forms are.
  3. Rectangular is what the operators need. add/subtract are component-wise in it; multiply, divide, negate and integer power all 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 / complexLiteralFromPolar going in, complexMagnitude / complexPhase coming back out — magnitude as a real number in the value's own unit, phase as a dimensionless real number of radians.

Semantics

  • add/subtract component-wise, requiring identical unit maps exactly as real numbers do.
  • multiply/divide as 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.
  • power for 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-zero domain-error. Dimensionless operands required, same as real power.
  • modulo is domain-error.
  • gt/gte/lt/lte are wrong-type for a complex operand; eq/neq are exact equality across both components, and memberOf matches the same way.
  • negate flips 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 an instant and a duration. 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.

compare stays kind-strict, so complex-vs-real is wrong-type there. 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 an instant is never compared against a plain number despite being a millisecond count underneath.

The one deliberate limitation

power with a non-integer real exponent, or with a complex exponent, is wrong-type rather 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-type rather than domain-error is the consistent reading of those two codes here: domain-error is for cases where no answer exists (division by zero, an empty max), wrong-type for "an answer exists, but this operator doesn't accept this operand" — which is also how power'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 (call resolves through a consumer-supplied registry by design). A tree that needs |z| > limit registers a one-line function over the exported helper:

const functions: FunctionRegistry = {
  magnitude: (args) =>
    args[0]?.kind === "complex"
      ? complexMagnitude(args[0])
      : { domainError: "expected a complex argument" },
};

test/integration/complex-arithmetic.test.ts exercises 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's unit expectation now accepts a complex resolution. It previously required the resolved value to be a number, 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) and workers (9), plus pnpm lint, pnpm typecheck, publint and attw --pack.

@Mearman
Mearman marked this pull request as ready for review September 1, 2026 13:51
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-01T13:59:50.863241Z 372687f Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

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
Mearman force-pushed the feat/complex-arithmetic branch from f41e60c to 7e1ee69 Compare September 1, 2026 15:13
@Mearman
Mearman merged commit 52215aa into main Sep 1, 2026
12 checks passed
@Mearman
Mearman deleted the feat/complex-arithmetic branch September 1, 2026 15:14
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.2.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add complex-number (phasor) arithmetic support

1 participant