From 195c642b06df69533cc6df10d50ff4848d07dd9e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 1 Sep 2026 15:00:00 +0100 Subject: [PATCH 1/3] feat: add a schema for complex (phasor) computed values and literal nodes A complex value is authored as either rectangular (re/im) or polar (magnitude/phase, in radians), discriminated structurally by which fields are present rather than by a form tag -- z.discriminatedUnion cannot host two members sharing one literal discriminant value ("complex" twice; it throws lazily at parse time), so ComputedValueSchema and ExpressionNodeSchema each become a plain z.union of their existing core discriminated union alongside a further nested union of the two complex shapes. Restructuring ExpressionNodeSchema this way changes its top-level JSON Schema conversion from a flat oneOf to a nested anyOf, so the two test helpers that walk its discriminant kinds (one against the runtime .options, one against the generated JSON Schema branches) now recurse into a nested union instead of assuming every branch is a flat leaf. --- src/computed-value.ts | 42 +++++++++- src/index.ts | 13 ++- src/tree.ts | 28 ++++++- .../json-schema-consistency.test.ts | 69 ++++++++++------ test/smoke.test.ts | 79 +++++++++++++------ 5 files changed, 183 insertions(+), 48 deletions(-) diff --git a/src/computed-value.ts b/src/computed-value.ts index 8fec25e..7e87efe 100644 --- a/src/computed-value.ts +++ b/src/computed-value.ts @@ -7,7 +7,28 @@ export type Unit = z.infer; export const DurationUnitSchema = z.enum(["ms", "s", "min", "h", "d"]); export type DurationUnit = z.infer; -export const ComputedValueSchema = z.discriminatedUnion("kind", [ +/** + * A complex number can be authored as EITHER rectangular (`re`/`im`) OR polar (`magnitude`/`phase`, in radians) -- discriminated structurally by which fields are present, not by an extra `form` tag. `z.discriminatedUnion("kind", [...])` cannot host two members sharing one literal discriminant value (`kind: "complex"` twice; it throws at parse time), so the two shapes are a plain `z.union` of two `z.strictObject`s instead, and `ComputedValueSchema` below wraps its own existing discriminated union alongside this one rather than folding "complex" into it. See the "Complex values" section of README.md. + */ +export const ComplexRectangularSchema = z.strictObject({ + kind: z.literal("complex"), + re: z.number(), + im: z.number(), + unit: UnitSchema.optional(), +}); +export const ComplexPolarSchema = z.strictObject({ + kind: z.literal("complex"), + magnitude: z.number(), + phase: z.number(), + unit: UnitSchema.optional(), +}); +export const ComplexValueSchema = z.union([ + ComplexRectangularSchema, + ComplexPolarSchema, +]); +export type ComplexValue = z.infer; + +const CoreComputedValueSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("number"), value: z.number(), @@ -22,8 +43,27 @@ export const ComputedValueSchema = z.discriminatedUnion("kind", [ unit: DurationUnitSchema, }), ]); + +export const ComputedValueSchema = z.union([ + CoreComputedValueSchema, + ComplexValueSchema, +]); export type ComputedValue = z.infer; +/** + * Normalises a complex value's rectangular components regardless of which form it was authored in -- the same treatment "Temporal values" already gives a `duration`, normalising every `DurationUnit` to milliseconds before combining two durations of different units. `'re' in value` narrows correctly here (unlike a single-object-with-optional-fields design) because `ComplexValue` is a genuine TS union of two distinct object types, not one type with optional fields plus a runtime check. + */ +export function toRectangular(value: ComplexValue): { + re: number; + im: number; +} { + if ("re" in value) return { re: value.re, im: value.im }; + return { + re: value.magnitude * Math.cos(value.phase), + im: value.magnitude * Math.sin(value.phase), + }; +} + /** Drops zero-exponent dimensions so that, e.g., dividing a unit by itself normalises to the same dimensionless `{}` as an absent unit -- without this, `{ m: 0 }` and `{}` would compare unequal despite representing the same dimension. */ function normalizeUnit(unit: Unit | undefined): Unit { if (unit === undefined) return {}; diff --git a/src/index.ts b/src/index.ts index fcc1dd3..193572b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,13 +9,22 @@ export { indeterminate, } from "./evaluation"; -export type { ComputedValue, DurationUnit, Unit } from "./computed-value"; +export type { + ComplexValue, + ComputedValue, + DurationUnit, + Unit, +} from "./computed-value"; export { + ComplexPolarSchema, + ComplexRectangularSchema, + ComplexValueSchema, ComputedValueSchema, DurationUnitSchema, UnitSchema, combineUnitsForDivide, combineUnitsForMultiply, + toRectangular, unitsEqual, } from "./computed-value"; @@ -55,6 +64,7 @@ export type { CallNode, CompareNode, ComparisonOperator, + ComplexLiteralNode, ConditionalCase, ConditionalNode, DelegateNode, @@ -91,6 +101,7 @@ export { CallNodeSchema, CompareNodeSchema, ComparisonOperatorSchema, + ComplexLiteralNodeSchema, ConditionalCaseSchema, ConditionalNodeSchema, DelegateNodeSchema, diff --git a/src/tree.ts b/src/tree.ts index 2abadfe..40543e9 100644 --- a/src/tree.ts +++ b/src/tree.ts @@ -322,7 +322,28 @@ export const DelegateNodeSchema = z.object({ }); export type DelegateNode = z.infer; -export const ExpressionNodeSchema = z.discriminatedUnion("kind", [ +/** + * The wire-format literal counterpart to computed-value.ts's `ComplexValueSchema`: authored as EITHER rectangular (`re`/`im`) OR polar (`magnitude`/`phase`, in radians), discriminated structurally rather than by a `form` tag, for the same reason -- `z.discriminatedUnion("kind", [...])` cannot host two members sharing one literal `kind: "complexLiteral"` value. `ExpressionNodeSchema` below wraps this plain union alongside its own existing discriminated union rather than folding `complexLiteral` into it. See the "Complex values" section of README.md. + */ +export const ComplexRectangularLiteralNodeSchema = z.strictObject({ + kind: z.literal("complexLiteral"), + re: z.number(), + im: z.number(), + unit: UnitSchema.optional(), +}); +export const ComplexPolarLiteralNodeSchema = z.strictObject({ + kind: z.literal("complexLiteral"), + magnitude: z.number(), + phase: z.number(), + unit: UnitSchema.optional(), +}); +export const ComplexLiteralNodeSchema = z.union([ + ComplexRectangularLiteralNodeSchema, + ComplexPolarLiteralNodeSchema, +]); +export type ComplexLiteralNode = z.infer; + +const CoreExpressionNodeSchema = z.discriminatedUnion("kind", [ NumberLiteralNodeSchema, TextLiteralNodeSchema, InstantLiteralNodeSchema, @@ -338,4 +359,9 @@ export const ExpressionNodeSchema = z.discriminatedUnion("kind", [ DelegateNodeSchema, TreeReferenceNodeSchema, ]); + +export const ExpressionNodeSchema = z.union([ + CoreExpressionNodeSchema, + ComplexLiteralNodeSchema, +]); export type ExpressionNode = z.infer; 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 eb8af17..08d0dae 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -214,25 +214,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[] { @@ -240,13 +274,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 { @@ -378,7 +412,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", () => { @@ -402,9 +437,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 99927002cb6a54e87fe7c0fbbdb81d8c3dc7479d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 1 Sep 2026 15:00:22 +0100 Subject: [PATCH 2/3] feat: implement complex-number arithmetic, comparison, and negation Adds a complex case throughout the evaluator: compare/memberOf equality (eq/neq only -- complex numbers have no natural ordering), negate (via rectangular normalisation), and the full arithmetic dispatch -- add/subtract/multiply/divide over two complex operands or a mixed complex/number pair (a plain number widens to complex with a zero imaginary part), power restricted to a real dimensionless integer exponent computed by repeated multiplication, and modulo left undefined. Division by a zero-magnitude complex number is domain-error, the same category as ordinary division by zero. --- src/computed-value.test.ts | 121 ++++++++++ src/evaluator.indeterminacy.test.ts | 10 + src/evaluator.test.ts | 346 ++++++++++++++++++++++++++++ src/evaluator.ts | 217 +++++++++++++++++ src/tree.test.ts | 45 ++++ 5 files changed, 739 insertions(+) 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..cd606eb --- /dev/null +++ b/src/computed-value.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + ComplexPolarSchema, + ComplexRectangularSchema, + ComplexValueSchema, + toRectangular, +} from "./computed-value"; + +/** `toBeCloseTo`'s own precision-digits argument, named individually since `@typescript-eslint/no-magic-numbers` only exempts -1/0/1/2 and object-literal property values, not a plain call argument. */ +const closeToPrecisionDigits = 10; + +describe("toRectangular", () => { + it("a rectangular value passes its own re/im straight through", () => { + expect(toRectangular({ kind: "complex", re: 3, im: -4 })).toEqual({ + re: 3, + im: -4, + }); + }); + + it("a polar value converts magnitude/phase to re/im via cos/sin", () => { + const magnitude = 2; + const phase = Math.PI / 2; + const { re, im } = toRectangular({ kind: "complex", magnitude, phase }); + expect(re).toBeCloseTo(0, closeToPrecisionDigits); + expect(im).toBeCloseTo(2, closeToPrecisionDigits); + }); + + it("a zero-magnitude polar value converts to the origin regardless of phase", () => { + expect( + toRectangular({ kind: "complex", magnitude: 0, phase: 1.23 }), + ).toEqual({ re: 0, im: 0 }); + }); +}); + +describe("ComplexRectangularSchema", () => { + it("parses a rectangular value with and without an optional unit", () => { + const withoutUnit = { kind: "complex", re: 1, im: 2 }; + const withUnit = { kind: "complex", re: 1, im: 2, unit: { m: 1 } }; + expect(ComplexRectangularSchema.parse(withoutUnit)).toEqual(withoutUnit); + expect(ComplexRectangularSchema.parse(withUnit)).toEqual(withUnit); + }); + + it("rejects a missing im and a polar-shaped payload", () => { + expect( + ComplexRectangularSchema.safeParse({ kind: "complex", re: 1 }).success, + ).toBe(false); + expect( + ComplexRectangularSchema.safeParse({ + kind: "complex", + magnitude: 1, + phase: 2, + }).success, + ).toBe(false); + }); +}); + +describe("ComplexPolarSchema", () => { + it("parses a polar value with and without an optional unit", () => { + const withoutUnit = { kind: "complex", magnitude: 1, phase: 2 }; + const withUnit = { + kind: "complex", + magnitude: 1, + phase: 2, + unit: { s: -1 }, + }; + expect(ComplexPolarSchema.parse(withoutUnit)).toEqual(withoutUnit); + expect(ComplexPolarSchema.parse(withUnit)).toEqual(withUnit); + }); + + it("rejects a missing phase and a rectangular-shaped payload", () => { + expect( + ComplexPolarSchema.safeParse({ kind: "complex", magnitude: 1 }).success, + ).toBe(false); + expect( + ComplexPolarSchema.safeParse({ kind: "complex", re: 1, im: 2 }).success, + ).toBe(false); + }); +}); + +describe("ComplexValueSchema", () => { + it("accepts a valid rectangular value", () => { + const valid = { kind: "complex", re: 1, im: 2 }; + expect(ComplexValueSchema.parse(valid)).toEqual(valid); + }); + + it("accepts a valid polar value", () => { + const valid = { kind: "complex", magnitude: 1, phase: 2 }; + expect(ComplexValueSchema.parse(valid)).toEqual(valid); + }); + + it("rejects a payload carrying both rectangular and polar fields at once", () => { + expect( + ComplexValueSchema.safeParse({ + kind: "complex", + re: 1, + im: 2, + magnitude: 3, + phase: 4, + }).success, + ).toBe(false); + }); + + it("rejects a payload carrying neither rectangular nor polar fields", () => { + expect(ComplexValueSchema.safeParse({ kind: "complex" }).success).toBe( + false, + ); + }); + + it("rejects a partial rectangular payload (re without im)", () => { + expect( + ComplexValueSchema.safeParse({ kind: "complex", re: 1 }).success, + ).toBe(false); + }); + + it("rejects a mixed payload (one rectangular field, one polar field)", () => { + expect( + ComplexValueSchema.safeParse({ kind: "complex", re: 1, phase: 2 }) + .success, + ).toBe(false); + }); +}); diff --git a/src/evaluator.indeterminacy.test.ts b/src/evaluator.indeterminacy.test.ts index affaeed..9301ff6 100644 --- a/src/evaluator.indeterminacy.test.ts +++ b/src/evaluator.indeterminacy.test.ts @@ -347,6 +347,16 @@ const fixtures: readonly Fixture[] = [ { kind: "compare", op: "eq", left: divideByZero, right: numberLiteral(1) }, isDomainError, ), + pred( + "compare: wrong-type when an ordering operator (not eq/neq) is used against complex operands", + { + kind: "compare", + op: "gt", + left: { kind: "complexLiteral", re: 1, im: 2 }, + right: { kind: "complexLiteral", re: 3, im: 4 }, + }, + isWrongType, + ), // textCompare pred( diff --git a/src/evaluator.test.ts b/src/evaluator.test.ts index 91d17cb..b75a918 100644 --- a/src/evaluator.test.ts +++ b/src/evaluator.test.ts @@ -80,6 +80,24 @@ describe("literals", () => { ); expectDefinite(result, { kind: "duration", value: 5, unit: "min" }); }); + + it("complexLiteral (rectangular form) is always definite", async () => { + const result = await evaluateValue( + { kind: "complexLiteral", re: 3, im: -4, unit: { m: 1 } }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 3, im: -4, unit: { m: 1 } }); + }); + + it("complexLiteral (polar form) is always definite", async () => { + const result = await evaluateValue( + { kind: "complexLiteral", magnitude: 1, phase: 2 }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", magnitude: 1, phase: 2 }); + }); }); describe("reference", () => { @@ -410,6 +428,35 @@ describe("negate", () => { ); expectIndeterminate(result, "not-found"); }); + + it("negates a rectangular complex literal, preserving its unit", async () => { + const result = await evaluateValue( + { + kind: "negate", + operand: { kind: "complexLiteral", re: 3, im: -4, unit: { m: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: -3, im: 4, unit: { m: 1 } }); + }); + + it("negates a polar complex literal, normalising the result to rectangular form", async () => { + const result = await evaluateValue( + { + kind: "negate", + operand: { kind: "complexLiteral", magnitude: 2, phase: 0 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { + kind: "complex", + re: -2, + im: -0, + unit: undefined, + }); + }); }); describe("temporal arithmetic -- the four well-defined combination rules", () => { @@ -625,6 +672,204 @@ describe("temporal arithmetic -- wrong-type violations", () => { }); }); +describe("complex arithmetic", () => { + it("add requires identical unit maps", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "complexLiteral", re: 1, im: 2, unit: { m: 1 } }, + right: { kind: "complexLiteral", re: 3, im: 4, unit: { m: 1 } }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 4, im: 6, unit: { m: 1 } }); + }); + + it("subtract is wrong-type on mismatched units", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "subtract", + left: { kind: "complexLiteral", re: 1, im: 2, unit: { m: 1 } }, + right: { kind: "complexLiteral", re: 3, im: 4, unit: { s: 1 } }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("multiply: (1+2i)(3+4i) = -5+10i, a known identity", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "multiply", + left: { kind: "complexLiteral", re: 1, im: 2 }, + right: { kind: "complexLiteral", re: 3, im: 4 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: -5, im: 10, unit: {} }); + }); + + it("multiply: i * i = -1, the defining identity of the imaginary unit", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "multiply", + left: { kind: "complexLiteral", re: 0, im: 1 }, + right: { kind: "complexLiteral", re: 0, im: 1 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: -1, im: 0, unit: {} }); + }); + + it("divide: (1+i)/(1-i) = i, a known identity", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "divide", + left: { kind: "complexLiteral", re: 1, im: 1 }, + right: { kind: "complexLiteral", re: 1, im: -1 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 0, im: 1, unit: {} }); + }); + + it("divide by a zero-magnitude complex number is domain-error", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "divide", + left: { kind: "complexLiteral", re: 1, im: 2 }, + right: { kind: "complexLiteral", re: 0, im: 0 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "domain-error"); + }); + + it("a number operand widens to complex for mixed arithmetic: 5 + (1+2i) = 6+2i", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "numberLiteral", value: 5 }, + right: { kind: "complexLiteral", re: 1, im: 2 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 6, im: 2 }); + }); + + it("combines a rectangular operand and a polar operand representing the same number in one operation", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "add", + left: { kind: "complexLiteral", re: 1, im: 2 }, + right: { kind: "complexLiteral", magnitude: 1, phase: 0 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 2, im: 2 }); + }); + + it("power: a positive integer exponent is repeated multiplication -- i^2 = -1", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 0, im: 1 }, + right: { kind: "numberLiteral", value: 2 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: -1, im: 0, unit: {} }); + }); + + it("power: a negative integer exponent inverts the result -- i^-1 = -i", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 0, im: 1 }, + right: { kind: "numberLiteral", value: -1 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 0, im: -1, unit: {} }); + }); + + it("power: a zero exponent is the multiplicative identity, regardless of the base", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 2, im: 3 }, + right: { kind: "numberLiteral", value: 0 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, { kind: "complex", re: 1, im: 0, unit: {} }); + }); + + it("power: a non-integer exponent is wrong-type against a complex base", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 1, im: 2 }, + right: { kind: "numberLiteral", value: 0.5 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("power: a complex exponent is wrong-type -- only a real number exponent is defined", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "power", + left: { kind: "complexLiteral", re: 1, im: 2 }, + right: { kind: "complexLiteral", re: 2, im: 0 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("modulo is wrong-type for complex values", async () => { + const result = await evaluateValue( + { + kind: "arithmetic", + op: "modulo", + left: { kind: "complexLiteral", re: 1, im: 2 }, + right: { kind: "complexLiteral", re: 3, im: 4 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); +}); + describe("compare", () => { it.each([ { op: "gt", left: 5, right: 3, expected: true }, @@ -767,6 +1012,90 @@ describe("compare", () => { ); expectIndeterminate(result, "not-found"); }); + + it("eq: two rectangular complex values with the same components are equal", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "complexLiteral", re: 1, im: 2 }, + right: { kind: "complexLiteral", re: 1, im: 2 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("eq: a rectangular value and a polar value representing the same underlying complex number are 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("neq: two complex values with different components are not equal", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "neq", + left: { kind: "complexLiteral", re: 1, im: 2 }, + right: { kind: "complexLiteral", re: 1, im: 3 }, + }, + undefined, + resolvers, + ); + expectDefinite(result, true); + }); + + it("an ordering operator (gt/gte/lt/lte) is wrong-type against complex operands -- complex numbers have no natural ordering", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "gt", + left: { kind: "complexLiteral", re: 1, im: 2 }, + right: { kind: "complexLiteral", re: 0, im: 0 }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); + + it("is wrong-type when comparing a complex value against a non-complex value", async () => { + const result = await evaluatePredicate( + { + kind: "compare", + op: "eq", + left: { kind: "complexLiteral", re: 1, im: 2 }, + right: { kind: "numberLiteral", value: 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: 1, im: 2, unit: { m: 1 } }, + right: { kind: "complexLiteral", re: 1, im: 2, unit: { s: 1 } }, + }, + undefined, + resolvers, + ); + expectIndeterminate(result, "wrong-type"); + }); }); describe("textCompare", () => { @@ -1084,6 +1413,23 @@ describe("memberOf", () => { ); expectIndeterminate(result, "wrong-type"); }); + + it("matches a rectangular operand against a candidate list including a polar value 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("exists", () => { diff --git a/src/evaluator.ts b/src/evaluator.ts index 74d0726..1204b61 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -2,6 +2,7 @@ import type { ComputedValue, DurationUnit, Unit } from "./computed-value"; import { combineUnitsForDivide, combineUnitsForMultiply, + toRectangular, unitsEqual, } from "./computed-value"; import { @@ -169,6 +170,31 @@ function compareValues( toMilliseconds(right.value, right.unit), ), ); + case "complex": { + if (right.kind !== "complex") { + return indeterminate( + "wrong-type", + `cannot compare a 'complex' value with a '${right.kind}' value`, + ); + } + if (op !== "eq" && op !== "neq") { + return indeterminate( + "wrong-type", + `'${op}' is not defined for 'complex' values -- complex numbers have no natural ordering; use 'eq'/'neq'`, + ); + } + if (!unitsEqual(left.unit, right.unit)) { + return indeterminate( + "wrong-type", + "cannot compare complex numbers with incompatible units", + ); + } + const leftRect = toRectangular(left); + const rightRect = toRectangular(right); + const isEqual = + leftRect.re === rightRect.re && leftRect.im === rightRect.im; + return definite(op === "eq" ? isEqual : !isEqual); + } default: throw new Error("unreachable computed-value kind"); } @@ -270,6 +296,26 @@ 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 numbers with incompatible units for membership", + ); + } + const operandRect = toRectangular(operand); + const candidateRect = toRectangular(candidate); + return definite( + operandRect.re === candidateRect.re && + operandRect.im === candidateRect.im, + ); + } default: throw new Error("unreachable computed-value kind"); } @@ -295,6 +341,15 @@ function applyNegate(operand: ComputedValue): Evaluation { value: -operand.value, unit: operand.unit, }); + case "complex": { + const rect = toRectangular(operand); + return definite({ + kind: "complex", + re: -rect.re, + im: -rect.im, + unit: operand.unit, + }); + } case "text": case "instant": return indeterminate( @@ -424,6 +479,126 @@ function applyArithmeticOnDurations( } } +/** Binary arithmetic between two `complex` operands, each normalised to rectangular form first via `toRectangular` -- the same "normalise, then combine" treatment `applyArithmeticOnDurations` above already gives `duration`. `add`/`subtract` require identical units (mirroring `applyArithmeticOnNumbers`'s own rule); `multiply`/`divide` combine units by dimensional analysis the same way. `power`/`modulo` are never reached here -- `applyArithmetic`'s own dispatcher intercepts both before calling this function, `power` routing to `applyComplexPower` and `modulo` reporting `wrong-type` directly, since neither has a general two-`complex`-operand definition. */ +function applyArithmeticOnComplex( + op: ArithmeticOperator, + left: Extract, + right: Extract, +): Evaluation { + const leftRect = toRectangular(left); + const rightRect = toRectangular(right); + switch (op) { + case "add": + if (!unitsEqual(left.unit, right.unit)) { + return indeterminate( + "wrong-type", + "cannot add complex numbers with incompatible units", + ); + } + return definite({ + kind: "complex", + re: leftRect.re + rightRect.re, + im: leftRect.im + rightRect.im, + unit: left.unit, + }); + case "subtract": + if (!unitsEqual(left.unit, right.unit)) { + return indeterminate( + "wrong-type", + "cannot subtract complex numbers with incompatible units", + ); + } + return definite({ + kind: "complex", + re: leftRect.re - rightRect.re, + im: leftRect.im - rightRect.im, + unit: left.unit, + }); + case "multiply": + return definite({ + kind: "complex", + re: leftRect.re * rightRect.re - leftRect.im * rightRect.im, + im: leftRect.re * rightRect.im + leftRect.im * rightRect.re, + unit: combineUnitsForMultiply(left.unit, right.unit), + }); + case "divide": { + const denominator = rightRect.re ** 2 + rightRect.im ** 2; + if (denominator === 0) { + return indeterminate( + "domain-error", + "division by a zero-magnitude complex number", + ); + } + return definite({ + kind: "complex", + re: + (leftRect.re * rightRect.re + leftRect.im * rightRect.im) / + denominator, + im: + (leftRect.im * rightRect.re - leftRect.re * rightRect.im) / + denominator, + unit: combineUnitsForDivide(left.unit, right.unit), + }); + } + case "power": + case "modulo": + throw new Error( + "unreachable: 'power'/'modulo' are intercepted before applyArithmeticOnComplex, in applyArithmetic's own dispatcher", + ); + default: + throw new Error("unreachable arithmetic operator"); + } +} + +/** `power` against a `complex` base: only defined for a real, integer, dimensionless exponent, computed as repeated multiplication (a negative exponent inverting the final result via one `divide` by the multiplicative identity `1 + 0i`) rather than the general complex-exponent (`z^w`) formula, which would need a complex logarithm and is out of scope for this design -- see the "Complex values" section of README.md. */ +function applyComplexPower( + base: Extract, + exponent: Extract, +): Evaluation { + if (!isDimensionless(exponent.unit)) { + return indeterminate( + "wrong-type", + "'power' requires a dimensionless exponent", + ); + } + if (!Number.isInteger(exponent.value)) { + return indeterminate( + "wrong-type", + "'power' against a 'complex' base is only defined for an integer exponent", + ); + } + const magnitude = Math.abs(exponent.value); + const one: Extract = { + kind: "complex", + re: 1, + im: 0, + unit: {}, + }; + // Explicitly typed as the full `rect | polar` union, not inferred from `one` -- assigning an object literal to a `let` without its own annotation narrows TypeScript's inference to the literal's own matching union member (here, just the rectangular shape) rather than keeping the wider declared type, which would then reject a later `result = ` even though it is a perfectly valid `Complex` value. + let result: Extract = one; + for (let i = 0; i < magnitude; i++) { + const stepResult = applyArithmeticOnComplex("multiply", result, base); + if (stepResult.status === "indeterminate") return stepResult; + if (stepResult.value.kind !== "complex") { + throw new Error("unreachable: complex multiply always returns complex"); + } + result = stepResult.value; + } + if (exponent.value >= 0) return definite(result); + return applyArithmeticOnComplex("divide", one, result); +} + +/** Widens a `number` operand to `complex` (imaginary part `0`, same unit) so mixed `complex`/`number` arithmetic (e.g. `5 + (1+2i)`) can be expressed as a single `complex`/`complex` operation via `applyArithmeticOnComplex` -- returns `undefined` for any other kind, which the caller reports as `wrong-type`. */ +function toComplexOperand( + value: ComputedValue, +): Extract | undefined { + if (value.kind === "complex") return value; + if (value.kind === "number") { + return { kind: "complex", re: value.value, im: 0, unit: value.unit }; + } + return undefined; +} + /** * 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. */ @@ -476,6 +651,32 @@ function applyArithmetic( `arithmetic operator '${op}' is not defined between a '${left.kind}' and a '${right.kind}' value`, ); } + if (left.kind === "complex" || right.kind === "complex") { + if (op === "power") { + if (left.kind !== "complex" || right.kind !== "number") { + return indeterminate( + "wrong-type", + "'power' is only defined for a 'complex' base with a real 'number' exponent", + ); + } + return applyComplexPower(left, right); + } + if (op === "modulo") { + return indeterminate( + "wrong-type", + "'modulo' is not defined for 'complex' values", + ); + } + const leftComplex = toComplexOperand(left); + const rightComplex = toComplexOperand(right); + if (leftComplex === undefined || rightComplex === undefined) { + return indeterminate( + "wrong-type", + `arithmetic operator '${op}' is not defined between a '${left.kind}' and a '${right.kind}' value`, + ); + } + return applyArithmeticOnComplex(op, leftComplex, rightComplex); + } if (left.kind !== "number") { return indeterminate( "wrong-type", @@ -942,6 +1143,22 @@ async function evaluateValueInternal( value: node.value, unit: node.unit, }); + case "complexLiteral": { + if ("re" in node) { + return definite({ + kind: "complex", + re: node.re, + im: node.im, + unit: node.unit, + }); + } + return definite({ + kind: "complex", + magnitude: node.magnitude, + phase: node.phase, + 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 8856ece..7c91021 100644 --- a/src/tree.test.ts +++ b/src/tree.test.ts @@ -7,6 +7,7 @@ import { ArithmeticNodeSchema, CallNodeSchema, CompareNodeSchema, + ComplexLiteralNodeSchema, ConditionalNodeSchema, DelegateNodeSchema, DurationLiteralNodeSchema, @@ -287,6 +288,48 @@ describe("expression tree", () => { ).toBe(false); }); + it("complexLiteral: parses a valid rectangular value and a valid polar value", () => { + const rectangular = { kind: "complexLiteral", re: 1, im: 2 }; + const polar = { kind: "complexLiteral", magnitude: 1, phase: 2 }; + expect(ComplexLiteralNodeSchema.parse(rectangular)).toEqual(rectangular); + expect(ComplexLiteralNodeSchema.parse(polar)).toEqual(polar); + }); + + 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 partial rectangular payload (re without im)", () => { + expect( + ComplexLiteralNodeSchema.safeParse({ kind: "complexLiteral", re: 1 }) + .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); @@ -455,6 +498,8 @@ describe("expression tree", () => { { kind: "textLiteral", value: "a" }, { kind: "instantLiteral", value: "2026-08-30T00:00:00Z" }, { kind: "durationLiteral", value: 1, unit: "d" }, + { kind: "complexLiteral", re: 1, im: 2 }, + { kind: "complexLiteral", magnitude: 1, phase: 2 }, { kind: "reference", key: "x" }, { kind: "arithmetic", From 3c2766c1e7f62356da708494fd35cc5d4235d291 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 1 Sep 2026 15:00:42 +0100 Subject: [PATCH 3/3] docs: document complex-number arithmetic and retire the complex-number out-of-scope bullet Adds a "Complex values" section covering the rectangular/polar dual-input design and why it needs a structural union rather than a form tag, the toRectangular normalisation (mirroring duration's own millisecond normalisation), the number-widens-to-complex mixed-arithmetic rule, and power/modulo/ordering-operator scope limits. Extends the ComputedValue and ExpressionNode type blocks, the compare section's operand-kinds sentence, and the indeterminacy reference table's compare row. Complex support moves this out of "Out of scope", leaving symbolic algebra as the only remaining delegation precedent there. --- README.md | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5581e24..0600869 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,7 @@ These hold across every part of the design below, and any implementation change - **No assumptions about consumer data.** The only places this package touches real data are three named resolver contracts (see [Resolvers](#resolvers)). The schema stores *what to pass* to a resolver, never any resolver logic itself, and never interprets the meaning of an opaque key, table identifier, or collection reference. - **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), and [Derived values](#derived-values). +- **A numeric extension is in scope if it stays within closed-form evaluation.** `complex` (see [Complex values](#complex-values)) extends the existing real-valued numeric model exactly the way a new `call` registry function would — a bigger addition, but the same *kind* of addition, not a new evaluation paradigm. Only a genuinely different kind of computation (symbolic algebra, batch diagnostics) stays behind `delegate`; see [Out of scope](#out-of-scope). - **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). - **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. @@ -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`, or `duration`; comparing across different computed-value kinds, or comparing two numbers with incompatible units, is `wrong-type`. +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 `complex` (`eq`/`neq` only — see [Complex values](#complex-values)); comparing across different computed-value kinds, or comparing two numbers with incompatible units, is `wrong-type`. ### `textCompare` @@ -401,7 +402,9 @@ type ComputedValue = | { kind: "number"; value: number; unit?: Unit } | { kind: "text"; value: string } | { 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 } // rectangular form + | { kind: "complex"; magnitude: number; phase: number; unit?: Unit }; // polar form (phase in radians) type ArithmeticOperator = "add" | "subtract" | "multiply" | "divide" | "power" | "modulo"; @@ -417,6 +420,8 @@ type ExpressionNode = | { kind: "textLiteral"; value: string } | { kind: "instantLiteral"; value: string } | { kind: "durationLiteral"; value: number; unit: DurationUnit } + | { kind: "complexLiteral"; re: number; im: number; unit?: Unit } // rectangular form + | { kind: "complexLiteral"; magnitude: number; phase: number; unit?: Unit } // polar form (phase in radians) | { kind: "reference"; key: JsonValue; unit?: Unit } | { kind: "arithmetic"; op: ArithmeticOperator; left: ExpressionNode; right: ExpressionNode } | { kind: "negate"; operand: ExpressionNode } @@ -463,6 +468,18 @@ 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 + +A `complex` computed value (and its literal counterpart, `complexLiteral`) can be authored as **either** rectangular (`re`/`im`) **or** polar (`magnitude`/`phase`, in radians), discriminated **structurally** by which fields are present, not by an extra `form` tag. This is a deliberate schema-design constraint rather than a stylistic choice: `z.discriminatedUnion("kind", [...])` cannot host two members sharing one literal discriminant value (`kind: "complex"` twice), so `ComputedValueSchema` and `ExpressionNodeSchema` are each a plain `z.union` of their existing core discriminated union alongside a further nested union of the two complex shapes, rather than one flat discriminated union throughout. `complex` is modelled as a genuine two-branch union — not a single object with optional fields plus a runtime check — specifically so the type is exhaustively narrowable (a plain `if ("re" in value)` correctly discriminates the two shapes with no assertion) and so the shipped JSON Schema faithfully reflects the constraint (rejecting a payload that supplies both rectangular and polar fields, or neither) rather than silently accepting a malformed value. + +Every operation normalises a `complex` operand to rectangular form first, via `toRectangular` — the same "normalise to one base representation before combining" treatment [Temporal values](#temporal-values) above already gives a `duration`, normalising every `DurationUnit` to milliseconds before combining two durations of different units. A polar value is normalised on the fly for each operation rather than stored in a canonical form; there is no requirement that a `complex` value be re-serialised as rectangular after evaluation. + +`add`/`subtract`/`multiply`/`divide` all support mixed `complex`/`number` operands: a plain `number` widens to `complex` (imaginary part `0`, same unit) before the operation runs, so `5 + (1+2i)` evaluates the same as `(5+0i) + (1+2i)`. `add`/`subtract` require identical units, exactly as they do for two plain `number`s; `multiply`/`divide` combine units by the same dimensional analysis `number` arithmetic already uses. Dividing by a zero-magnitude complex number is `domain-error`, the same category as ordinary division by zero. + +`power` against a `complex` base is defined **only** for a real, integer, dimensionless `number` exponent — computed as repeated multiplication, with a negative exponent inverting the final result — not the general complex-exponent (`z^w`) formula, which would need a complex logarithm and is out of scope for this design (see [`call`](#call) or [`delegate`](#delegate) for a consumer that needs it). A non-integer or complex exponent against a `complex` base is `wrong-type`. `modulo` is not defined for `complex` values at all. + +`compare`'s ordering operators (`gt`/`gte`/`lt`/`lte`) are `wrong-type` against `complex` operands — complex numbers have no natural ordering — leaving only `eq`/`neq`, decided by rectangular-form equality (respecting units, exactly as `compare`'s numeric `eq` already does). `memberOf`'s membership test extends the same equality to `complex` candidates, kind-agnostic across every computed-value kind exactly as it already was before this design. + ### `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`. @@ -713,7 +730,7 @@ 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 (`gt`/`gte`/`lt`/`lte`) is used against `complex` operands | 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 | @@ -815,10 +832,9 @@ 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 either of the two delegation cases 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 the delegation case above — it only defines the shape of the hand-off (an opaque payload plus a named destination system). ## Prior art