From e0765b331a40cdae439fec1664ae0d833b57744e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 1 Sep 2026 14:37:32 +0100 Subject: [PATCH 1/8] feat: add a complex computed-value kind and complexLiteral node 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. --- src/computed-value.test.ts | 20 ++ src/computed-value.ts | 7 + src/evaluator.test.ts | 510 +++++++++++++++++++++++++++++++++++++ src/evaluator.ts | 188 +++++++++++++- src/tree.test.ts | 13 + src/tree.ts | 10 + 6 files changed, 745 insertions(+), 3 deletions(-) create mode 100644 src/computed-value.test.ts diff --git a/src/computed-value.test.ts b/src/computed-value.test.ts new file mode 100644 index 0000000..eff4541 --- /dev/null +++ b/src/computed-value.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { ComputedValueSchema } from "./computed-value"; + +describe("the complex computed-value kind", () => { + it("parses with and without an optional unit", () => { + const withoutUnit = { kind: "complex", re: 3, im: 4 }; + const withUnit = { kind: "complex", re: 3, im: 4, unit: { V: 1, A: -1 } }; + expect(ComputedValueSchema.parse(withoutUnit)).toEqual(withoutUnit); + expect(ComputedValueSchema.parse(withUnit)).toEqual(withUnit); + }); + + it("rejects a value carrying only one of the two components", () => { + expect( + ComputedValueSchema.safeParse({ kind: "complex", re: 3 }).success, + ).toBe(false); + expect( + ComputedValueSchema.safeParse({ kind: "complex", im: 4 }).success, + ).toBe(false); + }); +}); diff --git a/src/computed-value.ts b/src/computed-value.ts index 02698f0..90c213d 100644 --- a/src/computed-value.ts +++ b/src/computed-value.ts @@ -22,6 +22,13 @@ export const ComputedValueSchema = z.discriminatedUnion("kind", [ value: z.number(), unit: DurationUnitSchema, }), + /** Stored canonically in rectangular form -- `re` + `im`i -- rather than as a magnitude and phase, and with no `form` discriminant offering both: see the "Complex values" section of README.md for why one canonical form is the whole point, and `complex.ts` for the polar builder/accessor helpers that make the other form reachable without a second encoding of the same value. */ + z.object({ + kind: z.literal("complex"), + re: z.number(), + im: z.number(), + unit: UnitSchema.optional(), + }), ]); export type ComputedValue = z.infer; diff --git a/src/evaluator.test.ts b/src/evaluator.test.ts index c59417c..c9278e7 100644 --- a/src/evaluator.test.ts +++ b/src/evaluator.test.ts @@ -91,6 +91,22 @@ describe("literals", () => { }); }); +describe("complexLiteral", () => { + it("is always definite, preserving both components and the optional unit", async () => { + const result = await evaluateValue( + { kind: "complexLiteral", re: 3, im: -4, unit: { V: 1, A: -1 } }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "complex", + re: 3, + im: -4, + unit: { V: 1, A: -1 }, + }); + }); +}); + describe("reference", () => { it("resolves a found value with no expected unit", async () => { const result = await evaluateValue( @@ -146,6 +162,36 @@ describe("reference", () => { ); expectDefinite(result, { kind: "boolean", value: true }); }); + + it("checks an expected unit against a complex resolution too, since a complex value carries one as well", async () => { + const complexResolvers: Resolvers = { + ...resolvers, + resolveValue: async () => + Promise.resolve({ + found: true, + value: { kind: "complex", re: 3, im: -4, unit: { V: 1, A: -1 } }, + }), + }; + + const matching = await evaluateValue( + { kind: "reference", key: "impedance", unit: { V: 1, A: -1 } }, + undefined, + complexResolvers, + ); + expectDefinite(matching, { + kind: "complex", + re: 3, + im: -4, + unit: { V: 1, A: -1 }, + }); + + const mismatched = await evaluateValue( + { kind: "reference", key: "impedance", unit: { V: 1 } }, + undefined, + complexResolvers, + ); + expectIndeterminate(mismatched, "wrong-type"); + }); }); /** `arithmetic`'s own indeterminate-propagation table -- contrast this against `and`/`or`'s absorption (see truth-tables.test.ts and `combineAnd`/`combineOr` in evaluator.ts): arithmetic has no absorbing value at all, so a definite operand on one side never rescues an indeterminate operand on the other, unlike OR's absorbing `true` or AND's absorbing `false`. */ @@ -377,6 +423,340 @@ describe("arithmetic domain errors", () => { }); }); +describe("arithmetic on complex values", () => { + it("add combines component-wise, preserving the shared unit", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "complexLiteral", re: 3, im: 4, unit: { V: 1 } }, + right: { kind: "complexLiteral", re: 1, im: -6, unit: { V: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "complex", + re: 4, + im: -2, + unit: { V: 1 }, + }); + }); + + it("subtract combines component-wise", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "subtract", + left: { kind: "complexLiteral", re: 3, im: 4 }, + right: { kind: "complexLiteral", re: 1, im: -6 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 2, im: 10 }); + }); + + it("add is wrong-type on mismatched units, exactly as it is for real numbers", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "complexLiteral", re: 3, im: 4, unit: { V: 1 } }, + right: { kind: "complexLiteral", re: 1, im: -6, unit: { A: 1 } }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("subtract is wrong-type on mismatched units", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "subtract", + left: { kind: "complexLiteral", re: 3, im: 4, unit: { V: 1 } }, + right: { kind: "complexLiteral", re: 1, im: -6 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("multiply is real complex multiplication, not component-wise, and combines units dimensionally", async () => { + // (3 + 4i)(1 - 2i) = (3 + 8) + (4 - 6)i = 11 - 2i -- a component-wise product would have given 3 - 8i instead. + const result = await evaluateValue( + { + kind: "arithmetic", + op: "multiply", + left: { kind: "complexLiteral", re: 3, im: 4, unit: { A: 1 } }, + right: { kind: "complexLiteral", re: 1, im: -2, unit: { V: 1, A: -1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "complex", + re: 11, + im: -2, + unit: { V: 1 }, + }); + }); + + it("divide is real complex division, and combines units dimensionally", async () => { + // (11 - 2i) / (1 - 2i) = ((11 + 4) + (-2 + 22)i) / 5 = 3 + 4i -- the exact inverse of the multiply above, chosen so the expected components are exactly representable. + const result = await evaluateValue( + { + kind: "arithmetic", + op: "divide", + left: { kind: "complexLiteral", re: 11, im: -2, unit: { V: 1 } }, + right: { kind: "complexLiteral", re: 1, im: -2, unit: { V: 1, A: -1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 3, im: 4, unit: { A: 1 } }); + }); + + it("divide by complex zero is domain-error, the same category as real division by zero", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "divide", + left: { kind: "complexLiteral", re: 3, im: 4 }, + right: { kind: "complexLiteral", re: 0, im: 0 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + + it("divide by a purely imaginary value is an ordinary quotient, not a division by zero", async () => { + // A zero real part alone must not be mistaken for a zero divisor: (4 + 0i) / (0 + 2i) = -2i. + const result = await evaluateValue( + { + kind: "arithmetic", + op: "divide", + left: { kind: "complexLiteral", re: 4, im: 0 }, + right: { kind: "complexLiteral", re: 0, im: 2 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 0, im: -2, unit: {} }); + }); + + it("modulo is domain-error -- there is no remainder on the complex plane", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "modulo", + left: { kind: "complexLiteral", re: 7, im: 1 }, + right: { kind: "complexLiteral", re: 3, im: 1 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); +}); + +describe("complex power -- a real integer exponent only", () => { + it("raises to a positive integer exponent by repeated multiplication", async () => { + // (1 + i)^4 = ((1 + i)^2)^2 = (2i)^2 = -4. + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 1, im: 1 }, + right: { kind: "numberLiteral", value: 4 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: -4, im: 0, unit: {} }); + }); + + it("raises to a negative integer exponent as the reciprocal of the positive power", async () => { + // (1 + i)^-2 = 1 / (1 + i)^2 = 1 / 2i = -0.5i. + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 1, im: 1 }, + right: { kind: "numberLiteral", value: -2 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 0, im: -0.5, unit: {} }); + }); + + it("raises to a zero exponent as the complex unit, exactly as a real base does", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 3, im: 4 }, + right: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 1, im: 0, unit: {} }); + }); + + it("is domain-error for a zero base with a negative exponent, the same as a real base", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 0, im: 0 }, + right: { kind: "numberLiteral", value: -1 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + + it("is wrong-type for a non-integer exponent -- an answer this design does not define, not one that does not exist", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 1, im: 1 }, + right: { kind: "numberLiteral", value: 0.5 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type for a complex exponent, whatever the base", async () => { + const complexExponent = { kind: "complexLiteral", re: 1, im: 2 } as const; + const complexBase = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 1, im: 1 }, + right: complexExponent, + }, + undefined, + resolvers, + ); + expectIndeterminate(complexBase, "wrong-type"); + + const realBase = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "numberLiteral", value: 2 }, + right: complexExponent, + }, + undefined, + resolvers, + ); + expectIndeterminate(realBase, "wrong-type"); + }); + + it("requires dimensionless operands, exactly as a real power does", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 1, im: 1, unit: { V: 1 } }, + right: { kind: "numberLiteral", value: 2 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); +}); + +describe("arithmetic mixing real and complex operands", () => { + it("promotes a real operand to the complex plane rather than rejecting the pair", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "numberLiteral", value: 5, unit: { V: 1 } }, + right: { kind: "complexLiteral", re: 1, im: -6, unit: { V: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 6, im: -6, unit: { V: 1 } }); + }); + + it("scales a complex value by a real one, combining units dimensionally", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "multiply", + left: { kind: "complexLiteral", re: 3, im: 4, unit: { A: 1 } }, + right: { kind: "numberLiteral", value: 2, unit: { V: 1, A: -1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "complex", + re: 6, + im: 8, + unit: { V: 1 }, + }); + }); + + it("stays complex even when the result's imaginary part is zero", async () => { + // The result kind follows the operand kinds, never the runtime values -- a tree's shape would otherwise depend on the data flowing through it. + const result = await evaluateValue( + { + kind: "arithmetic", + op: "subtract", + left: { kind: "complexLiteral", re: 3, im: 4 }, + right: { kind: "complexLiteral", re: 1, im: 4 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 2, im: 0 }); + }); + + it("is wrong-type when a complex operand meets a temporal one", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "complexLiteral", re: 3, im: 4 }, + right: { kind: "durationLiteral", value: 5, unit: "min" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type when a complex operand meets a text one", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "complexLiteral", re: 3, im: 4 }, + right: { kind: "textLiteral", value: "active" }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); +}); + describe("arithmetic on non-numeric, non-temporal operands", () => { it("is wrong-type when an operand is text", async () => { const result = await evaluateValue( @@ -418,6 +798,23 @@ describe("negate", () => { expectDefinite(result, { kind: "duration", value: -5, unit: "min" }); }); + it("flips both components of a complex value, preserving its unit", async () => { + const result = await evaluateValue( + { + kind: "negate", + operand: { kind: "complexLiteral", re: 3, im: -4, unit: { V: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "complex", + re: -3, + im: 4, + unit: { V: 1 }, + }); + }); + it("is wrong-type on an instant", async () => { const result = await evaluateValue( { @@ -760,6 +1157,74 @@ describe("compare", () => { expectDefinite(result, true); }); + it.each([ + { op: "eq", right: { re: 3, im: -4 }, expected: true }, + { op: "eq", right: { re: 3, im: 4 }, expected: false }, + { op: "eq", right: { re: -3, im: -4 }, expected: false }, + { op: "neq", right: { re: 3, im: 4 }, expected: true }, + { op: "neq", right: { re: 3, im: -4 }, expected: false }, + ] as const)( + "$op against a complex value is exact equality across both components => $expected", + async ({ op, right, expected }) => { + const result = await evaluatePredicate( + { + kind: "compare", + op, + left: { kind: "complexLiteral", re: 3, im: -4 }, + right: { kind: "complexLiteral", ...right }, + }, + undefined, + resolvers, + ); + expectDefinite(result, expected); + }, + ); + + it.each(["gt", "gte", "lt", "lte"] as const)( + "%s is wrong-type on complex operands -- the complex plane carries no total order", + async (op) => { + const result = await evaluatePredicate( + { + kind: "compare", + op, + left: { kind: "complexLiteral", re: 3, im: -4 }, + right: { kind: "complexLiteral", re: 1, im: 1 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }, + ); + + it("is wrong-type when two complex values have incompatible units", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "complexLiteral", re: 3, im: -4, unit: { V: 1 } }, + right: { kind: "complexLiteral", re: 3, im: -4, unit: { A: 1 } }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type when comparing a complex value against a real one, unlike arithmetic's promotion", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "complexLiteral", re: 3, im: 0 }, + right: { kind: "numberLiteral", value: 3 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + it("is wrong-type when comparing across different computed-value kinds", async () => { const result = await evaluatePredicate( { @@ -1090,6 +1555,51 @@ describe("memberOf", () => { expectIndeterminate(result, "wrong-type"); }); + it("matches a complex candidate on both components at once", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "complexLiteral", re: 3, im: -4 }, + candidates: [ + { kind: "complexLiteral", re: 3, im: 4 }, + { kind: "complexLiteral", re: 3, im: -4 }, + ], + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("is wrong-type when a complex operand meets a candidate of another kind", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "complexLiteral", re: 3, im: 0 }, + candidates: [{ kind: "numberLiteral", value: 3 }], + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type when a complex candidate carries an incompatible unit", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "complexLiteral", re: 3, im: -4, unit: { V: 1 } }, + candidates: [{ kind: "complexLiteral", re: 3, im: -4, unit: { A: 1 } }], + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + it("a definite match short-circuits the result past a later indeterminate candidate", async () => { const result = await evaluatePredicate( { diff --git a/src/evaluator.ts b/src/evaluator.ts index 9e67e43..f5bbff0 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -185,6 +185,31 @@ function compareValues( toMilliseconds(right.value, right.unit), ), ); + // Complex operands stay kind-strict here, deliberately unlike `arithmetic`'s promotion of a real operand: 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`). + case "complex": + if (op !== "eq" && op !== "neq") { + return indeterminate( + "wrong-type", + `ordering operator '${op}' is not defined for complex values; the complex plane has no total order`, + ); + } + if (right.kind !== "complex") { + return indeterminate( + "wrong-type", + `cannot compare a 'complex' value with a '${right.kind}' value`, + ); + } + if (!unitsEqual(left.unit, right.unit)) { + return indeterminate( + "wrong-type", + "cannot compare complex values with incompatible units", + ); + } + return definite( + op === "eq" + ? left.re === right.re && left.im === right.im + : left.re !== right.re || left.im !== right.im, + ); default: throw new Error("unreachable computed-value kind"); } @@ -294,6 +319,22 @@ function computeMembershipMatch( toMilliseconds(operand.value, operand.unit) === toMilliseconds(candidate.value, candidate.unit), ); + case "complex": + if (candidate.kind !== "complex") { + return indeterminate( + "wrong-type", + `cannot compare a 'complex' value with a '${candidate.kind}' value for membership`, + ); + } + if (!unitsEqual(operand.unit, candidate.unit)) { + return indeterminate( + "wrong-type", + "cannot compare complex values with incompatible units for membership", + ); + } + return definite( + operand.re === candidate.re && operand.im === candidate.im, + ); default: throw new Error("unreachable computed-value kind"); } @@ -319,6 +360,13 @@ function applyNegate(operand: ComputedValue): Evaluation { value: -operand.value, unit: operand.unit, }); + case "complex": + return definite({ + kind: "complex", + re: -operand.re, + im: -operand.im, + unit: operand.unit, + }); case "text": case "instant": case "boolean": @@ -449,6 +497,120 @@ function applyArithmeticOnDurations( } } +/** Every real number is a complex number with a zero imaginary part, so promoting one is exact and total -- unlike the temporal cross-kind combinations above, which each had to be enumerated because no such embedding exists between an `instant` and a `duration`. This is what lets a tree mix real and complex terms freely instead of forcing every real literal to be written as a complex one. */ +function toComplexValue( + value: Readonly>, +): Extract { + if (value.kind === "complex") return value; + return { kind: "complex", re: value.value, im: 0, unit: value.unit }; +} + +/** The complex product, as its own component-level helper rather than only a branch of the operator switch below, because `power`'s repeated multiplication needs exactly this and must not re-derive it. */ +function multiplyComplexValues( + left: Readonly>, + right: Readonly>, +): Extract { + return { + kind: "complex", + re: left.re * right.re - left.im * right.im, + im: left.re * right.im + left.im * right.re, + unit: combineUnitsForMultiply(left.unit, right.unit), + }; +} + +/** Complex arithmetic, over the one canonical rectangular representation the `complex` kind stores (see the "Complex values" section of README.md). `add`/`subtract` are component-wise and carry the same identical-units requirement real numbers already have. `power` is deliberately excluded from this operator set rather than handled and rejected here: its exponent must stay an un-promoted real number, so the dispatcher routes it to `applyComplexPower` first, and excluding it from the type is what makes the compiler enforce that routing instead of leaving a dead branch behind. */ +function applyArithmeticOnComplex( + op: Exclude, + left: Readonly>, + right: Readonly>, +): Evaluation { + switch (op) { + case "add": + if (!unitsEqual(left.unit, right.unit)) { + return indeterminate( + "wrong-type", + "cannot add complex values with incompatible units", + ); + } + return definite({ + kind: "complex", + re: left.re + right.re, + im: left.im + right.im, + unit: left.unit, + }); + case "subtract": + if (!unitsEqual(left.unit, right.unit)) { + return indeterminate( + "wrong-type", + "cannot subtract complex values with incompatible units", + ); + } + return definite({ + kind: "complex", + re: left.re - right.re, + im: left.im - right.im, + unit: left.unit, + }); + case "multiply": + return definite(multiplyComplexValues(left, right)); + case "divide": { + // Multiplying both sides by the divisor's conjugate makes the denominator the real |divisor|^2, which is what turns a complex quotient into two ordinary real divisions. + const divisorSquaredMagnitude = right.re * right.re + right.im * right.im; + // Zero is the one complex value with no reciprocal, and it is zero in *both* components -- a divisor with only a zero real part (a purely imaginary one) divides perfectly well. + if (divisorSquaredMagnitude === 0) { + return indeterminate("domain-error", "division by zero"); + } + return definite({ + kind: "complex", + re: (left.re * right.re + left.im * right.im) / divisorSquaredMagnitude, + im: (left.im * right.re - left.re * right.im) / divisorSquaredMagnitude, + unit: combineUnitsForDivide(left.unit, right.unit), + }); + } + // Unlike the `wrong-type` cases elsewhere in this design, which mean "an answer exists but this operator does not accept this operand", modulo has no answer to accept: a remainder needs a canonical notion of "how many whole divisors fit", and the complex plane has no ordering to provide one. That is a genuine domain violation, the same category as division by zero. + case "modulo": + return indeterminate( + "domain-error", + "'modulo' is undefined for complex values", + ); + default: + throw new Error("unreachable arithmetic operator"); + } +} + +/** `power` with a complex operand on either side, defined for exactly one case: a real integer exponent, evaluated as the repeated multiplication that integer exponentiation *is* (see the "Complex values" section of README.md for why an arbitrary complex exponent stays out of scope). */ +function applyComplexPower( + base: Readonly>, + exponent: Readonly>, +): Evaluation { + if (exponent.kind !== "number" || !Number.isInteger(exponent.value)) { + return indeterminate( + "wrong-type", + "'power' with a complex operand requires a real integer exponent", + ); + } + if (!isDimensionless(base.unit) || !isDimensionless(exponent.unit)) { + return indeterminate( + "wrong-type", + "'power' requires dimensionless operands", + ); + } + const complexUnit: Extract = { + kind: "complex", + re: 1, + im: 0, + unit: {}, + }; + const complexBase = toComplexValue(base); + let repeatedProduct = complexUnit; + for (let applied = 0; applied < Math.abs(exponent.value); applied += 1) { + repeatedProduct = multiplyComplexValues(repeatedProduct, complexBase); + } + if (exponent.value >= 0) return definite(repeatedProduct); + // A negative exponent is the reciprocal of the positive one by definition, so it reuses the division above rather than re-deriving it -- which also means a zero base inherits that operator's own division-by-zero domain-error instead of needing its own check. + return applyArithmeticOnComplex("divide", complexUnit, repeatedProduct); +} + /** * Dispatches `arithmetic` by operand kind. The three cross-kind temporal combinations this design defines (`instant - instant`, `instant + duration`, `duration + instant`) are checked explicitly first, in that order, against the exact operator each requires; any other combination touching an `instant` is `wrong-type` (see "Temporal values" in README.md -- e.g. adding two instants, or subtracting a `duration` from an `instant`, are deliberately *not* defined). Same-kind `duration`/`duration` combinations are delegated to `applyArithmeticOnDurations`; a `duration` paired with anything other than an `instant` or another `duration` is `wrong-type`. Everything remaining requires two `number` operands. */ @@ -501,18 +663,27 @@ function applyArithmetic( `arithmetic operator '${op}' is not defined between a '${left.kind}' and a '${right.kind}' value`, ); } - if (left.kind !== "number") { + if (left.kind !== "number" && left.kind !== "complex") { return indeterminate( "wrong-type", `arithmetic requires numeric operands; got a '${left.kind}' value`, ); } - if (right.kind !== "number") { + if (right.kind !== "number" && right.kind !== "complex") { return indeterminate( "wrong-type", `arithmetic requires numeric operands; got a '${right.kind}' value`, ); } + if (left.kind === "complex" || right.kind === "complex") { + if (op === "power") return applyComplexPower(left, right); + // Both operands are `number` or `complex` by this point, so promoting the real side is always possible; the result is complex whenever either operand is, regardless of what the components turn out to be. + return applyArithmeticOnComplex( + op, + toComplexValue(left), + toComplexValue(right), + ); + } return applyArithmeticOnNumbers(op, left, right); } @@ -896,7 +1067,11 @@ async function evaluateValueInternal( ); } if (node.unit !== undefined) { - if (resolution.value.kind !== "number") { + // `complex` counts as numeric here alongside `number`: it carries a `unit` of its own for exactly the same dimensional-analysis reason, so a reference to a complex-valued quantity can declare what it expects like any other. + if ( + resolution.value.kind !== "number" && + resolution.value.kind !== "complex" + ) { return indeterminate( "wrong-type", "a unit was expected on a reference that resolved to a non-numeric value", @@ -969,6 +1144,13 @@ async function evaluateValueInternal( value: node.value, unit: node.unit, }); + case "complexLiteral": + return definite({ + kind: "complex", + re: node.re, + im: node.im, + unit: node.unit, + }); case "arithmetic": { const [left, right] = await Promise.all([ evaluateValueInternal( diff --git a/src/tree.test.ts b/src/tree.test.ts index d7156b6..bf2f9a7 100644 --- a/src/tree.test.ts +++ b/src/tree.test.ts @@ -8,6 +8,7 @@ import { BooleanLiteralNodeSchema, CallNodeSchema, CompareNodeSchema, + ComplexLiteralNodeSchema, ConditionalNodeSchema, DelegateNodeSchema, DurationLiteralNodeSchema, @@ -299,6 +300,17 @@ describe("expression tree", () => { ).toBe(false); }); + it("complexLiteral: parses with and without an optional unit, and rejects a node carrying only one component", () => { + const withoutUnit = { kind: "complexLiteral", re: 3, im: 4 }; + const withUnit = { kind: "complexLiteral", re: 3, im: 4, unit: { V: 1 } }; + expect(ComplexLiteralNodeSchema.parse(withoutUnit)).toEqual(withoutUnit); + expect(ComplexLiteralNodeSchema.parse(withUnit)).toEqual(withUnit); + expect( + ComplexLiteralNodeSchema.safeParse({ kind: "complexLiteral", re: 3 }) + .success, + ).toBe(false); + }); + it("reference: parses with and without an optional unit, and rejects an unrecognised unit shape", () => { const valid = { kind: "reference", key: "x" }; expect(ReferenceNodeSchema.parse(valid)).toEqual(valid); @@ -468,6 +480,7 @@ describe("expression tree", () => { { kind: "booleanLiteral", value: true }, { kind: "instantLiteral", value: "2026-08-30T00:00:00Z" }, { kind: "durationLiteral", value: 1, unit: "d" }, + { kind: "complexLiteral", re: 1, im: -1 }, { kind: "reference", key: "x" }, { kind: "arithmetic", diff --git a/src/tree.ts b/src/tree.ts index 9c47054..e899676 100644 --- a/src/tree.ts +++ b/src/tree.ts @@ -213,6 +213,15 @@ export const DurationLiteralNodeSchema = z.object({ }); export type DurationLiteralNode = z.infer; +/** The rectangular-form literal, mirroring the `complex` computed-value kind's own single canonical representation exactly (see the "Complex values" section of README.md); `complexLiteralFromPolar` in complex.ts builds one of these from a magnitude and a phase for the domains that reason that way. */ +export const ComplexLiteralNodeSchema = z.object({ + kind: z.literal("complexLiteral"), + re: z.number(), + im: z.number(), + unit: UnitSchema.optional(), +}); +export type ComplexLiteralNode = z.infer; + export const ReferenceNodeSchema = z.object({ kind: z.literal("reference"), key: JsonValueSchema, @@ -334,6 +343,7 @@ export const ExpressionNodeSchema = z.discriminatedUnion("kind", [ BooleanLiteralNodeSchema, InstantLiteralNodeSchema, DurationLiteralNodeSchema, + ComplexLiteralNodeSchema, ReferenceNodeSchema, ArithmeticNodeSchema, NegateNodeSchema, From f24b0e813dd07353a6e246dad077b9d0dc008ee3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 1 Sep 2026 14:40:36 +0100 Subject: [PATCH 2/8] feat: add polar builders and accessors for complex values 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. --- src/complex.test.ts | 76 +++++++++++++++++++++++++++++++++++++++++++++ src/complex.ts | 45 +++++++++++++++++++++++++++ src/index.ts | 8 +++++ test/smoke.test.ts | 4 +++ 4 files changed, 133 insertions(+) create mode 100644 src/complex.test.ts create mode 100644 src/complex.ts diff --git a/src/complex.test.ts b/src/complex.test.ts new file mode 100644 index 0000000..e06474f --- /dev/null +++ b/src/complex.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + complexFromPolar, + complexLiteralFromPolar, + complexMagnitude, + complexPhase, +} from "./complex"; +import { evaluateValue } from "./evaluator"; +import type { Resolvers } from "./resolvers"; +import { ExpressionNodeSchema } from "./tree"; + +/** No helper below reaches a resolver at all; these exist only to satisfy `evaluateValue`'s signature for the one node-building check. */ +const resolvers: Resolvers = { + resolveValue: async () => Promise.resolve({ found: false }), + resolveLookup: async () => Promise.resolve({ found: false }), + resolveCollection: async () => Promise.resolve([]), +}; + +/** The 3-4-5 right triangle, used so the magnitude below is exact rather than a floating-point approximation. */ +const pythagoreanMagnitude = 5; +const quarterTurn = Math.PI / 2; +const arbitraryPhase = 0.7; + +describe("complexFromPolar", () => { + it("places a zero-phase value entirely on the real axis", () => { + expect(complexFromPolar(2, 0)).toEqual({ + kind: "complex", + re: 2, + im: 0, + }); + }); + + it("carries the unit through unchanged", () => { + expect(complexFromPolar(2, 0, { V: 1, A: -1 })).toEqual({ + kind: "complex", + re: 2, + im: 0, + unit: { V: 1, A: -1 }, + }); + }); +}); + +describe("complexMagnitude, complexPhase", () => { + it("reads the magnitude back as a real number carrying the value's own unit", () => { + expect( + complexMagnitude({ kind: "complex", re: 3, im: -4, unit: { V: 1 } }), + ).toEqual({ kind: "number", value: pythagoreanMagnitude, unit: { V: 1 } }); + }); + + it("reads the phase back as a dimensionless angle in radians, whatever unit the value carries", () => { + expect( + complexPhase({ kind: "complex", re: 0, im: 2, unit: { V: 1 } }), + ).toEqual({ kind: "number", value: quarterTurn }); + }); + + it("round-trips a magnitude and phase through the rectangular form", () => { + const roundTripped = complexFromPolar(pythagoreanMagnitude, arbitraryPhase); + expect(complexMagnitude(roundTripped).value).toBeCloseTo( + pythagoreanMagnitude, + ); + expect(complexPhase(roundTripped).value).toBeCloseTo(arbitraryPhase); + }); +}); + +describe("complexLiteralFromPolar", () => { + it("builds a complexLiteral the expression tree accepts, evaluating to the value complexFromPolar would have produced directly", async () => { + const node = complexLiteralFromPolar(2, 0, { V: 1 }); + expect(ExpressionNodeSchema.parse(node)).toEqual(node); + + const result = await evaluateValue(node, undefined, resolvers); + expect(result).toEqual({ + status: "definite", + value: complexFromPolar(2, 0, { V: 1 }), + }); + }); +}); diff --git a/src/complex.ts b/src/complex.ts new file mode 100644 index 0000000..a03921a --- /dev/null +++ b/src/complex.ts @@ -0,0 +1,45 @@ +import type { ComputedValue, Unit } from "./computed-value"; +import type { ComplexLiteralNode } from "./tree"; + +/** + * The `complex` computed-value kind stores one canonical representation, rectangular (`re` + `im`i), with no `form` discriminant offering a polar alternative alongside it -- see the "Complex values" section of README.md for the reasoning. These four helpers are what keep the magnitude-and-phase view reachable for the domains that reason that way, as conversions at the edges rather than a second encoding of the same value that every operator would then have to branch on. + * + * Phase is in radians throughout, measured from the positive real axis, matching `Math.atan2`'s own range of (-pi, pi]. + */ + +type ComplexValue = Extract; +type NumberValue = Extract; + +export const complexFromPolar = ( + magnitude: number, + phase: number, + unit?: Unit, +): ComplexValue => ({ + kind: "complex", + re: magnitude * Math.cos(phase), + im: magnitude * Math.sin(phase), + unit, +}); + +/** The `complexLiteral` node counterpart of `complexFromPolar`, for authoring a tree in polar terms; the conversion itself is done once, there, rather than repeated here. */ +export const complexLiteralFromPolar = ( + magnitude: number, + phase: number, + unit?: Unit, +): ComplexLiteralNode => { + const { re, im } = complexFromPolar(magnitude, phase, unit); + return { kind: "complexLiteral", re, im, unit }; +}; + +/** |z|, as a real number in the same unit the complex value itself carries -- the magnitude of an impedance in ohms is a real quantity in ohms. */ +export const complexMagnitude = (value: ComplexValue): NumberValue => ({ + kind: "number", + value: Math.hypot(value.re, value.im), + unit: value.unit, +}); + +/** arg(z), as a dimensionless real number of radians: an angle is a ratio of two lengths, so it carries no unit of its own regardless of what the value it was read from carried. */ +export const complexPhase = (value: ComplexValue): NumberValue => ({ + kind: "number", + value: Math.atan2(value.im, value.re), +}); diff --git a/src/index.ts b/src/index.ts index 85236d3..39811bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,12 @@ export { } from "./derived-connectives"; export { average, count, presenceOf, sum } from "./derived-aggregates"; export { coalesce } from "./derived-values"; +export { + complexFromPolar, + complexLiteralFromPolar, + complexMagnitude, + complexPhase, +} from "./complex"; export type { AccumulatorNode, @@ -56,6 +62,7 @@ export type { CallNode, CompareNode, ComparisonOperator, + ComplexLiteralNode, ConditionalCase, ConditionalNode, DelegateNode, @@ -93,6 +100,7 @@ export { CallNodeSchema, CompareNodeSchema, ComparisonOperatorSchema, + ComplexLiteralNodeSchema, ConditionalCaseSchema, ConditionalNodeSchema, DelegateNodeSchema, diff --git a/test/smoke.test.ts b/test/smoke.test.ts index eb8af17..d411d74 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -57,6 +57,10 @@ const expectedIndexExports: readonly [ ["average", "function"], ["presenceOf", "function"], ["coalesce", "function"], + ["complexFromPolar", "function"], + ["complexLiteralFromPolar", "function"], + ["complexMagnitude", "function"], + ["complexPhase", "function"], ]; // The deep-import subpath's surface, kept as one typed list for the same reason as expectedIndexExports above: both the import()-based assertions and the require()-based ones below check the same names, and `keyof typeof TreeModule` makes a rename fail at typecheck time rather than silently thinning what either checks. From 5650f5145398bcc6d897183d8982eaf8e0764863 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 1 Sep 2026 14:46:41 +0100 Subject: [PATCH 3/8] test: exercise complex values across a whole tree 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. --- src/evaluator.indeterminacy.test.ts | 5 + test/integration/complex-arithmetic.test.ts | 113 ++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 test/integration/complex-arithmetic.test.ts diff --git a/src/evaluator.indeterminacy.test.ts b/src/evaluator.indeterminacy.test.ts index affaeed..7de28dd 100644 --- a/src/evaluator.indeterminacy.test.ts +++ b/src/evaluator.indeterminacy.test.ts @@ -490,6 +490,11 @@ const fixtures: readonly Fixture[] = [ { kind: "durationLiteral", value: 1, unit: "min" }, isDefinite, ), + expr( + "complexLiteral: never indeterminate", + { kind: "complexLiteral", re: 1, im: -1 }, + isDefinite, + ), // reference expr( diff --git a/test/integration/complex-arithmetic.test.ts b/test/integration/complex-arithmetic.test.ts new file mode 100644 index 0000000..0d0062b --- /dev/null +++ b/test/integration/complex-arithmetic.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { complexMagnitude, createEvaluator } from "../../src/index"; +import type { + ComputedValue, + ExpressionNode, + FunctionRegistry, + PredicateNode, + Resolvers, +} from "../../src/index"; + +/** + * Complex values as part of a whole tree rather than in isolation: resolver-supplied complex operands combined with a real one in the same expression, the result carried into a `compare` leaf, and the one bridge back to the real ordering domain -- a registry function built on the exported `complexMagnitude` helper, which is how a tree asks "is this larger than that" about a quantity the complex plane itself cannot order (see the "Complex values" section of README.md). + */ + +const resolvers: Resolvers = { + resolveValue: async (key) => { + if (key === "firstSample") { + return Promise.resolve({ + found: true, + value: { kind: "complex", re: 3, im: 4 }, + }); + } + if (key === "secondSample") { + return Promise.resolve({ + found: true, + value: { kind: "complex", re: 1, im: -2 }, + }); + } + if (key === "offset") { + return Promise.resolve({ + found: true, + value: { kind: "number", value: 2 }, + }); + } + return Promise.resolve({ found: false }); + }, + resolveLookup: async () => Promise.resolve({ found: false }), + resolveCollection: async () => Promise.resolve([]), +}; + +const functions: FunctionRegistry = { + // The package ships no built-in function set (see the `call` section of README.md), so a tree that needs a complex value's magnitude as a real number registers one itself -- one line, over the exported helper, with no separate accessor node kind needed in the grammar. + magnitude: (args: readonly ComputedValue[]) => { + const arg = args[0]; + if (arg?.kind !== "complex") { + return { domainError: "expected a complex argument" }; + } + return complexMagnitude(arg); + }, +}; + +const { evaluatePredicate, evaluateValue } = createEvaluator({ functions }); + +/** `(firstSample * secondSample) + offset` -- two complex operands multiplied, then a real one added to the complex product. */ +const combinedSample: ExpressionNode = { + kind: "arithmetic", + op: "add", + left: { + kind: "arithmetic", + op: "multiply", + left: { kind: "reference", key: "firstSample" }, + right: { kind: "reference", key: "secondSample" }, + }, + right: { kind: "reference", key: "offset" }, +}; + +const exceedsMagnitude = (limit: number): PredicateNode => ({ + kind: "compare", + op: "gt", + left: { kind: "call", fn: "magnitude", args: [combinedSample] }, + right: { kind: "numberLiteral", value: limit }, +}); + +const belowLimit = 13; +const aboveLimit = 14; + +describe("complex values compose through a whole tree", () => { + it("carries complex operands and a real one through one expression", async () => { + // (3 + 4i)(1 - 2i) = 11 - 2i, and the real 2 is promoted rather than rejected: 13 - 2i. + const result = await evaluateValue(combinedSample, undefined, resolvers); + expect(result).toEqual({ + status: "definite", + value: { kind: "complex", re: 13, im: -2, unit: {} }, + }); + }); + + it("reaches an ordering comparison through a registered magnitude function", async () => { + // |13 - 2i| is a little over 13.15. + await expect( + evaluatePredicate(exceedsMagnitude(belowLimit), undefined, resolvers), + ).resolves.toEqual({ status: "definite", value: true }); + await expect( + evaluatePredicate(exceedsMagnitude(aboveLimit), undefined, resolvers), + ).resolves.toEqual({ status: "definite", value: false }); + }); + + it("is wrong-type when the same comparison is attempted on the complex value directly", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "gt", + left: combinedSample, + right: { kind: "complexLiteral", re: 13, im: 0 }, + }, + undefined, + resolvers, + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("wrong-type"); + } + }); +}); From 9f172c705843baeafea132680560750508c3668a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 1 Sep 2026 14:47:07 +0100 Subject: [PATCH 4/8] docs: document complex values and drop them from the exclusion list 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. --- README.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9dda44b..0bd0a1b 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ These hold across every part of the design below, and any implementation change - **Three outcomes, never two.** Every evaluation produces a definite result or an indeterminate result carrying a reason — never a bare `boolean`/`number`, and never a thrown exception for a data-quality problem. See [The evaluation model](#the-evaluation-model). - **Derived constructs are compositions, not new logic.** Anything describable as "some other primitive, wired together" is implemented that way, so its correctness is inherited rather than requiring separate proof. See [Derived connectives](#derived-connectives), [Derived aggregates](#derived-aggregates), [Derived values](#derived-values), and [Defining your own named presets](#defining-your-own-named-presets). - **One schema, mechanically derived artefacts.** A single canonical type definition produces the runtime validator and the portable wire-format schema; they cannot drift apart because there is only one source. See [Schema strategy](#schema-strategy). +- **A numeric extension that stays closed-form is in scope; a different kind of computation is not.** When something the current numeric model does not cover comes up, the test is whether evaluating it is still closed-form numeric evaluation — no solving, no simplification, no code execution. If it is, it belongs here, however unlike the existing kinds it looks: [Complex values](#complex-values) were once listed under [Out of scope](#out-of-scope) on a sizing judgement that turned out to be wrong, since complex arithmetic is exactly the closed-form evaluation this evaluator already does for every other kind. What stays behind [`delegate`](#delegate) is a genuinely different *kind* of computation — symbolic algebra, arbitrary external computation — not merely a kind of number the model has not reached yet. - **Generic examples only.** Every example in this document uses invented, placeholder field names (`temperature`, `orderTotal`, `isActive`, `x`, `y`, `amount`, `items`) with no resemblance to any particular company, product, or industry's real data model. ## The evaluation model @@ -367,7 +368,7 @@ The N-ary forms of `and`/`or`: given an ordered list of operands (rather than ex ### `compare` -A relational-comparison leaf: compares two computed values using `gt`/`gte`/`lt`/`lte`/`eq`/`neq`. **Both `left` and `right` are `ExpressionNode`** — either side may be a plain literal/reference or an arbitrary formula from the expression tree; the comparison is symmetric, and an implementation that only allows a formula on one side is incomplete. Valid operand kinds are `number` (matching units required — see [Units](#units)), `instant`, `duration`, or `boolean`; comparing across different computed-value kinds, or comparing two numbers with incompatible units, is `wrong-type`. `boolean` only supports `eq`/`neq` — there is no natural ordering for a truth value, so `gt`/`gte`/`lt`/`lte` are `wrong-type` for a `boolean` operand. +A relational-comparison leaf: compares two computed values using `gt`/`gte`/`lt`/`lte`/`eq`/`neq`. **Both `left` and `right` are `ExpressionNode`** — either side may be a plain literal/reference or an arbitrary formula from the expression tree; the comparison is symmetric, and an implementation that only allows a formula on one side is incomplete. Valid operand kinds are `number` (matching units required — see [Units](#units)), `instant`, `duration`, or `boolean`, plus `complex` for `eq`/`neq` only (see [Complex values](#complex-values)); comparing across different computed-value kinds, or comparing two numbers with incompatible units, is `wrong-type`. `boolean` only supports `eq`/`neq` — there is no natural ordering for a truth value, so `gt`/`gte`/`lt`/`lte` are `wrong-type` for a `boolean` operand. ### `textCompare` @@ -402,7 +403,8 @@ type ComputedValue = | { kind: "text"; value: string } | { kind: "boolean"; value: boolean } | { kind: "instant"; value: string } // ISO-8601 timestamp - | { kind: "duration"; value: number; unit: DurationUnit }; + | { kind: "duration"; value: number; unit: DurationUnit } + | { kind: "complex"; re: number; im: number; unit?: Unit }; type ArithmeticOperator = "add" | "subtract" | "multiply" | "divide" | "power" | "modulo"; @@ -419,6 +421,7 @@ type ExpressionNode = | { kind: "booleanLiteral"; value: boolean } | { kind: "instantLiteral"; value: string } | { kind: "durationLiteral"; value: number; unit: DurationUnit } + | { kind: "complexLiteral"; re: number; im: number; unit?: Unit } | { kind: "reference"; key: JsonValue; unit?: Unit } | { kind: "arithmetic"; op: ArithmeticOperator; left: ExpressionNode; right: ExpressionNode } | { kind: "negate"; operand: ExpressionNode } @@ -435,7 +438,7 @@ A `textLiteral` kind is included even though it is not separately enumerated as ### Literals -`numberLiteral`, `textLiteral`, `booleanLiteral`, `instantLiteral` (an ISO-8601 timestamp string), and `durationLiteral` (a magnitude plus a `DurationUnit`) are always definite by construction — a literal node never itself produces an indeterminate outcome. +`numberLiteral`, `textLiteral`, `booleanLiteral`, `instantLiteral` (an ISO-8601 timestamp string), `durationLiteral` (a magnitude plus a `DurationUnit`), and `complexLiteral` (a real and an imaginary component, plus an optional `Unit`) are always definite by construction — a literal node never itself produces an indeterminate outcome. ### `reference` @@ -443,7 +446,7 @@ A reference to a single external value, identified by an opaque `key` whose mean ### `arithmetic`, `negate` -Binary arithmetic (`add`/`subtract`/`multiply`/`divide`/`power`/`modulo`) and unary negation, each over `number` computed values by default, with the temporal exceptions listed under [Temporal values](#temporal-values) below. `negate` is an explicit node — never sugar for "zero minus the value" — because it also applies to `duration` values (negating a duration reverses its direction) where "zero minus" has no natural literal-zero counterpart. Division by zero, or any operator given an operand outside its mathematical domain, is `domain-error`; a non-numeric, non-temporal operand where a number was required is `wrong-type`; any operand that is itself indeterminate makes the whole node indeterminate, with no rescuing value on the other side (see [Three-valued propagation rules](#three-valued-propagation-rules)). +Binary arithmetic (`add`/`subtract`/`multiply`/`divide`/`power`/`modulo`) and unary negation, each over `number` computed values by default, with the temporal exceptions listed under [Temporal values](#temporal-values) and the complex ones under [Complex values](#complex-values) below. `negate` is an explicit node — never sugar for "zero minus the value" — because it also applies to `duration` values (negating a duration reverses its direction) where "zero minus" has no natural literal-zero counterpart; over a `complex` value it flips both components. Division by zero, or any operator given an operand outside its mathematical domain, is `domain-error`; a non-numeric, non-temporal operand where a number was required is `wrong-type`; any operand that is itself indeterminate makes the whole node indeterminate, with no rescuing value on the other side (see [Three-valued propagation rules](#three-valued-propagation-rules)). ### `call` @@ -451,7 +454,7 @@ A named function applied to an ordered list of `ExpressionNode` arguments. The s ### Units -`numberLiteral` and `reference` may carry a `unit`, represented as a dimensional-exponent map (e.g. `{ m: 1, s: -1 }` for metres per second) rather than an opaque string, so that unit combination follows real dimensional analysis instead of string matching. A bare symbol like `"kg"` is shorthand for `{ kg: 1 }`. +`numberLiteral`, `complexLiteral`, and `reference` may carry a `unit`, represented as a dimensional-exponent map (e.g. `{ m: 1, s: -1 }` for metres per second) rather than an opaque string, so that unit combination follows real dimensional analysis instead of string matching. A bare symbol like `"kg"` is shorthand for `{ kg: 1 }`. - `add`/`subtract` between two unit-tagged numbers require **identical** dimensional-exponent maps. A mismatch is `wrong-type` ("incompatible units") — units are never silently coerced or dropped. - `multiply`/`divide` combine the two operands' unit maps by dimensional analysis: multiplying adds exponents per dimension, dividing subtracts them. An operand with no `unit` is treated as dimensionless (an empty map) for this purpose. @@ -465,6 +468,51 @@ A named function applied to an ordered list of `ExpressionNode` arguments. The s Any other arithmetic combination touching an `instant` or `duration` (adding two instants, multiplying a duration by an instant, comparing an instant against a plain number, and so on) is `wrong-type`. A reference implementation normalises `duration` values to a single base unit (milliseconds) internally before combining two durations of different `DurationUnit`s, then reports the result in whichever unit the node's own context calls for. +### Complex values + +`complex` is a computed-value kind alongside `number`, for the domains — signal processing, control theory, anything phasor-shaped — where a formula naturally mixes real and complex terms in one expression. It stays inside this evaluator rather than behind [`delegate`](#delegate) because it is closed-form numeric evaluation, exactly what every other kind here already does; see [Design principles](#design-principles) for that scope test in general. + +**One canonical representation, rectangular.** A `complex` value is stored as `{ re, im }` and never as a magnitude and a phase, and there is deliberately no `form` discriminant offering both. Three reasons, in order of weight: + +1. **A second form would make equality ambiguous.** Polar coordinates do not 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 complex number would have unboundedly many polar encodings. `eq` and `memberOf` are exact equality throughout this design (see [`compare`](#compare)); making them work across two forms would mean either normalising on every comparison or introducing an approximate equality for this one kind, and neither belongs in a design where every other kind compares exactly. +2. **A discriminant would double the branching in every operator** — quadruple it for a binary one — for a choice that changes no value. Every operator would still convert to rectangular internally, because that is where the closed forms live, so the discriminant would buy nothing at evaluation time and cost at every boundary. +3. **Rectangular is what the operators actually need.** `add`/`subtract` are component-wise in it; `multiply`, `divide`, `negate`, and integer `power` all have standard closed forms in it. Polar's advantage — multiplication and division as one product of magnitudes and one sum of angles — does not extend to addition at all, which would have to convert back and forth. + +The magnitude-and-phase view stays reachable through four exported conversion helpers rather than a second encoding: `complexFromPolar(magnitude, phase, unit?)` and `complexLiteralFromPolar(magnitude, phase, unit?)` build a value or a literal node from polar terms, and `complexMagnitude(value)` and `complexPhase(value)` read them back out — the magnitude as a real number in the value's own unit, the phase as a dimensionless real number of radians. Conversions at the edges, one representation in the middle. + +**Arithmetic.** + +- `add`/`subtract` are component-wise, requiring **identical** dimensional-exponent maps exactly as real numbers do (see [Units](#units)). +- `multiply`/`divide` are real complex multiplication and division — `(a + bi)(c + di) = (ac − bd) + (ad + bc)i`, and the corresponding quotient — never component-wise. Units combine by the same dimensional analysis real numbers use. A zero divisor means both components zero; a divisor with only a zero real part divides perfectly well. +- `power` is defined for a **real integer exponent** and evaluated as the repeated multiplication that integer exponentiation is, with a negative exponent the reciprocal of the positive one. Like a real `power`, it requires dimensionless operands. An arbitrary complex exponent is a genuinely bigger question — it needs the complex logarithm, which is multivalued, so it needs a branch-cut convention this design has not chosen — and is deliberately out of scope for now: it is `wrong-type`, as is a non-integer real exponent, on the same reading of that code used throughout ("an answer exists, but this operator does not accept this operand" — compare `power`'s existing dimensionless-operands requirement, also `wrong-type`). +- `modulo` is `domain-error`, not `wrong-type`: a remainder needs a canonical notion of how many whole divisors fit, and the complex plane has no ordering to supply one. There is no answer to accept, which is the same category as division by zero. + +**A real operand is promoted, never rejected.** Mixing a `number` with a `complex` in one `arithmetic` node works: every real number *is* a complex number with a zero imaginary part, so the promotion is exact, total, and canonical — unlike the temporal cross-kind combinations above, which had to be enumerated one by one precisely because no such embedding exists between an `instant` and a `duration`. Scaling a complex value by a real one, or offsetting it by a real constant, is the common case, and forcing every real literal in such a formula to be rewritten as a complex one would defeat the point. 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 that happen to flow through it. + +**Comparison is kind-strict, deliberately unlike arithmetic.** `gt`/`gte`/`lt`/`lte` are `wrong-type` for a `complex` operand — the complex plane carries no total order — exactly as they already are for `text`. `eq`/`neq` work normally, as exact equality across both components under the same unit-compatibility rule numbers already have, and `memberOf` matches the same way. But a `complex` compared against a `number` is `wrong-type`, with no promotion: arithmetic *produces* a value, so promoting a real operand 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 count of milliseconds underneath. + +Ordering a complex quantity therefore goes through whichever real projection the formula actually means — most often its magnitude. This package ships no built-in function set (see [`call`](#call)), so that bridge is an ordinary registry entry, one line over the exported helper: + +```ts +const functions: FunctionRegistry = { + magnitude: (args) => + args[0]?.kind === "complex" + ? complexMagnitude(args[0]) + : { domainError: "expected a complex argument" }, +}; +``` + +which a tree then calls like any other function, putting a real number back on the left of an ordinary `compare`: + +```json +{ + "kind": "compare", + "op": "gt", + "left": { "kind": "call", "fn": "magnitude", "args": [{ "kind": "reference", "key": "x" }] }, + "right": { "kind": "numberLiteral", "value": 13 } +} +``` + ### `lookup` Resolves a single value from a named external table-like source, keyed by one or more `ExpressionNode` keys, via resolver 2 (see [Resolvers](#resolvers)). The schema never interprets what "table" or "key" mean to a given consumer; `table` and the resolved key values are passed through verbatim. If any key expression is itself indeterminate, the lookup is indeterminate with that reason (no key evaluation, no lookup attempt). If the resolver reports no match, the result is `not-found`. @@ -738,15 +786,15 @@ How each reason category can arise, per node kind. "Propagates" means: an indete | `and` | propagates, **unless** the other operand is definitely `false` (absorbs) | as `not-found` | as `not-found` | | `or` | propagates, **unless** the other operand is definitely `true` (absorbs) | as `not-found` | as `not-found` | | `allOf` / `anyOf` | as `and`/`or`, extended pairwise across the list | as `and`/`or` | as `and`/`or` | -| `compare` | either operand not found | operand kinds differ, or units incompatible, or kind is not `number`/`instant`/`duration` | never directly (comparison itself has no domain restriction) | +| `compare` | either operand not found | operand kinds differ, or units incompatible, or kind is not `number`/`instant`/`duration`/`complex`, or an ordering operator was given a `complex` operand (see [Complex values](#complex-values)) | never directly (comparison itself has no domain restriction) | | `textCompare` | either operand not found | either operand is not `text` | never directly | | `memberOf` | `operand` not found, or (with no definite match found) a scanned candidate not found | `operand`/a candidate resolves to an incompatible kind or unit, with no definite match found among the rest | never directly | | `exists` | never — converts operand `not-found` to definite `false` | never — converts operand `wrong-type`/`domain-error` to definite `true` | never — see `wrong-type` column | | `some` / `every` | an item's `filter` or `item` sub-node reports not-found, and it is not absorbed by an already-decided item | as `not-found` | as `not-found` | -| literals (`numberLiteral`, `textLiteral`, `instantLiteral`, `durationLiteral`) | never | never | never | +| literals (`numberLiteral`, `textLiteral`, `instantLiteral`, `durationLiteral`, `complexLiteral`) | never | never | never | | `reference` | resolver reports absence | resolver's value doesn't match an expected `unit`, or is used where an incompatible kind is required upstream | never directly | -| `arithmetic` | either operand not found | operand not numeric (or temporal-kind mismatch — see [Temporal values](#temporal-values)), or unit mismatch on add/subtract | zero divisor, or any other documented domain violation for the operator | -| `negate` | operand not found | operand not `number`/`duration` | never directly | +| `arithmetic` | either operand not found | operand not numeric (or temporal-kind mismatch — see [Temporal values](#temporal-values)), or unit mismatch on add/subtract, or a `power` exponent that is not a real integer over a `complex` operand (see [Complex values](#complex-values)) | zero divisor, `modulo` over a `complex` operand, or any other documented domain violation for the operator | +| `negate` | operand not found | operand not `number`/`duration`/`complex` | never directly | | `call` | any argument not found | unregistered function name, or an argument of the wrong kind for that function | argument outside the function's valid domain (e.g. negative input to `squareRoot`) | | `lookup` | any key not found, or resolver reports no match | a key expression resolves to the wrong kind for that table | never directly | | `conditional` | `"first"`: an unmatched guard's own evaluation is `not-found`, before any earlier guard matched.
`"unique"`: any case's `when` is `not-found`, unless 2+ cases already definitely matched (see `domain-error`, which then takes priority).
Both: also the chosen branch's (`then`/`fallback`) own result if it is `not-found`. | Same pattern as `not-found`, substituting `wrong-type` throughout (guard evaluation and chosen branch alike). | `"unique"` only: 2+ cases are definitely `true` — see [`conditional`](#conditional)'s absorption order.
Both: same pattern as `not-found`, substituting `domain-error` (guard evaluation and chosen branch alike). | @@ -842,10 +890,11 @@ Two variations show the propagation rules in action without changing the tree at This package is a representation-plus-evaluator for conditions and formulas over already-available (or resolver-obtained) data. It deliberately does not include: - **Symbolic algebra.** It cannot solve an expression for an unknown quantity, symbolically simplify an expression, or perform symbolic differentiation or integration. A consumer needing any of that is expected to translate the pure-arithmetic portion of an expression tree into the input format of existing, general-purpose symbolic-mathematics software — several mature, freely available options already exist — and let that external system do the symbolic work. This package's job stops at representing and numerically evaluating a tree, not manipulating it symbolically. -- **Complex-number or phasor arithmetic.** Every numeric value in this design is real-valued. Some domains occasionally need calculations naturally expressed with complex numbers; rather than extending the core numeric model to support that — a far larger and more invasive change than adding one more named function — the recommended approach is the same delegation escape hatch described under [`delegate`](#delegate): hand the relevant subtree, unevaluated, to an external system built for that kind of mathematics, several of which already exist as mature, freely available tooling. -- **Batch unresolvable-reference reporting.** This design deliberately has no node kind for asking "which of these references, across a whole batch, are unresolvable" as a single evaluation — only the [`exists`](#exists) leaf's one-at-a-time true/false/false-on-absence check. A tool that wants to report a *list* of every missing reference (for an authoring UI validating a tree before it's saved, say) is expected to build that on top of `exists` — walk the references of interest and evaluate an `exists` leaf over each — at the authoring/tooling layer, rather than this package growing a bespoke aggregate-diagnostic node kind for it. This is a deliberate boundary, not an oversight: it keeps the evaluation tree itself limited to producing one `Evaluation` per node, and leaves "collect many such results and report on them together" to whatever sits above the evaluator, exactly like symbolic algebra and complex-number arithmetic above are left to whatever sits beside it. +- **Batch unresolvable-reference reporting.** This design deliberately has no node kind for asking "which of these references, across a whole batch, are unresolvable" as a single evaluation — only the [`exists`](#exists) leaf's one-at-a-time true/false/false-on-absence check. A tool that wants to report a *list* of every missing reference (for an authoring UI validating a tree before it's saved, say) is expected to build that on top of `exists` — walk the references of interest and evaluate an `exists` leaf over each — at the authoring/tooling layer, rather than this package growing a bespoke aggregate-diagnostic node kind for it. This is a deliberate boundary, not an oversight: it keeps the evaluation tree itself limited to producing one `Evaluation` per node, and leaves "collect many such results and report on them together" to whatever sits above the evaluator, exactly like symbolic algebra above is left to whatever sits beside it. + +This package does not name or depend on any specific external tool for the delegation case above — it only defines the shape of the hand-off (an opaque payload plus a named destination system). -This package does not name or depend on any specific external tool for either of the two delegation cases above — it only defines the shape of the hand-off (an opaque payload plus a named destination system). +Complex-number and phasor arithmetic used to be listed here too, delegated out on the reasoning that supporting them would be a far larger and more invasive change than adding one more named function. That sizing was wrong: unlike symbolic algebra, which is a genuinely different kind of system, complex arithmetic is closed-form numeric evaluation, exactly what this evaluator already does for every other computed-value kind. It is now part of the core numeric model — see [Complex values](#complex-values), and [Design principles](#design-principles) for the scope test that judgement is now written down as. ## Prior art From c9ccf3df44b50080becd628804e387ce14931e99 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 1 Sep 2026 15:41:08 +0100 Subject: [PATCH 5/8] feat: accept polar authoring for complex literals, normalised to rectangular 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. --- src/evaluator.ts | 17 +++++++++++------ src/index.ts | 4 ++++ src/tree.ts | 36 ++++++++++++++++++++++++++++++++---- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/evaluator.ts b/src/evaluator.ts index f5bbff0..f98c708 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -1,3 +1,4 @@ +import { complexFromPolar } from "./complex"; import type { ComputedValue, DurationUnit, Unit } from "./computed-value"; import { combineUnitsForDivide, @@ -1145,12 +1146,16 @@ async function evaluateValueInternal( unit: node.unit, }); case "complexLiteral": - return definite({ - kind: "complex", - re: node.re, - im: node.im, - unit: node.unit, - }); + // Whichever authoring form was used (see the "Complex values" section of README.md), normalise to the single rectangular `ComputedValue` immediately -- nothing downstream (arithmetic, compare, memberOf, negate) ever sees a polar-authored value. + if ("re" in node) { + return definite({ + kind: "complex", + re: node.re, + im: node.im, + unit: node.unit, + }); + } + return definite(complexFromPolar(node.magnitude, node.phase, node.unit)); case "arithmetic": { const [left, right] = await Promise.all([ evaluateValueInternal( diff --git a/src/index.ts b/src/index.ts index 39811bb..9f5481e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,8 @@ export type { CompareNode, ComparisonOperator, ComplexLiteralNode, + ComplexPolarLiteralNode, + ComplexRectangularLiteralNode, ConditionalCase, ConditionalNode, DelegateNode, @@ -101,6 +103,8 @@ export { CompareNodeSchema, ComparisonOperatorSchema, ComplexLiteralNodeSchema, + ComplexPolarLiteralNodeSchema, + ComplexRectangularLiteralNodeSchema, ConditionalCaseSchema, ConditionalNodeSchema, DelegateNodeSchema, diff --git a/src/tree.ts b/src/tree.ts index e899676..f99b271 100644 --- a/src/tree.ts +++ b/src/tree.ts @@ -213,13 +213,36 @@ export const DurationLiteralNodeSchema = z.object({ }); export type DurationLiteralNode = z.infer; -/** The rectangular-form literal, mirroring the `complex` computed-value kind's own single canonical representation exactly (see the "Complex values" section of README.md); `complexLiteralFromPolar` in complex.ts builds one of these from a magnitude and a phase for the domains that reason that way. */ -export const ComplexLiteralNodeSchema = z.object({ +/** + * The wire-format literal accepts either of the two authoring forms real-world complex data arrives in, discriminated structurally rather than by a `form` tag: rectangular (`re`/`im`), matching the `complex` computed-value kind's own single canonical representation exactly (see the "Complex values" section of README.md), or polar (`magnitude`/`phase`, in radians), for the domains that reason that way natively and would otherwise have to hand-compute the conversion before ever constructing the tree. `strictObject` on both members is what makes the two forms mutually exclusive on the wire -- a plain `object` would silently strip an unrecognised field rather than reject a payload that mixes both. + * + * The evaluator normalises whichever form was authored into the single rectangular `ComputedValue` immediately on evaluation (see the `complexLiteral` case in evaluator.ts), so nothing downstream of that -- arithmetic, `compare`, `memberOf`, `negate` -- ever sees a polar value; they keep consuming the one canonical `complex` shape unchanged. + */ +export const ComplexRectangularLiteralNodeSchema = z.strictObject({ kind: z.literal("complexLiteral"), re: z.number(), im: z.number(), unit: UnitSchema.optional(), }); +export type ComplexRectangularLiteralNode = z.infer< + typeof ComplexRectangularLiteralNodeSchema +>; + +export const ComplexPolarLiteralNodeSchema = z.strictObject({ + kind: z.literal("complexLiteral"), + magnitude: z.number(), + phase: z.number(), + unit: UnitSchema.optional(), +}); +export type ComplexPolarLiteralNode = z.infer< + typeof ComplexPolarLiteralNodeSchema +>; + +/** Both literal forms share the one literal `kind: "complexLiteral"` value, which is exactly why this is a plain `z.union` rather than a `z.discriminatedUnion` -- `z.discriminatedUnion` requires a unique discriminant literal per member and throws ("Duplicate discriminator value") the moment two members share one. `ExpressionNodeSchema` below folds this plain union in alongside its own discriminated union of every other kind, rather than trying to host it as a normal member. */ +export const ComplexLiteralNodeSchema = z.union([ + ComplexRectangularLiteralNodeSchema, + ComplexPolarLiteralNodeSchema, +]); export type ComplexLiteralNode = z.infer; export const ReferenceNodeSchema = z.object({ @@ -337,13 +360,13 @@ export const DelegateNodeSchema = z.object({ }); export type DelegateNode = z.infer; -export const ExpressionNodeSchema = z.discriminatedUnion("kind", [ +/** Every expression node kind except `complexLiteral`, which cannot be a member of this same discriminated union alongside its own two forms (see `ComplexLiteralNodeSchema` above) -- folded back in below via a plain `z.union` rather than `z.discriminatedUnion`. */ +const CoreExpressionNodeSchema = z.discriminatedUnion("kind", [ NumberLiteralNodeSchema, TextLiteralNodeSchema, BooleanLiteralNodeSchema, InstantLiteralNodeSchema, DurationLiteralNodeSchema, - ComplexLiteralNodeSchema, ReferenceNodeSchema, ArithmeticNodeSchema, NegateNodeSchema, @@ -355,4 +378,9 @@ export const ExpressionNodeSchema = z.discriminatedUnion("kind", [ DelegateNodeSchema, TreeReferenceNodeSchema, ]); + +export const ExpressionNodeSchema = z.union([ + CoreExpressionNodeSchema, + ComplexLiteralNodeSchema, +]); export type ExpressionNode = z.infer; From 98492d389f33be8b454f6ff35da29bb7b8bed4d5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 1 Sep 2026 15:41:25 +0100 Subject: [PATCH 6/8] test: cover polar-authored complex literals and their normalisation to 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. --- src/evaluator.test.ts | 51 ++++++++++++ src/tree.test.ts | 49 +++++++++++- test/integration/complex-arithmetic.test.ts | 37 +++++++++ .../json-schema-consistency.test.ts | 69 ++++++++++------ test/smoke.test.ts | 79 +++++++++++++------ 5 files changed, 239 insertions(+), 46 deletions(-) diff --git a/src/evaluator.test.ts b/src/evaluator.test.ts index c9278e7..8e51a50 100644 --- a/src/evaluator.test.ts +++ b/src/evaluator.test.ts @@ -105,6 +105,57 @@ describe("complexLiteral", () => { unit: { V: 1, A: -1 }, }); }); + + it("normalises a polar literal to rectangular on evaluation, preserving the optional unit", async () => { + const result = await evaluateValue( + { + kind: "complexLiteral", + magnitude: 1, + phase: Math.PI / 2, + unit: { V: 1 }, + }, + undefined, + resolvers, + ); + expect(result.status).toBe("definite"); + if (result.status !== "definite") return; + expect(result.value.kind).toBe("complex"); + if (result.value.kind !== "complex") return; + expect(result.value.re).toBeCloseTo(0); + expect(result.value.im).toBeCloseTo(1); + expect(result.value.unit).toEqual({ V: 1 }); + }); + + it("eq: a rectangular literal and a polar literal representing the same underlying complex number compare as equal", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "complexLiteral", re: 2, im: 0 }, + right: { kind: "complexLiteral", magnitude: 2, phase: 0 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("memberOf: a rectangular operand matches a candidate list containing a polar literal representing the same number", async () => { + const result = await evaluatePredicate( + { + kind: "memberOf", + op: "in", + operand: { kind: "complexLiteral", re: 1, im: 0 }, + candidates: [ + { kind: "complexLiteral", re: 0, im: 1 }, + { kind: "complexLiteral", magnitude: 1, phase: 0 }, + ], + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); }); describe("reference", () => { diff --git a/src/tree.test.ts b/src/tree.test.ts index bf2f9a7..ff88476 100644 --- a/src/tree.test.ts +++ b/src/tree.test.ts @@ -300,7 +300,7 @@ describe("expression tree", () => { ).toBe(false); }); - it("complexLiteral: parses with and without an optional unit, and rejects a node carrying only one component", () => { + it("complexLiteral: parses a rectangular value with and without an optional unit, and rejects a node carrying only one rectangular component", () => { const withoutUnit = { kind: "complexLiteral", re: 3, im: 4 }; const withUnit = { kind: "complexLiteral", re: 3, im: 4, unit: { V: 1 } }; expect(ComplexLiteralNodeSchema.parse(withoutUnit)).toEqual(withoutUnit); @@ -311,6 +311,52 @@ describe("expression tree", () => { ).toBe(false); }); + it("complexLiteral: also parses a polar value (magnitude/phase) with and without an optional unit, and rejects a node carrying only one polar component", () => { + const withoutUnit = { kind: "complexLiteral", magnitude: 5, phase: 1.2 }; + const withUnit = { + kind: "complexLiteral", + magnitude: 5, + phase: 1.2, + unit: { V: 1 }, + }; + expect(ComplexLiteralNodeSchema.parse(withoutUnit)).toEqual(withoutUnit); + expect(ComplexLiteralNodeSchema.parse(withUnit)).toEqual(withUnit); + expect( + ComplexLiteralNodeSchema.safeParse({ + kind: "complexLiteral", + magnitude: 5, + }).success, + ).toBe(false); + }); + + it("complexLiteral: rejects both rectangular and polar fields provided at once", () => { + expect( + ComplexLiteralNodeSchema.safeParse({ + kind: "complexLiteral", + re: 1, + im: 2, + magnitude: 3, + phase: 4, + }).success, + ).toBe(false); + }); + + it("complexLiteral: rejects neither rectangular nor polar fields provided", () => { + expect( + ComplexLiteralNodeSchema.safeParse({ kind: "complexLiteral" }).success, + ).toBe(false); + }); + + it("complexLiteral: rejects a mixed payload (one rectangular field, one polar field)", () => { + expect( + ComplexLiteralNodeSchema.safeParse({ + kind: "complexLiteral", + re: 1, + phase: 2, + }).success, + ).toBe(false); + }); + it("reference: parses with and without an optional unit, and rejects an unrecognised unit shape", () => { const valid = { kind: "reference", key: "x" }; expect(ReferenceNodeSchema.parse(valid)).toEqual(valid); @@ -481,6 +527,7 @@ describe("expression tree", () => { { kind: "instantLiteral", value: "2026-08-30T00:00:00Z" }, { kind: "durationLiteral", value: 1, unit: "d" }, { kind: "complexLiteral", re: 1, im: -1 }, + { kind: "complexLiteral", magnitude: 1, phase: Math.PI / 2 }, { kind: "reference", key: "x" }, { kind: "arithmetic", diff --git a/test/integration/complex-arithmetic.test.ts b/test/integration/complex-arithmetic.test.ts index 0d0062b..240168f 100644 --- a/test/integration/complex-arithmetic.test.ts +++ b/test/integration/complex-arithmetic.test.ts @@ -111,3 +111,40 @@ describe("complex values compose through a whole tree", () => { } }); }); + +describe("a complexLiteral authored in polar form composes through a whole tree exactly like a rectangular one", () => { + it("combines a polar-authored literal with a resolver-supplied complex value via arithmetic", async () => { + // secondSample (1 - 2i) plus a polar-authored unit real number (magnitude 1, phase 0 -> 1 + 0i): 2 - 2i. + const tree: ExpressionNode = { + kind: "arithmetic", + op: "add", + left: { kind: "reference", key: "secondSample" }, + right: { kind: "complexLiteral", magnitude: 1, phase: 0 }, + }; + const result = await evaluateValue(tree, undefined, resolvers); + expect(result).toEqual({ + status: "definite", + value: { kind: "complex", re: 2, im: -2 }, + }); + }); + + it("reaches the same ordering comparison through a polar-authored operand as through a rectangular one", async () => { + // firstSample's own re/im (see the resolver above) -- named individually so Math.atan2's arguments below aren't bare call-site literals (firstSample (3 + 4i) has magnitude exactly 5). + const firstSampleRealPart = 3; + const firstSampleImaginaryPart = 4; + const polarEquivalent: ExpressionNode = { + kind: "complexLiteral", + magnitude: 5, + phase: Math.atan2(firstSampleImaginaryPart, firstSampleRealPart), + }; + const rule: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "call", fn: "magnitude", args: [polarEquivalent] }, + right: { kind: "numberLiteral", value: 4 }, + }; + await expect( + evaluatePredicate(rule, undefined, resolvers), + ).resolves.toEqual({ status: "definite", value: true }); + }); +}); diff --git a/test/integration/json-schema-consistency.test.ts b/test/integration/json-schema-consistency.test.ts index 0015fcd..07c066a 100644 --- a/test/integration/json-schema-consistency.test.ts +++ b/test/integration/json-schema-consistency.test.ts @@ -14,37 +14,60 @@ function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** Every `kind` this schema's discriminated union actually accepts -- read off the schema's own `.options`/literal shape, never a hand-maintained literal array that could silently drift from the real type. */ -function discriminantKinds( - schema: typeof PredicateNodeSchema | typeof ExpressionNodeSchema, -): string[] { - return schema.options.map((option) => option.shape.kind.value); +/** An `.options` entry that is itself a leaf node schema (a `z.object`/`z.strictObject` with a literal `kind` discriminant), as opposed to a nested union -- returns that leaf's own `kind` literal, or `undefined` if `option` isn't shaped this way. */ +function readKindLiteral(option: unknown): string | undefined { + if (!isPlainRecord(option)) return undefined; + const shape = option.shape; + if (!isPlainRecord(shape)) return undefined; + const kind = shape.kind; + if (!isPlainRecord(kind)) return undefined; + const value = kind.value; + return typeof value === "string" ? value : undefined; } -/** A discriminated-union branch as generated JSON Schema shape: `{ properties: { kind: { const: "..." } } }`. Narrowed step by step via plain type guards, per this repo's `object -> Record` narrowing convention, rather than an `as` assertion on zod's own loosely-typed JSON Schema output. Throwing rather than returning a placeholder is what makes a degenerate branch (no `properties`, no `kind`, no `const`) a test failure instead of a silently-tolerated hole. */ -function extractKindConst(branch: unknown): string { +/** An `.options` entry that is itself a union (`ZodDiscriminatedUnion` or `ZodUnion`) rather than a leaf object schema -- recognised structurally by its own `.options` array, the same property every zod union type (discriminated or plain) exposes. */ +function hasOptionsArray( + value: unknown, +): value is { options: readonly unknown[] } { + if (!isPlainRecord(value)) return false; + return Array.isArray(value.options); +} + +/** + * Every `kind` this schema's union actually accepts -- read off the schema's own `.options`/literal shape, never a hand-maintained literal array that could silently drift from the real type. Recurses when an option is itself a nested union rather than a flat object: `ExpressionNodeSchema` is `z.union([CoreExpressionNodeSchema, ComplexLiteralNodeSchema])` (a plain union wrapping a discriminated union and a further nested union, since `z.discriminatedUnion` cannot host two `complexLiteral` members sharing one literal discriminant -- see the "Complex values" section of README.md), so its own top-level `.options` are two further unions, not flat leaf schemas. `PredicateNodeSchema` is untouched by this and stays a single flat discriminated union, but recursing costs nothing extra for it -- every one of its options resolves a `kind` literal on the first call. + */ +function discriminantKinds(schema: { options: readonly unknown[] }): string[] { + return schema.options.flatMap((option): string[] => { + const kind = readKindLiteral(option); + if (kind !== undefined) return [kind]; + if (hasOptionsArray(option)) return discriminantKinds(option); + throw new Error( + "unreachable: union option has neither a 'kind' literal shape nor a nested .options array", + ); + }); +} + +/** A discriminated-union branch as generated JSON Schema shape: `{ properties: { kind: { const: "..." } } }` -- or, since `ExpressionNodeSchema` is now a plain union wrapping a nested discriminated union and a further nested union (see `discriminantKinds` above), a branch that is itself another `oneOf`/`anyOf` list, recursed into rather than assumed to be a flat leaf. Narrowed step by step via plain type guards, per this repo's `object -> Record` narrowing convention, rather than an `as` assertion on zod's own loosely-typed JSON Schema output. Throwing rather than returning a placeholder is what makes a genuinely degenerate branch (no `properties`, no `kind`, no `const`, and no `oneOf`/`anyOf` either) a test failure instead of a silently-tolerated hole. */ +function extractKindConst(branch: unknown): string[] { if (!isPlainRecord(branch)) { throw new Error( "expected a JSON Schema object for a discriminated-union branch", ); } const properties = branch.properties; - if (!isPlainRecord(properties)) { - throw new Error( - "expected a 'properties' object on a discriminated-union branch", - ); + if (isPlainRecord(properties)) { + const kind = properties.kind; + if (isPlainRecord(kind) && typeof kind.const === "string") { + return [kind.const]; + } } - const kind = properties.kind; - if (!isPlainRecord(kind)) { + const nested = branch.oneOf ?? branch.anyOf; + if (!Array.isArray(nested)) { throw new Error( - "expected a 'kind' property schema on a discriminated-union branch", + "expected a 'kind' const on a discriminated-union branch, or a nested 'oneOf'/'anyOf' branch list", ); } - const constValue = kind.const; - if (typeof constValue !== "string") { - throw new Error("expected a string 'const' on the 'kind' property schema"); - } - return constValue; + return nested.flatMap((entry) => extractKindConst(entry)); } function extractGeneratedKinds(nodeDefinition: unknown): string[] { @@ -53,13 +76,13 @@ function extractGeneratedKinds(nodeDefinition: unknown): string[] { "expected the generated node definition to be a JSON Schema object", ); } - const oneOf = nodeDefinition.oneOf; - if (!Array.isArray(oneOf)) { + const branches = nodeDefinition.oneOf ?? nodeDefinition.anyOf; + if (!Array.isArray(branches)) { throw new Error( - "expected the generated node definition to have a 'oneOf' branch list", + "expected the generated node definition to have a 'oneOf' or 'anyOf' branch list", ); } - return oneOf.map(extractKindConst); + return branches.flatMap((branch) => extractKindConst(branch)); } function collectRefs(value: unknown, found = new Set()): Set { diff --git a/test/smoke.test.ts b/test/smoke.test.ts index d411d74..5d8858e 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -218,25 +218,59 @@ describe.each([ }, ); -/** A discriminated-union branch as generated JSON Schema shape: `{ properties: { kind: { const: "..." } } }`. */ -function extractKindConst(branch: unknown): string { +/** A runtime `.options` entry that is itself a leaf node schema (a `z.object`/`z.strictObject` with a literal `kind` discriminant), as opposed to a nested union -- returns that leaf's own `kind` literal, or `undefined` if `option` isn't shaped this way. */ +function readKindLiteral(option: unknown): string | undefined { + if (!isPlainRecord(option)) return undefined; + const shape = option.shape; + if (!isPlainRecord(shape)) return undefined; + const kind = shape.kind; + if (!isPlainRecord(kind)) return undefined; + const value = kind.value; + return typeof value === "string" ? value : undefined; +} + +/** A runtime `.options` entry that is itself a union (`ZodDiscriminatedUnion` or `ZodUnion`) rather than a leaf object schema -- recognised structurally by its own `.options` array, the same property every zod union type (discriminated or plain) exposes. */ +function hasOptionsArray( + value: unknown, +): value is { options: readonly unknown[] } { + if (!isPlainRecord(value)) return false; + return Array.isArray(value.options); +} + +/** Every `kind` a runtime union schema (`PredicateNodeSchema`/`ExpressionNodeSchema`, as built) actually accepts, read off its own `.options`. Recurses when an option is itself a nested union rather than a flat object -- built `ExpressionNodeSchema` is `z.union([CoreExpressionNodeSchema, ComplexLiteralNodeSchema])` (see `extractKindConst` below), so its own top-level `.options` are two further unions, not flat leaf schemas. Mirrors test/integration/json-schema-consistency.test.ts's own `discriminantKinds`, duplicated here rather than imported to keep this file self-contained (see this file's own top comment). */ +function runtimeDiscriminantKinds(schema: { + options: readonly unknown[]; +}): string[] { + return schema.options.flatMap((option): string[] => { + const kind = readKindLiteral(option); + if (kind !== undefined) return [kind]; + if (hasOptionsArray(option)) return runtimeDiscriminantKinds(option); + throw new Error( + "unreachable: union option has neither a 'kind' literal shape nor a nested .options array", + ); + }); +} + +/** A discriminated-union branch as generated JSON Schema shape: `{ properties: { kind: { const: "..." } } }` -- or, since built `ExpressionNodeSchema` is a plain union wrapping a nested discriminated union and a further nested union (rect/polar `complexLiteral`, which can't share one discriminant value inside a single discriminatedUnion -- see README.md's "Complex values" section), a branch that is itself another `oneOf`/`anyOf` list, recursed into rather than assumed to be a flat leaf. */ +function extractKindConst(branch: unknown): string[] { const branchRecord = asPlainRecord( branch, "expected a JSON Schema object for a discriminated-union branch", ); - const properties = asPlainRecord( - branchRecord.properties, - "expected a 'properties' object on a discriminated-union branch", - ); - const kindSchema = asPlainRecord( - properties.kind, - "expected a 'kind' property schema on a discriminated-union branch", - ); - const constValue = kindSchema.const; - if (typeof constValue !== "string") { - throw new Error("expected a string 'const' on the 'kind' property schema"); + const properties = branchRecord.properties; + if (isPlainRecord(properties)) { + const kindSchema = properties.kind; + if (isPlainRecord(kindSchema) && typeof kindSchema.const === "string") { + return [kindSchema.const]; + } + } + const nested = branchRecord.oneOf ?? branchRecord.anyOf; + if (!Array.isArray(nested)) { + throw new Error( + "expected a 'kind' const on a discriminated-union branch, or a nested 'oneOf'/'anyOf' branch list", + ); } - return constValue; + return nested.flatMap((entry) => extractKindConst(entry)); } function generatedKinds(nodeDefinition: unknown): string[] { @@ -244,13 +278,13 @@ function generatedKinds(nodeDefinition: unknown): string[] { nodeDefinition, "expected the generated node definition to be a JSON Schema object", ); - const oneOf = definition.oneOf; - if (!Array.isArray(oneOf)) { + const branches = definition.oneOf ?? definition.anyOf; + if (!Array.isArray(branches)) { throw new Error( - "expected the generated node definition to have a 'oneOf' branch list", + "expected the generated node definition to have a 'oneOf' or 'anyOf' branch list", ); } - return oneOf.map(extractKindConst).sort(); + return branches.flatMap((branch) => extractKindConst(branch)).sort(); } function collectRefs(value: unknown, found = new Set()): Set { @@ -382,7 +416,8 @@ describe("generated schemas/trilean.schema.json", () => { "expected $defs.ExpressionNode to be a JSON object", ); expect(Array.isArray(predicateNode.oneOf)).toBe(true); - expect(Array.isArray(expressionNode.oneOf)).toBe(true); + // ExpressionNode's own top level is a plain `z.union` (not a `z.discriminatedUnion`), wrapping the core discriminated union alongside the `complexLiteral` rect/polar union -- see README.md's "Complex values" section -- so it converts to `anyOf`, not `oneOf`, unlike PredicateNode above, which is untouched by this and stays a single flat discriminated union. + expect(Array.isArray(expressionNode.anyOf)).toBe(true); }); it("is self-contained: every $ref resolves to a definition inside the document", () => { @@ -406,9 +441,9 @@ describe("generated schemas/trilean.schema.json", () => { ])( "%s's generated 'kind' consts are exactly the built package's own %s options", (definitionName, schemaExportName) => { - const runtimeKinds = esmIndex[schemaExportName].options - .map((option) => option.shape.kind.value) - .sort(); + const runtimeKinds = runtimeDiscriminantKinds( + esmIndex[schemaExportName], + ).sort(); const document = asPlainRecord( schemaDocument, "expected the generated schema document to be a JSON object", From f4fea34c9a1c8ba8563ca6dba36d68eac22b678b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 1 Sep 2026 15:41:37 +0100 Subject: [PATCH 7/8] docs: document polar authoring for complex literals 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. --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0bd0a1b..6769a30 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,8 @@ type ExpressionNode = | { kind: "booleanLiteral"; value: boolean } | { kind: "instantLiteral"; value: string } | { kind: "durationLiteral"; value: number; unit: DurationUnit } - | { kind: "complexLiteral"; re: number; im: number; unit?: Unit } + | { kind: "complexLiteral"; re: number; im: number; unit?: Unit } // rectangular + | { kind: "complexLiteral"; magnitude: number; phase: number; unit?: Unit } // polar -- see Complex values | { kind: "reference"; key: JsonValue; unit?: Unit } | { kind: "arithmetic"; op: ArithmeticOperator; left: ExpressionNode; right: ExpressionNode } | { kind: "negate"; operand: ExpressionNode } @@ -438,7 +439,7 @@ A `textLiteral` kind is included even though it is not separately enumerated as ### Literals -`numberLiteral`, `textLiteral`, `booleanLiteral`, `instantLiteral` (an ISO-8601 timestamp string), `durationLiteral` (a magnitude plus a `DurationUnit`), and `complexLiteral` (a real and an imaginary component, plus an optional `Unit`) are always definite by construction — a literal node never itself produces an indeterminate outcome. +`numberLiteral`, `textLiteral`, `booleanLiteral`, `instantLiteral` (an ISO-8601 timestamp string), `durationLiteral` (a magnitude plus a `DurationUnit`), and `complexLiteral` (either a real and an imaginary component, or a magnitude and a phase, plus an optional `Unit` — see [Complex values](#complex-values)) are always definite by construction — a literal node never itself produces an indeterminate outcome. ### `reference` @@ -480,6 +481,8 @@ Any other arithmetic combination touching an `instant` or `duration` (adding two The magnitude-and-phase view stays reachable through four exported conversion helpers rather than a second encoding: `complexFromPolar(magnitude, phase, unit?)` and `complexLiteralFromPolar(magnitude, phase, unit?)` build a value or a literal node from polar terms, and `complexMagnitude(value)` and `complexPhase(value)` read them back out — the magnitude as a real number in the value's own unit, the phase as a dimensionless real number of radians. Conversions at the edges, one representation in the middle. +**The wire-format literal accepts either authoring form, structurally discriminated.** `ComputedValue`'s own `complex` kind stays exactly the single rectangular shape described above — nothing about it changes. But the `complexLiteral` *node* is a plain union of two shapes, `{ kind: "complexLiteral", re, im, unit? }` and `{ kind: "complexLiteral", magnitude, phase, unit? }`, told apart by which fields are present rather than by a `form` tag, since both still share the one literal `kind`. This is not a second encoding of `ComputedValue` reappearing through the back door — it exists only at the authoring boundary, for whichever of the two forms is natural for a given domain to write directly into JSON rather than hand-computing a conversion before ever constructing the tree, and the evaluator normalises whichever form was used to the single rectangular `ComputedValue` immediately, before any arithmetic, comparison, or negation ever runs. A rectangular literal and a polar literal representing the same underlying number are therefore indistinguishable from that point on: they evaluate to the identical `ComputedValue` and compare `eq` to one another exactly as two rectangular literals with the same components would. + **Arithmetic.** - `add`/`subtract` are component-wise, requiring **identical** dimensional-exponent maps exactly as real numbers do (see [Units](#units)). From 7e1ee69d4c0913440af58f21e582708f36e1ceb9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 1 Sep 2026 15:56:21 +0100 Subject: [PATCH 8/8] test: use a non-trivial phase for polar-literal eq/memberOf comparisons 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. --- src/evaluator.test.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/evaluator.test.ts b/src/evaluator.test.ts index 8e51a50..9124a93 100644 --- a/src/evaluator.test.ts +++ b/src/evaluator.test.ts @@ -127,12 +127,19 @@ describe("complexLiteral", () => { }); it("eq: a rectangular literal and a polar literal representing the same underlying complex number compare as equal", async () => { + // A deliberately non-trivial phase (not a multiple of pi/2), with the rectangular side computed from the raw trig formula independently of the evaluator/complexFromPolar under test -- a sin/cos swap, or a bug that ignored `phase` entirely, would each produce a rectangular value that differs from this one, unlike phase 0's trivial identity. + const arbitraryPhase = 0.7; + const magnitude = 2; const result = await evaluatePredicate( { kind: "compare", op: "eq", - left: { kind: "complexLiteral", re: 2, im: 0 }, - right: { kind: "complexLiteral", magnitude: 2, phase: 0 }, + left: { + kind: "complexLiteral", + re: magnitude * Math.cos(arbitraryPhase), + im: magnitude * Math.sin(arbitraryPhase), + }, + right: { kind: "complexLiteral", magnitude, phase: arbitraryPhase }, }, undefined, resolvers, @@ -141,14 +148,21 @@ describe("complexLiteral", () => { }); it("memberOf: a rectangular operand matches a candidate list containing a polar literal representing the same number", async () => { + // Same non-trivial-phase, independently-computed-rectangular reasoning as the `eq` test above applies here. + const arbitraryPhase = 0.7; + const magnitude = 1; const result = await evaluatePredicate( { kind: "memberOf", op: "in", - operand: { kind: "complexLiteral", re: 1, im: 0 }, + operand: { + kind: "complexLiteral", + re: magnitude * Math.cos(arbitraryPhase), + im: magnitude * Math.sin(arbitraryPhase), + }, candidates: [ { kind: "complexLiteral", re: 0, im: 1 }, - { kind: "complexLiteral", magnitude: 1, phase: 0 }, + { kind: "complexLiteral", magnitude, phase: arbitraryPhase }, ], }, undefined,