diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc97dfe6..3cfb8d0bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,33 @@ does for `sin(x) = 2`. So `x·eˣ = 3` still solves to `W(3)` only, and `x·eˣ = -1` has no root. +### New Features + +- **`ResidueClass(k, n)` is an element of ℤ/nℤ**, and `QuotientRing(Integers, n)` + lists them (#399, contributed by [enumeratio](https://github.com/enumeratio)). + The canonical form reduces `k` into `0…n−1`, so `ResidueClass(7, 5)` is + `ResidueClass(2, 5)` and `ResidueClass(-1, 7)` is `ResidueClass(6, 7)`, and + `ResidueClass(7, 5) == ResidueClass(2, 5)` is `True`. Sums, differences, + products and integer powers of classes are classes: + `ResidueClass(5, 7) + ResidueClass(4, 7)` is `ResidueClass(2, 7)` and + `ResidueClass(3, 7)^6` is `ResidueClass(1, 7)`. An integer next to a class is + read in its ring: `ResidueClass(5, 7) + 3` is `ResidueClass(1, 7)`, and + `ResidueClass(1/3, 7)` is `ResidueClass(5, 7)`. An inverse, a division and a + negative power need `gcd(k, n) = 1`: `1/ResidueClass(3, 7)` is + `ResidueClass(5, 7)`, and `1/ResidueClass(2, 4)` stays unevaluated. Classes of + two moduli meet in ℤ/gcd(m, n): `ResidueClass(2, 4) + ResidueClass(1, 6)` is + `ResidueClass(1, 2)`. The class has type `value`, and the element type of + `QuotientRing(Integers, n)` is `value`, not `unknown`. ℤ/5ℤ lists + `ResidueClass(0, 5)` … `ResidueClass(4, 5)` (`ListFrom`, `Union`, `Tally`… + walk them), and `Element(ResidueClass(7, 5), ℤ/5ℤ)` is `True`; the + membership of a bare integer, `Element(7, ℤ/5ℤ)`, is still not decided. In + LaTeX the class is `\overline{k}_{n}` for integer literals `k` and `n ≥ 1`: + `\overline{7}_{5}` was `Subscript(Conjugate(7), 5)` and is now + `ResidueClass(2, 5)`, while `\overline{7}`, `\overline{z}_1` and the + repeating decimal `0.\overline{3}` are unchanged. `Mod` stays the remainder, + classes are not ordered, a modulus past 2^53 is exact, and compiled code does + not lower a class (it falls back, as for any operator without a lowering). + ### Improvements - **More accurate `erf`, `erfc` and `erfi` in doubles.** The machine kernels @@ -125,14 +152,14 @@ same element of ℤ/5ℤ. For a positive integer literal `n`, the collection now has the count `n`, is finite and is not empty, and `Count` evaluates: `Count(\mathbb{Z}/5\mathbb{Z})` is `5`. A symbolic, zero or negative - modulus, or a base other than `Integers`, stays inert. The type is - `set`, as for an `Adjoin` adjunct that the engine cannot type. The - engine has no value for a residue class, so the classes are not listed and - membership is not decided. + modulus, or a base other than `Integers`, stays inert. Its elements are + `ResidueClass` values (see New Features), so the type is `set` for + the base `Integers`, and `set`, as for an `Adjoin` adjunct that the + engine cannot type, for another base. - **A finite collection whose elements cannot be computed no longer reads as empty.** `Linspace(a, 1, 3)` with a symbolic `a` has the count 3 but no - elements that can be computed, and so does ℤ/5ℤ. Many operators checked + elements that can be computed. Many operators checked only that a collection was finite and then walked its elements, so they read it as empty and gave a wrong answer with no diagnostic: `Union(Linspace(a, 1, 3), {1})` was `Set(1)`, `Unique(…)` was `[]`, diff --git a/src/compute-engine/boxed-expression/residue-class.ts b/src/compute-engine/boxed-expression/residue-class.ts new file mode 100644 index 000000000..72a9fa044 --- /dev/null +++ b/src/compute-engine/boxed-expression/residue-class.ts @@ -0,0 +1,216 @@ +/** + * Residue classes: the elements of ℤ/nℤ, `ResidueClass(k, n)`. + * + * A class is held as a pair of integers `k` in [0, n) and `n ≥ 1`, in + * `bigint`, so a modulus past 2^53 stays exact. Every function here works on + * that pair; the arithmetic handlers of `Add`, `Multiply`, `Divide`, `Power` + * and `Negate` call the `residue*` functions on their evaluated operands. + * + * Two classes with different moduli meet in ℤ/gcd(m, n), the largest ring + * that both reduce onto: `ResidueClass(2, 4) + ResidueClass(1, 6)` is + * `ResidueClass(1, 2)`. An integer, or a rational whose denominator is a unit, + * is read in the ring that the classes meet in. + */ + +import type { + Expression, + IComputeEngine as ComputeEngine, + OperandDescriptor, +} from '../global-types.js'; +import { asBigint, asRational } from './numerics.js'; +import { isFunction, isNumber } from './type-guards.js'; +import { gcd, modularInverse } from '../numerics/numeric-bigint.js'; +import { modPow } from '../numerics/primes.js'; + +/** A class `k + nℤ`, with `0 ≤ k < n`. */ +export interface Residue { + readonly k: bigint; + readonly n: bigint; +} + +const mod = (a: bigint, n: bigint): bigint => ((a % n) + n) % n; + +/** The class of the rational `num/den` in ℤ/nℤ, if `den` is a unit mod `n`. */ +export function residueOfRational( + num: bigint, + den: bigint, + n: bigint +): Residue | undefined { + if (n < 1n) return undefined; + if (den === 1n) return { k: mod(num, n), n }; + const inverse = modularInverse(den, n); + return inverse === null ? undefined : { k: mod(num * inverse, n), n }; +} + +/** + * The class of an integer or rational literal in ℤ/nℤ. `undefined` for + * anything else, a non-unit denominator included (`1/2` mod 4). + */ +export function residueOfLiteral( + expr: Expression, + n: bigint +): Residue | undefined { + const integer = asBigint(expr); + if (integer !== null) return { k: mod(integer, n), n }; + if (!isNumber(expr)) return undefined; + const q = asRational(expr); + if (q === undefined) return undefined; + return residueOfRational(BigInt(q[0]), BigInt(q[1]), n); +} + +/** `ResidueClass(k, n)` with literal integer operands, read as a pair. */ +export function residueOf(expr: Expression | undefined): Residue | undefined { + if (!isFunction(expr, 'ResidueClass') || expr.nops !== 2) return undefined; + const k = asBigint(expr.op1); + const n = asBigint(expr.op2); + if (k === null || n === null || n < 1n || k < 0n || k >= n) return undefined; + return { k, n }; +} + +export function isResidueClass(expr: Expression | undefined): boolean { + return residueOf(expr) !== undefined; +} + +/** An operand that is a `ResidueClass(…)` call, as a type handler sees it. */ +export function isResidueClassOperand(d: OperandDescriptor): boolean { + const s = d.structureOf?.(); + return s?.kind === 'application' && s.head === 'ResidueClass'; +} + +export function residueExpression(ce: ComputeEngine, x: Residue): Expression { + return ce._fn('ResidueClass', [ce.number(x.k), ce.number(x.n)]); +} + +/** + * `ResidueClass(k, n)` in canonical form: `k` reduced into [0, n). `undefined` + * when `n` is not an integer literal ≥ 1, or `k` is not an integer or a + * rational literal that is a unit mod `n`: the call stays as it is. + */ +export function residueClassOf( + ce: ComputeEngine, + k: Expression, + n: Expression +): Expression | undefined { + const modulus = asBigint(n); + if (modulus === null || modulus < 1n) return undefined; + const r = residueOfLiteral(k, modulus); + return r === undefined ? undefined : residueExpression(ce, r); +} + +// +// Arithmetic on pairs +// + +/** The same class read in ℤ/mℤ, for `m` dividing its modulus. */ +const reduce = (x: Residue, m: bigint): Residue => ({ k: mod(x.k, m), n: m }); + +const meet = (x: Residue, y: Residue): [Residue, Residue] => { + const m = gcd(x.n, y.n); + return [reduce(x, m), reduce(y, m)]; +}; + +const add = (x: Residue, y: Residue): Residue => { + const [a, b] = meet(x, y); + return { k: mod(a.k + b.k, a.n), n: a.n }; +}; + +const multiply = (x: Residue, y: Residue): Residue => { + const [a, b] = meet(x, y); + return { k: mod(a.k * b.k, a.n), n: a.n }; +}; + +const negate = (x: Residue): Residue => ({ k: mod(-x.k, x.n), n: x.n }); + +/** x⁻¹, or `undefined` when gcd(k, n) ≠ 1. */ +const inverse = (x: Residue): Residue | undefined => { + const k = modularInverse(x.k, x.n); + return k === null ? undefined : { k, n: x.n }; +}; + +/** xᵉ; a negative exponent inverts first, so it needs a unit. */ +const power = (x: Residue, e: bigint): Residue | undefined => { + const base = e < 0n ? inverse(x) : x; + if (base === undefined) return undefined; + return { k: modPow(base.k, e < 0n ? -e : e, x.n), n: x.n }; +}; + +// +// Operands +// + +/** + * Every operand as a class of the ring that the classes among them meet in. + * `undefined` when an operand is neither a class nor an integer or rational + * literal that reads in that ring (a symbol, a float, `1/2` mod 4). + */ +function lift(ops: ReadonlyArray): Residue[] | undefined { + const classes = ops.map(residueOf).filter((x) => x !== undefined); + if (classes.length === 0) return undefined; + const ring = classes.map((x) => x.n).reduce(gcd); + const values = ops.map((op) => + isResidueClass(op) ? residueOf(op) : residueOfLiteral(op, ring) + ); + return values.every((x) => x !== undefined) ? values : undefined; +} + +function fold( + ce: ComputeEngine, + ops: ReadonlyArray, + step: (x: Residue, y: Residue) => Residue | undefined +): Expression | undefined { + const values = lift(ops); + if (values === undefined) return undefined; + let acc: Residue | undefined = values[0]; + for (const next of values.slice(1)) + acc = acc === undefined ? undefined : step(acc, next); + return acc === undefined ? undefined : residueExpression(ce, acc); +} + +/** The sum of operands one of which is a class, or `undefined`. */ +export function residueAdd( + ce: ComputeEngine, + ops: ReadonlyArray +): Expression | undefined { + return fold(ce, ops, add); +} + +export function residueMultiply( + ce: ComputeEngine, + ops: ReadonlyArray +): Expression | undefined { + return fold(ce, ops, multiply); +} + +export function residueNegate( + ce: ComputeEngine, + x: Expression +): Expression | undefined { + const r = residueOf(x); + return r === undefined ? undefined : residueExpression(ce, negate(r)); +} + +/** `num / den`: `undefined` when `den` is not a unit of the common ring. */ +export function residueDivide( + ce: ComputeEngine, + num: Expression, + den: Expression +): Expression | undefined { + return fold(ce, [num, den], (x, y) => { + const [a, b] = meet(x, y); + const b1 = inverse(b); + return b1 === undefined ? undefined : multiply(a, b1); + }); +} + +/** `x ^ e` for an integer exponent: `undefined` for a negative one on a non-unit. */ +export function residuePower( + ce: ComputeEngine, + x: Expression, + e: Expression +): Expression | undefined { + const r = residueOf(x); + const exponent = asBigint(e); + if (r === undefined || exponent === null) return undefined; + const p = power(r, exponent); + return p === undefined ? undefined : residueExpression(ce, p); +} diff --git a/src/compute-engine/boxed-expression/simplify.ts b/src/compute-engine/boxed-expression/simplify.ts index 1057f3229..f50a2ad7d 100644 --- a/src/compute-engine/boxed-expression/simplify.ts +++ b/src/compute-engine/boxed-expression/simplify.ts @@ -8,6 +8,7 @@ import { sameSyntactic } from './compare.js'; import { holdMap } from './hold.js'; import { expToTrig } from './exp-to-trig.js'; import { expand } from './expand.js'; +import { isResidueClass } from './residue-class.js'; import { rebindEscaping, hasAssignedVariable, @@ -113,7 +114,7 @@ function evaluateNumericSubexpressions(expr: Expression): Expression { !hasAssignedVariable(expr) ) { const evaluated = expr.evaluate(); - if (isNumber(evaluated)) return evaluated; + if (isNumber(evaluated) || isResidueClass(evaluated)) return evaluated; } // Constant logarithms are folded to their exact value even when they are @@ -921,7 +922,7 @@ function simplifyOperands( continue; } const evaluated = x.evaluate(); - if (isNumber(evaluated)) { + if (isNumber(evaluated) || isResidueClass(evaluated)) { simplifiedOps.push(evaluated); continue; } @@ -1008,6 +1009,22 @@ function simplifyExpression( expr = alt; } + // Arithmetic on residue classes folds to a class, as for numbers: + // `ResidueClass(5, 7) + ResidueClass(4, 7)` is `ResidueClass(2, 7)`. A + // class has no number literal, so the numeric fold of the operands does + // not reach it. + if ( + isFunction(expr) && + BASIC_ARITHMETIC.includes(expr.operator) && + expr.ops.some(isResidueClass) && + expr.unknowns.length === 0 && + !hasAssignedVariable(expr) + ) { + const folded = expr.evaluate(); + if (isResidueClass(folded)) + return [...steps, { value: folded, because: 'residue class arithmetic' }]; + } + // A `NaN` or `Indeterminate` operand at a position whose NaN policy is // `propagate` decides the value, as it does for `evaluate()`: // `sin(NaN)` simplifies to `NaN` and `sin(Indeterminate)` to diff --git a/src/compute-engine/latex-syntax/dictionary/definitions-complex.ts b/src/compute-engine/latex-syntax/dictionary/definitions-complex.ts index 40972717d..87959bbf5 100644 --- a/src/compute-engine/latex-syntax/dictionary/definitions-complex.ts +++ b/src/compute-engine/latex-syntax/dictionary/definitions-complex.ts @@ -1,7 +1,24 @@ -import { LatexDictionary, Parser } from '../types.js'; +import { LatexDictionary, Parser, Serializer } from '../types.js'; import { MathJsonExpression } from '../../../math-json/types.js'; +import { isNumberObject, operand } from '../../../math-json/utils.js'; import { singleArgSerializer } from './definitions-other.js'; +/** + * The digits of a non-negative integer literal (`7`, `{num: '123456…'}`), or + * `null` for anything else: a symbol, a sum, a negation, a decimal. + */ +function integerLiteralDigits(expr: MathJsonExpression | null): string | null { + if (typeof expr === 'number') + return Number.isSafeInteger(expr) && expr >= 0 ? String(expr) : null; + if (isNumberObject(expr)) { + const digits = String(expr.num); + return /^\d+$/.test(digits) ? digits : null; + } + return null; +} + +const isPositive = (digits: string): boolean => /[1-9]/.test(digits); + export const DEFINITIONS_COMPLEX: LatexDictionary = [ { name: 'Real', @@ -56,10 +73,39 @@ export const DEFINITIONS_COMPLEX: LatexDictionary = [ latexTrigger: ['\\overline'], parse: (parser: Parser): MathJsonExpression => { const arg = parser.parseGroup(); - return arg === null ? ['Conjugate'] : ['Conjugate', arg]; + if (arg === null) return ['Conjugate']; + + // `\overline{k}_{n}` with integer literals `k` and `n ≥ 1` is the + // residue class of `k` mod `n`. Anything else keeps the conjugate: + // `\overline{7}` and `\overline{z}_1` are not classes, and the + // subscript is left for the subscript parselet. + if (integerLiteralDigits(arg) !== null) { + const start = parser.index; + if (parser.match('_')) { + const modulus = parser.parseGroup() ?? parser.parseToken(); + const digits = integerLiteralDigits(modulus); + if (digits !== null && isPositive(digits)) + return ['ResidueClass', arg, modulus!]; + } + parser.index = start; + } + return ['Conjugate', arg]; }, serialize: singleArgSerializer('\\overline'), }, + // `ResidueClass(k, n)` is written `\overline{k}_{n}` when both operands are + // integer literals, the only form that reads back as a class; otherwise it + // is written as a function call. + { + name: 'ResidueClass', + serialize: (serializer: Serializer, expr: MathJsonExpression): string => { + const k = integerLiteralDigits(operand(expr, 1)); + const n = integerLiteralDigits(operand(expr, 2)); + if (k === null || n === null || !isPositive(n)) + return serializer.serializeFunction(expr); + return `\\overline{${k}}_{${n}}`; + }, + }, // Function-style alias: `\operatorname{conj}(z)`, the spelling Desmos writes // for the complex conjugate. Without it `conj` lexed as an undeclared symbol: // `\operatorname{conj}(z)` was the product `conj·z`, and over a list argument diff --git a/src/compute-engine/library/arithmetic.ts b/src/compute-engine/library/arithmetic.ts index da1f84f31..e5bf26bdb 100755 --- a/src/compute-engine/library/arithmetic.ts +++ b/src/compute-engine/library/arithmetic.ts @@ -341,6 +341,15 @@ import { quantityDivide, quantityPower, } from './quantity-arithmetic.js'; +import { + isResidueClass, + isResidueClassOperand, + residueAdd, + residueDivide, + residueMultiply, + residueNegate, + residuePower, +} from '../boxed-expression/residue-class.js'; import { foldMeasurementOperands, isMeasurement, @@ -4082,7 +4091,12 @@ export const ARITHMETIC_LIBRARY: SymbolDefinitions[] = [ missingBehavior: 'propagate', type: (ops, context) => BoxedType.forResult( - signedInfinitySum(ops, addTypeOnTypes(ops.map(withoutFunctionArm))), + ops.some(isResidueClassOperand) + ? 'value' + : signedInfinitySum( + ops, + addTypeOnTypes(ops.map(withoutFunctionArm)) + ), context.engine._typeResolver ), @@ -4174,6 +4188,13 @@ export const ARITHMETIC_LIBRARY: SymbolDefinitions[] = [ if (hasAbsentScalarOperand(evaluated)) return absentScalarMarker(engine!, expression); } + // A class is exact: under `.N()` its integer operands are read + // exactly too, not as the floats `evaluated` holds. + if (evaluated.some(isResidueClass)) + return residueAdd( + engine!, + numericApproximation ? ops.map((x) => x.evaluate()) : evaluated + ); if (evaluated.some((x) => x.operator === 'Quantity')) { const r = quantityAdd(engine!, evaluated); if ( @@ -4405,6 +4426,9 @@ export const ARITHMETIC_LIBRARY: SymbolDefinitions[] = [ nanBehavior: 'propagate', type: (ops, context) => { const [num, den] = ops; + // A class divided by a class or an integer is a class. + if (ops.some(isResidueClassOperand)) + return BoxedType.forResult('value', context.engine._typeResolver); if (operandLiteralValueOnTypes(den) === 1) return BoxedType.forResult(num.type, context.engine._typeResolver); // A numeric tuple (point/vector) divided by a scalar keeps the tuple @@ -4664,6 +4688,8 @@ export const ARITHMETIC_LIBRARY: SymbolDefinitions[] = [ if (listTuple !== undefined) return listTuple; const evalNum = num; const evalDen = den; + if (isResidueClass(evalNum) || isResidueClass(evalDen)) + return residueDivide(engine!, evalNum, evalDen); if ( evalNum.operator === 'Quantity' || evalDen.operator === 'Quantity' @@ -6708,6 +6734,8 @@ export const ARITHMETIC_LIBRARY: SymbolDefinitions[] = [ type: (ops, { engine, derive }) => { if (ops.length === 0) return BoxedType.forResult('integer', engine._typeResolver); // = 1 + if (ops.some(isResidueClassOperand)) + return BoxedType.forResult('value', engine._typeResolver); if (ops.length === 1) return BoxedType.forResult(ops[0].type, engine._typeResolver); // A factor that is NaN or a number (`nan | real`, the type of an @@ -7348,6 +7376,11 @@ export const ARITHMETIC_LIBRARY: SymbolDefinitions[] = [ if (hasAbsentScalarOperand(evaluated)) return absentScalarMarker(engine!, expression); } + if (evaluated.some(isResidueClass)) + return residueMultiply( + engine!, + numericApproximation ? ops.map((x) => x.evaluate()) : evaluated + ); if (evaluated.some((x) => x.operator === 'Quantity')) { const r = quantityMultiply(engine!, evaluated); if ( @@ -7497,6 +7530,7 @@ export const ARITHMETIC_LIBRARY: SymbolDefinitions[] = [ const listTuple = listCoordinateTupleOperandError(engine!, [x]); if (listTuple !== undefined) return listTuple; const evalX = x; + if (isResidueClass(evalX)) return residueNegate(engine, evalX); if (isQuantity(evalX)) { if (isMeasurement(evalX.op1)) { const negM = measurementNegate(engine, evalX.op1); @@ -7657,6 +7691,8 @@ export const ARITHMETIC_LIBRARY: SymbolDefinitions[] = [ context, ((): BoxedType | undefined => { const [base, exp] = ops; + if (base !== undefined && isResidueClassOperand(base)) + return BoxedType.forResult('value', context.engine._typeResolver); // A proven-NaN operand: decline, so the framework's proven-NaN arm // answers the sharp `nan` from the propagate policy (the // `Sqrt`/`Erf` precedent). @@ -8031,6 +8067,7 @@ export const ARITHMETIC_LIBRARY: SymbolDefinitions[] = [ ) return engine!.typeError(POWER_EXPONENT_CARRIER_TYPE, n.type, n); const evalBase = x; + if (isResidueClass(evalBase)) return residuePower(engine!, evalBase, n); if (evalBase.operator === 'Quantity') { const r = quantityPower(engine!, evalBase, n); if ( diff --git a/src/compute-engine/library/sets.ts b/src/compute-engine/library/sets.ts index 7872cec7e..1f5179897 100755 --- a/src/compute-engine/library/sets.ts +++ b/src/compute-engine/library/sets.ts @@ -20,7 +20,12 @@ import { isSymbol, sym, } from '../boxed-expression/type-guards.js'; -import { validateArguments } from '../boxed-expression/validate.js'; +import { checkArity, validateArguments } from '../boxed-expression/validate.js'; +import { + residueClassOf, + residueExpression, + residueOf, +} from '../boxed-expression/residue-class.js'; import { contextAssumptions, getFactIndex, @@ -1194,7 +1199,7 @@ export const SETS_LIBRARY: SymbolDefinitions = { 'The quotient of a ring by the ideal generated by the second argument.', '`QuotientRing(Integers, n)` is ℤ/nℤ, the integers modulo `n`.', 'For an integer literal `n` ≥ 1 it is a finite collection with `n` elements, and `Count` answers. A symbolic modulus, or a base other than `Integers`, stays inert.', - 'An element is a residue class, which the engine has no value for: the classes are not enumerated, membership is not decided, and the element type is `unknown`.', + 'The elements of ℤ/nℤ are `ResidueClass(0, n)` … `ResidueClass(n - 1, n)`, which it lists, and the element type is `value`. `Element(ResidueClass(k, n), ℤ/nℤ)` is `True`; a bare integer is not decided.', ], // LaTeX: `\mathbb{Z}_n` and `\mathbb{Z}/n\mathbb{Z}` both parse to this; // the subscript form is what it serializes back to. @@ -1222,17 +1227,81 @@ export const SETS_LIBRARY: SymbolDefinitions = { integerQuotientModulus(expr) === undefined ? undefined : false, isFinite: (expr) => integerQuotientModulus(expr) === undefined ? undefined : true, - // A residue class has no value in the engine, so the classes cannot be - // listed and membership is not decided: an operator that must walk the - // elements leaves the expression unevaluated. isEnumerable: (expr) => - integerQuotientModulus(expr) === undefined ? undefined : false, - // A collection definition must have an `iterator` handler. This one - // returns no iterator, so `each()` yields nothing: the classes are not - // listed. - iterator: () => undefined, + integerQuotientModulus(expr) === undefined ? undefined : true, + // `ResidueClass(0, n)` … `ResidueClass(n - 1, n)`, produced lazily: a + // modulus of 10^9 is counted without building its classes. + iterator: (expr) => { + const n = integerQuotientModulus(expr); + if (n === undefined) return undefined; + const ce = expr.engine; + let k = 0; + return { + next: () => + k >= n + ? { value: undefined, done: true as const } + : { + value: residueExpression(ce, { + k: BigInt(k++), + n: BigInt(n), + }), + done: false as const, + }, + }; + }, + // A class of ℤ/nℤ is a member. A class of another modulus is not. An + // integer is not decided: `7` is a representative of a class, not the + // class, and a symbol may be either. + contains: (expr, x) => { + const n = integerQuotientModulus(expr); + const r = residueOf(x); + if (n === undefined || r === undefined) return undefined; + return r.n === BigInt(n); + }, elttype: (expr) => - integerQuotientModulus(expr) === undefined ? undefined : 'unknown', + integerQuotientModulus(expr) === undefined ? undefined : 'value', + }, + }, + + ResidueClass: { + examples: [ + 'ResidueClass(7, 5)', + 'ResidueClass(5, 7) + ResidueClass(4, 7)', + '1 / ResidueClass(3, 7)', + ], + description: [ + 'An element of ℤ/nℤ: the class of the integer `k` modulo `n`.', + 'The canonical form reduces `k` into 0…n−1, so `ResidueClass(7, 5)` is `ResidueClass(2, 5)`, and two classes are equal when their canonical forms are. `n` must be an integer literal ≥ 1, and `k` an integer (or a rational with a denominator that is a unit mod `n`); any other call stays inert.', + 'Sums, differences, products and integer powers of classes are classes. An integer or rational next to a class is read in its ring. Classes of different moduli meet in ℤ/gcd(m, n). The inverse, a division and a negative power need a unit: when `gcd(k, n) ≠ 1` they stay unevaluated.', + 'Classes are not ordered, and `Mod` is not this: `Mod(7, 5)` is the integer remainder 2. The elements of `QuotientRing(Integers, n)` are the classes `ResidueClass(0, n)` … `ResidueClass(n - 1, n)`.', + 'LaTeX: `\\overline{k}_{n}`, for integer literals `k` and `n ≥ 1`.', + ], + complexity: 1200, + // `any` for both: a call that is not a class of ℤ/nℤ (`k` a float or a + // symbol, `n` symbolic or not a positive integer) stays inert, as + // `QuotientRing` does, and a free `k` or `n` is not inferred an integer. + // A fraction `k` with a unit denominator reads as `u · v⁻¹`, so + // `ResidueClass(1/3, 7)` is `ResidueClass(5, 7)`. + signature: '(any, any) -> value', + canonical: (ops, { engine: ce }) => { + const args = checkArity( + ce, + ops.map((x) => x.canonical), + 2 + ); + return ( + residueClassOf(ce, args[0], args[1]) ?? ce._fn('ResidueClass', args) + ); + }, + evaluate: ([k, n], { engine: ce }) => residueClassOf(ce, k, n), + // Two classes of one modulus are equal when their residues are. Classes + // of different moduli, or a class and an integer, are not compared: no + // answer rather than a wrong one. + eq: (a, b) => { + const x = residueOf(a); + const y = residueOf(b); + if (x === undefined || y === undefined || x.n !== y.n) return undefined; + return x.k === y.k; }, }, diff --git a/src/compute-engine/library/type-handlers.ts b/src/compute-engine/library/type-handlers.ts index f0620ab15..251e2fc10 100644 --- a/src/compute-engine/library/type-handlers.ts +++ b/src/compute-engine/library/type-handlers.ts @@ -2099,11 +2099,16 @@ export function adjoinType(ops: ReadonlyArray): Type { * by `m` (`ℤ_n` = `ℤ/nℤ`). * * An element is a residue class, which no element of the base represents - * (7 and 2 are one element of ℤ/5ℤ, so `set` would be wrong), and - * the engine has no value for a residue class. The element type is therefore - * `unknown`, as for an `Adjoin` adjunct the engine cannot type. + * (7 and 2 are one element of ℤ/5ℤ, so `set` would be wrong). The + * classes of the library `Integers` are `ResidueClass` values, typed `value`: + * `QuotientRing(Integers, n)` is a `set`. For any other base the + * engine has no value for an element, and the element type is `unknown`, as + * for an `Adjoin` adjunct the engine cannot type. */ -export function quotientRingType(_ops: ReadonlyArray): Type { +export function quotientRingType(ops: ReadonlyArray): Type { + const base = ops[0]?.structureOf?.(); + if (base?.kind === 'symbol' && base.name === 'Integers' && base.system) + return { kind: 'set', elements: 'value' }; return { kind: 'set', elements: 'unknown' }; } diff --git a/src/epsil/docs/library.md b/src/epsil/docs/library.md index feeb8576f..d36a50a28 100644 --- a/src/epsil/docs/library.md +++ b/src/epsil/docs/library.md @@ -11,7 +11,7 @@ date: Last Modified --- # Epsil Standard Library -The 698 functions and constants of the standard library, by category. +The 699 functions and constants of the standard library, by category. Each row gives a name, its signature (for a function) or its kind and type (for a constant or variable), and the first sentence of its description — the same description `epsil doc ` prints in full and the editor @@ -25,7 +25,7 @@ To search the library by concept rather than by name, use - [Core](#core) — 112 definitions · [full reference](/epsil/reference/core/) - [Control structures](#control-structures) — 13 definitions · [full reference](/epsil/reference/control-structures/) - [Logic](#logic) — 27 definitions · [full reference](/epsil/reference/logic/) -- [Collections](#collections) — 125 definitions · [full reference](/epsil/reference/collections/) +- [Collections](#collections) — 126 definitions · [full reference](/epsil/reference/collections/) - [Colors](#colors) — 20 definitions · [full reference](/epsil/reference/colors/) - [Regular expressions](#regular-expressions) — 4 definitions · [full reference](/epsil/reference/regexp/) - [Relations](#relations) — 30 definitions · [full reference](/epsil/reference/relop/) @@ -315,6 +315,7 @@ The [Collections reference](/epsil/reference/collections/) has the full descript | `reduce` | `Reduce` | `(collection, reducer: (unknown, T) any -> unknown, initial: value?) -> value where T` | Reduce (fold) a collection to a single value by repeatedly applying a binary function, with an optional initial value. | | `repeat` | `Repeat` | `(value: any, count: integer?) -> list` | Produce a sequence by repeating a single value. | | `replaceAt` | `ReplaceAt` | `(indexed_collection, integer, T) -> list where T` | Return a copy of the indexed collection with the element at the 1-based `index` replaced by `value`. | +| `residueClass` | `ResidueClass` | `(any, any) -> value` | An element of ℤ/nℤ: the class of the integer `k` modulo `n`. | | `rest` | `Rest` | `((T) -> T where T: string) & ((indexed_collection) -> list where T)` | Return the collection without the first element. | | `reverse` | `Reverse` | `((T) -> T where T: string) & ((T) -> T where T: list) & ((indexed_collection) -> list where T)` | Reverse the order of the elements of an indexed collection. | | `rotateLeft` | `RotateLeft` | `((T, integer?) -> T where T: string) & ((T, integer?) -> T where T: list) & ((indexed_collection, integer?) -> list where T)` | Rotate the elements of the collection to the left by n positions. | diff --git a/src/epsil/docs/reference/collections.md b/src/epsil/docs/reference/collections.md index 21bea70cc..937986c64 100644 --- a/src/epsil/docs/reference/collections.md +++ b/src/epsil/docs/reference/collections.md @@ -1339,7 +1339,7 @@ The quotient of a ring by the ideal generated by the second argument. For an integer literal `n` ≥ 1 it is a finite collection with `n` elements, and `Count` answers. A symbolic modulus, or a base other than `Integers`, stays inert. -An element is a residue class, which the engine has no value for: the classes are not enumerated, membership is not decided, and the element type is `unknown`. +The elements of ℤ/nℤ are `ResidueClass(0, n)` … `ResidueClass(n - 1, n)`, which it lists, and the element type is `value`. `Element(ResidueClass(k, n), ℤ/nℤ)` is `True`; a bare integer is not decided. ```epsil quotientRing(integers, 5) @@ -1444,6 +1444,35 @@ replaceAt([1, 2, 3], 2, 20) // ➔ [1,20,3] ``` +### residueClass + +MathJSON `ResidueClass` · `(any, any) -> value` + +An element of ℤ/nℤ: the class of the integer `k` modulo `n`. + +The canonical form reduces `k` into 0…n−1, so `ResidueClass(7, 5)` is `ResidueClass(2, 5)`, and two classes are equal when their canonical forms are. `n` must be an integer literal ≥ 1, and `k` an integer (or a rational with a denominator that is a unit mod `n`); any other call stays inert. + +Sums, differences, products and integer powers of classes are classes. An integer or rational next to a class is read in its ring. Classes of different moduli meet in ℤ/gcd(m, n). The inverse, a division and a negative power need a unit: when `gcd(k, n) ≠ 1` they stay unevaluated. + +Classes are not ordered, and `Mod` is not this: `Mod(7, 5)` is the integer remainder 2. The elements of `QuotientRing(Integers, n)` are the classes `ResidueClass(0, n)` … `ResidueClass(n - 1, n)`. + +LaTeX: `\overline{k}_{n}`, for integer literals `k` and `n ≥ 1`. + +```epsil +residueClass(7, 5) +// ➔ ResidueClass(2, 5) +``` + +```epsil +residueClass(5, 7) + residueClass(4, 7) +// ➔ ResidueClass(2, 7) +``` + +```epsil +1 / residueClass(3, 7) +// ➔ ResidueClass(5, 7) +``` + ### rest MathJSON `Rest` · `((T) -> T where T: string) & ((indexed_collection) -> list where T)` diff --git a/src/math-json/CATEGORIES.json b/src/math-json/CATEGORIES.json index b3d1932f1..a8c141b75 100644 --- a/src/math-json/CATEGORIES.json +++ b/src/math-json/CATEGORIES.json @@ -209,6 +209,7 @@ "Reduce", "Repeat", "ReplaceAt", + "ResidueClass", "Rest", "Reverse", "RotateLeft", diff --git a/src/math-json/OPERATORS.json b/src/math-json/OPERATORS.json index 7e9e8d792..3ba2150fc 100644 --- a/src/math-json/OPERATORS.json +++ b/src/math-json/OPERATORS.json @@ -6861,7 +6861,7 @@ "idempotent": false, "lazy": false, "broadcastable": false, - "description": "The quotient of a ring by the ideal generated by the second argument.\n\n`QuotientRing(Integers, n)` is ℤ/nℤ, the integers modulo `n`.\n\nFor an integer literal `n` ≥ 1 it is a finite collection with `n` elements, and `Count` answers. A symbolic modulus, or a base other than `Integers`, stays inert.\n\nAn element is a residue class, which the engine has no value for: the classes are not enumerated, membership is not decided, and the element type is `unknown`.", + "description": "The quotient of a ring by the ideal generated by the second argument.\n\n`QuotientRing(Integers, n)` is ℤ/nℤ, the integers modulo `n`.\n\nFor an integer literal `n` ≥ 1 it is a finite collection with `n` elements, and `Count` answers. A symbolic modulus, or a base other than `Integers`, stays inert.\n\nThe elements of ℤ/nℤ are `ResidueClass(0, n)` … `ResidueClass(n - 1, n)`, which it lists, and the element type is `value`. `Element(ResidueClass(k, n), ℤ/nℤ)` is `True`; a bare integer is not decided.", "examples": [ "QuotientRing(Integers, 5)" ] @@ -7220,6 +7220,23 @@ "broadcastable": false, "description": "Residue of a function at a point (the coefficient of (x-a)⁻¹ in its Laurent expansion)" }, + { + "name": "ResidueClass", + "category": "Collections", + "arity": "2", + "signature": "(any, any) -> value", + "associative": false, + "commutative": false, + "idempotent": false, + "lazy": false, + "broadcastable": false, + "description": "An element of ℤ/nℤ: the class of the integer `k` modulo `n`.\n\nThe canonical form reduces `k` into 0…n−1, so `ResidueClass(7, 5)` is `ResidueClass(2, 5)`, and two classes are equal when their canonical forms are. `n` must be an integer literal ≥ 1, and `k` an integer (or a rational with a denominator that is a unit mod `n`); any other call stays inert.\n\nSums, differences, products and integer powers of classes are classes. An integer or rational next to a class is read in its ring. Classes of different moduli meet in ℤ/gcd(m, n). The inverse, a division and a negative power need a unit: when `gcd(k, n) ≠ 1` they stay unevaluated.\n\nClasses are not ordered, and `Mod` is not this: `Mod(7, 5)` is the integer remainder 2. The elements of `QuotientRing(Integers, n)` are the classes `ResidueClass(0, n)` … `ResidueClass(n - 1, n)`.\n\nLaTeX: `\\overline{k}_{n}`, for integer literals `k` and `n ≥ 1`.", + "examples": [ + "ResidueClass(7, 5)", + "ResidueClass(5, 7) + ResidueClass(4, 7)", + "1 / ResidueClass(3, 7)" + ] + }, { "name": "Rest", "category": "Collections", diff --git a/test/compute-engine/finite-non-enumerable-collections.test.ts b/test/compute-engine/finite-non-enumerable-collections.test.ts index 6238e8104..cfe944097 100644 --- a/test/compute-engine/finite-non-enumerable-collections.test.ts +++ b/test/compute-engine/finite-non-enumerable-collections.test.ts @@ -1,10 +1,8 @@ /** * A finite collection can know its size and still have no computable * elements. `Linspace(a, 1, 3)` with a symbolic `a` has `count` 3, but its - * elements are not numbers yet; `QuotientRing(Integers, 5)` (ℤ/5ℤ) has 5 - * elements, which are residue classes the engine does not list. For both, - * `isFiniteCollection` is `true`, `isEnumerableCollection` is `false`, and - * `each()` yields nothing. + * elements are not numbers yet. `isFiniteCollection` is `true`, + * `isEnumerableCollection` is `false`, and `each()` yields nothing. * * An operator that checked only `isFiniteCollection` and then walked the * elements read such a collection as EMPTY and gave a wrong answer with no @@ -18,14 +16,14 @@ import type { Expression } from '../../src/compute-engine'; type Json = Parameters[0]; const LINSPACE: Json = ['Linspace', 'a', 1, 3]; -const Z5: Json = ['QuotientRing', 'Integers', 5]; +// The same elements as a set, the operand type that the set operators take. +const LSET: Json = ['SetFrom', LINSPACE]; +// Five elements, none computable: a count that differs from a three-element list. +const LINSPACE5: Json = ['Linspace', 'a', 1, 5]; -const COLLECTIONS: [string, Json][] = [ - ['Linspace(a, 1, 3)', LINSPACE], - ['QuotientRing(Integers, 5)', Z5], -]; +const COLLECTIONS: [string, Json][] = [['Linspace(a, 1, 3)', LINSPACE]]; -describe('the two collections have a count and no computable elements', () => { +describe('a collection with a count and no computable elements', () => { const ce = new ComputeEngine(); for (const [name, json] of COLLECTIONS) { test(name, () => { @@ -50,10 +48,7 @@ describe('operators that walk the elements stay unevaluated', () => { ['SetFrom', ['SetFrom', xs]], ['SubsetEqual', ['SubsetEqual', xs, ['Set', 1]]], ]; - // `Sort` reads its source by position, so a set operand (ℤ/5ℤ) is a type - // error, as `Sort(Set(3, 1))` is. `SetMinus` takes a set first. - if (xs === LINSPACE) cases.push(['Sort', ['Sort', xs]]); - else cases.push(['SetMinus', ['SetMinus', xs, ['Set', 1]]]); + cases.push(['Sort', ['Sort', xs]]); for (const [op, json] of cases) { test(`${op} over ${name}`, () => { const result = ce.box(json).evaluate(); @@ -74,7 +69,6 @@ describe('operators that walk the elements stay unevaluated', () => { }); test('membership is not decided', () => { - expect(ce.box(['Element', 7, Z5]).evaluate().operator).toBe('Element'); // 1 is the last element of `Linspace(a, 1, 3)`: the intersection with a // concrete list is not empty, and it is not decided. expect( @@ -91,7 +85,7 @@ describe('operators that walk the elements stay unevaluated', () => { ).toBe('IdenticallyEqual'); expect(ce.box(LINSPACE).isEqual(ce.box(list))).toBeUndefined(); // Known counts that differ still decide the comparison. - expect(ce.box(['Equal', Z5, list]).evaluate().json).toBe('False'); + expect(ce.box(['Equal', LINSPACE5, list]).evaluate().json).toBe('False'); // The same expression on both sides is still equal. expect(ce.box(['Equal', LINSPACE, LINSPACE]).evaluate().json).toBe('True'); }); @@ -115,9 +109,6 @@ describe('operators that decide membership or splice elements', () => { // An undecided membership in a removed operand is not an absence. test('SetMinus with a removed operand that has no computable elements', () => { - expect(evaluate(['SetMinus', ['Set', 1], Z5])).toBe( - 'SetMinus(Set(1), QuotientRing("Integers", 5))' - ); expect(evaluate(['SetMinus', ['Set', 1], LINSPACE])).toBe( 'SetMinus(Set(1), Linspace(a, 1, 3))' ); @@ -127,28 +118,18 @@ describe('operators that decide membership or splice elements', () => { // its `count` handler read an undecided membership as "not removed". test('a held SetMinus has no count and is not walked', () => { expect( - ce.box(['Count', ['SetMinus', ['Set', 1], Z5]]).evaluate().operator + ce.box(['Count', ['SetMinus', ['Set', 1], LINSPACE]]).evaluate().operator ).toBe('Count'); expect( - ce.box(['Union', ['SetMinus', ['Set', 1], Z5], ['Set', 2]]).evaluate() - .operator - ).toBe('Union'); - }); - - test('a held set operation over ℤ/5ℤ has no count', () => { - for (const op of ['Union', 'Intersection', 'SymmetricDifference']) { - const result = ce.box(['Count', [op, ['Set', 1], Z5]]).evaluate(); - expect(result.operator).toBe('Count'); - } - expect( - ce.box(['Union', ['Intersection', ['Set', 1], Z5], ['Set', 2]]).evaluate() - .operator + ce + .box(['Union', ['SetMinus', ['Set', 1], LINSPACE], ['Set', 2]]) + .evaluate().operator ).toBe('Union'); }); - test('SymmetricDifference with ℤ/5ℤ stays unevaluated', () => { - expect(evaluate(['SymmetricDifference', ['Set', 1], Z5])).toBe( - 'SymmetricDifference(Set(1), QuotientRing("Integers", 5))' + test('SymmetricDifference with such a collection stays unevaluated', () => { + expect(evaluate(['SymmetricDifference', ['Set', 1], LSET])).toBe( + 'SymmetricDifference(Set(1), SetFrom(Linspace(a, 1, 3)))' ); }); @@ -175,12 +156,9 @@ describe('operators that decide membership or splice elements', () => { }); test('When over a carrier with no computable elements', () => { - expect(evaluate(['When', Z5, ['List', 'True', 'False']])).toBe( - ce.box(['When', Z5, ['List', 'True', 'False']]).toString() + expect(evaluate(['When', LINSPACE, ['List', 'True', 'False']])).toBe( + ce.box(['When', LINSPACE, ['List', 'True', 'False']]).toString() ); - expect( - ce.box(['When', Z5, ['List', 'True', 'False']]).evaluate().operator - ).toBe('When'); expect( ce.box(['When', LINSPACE, ['List', 'True', 'False']]).evaluate().operator ).toBe('When'); @@ -199,10 +177,10 @@ describe('operators that decide membership or splice elements', () => { expect(ce.box(['Intersection', ['List'], LINSPACE]).evaluate().json).toBe( 'EmptySet' ); - expect(ce.box(['Intersection', 'EmptySet', Z5]).evaluate().json).toBe( + expect(ce.box(['Intersection', 'EmptySet', LINSPACE]).evaluate().json).toBe( 'EmptySet' ); - expect(ce.box(['Intersection', ['Set'], Z5]).evaluate().json).toBe( + expect(ce.box(['Intersection', ['Set'], LINSPACE]).evaluate().json).toBe( 'EmptySet' ); }); diff --git a/test/compute-engine/item-399-quotient-ring.test.ts b/test/compute-engine/item-399-quotient-ring.test.ts index c073b8ba9..3269f4103 100644 --- a/test/compute-engine/item-399-quotient-ring.test.ts +++ b/test/compute-engine/item-399-quotient-ring.test.ts @@ -50,25 +50,33 @@ describe('QuotientRing(Integers, n) is a finite collection (#399)', () => { }); describe('QuotientRing element type (#399)', () => { - test('the element type is unknown: an element is a residue class', () => { + test('the element type is value: an element is a residue class', () => { expect(ce.parse('\\mathbb{Z}/5\\mathbb{Z}').type.toString()).toBe( - 'set' + 'set' ); expect(ce.box(['QuotientRing', 'Integers', 'n']).type.toString()).toBe( + 'set' + ); + }); + test('a base other than Integers has no class value: unknown', () => { + expect(ce.box(['QuotientRing', 'RationalNumbers', 5]).type.toString()).toBe( 'set' ); }); }); -// A residue class has no value in the engine: the classes are counted but -// not listed, so an operator that must walk the elements stays unevaluated -// instead of reading ℤ/5ℤ as empty. -describe('QuotientRing classes are counted, not listed (#399)', () => { +// The classes are `ResidueClass` values (item-399-residue-class.test.ts), so +// ℤ/5ℤ is listed and decides the membership of a class. A bare integer is a +// representative of a class, not the class, and its membership is not decided. +describe('QuotientRing classes are listed (#399)', () => { const z5 = ce.box(['QuotientRing', 'Integers', 5]); - test('does not enumerate', () => { - expect(z5.isEnumerableCollection).toBe(false); + test('enumerates', () => { + expect(z5.isEnumerableCollection).toBe(true); + expect([...z5.each()].map((x) => x.json)).toEqual( + [0, 1, 2, 3, 4].map((k) => ['ResidueClass', k, 5]) + ); }); - test('does not decide membership', () => { + test('does not decide the membership of a bare integer', () => { expect(ce.box(['Element', 7, z5]).evaluate().operator).toBe('Element'); }); test('IsEmpty is False', () => { @@ -76,14 +84,13 @@ describe('QuotientRing classes are counted, not listed (#399)', () => { }); const walking: [string, unknown][] = [ ['Union', ['Union', z5.json, ['Set', 1]]], - ['Intersection', ['Intersection', z5.json, ['Set', 1]]], ['Unique', ['Unique', z5.json]], ['Tally', ['Tally', z5.json]], ]; for (const [name, json] of walking) { - test(`${name} stays unevaluated`, () => { + test(`${name} walks the classes`, () => { const result = ce.box(json as Expression).evaluate(); - expect(result.operator).toBe(name); + expect(result.operator).not.toBe(name); expect(result.isValid).toBe(true); }); } diff --git a/test/compute-engine/item-399-residue-class.test.ts b/test/compute-engine/item-399-residue-class.test.ts new file mode 100644 index 000000000..3ab8333e8 --- /dev/null +++ b/test/compute-engine/item-399-residue-class.test.ts @@ -0,0 +1,503 @@ +import { ComputeEngine, compile } from '../../src/compute-engine'; +import type { Expression } from '../../src/compute-engine'; +import { executeEpsil } from '../../src/epsil/execute-epsil'; +import { serializeEpsil } from '../../src/epsil/serialize-epsil'; + +const ce = new ComputeEngine(); + +const RC = (k: unknown, n: unknown) => ['ResidueClass', k, n]; +const Z = (n: unknown) => ['QuotientRing', 'Integers', n]; +const big = (digits: string) => ({ num: digits }); + +const json = (input: unknown) => ce.box(input as Expression).json; +const evaluated = (input: unknown) => + ce.box(input as Expression).evaluate().json; + +describe('ResidueClass: canonical form (#399)', () => { + const cases: [string, unknown, unknown][] = [ + ['k above n reduces', RC(7, 5), RC(2, 5)], + ['k = n reduces to 0', RC(5, 5), RC(0, 5)], + ['negative k reduces into 0…n-1', RC(-1, 7), RC(6, 7)], + ['k already reduced is kept', RC(3, 7), RC(3, 7)], + ['n = 1 is the zero ring: every k is 0', RC(5, 1), RC(0, 1)], + [ + 'a rational with a unit denominator reads as u·v⁻¹', + RC(['Rational', 1, 3], 7), + RC(5, 7), + ], + [ + 'a rational with a non-unit denominator stays inert', + RC(['Rational', 1, 2], 4), + RC(['Rational', 1, 2], 4), + ], + ['n = 0 stays inert', RC(3, 0), RC(3, 0)], + ['negative n stays inert', RC(3, -5), RC(3, -5)], + ['non-integer n stays inert', RC(3, 2.5), RC(3, 2.5)], + ['symbolic n stays inert', RC(3, 'n'), RC(3, 'n')], + ['symbolic k stays inert', RC('k', 5), RC('k', 5)], + ['float k stays inert', RC(2.5, 5), RC(2.5, 5)], + ]; + for (const [name, input, expected] of cases) { + test(`${name}: ${JSON.stringify(input)} is ${JSON.stringify(expected)}`, () => { + expect(json(input)).toEqual(expected); + expect(evaluated(input)).toEqual(expected); + }); + } + + test('a modulus past 2^53 is exact', () => { + const n = '1000000000000000000000000000057'; + expect(json(RC(big('1000000000000000000000000000060'), big(n)))).toEqual( + RC(3, big(n)) + ); + expect(json(RC(big('1' + '0'.repeat(36)), big(n)))).toEqual( + RC(big('999999999999999999999943000057'), big(n)) + ); + }); + + test('a class has type value', () => { + expect(ce.box(RC(7, 5) as Expression).type.toString()).toBe('value'); + }); + + test('evaluate() re-reduces a class whose operands became literals', () => { + ce.declare('kk', 'integer'); + ce.assign('kk', 12); + expect(ce.box(RC('kk', 5) as Expression).evaluate().json).toEqual(RC(2, 5)); + }); +}); + +describe('ResidueClass: equality (#399)', () => { + test('ResidueClass(7, 5) is ResidueClass(2, 5)', () => { + expect( + ce.box(RC(7, 5) as Expression).isSame(ce.box(RC(2, 5) as Expression)) + ).toBe(true); + expect(evaluated(['Equal', RC(7, 5), RC(2, 5)])).toBe('True'); + }); + + const cases: [string, unknown, unknown][] = [ + [ + 'different residues of one modulus are not equal', + ['Equal', RC(2, 5), RC(3, 5)], + 'False', + ], + [ + 'NotEqual of different residues', + ['NotEqual', RC(2, 5), RC(3, 5)], + 'True', + ], + ['NotEqual of one class', ['NotEqual', RC(7, 5), RC(2, 5)], 'False'], + ]; + for (const [name, input, expected] of cases) { + test(`${name}: ${expected}`, () => { + expect(evaluated(input)).toEqual(expected); + }); + } + + // No answer rather than a wrong one. + const undecided: [string, unknown][] = [ + ['classes of different moduli', ['Equal', RC(2, 5), RC(2, 6)]], + ['a class and an integer', ['Equal', RC(2, 5), 2]], + ]; + for (const [name, input] of undecided) { + test(`${name} are not compared`, () => { + expect(ce.box(input as Expression).evaluate().operator).toBe('Equal'); + }); + } +}); + +describe('ResidueClass: arithmetic (#399)', () => { + const cases: [string, unknown, unknown][] = [ + ['sum', ['Add', RC(5, 7), RC(4, 7)], RC(2, 7)], + [ + 'sum of three classes and an integer', + ['Add', RC(1, 7), RC(2, 7), RC(3, 7), 10], + RC(2, 7), + ], + ['difference', ['Subtract', RC(2, 7), RC(5, 7)], RC(4, 7)], + ['product', ['Multiply', RC(3, 7), RC(5, 7)], RC(1, 7)], + ['negation', ['Negate', RC(3, 7)], RC(4, 7)], + ['negation of 0', ['Negate', RC(0, 7)], RC(0, 7)], + ['power', ['Power', RC(3, 7), 6], RC(1, 7)], + ['power 0', ['Power', RC(0, 7), 0], RC(1, 7)], + ['power 1', ['Power', RC(3, 7), 1], RC(3, 7)], + ['power -1 of a unit is the inverse', ['Power', RC(3, 7), -1], RC(5, 7)], + ['power -2 of a unit', ['Power', RC(3, 7), -2], RC(4, 7)], + ['1 / a unit', ['Divide', 1, RC(3, 7)], RC(5, 7)], + ['class / unit', ['Divide', RC(1, 7), RC(3, 7)], RC(5, 7)], + ['class / integer unit', ['Divide', RC(1, 7), 3], RC(5, 7)], + ['in the zero ring', ['Divide', RC(0, 1), RC(0, 1)], RC(0, 1)], + ]; + for (const [name, input, expected] of cases) { + test(`${name}: ${JSON.stringify(input)} is ${JSON.stringify(expected)}`, () => { + expect(evaluated(input)).toEqual(expected); + // A class is exact: `.N()` leaves it alone. + expect(ce.box(input as Expression).N().json).toEqual(expected); + expect(ce.box(input as Expression).simplify().json).toEqual(expected); + }); + } + + test('a modulus past 2^53: the inverse and a large power are exact', () => { + const p = big('1000000000000000000000000000057'); + expect( + evaluated(['Add', RC(big('1000000000000000000000000000056'), p), 1]) + ).toEqual(RC(0, p)); + // Fermat: 3^(p-1) = 1 mod p, when p is prime + expect( + evaluated(['Power', RC(3, p), big('1000000000000000000000000000056')]) + ).toEqual(RC(1, p)); + const inverse = ce.box(['Divide', 1, RC(3, p)] as Expression).evaluate(); + expect( + ce.box(['Multiply', RC(3, p), inverse.json] as Expression).evaluate().json + ).toEqual(RC(1, p)); + }); +}); + +describe('ResidueClass: inverse needs gcd(k, n) = 1 (#399)', () => { + // No answer rather than a wrong one: the call stays unevaluated. + const cases: [string, unknown, unknown][] = [ + ['1 / 2 mod 4', ['Divide', 1, RC(2, 4)], ['Divide', 1, RC(2, 4)]], + ['2^-1 mod 4', ['Power', RC(2, 4), -1], ['Divide', 1, RC(2, 4)]], + ['0^-1 mod 7', ['Power', RC(0, 7), -1], ['Divide', 1, RC(0, 7)]], + [ + 'class / non-unit', + ['Divide', RC(1, 6), RC(2, 6)], + ['Divide', RC(1, 6), RC(2, 6)], + ], + ]; + for (const [name, input, expected] of cases) { + test(`${name} stays unevaluated`, () => { + expect(evaluated(input)).toEqual(expected); + expect(ce.box(input as Expression).N().json).toEqual(expected); + }); + } + + test('a class that is a unit, mod a composite', () => { + expect(evaluated(['Divide', 1, RC(5, 6)])).toEqual(RC(5, 6)); + }); +}); + +describe('ResidueClass: integers combine with a class (#399)', () => { + const cases: [string, unknown, unknown][] = [ + ['class + integer', ['Add', RC(5, 7), 3], RC(1, 7)], + ['integer + class', ['Add', 3, RC(5, 7)], RC(1, 7)], + ['integer - class', ['Subtract', 3, RC(5, 7)], RC(5, 7)], + ['integer * class', ['Multiply', 3, RC(5, 7)], RC(1, 7)], + ['negative integer * class', ['Multiply', -1, RC(5, 7)], RC(2, 7)], + [ + 'class + rational with a unit denominator', + ['Add', RC(1, 7), ['Rational', 1, 3]], + RC(6, 7), + ], + ['class * 2^100', ['Multiply', RC(1, 7), ['Power', 2, 100]], RC(2, 7)], + ]; + for (const [name, input, expected] of cases) { + test(`${name}: ${JSON.stringify(input)} is ${JSON.stringify(expected)}`, () => { + expect(evaluated(input)).toEqual(expected); + expect(ce.box(input as Expression).simplify().json).toEqual(expected); + }); + } + + const stays: [string, unknown][] = [ + ['a symbol', ['Add', RC(2, 7), 'x']], + ['a float', ['Add', RC(2, 7), 0.5]], + [ + 'a rational with a non-unit denominator', + ['Add', RC(1, 4), ['Rational', 1, 2]], + ], + ]; + for (const [name, input] of stays) { + test(`class + ${name} stays unevaluated`, () => { + const r = ce.box(input as Expression).evaluate(); + expect(r.operator).toBe('Add'); + expect(r.isValid).toBe(true); + }); + } + + test('Mod stays the remainder and does not take a class', () => { + expect(evaluated(['Mod', 7, 5])).toBe(2); + expect(ce.box(['Mod', RC(2, 7), 5] as Expression).evaluate().operator).toBe( + 'Mod' + ); + }); + + test('a class is not ordered', () => { + for (const op of ['Less', 'LessEqual', 'Greater', 'GreaterEqual']) { + const result = ce.box([op, RC(1, 5), RC(2, 5)] as Expression).evaluate(); + expect(['True', 'False']).not.toContain(result.json); + } + }); +}); + +// Classes of two moduli meet in ℤ/gcd(m, n), the largest ring that both +// reduce onto, and an integer is read in that ring. +describe('ResidueClass: classes of different moduli (#399)', () => { + const cases: [string, unknown, unknown][] = [ + ['2 mod 4 + 1 mod 6 is in ℤ/2', ['Add', RC(2, 4), RC(1, 6)], RC(1, 2)], + ['3 mod 4 * 5 mod 6 is in ℤ/2', ['Multiply', RC(3, 4), RC(5, 6)], RC(1, 2)], + [ + 'coprime moduli meet in the zero ring', + ['Add', RC(2, 3), RC(1, 5)], + RC(0, 1), + ], + [ + 'the quotient is taken in the common ring', + ['Divide', RC(2, 6), RC(1, 4)], + RC(0, 2), + ], + [ + 'an integer is read in the common ring', + ['Add', RC(2, 4), RC(1, 6), 3], + RC(0, 2), + ], + ]; + for (const [name, input, expected] of cases) { + test(`${name}`, () => { + expect(evaluated(input)).toEqual(expected); + }); + } + + test('a non-unit divisor in the common ring stays unevaluated', () => { + expect( + ce.box(['Divide', RC(1, 4), RC(2, 6)] as Expression).evaluate().operator + ).toBe('Divide'); + }); +}); + +describe('ResidueClass: type (#399)', () => { + const cases: [string, unknown, string][] = [ + ['a class', RC(2, 5), 'value'], + ['a sum with a class', ['Add', RC(2, 5), 'x'], 'broadcastable'], + [ + 'a product with a class', + ['Multiply', RC(2, 5), 'x'], + 'broadcastable', + ], + ['a power of a class', ['Power', RC(2, 5), 'x'], 'broadcastable'], + ['ℤ/5ℤ has classes of type value', Z(5), 'set'], + ['ℤ/nℤ, n symbolic', Z('n'), 'set'], + [ + 'ℚ/5ℚ has no class type', + ['QuotientRing', 'RationalNumbers', 5], + 'set', + ], + ]; + for (const [name, input, expected] of cases) { + test(`${name} has type ${expected}`, () => { + expect(ce.box(input as Expression).type.toString()).toBe(expected); + }); + } + + test('the parsed ℤ/5ℤ has the element type value', () => { + expect(ce.parse('\\mathbb{Z}/5\\mathbb{Z}').type.toString()).toBe( + 'set' + ); + }); +}); + +describe('QuotientRing(Integers, n) through ResidueClass (#399)', () => { + for (const n of [1, 2, 5, 12]) { + test(`ℤ/${n}ℤ lists ResidueClass(0, ${n}) … ResidueClass(${n - 1}, ${n})`, () => { + const classes = Array.from({ length: n }, (_, k) => RC(k, n)); + expect(evaluated(['ListFrom', Z(n)])).toEqual(['List', ...classes]); + expect(ce.box(Z(n) as Expression).isEnumerableCollection).toBe(true); + }); + } + + test('ℤ/5ℤ is still finite with count 5', () => { + const z5 = ce.box(Z(5) as Expression); + expect(z5.isCollection).toBe(true); + expect(z5.isFiniteCollection).toBe(true); + expect(z5.isEmptyCollection).toBe(false); + expect(z5.count).toBe(5); + expect(evaluated(['Count', Z(5)])).toBe(5); + }); + + test('a large modulus is counted without listing', () => { + const z = ce.box(Z(1000000007) as Expression); + expect(z.count).toBe(1000000007); + expect(evaluated(['Count', Z(1000000007)])).toBe(1000000007); + }); + + const inert: [string, unknown][] = [ + ['a symbolic modulus', Z('n')], + ['modulus 0', Z(0)], + ['a non-Integers base', ['QuotientRing', 'RationalNumbers', 5]], + ]; + for (const [name, input] of inert) { + test(`${name} is not a collection`, () => { + expect(ce.box(input as Expression).isCollection).toBe(false); + }); + } + + const membership: [string, unknown, unknown][] = [ + [ + 'Element(ResidueClass(7, 5), ℤ/5ℤ) is True', + ['Element', RC(7, 5), Z(5)], + 'True', + ], + [ + 'Element(ResidueClass(0, 5), ℤ/5ℤ) is True', + ['Element', RC(0, 5), Z(5)], + 'True', + ], + [ + 'a class of another modulus is not a member', + ['Element', RC(2, 6), Z(5)], + 'False', + ], + [ + 'NotElement(ResidueClass(2, 6), ℤ/5ℤ)', + ['NotElement', RC(2, 6), Z(5)], + 'True', + ], + ]; + for (const [name, input, expected] of membership) { + test(name, () => { + expect(evaluated(input)).toEqual(expected); + }); + } + + // 7 is a representative of a class, not the class: membership of a bare + // integer is not decided, as before ResidueClass. + test('Element(7, ℤ/5ℤ) is not decided', () => { + expect(ce.box(['Element', 7, Z(5)] as Expression).evaluate().operator).toBe( + 'Element' + ); + }); + + test('the parsed \\overline{7}_{5} \\in \\mathbb{Z}/5\\mathbb{Z} is True', () => { + expect( + ce.parse('\\overline{7}_{5}\\in\\mathbb{Z}/5\\mathbb{Z}').evaluate().json + ).toBe('True'); + }); + + test('set operators walk the listed classes', () => { + expect( + evaluated(['Intersection', Z(5), ['Set', RC(2, 5), RC(2, 6)]]) + ).toEqual(['Set', RC(2, 5)]); + expect(evaluated(['SetMinus', Z(3), ['Set', RC(1, 3)]])).toEqual([ + 'Set', + RC(0, 3), + RC(2, 3), + ]); + expect(evaluated(['Union', Z(2), ['Set', RC(1, 2)]])).toEqual([ + 'Set', + RC(0, 2), + RC(1, 2), + ]); + }); + + test('.N() leaves the classes exact', () => { + expect(ce.box(['ListFrom', Z(3)] as Expression).N().json).toEqual([ + 'List', + RC(0, 3), + RC(1, 3), + RC(2, 3), + ]); + }); +}); + +describe('ResidueClass: LaTeX \\overline{k}_{n} (#399)', () => { + const parses: [string, unknown][] = [ + ['\\overline{7}_{5}', RC(2, 5)], + ['\\overline{7}_5', RC(2, 5)], + ['\\overline{0}_{1}', RC(0, 1)], + ['\\overline{12}_{5}', RC(2, 5)], + ['\\overline{7}_{5}+\\overline{4}_{5}', ['Add', RC(2, 5), RC(4, 5)]], + ['\\overline{3}_{7}^{-1}', ['Divide', 1, RC(3, 7)]], + [ + '\\overline{123456789012345678901234567890}_{1000000000000000000000000000057}', + RC( + big('123456789012345678901234567890'), + big('1000000000000000000000000000057') + ), + ], + ]; + for (const [latex, expected] of parses) { + test(`${latex} reads as ${JSON.stringify(expected)}`, () => { + expect(ce.parse(latex).json).toEqual(expected); + }); + } + + // Not classes: only an integer literal over an integer literal is read so. + const unchanged: [string, unknown][] = [ + ['\\overline{7}', ['Conjugate', 7]], + ['\\overline{z}', ['Conjugate', 'z']], + ['\\overline{z}_1', ['Subscript', ['Conjugate', 'z'], 1]], + ['\\overline{z}_{12}', ['Subscript', ['Conjugate', 'z'], 12]], + ['\\overline{7}_{n}', ['Subscript', ['Conjugate', 7], 'n']], + ['\\overline{7}_{0}', ['Subscript', ['Conjugate', 7], 0]], + ['\\overline{7}_{2.5}', ['Subscript', ['Conjugate', 7], 2.5]], + ['\\overline{n}_{5}', ['Subscript', ['Conjugate', 'n'], 5]], + ['\\overline{-3}_{5}', ['Subscript', ['Conjugate', -3], 5]], + ['\\overline{x+1}_{5}', ['Subscript', ['Conjugate', ['Add', 'x', 1]], 5]], + ]; + for (const [latex, expected] of unchanged) { + test(`${latex} is unchanged`, () => { + const parsed = ce.parse(latex); + expect(parsed.json).toEqual(expected); + expect(parsed.operator).not.toBe('ResidueClass'); + }); + } + + test('\\overline{7} evaluates as the conjugate, 7', () => { + expect(ce.parse('\\overline{7}').evaluate().json).toBe(7); + }); + + test('a repeating decimal is not affected', () => { + expect(ce.parse('0.\\overline{3}').json).toEqual(['Rational', 1, 3]); + expect(ce.parse('0.1\\overline{6}').json).toEqual(['Rational', 1, 6]); + }); + + const serializes: [string, unknown, string][] = [ + ['a class', RC(2, 5), '\\overline{2}_{5}'], + ['a class mod 1', RC(0, 1), '\\overline{0}_{1}'], + [ + 'a symbolic modulus is a call', + RC(2, 'n'), + '\\mathrm{ResidueClass}(2, n)', + ], + ['modulus 0 is a call', RC(2, 0), '\\mathrm{ResidueClass}(2, 0)'], + ]; + for (const [name, input, expected] of serializes) { + test(`${name} serializes as ${expected}`, () => { + expect(ce.box(input as Expression).latex).toBe(expected); + }); + } + + const roundTrips: unknown[] = [ + RC(2, 5), + RC(0, 1), + RC(2, 'n'), + RC(2, 0), + RC(['Rational', 1, 2], 4), + ['Add', RC(2, 5), RC(4, 5)], + ['Multiply', RC(3, 7), RC(5, 7)], + ['Divide', 1, RC(3, 7)], + ['Power', RC(3, 7), 6], + RC(big('3'), big('1000000000000000000000000000057')), + ]; + for (const input of roundTrips) { + test(`${JSON.stringify(input)} round-trips through LaTeX`, () => { + const boxed = ce.box(input as Expression); + expect(ce.parse(boxed.latex).isSame(boxed)).toBe(true); + }); + } +}); + +describe('ResidueClass: other routes (#399)', () => { + test('Epsil spells a class as residueClass(k, n), and runs it', () => { + const epsil = new ComputeEngine(); + expect(executeEpsil(epsil, 'residueClass(7, 5)').value.json).toEqual( + RC(2, 5) + ); + expect( + executeEpsil(epsil, 'residueClass(3, 7) * residueClass(5, 7)').value.json + ).toEqual(RC(1, 7)); + expect(serializeEpsil(RC(2, 5) as Expression)).toBe('residueClass(2, 5)'); + }); + + test('the compiled lane is not lowered: the call falls back, it does not guess', () => { + const result = compile(ce.box(['Add', RC(2, 7), RC(3, 7)] as Expression)); + expect(result.success).toBe(false); + }); +}); diff --git a/test/compute-engine/ring-constructions.test.ts b/test/compute-engine/ring-constructions.test.ts index 13864f04c..744bfbaeb 100644 --- a/test/compute-engine/ring-constructions.test.ts +++ b/test/compute-engine/ring-constructions.test.ts @@ -289,8 +289,8 @@ describe('Types', () => { expect(ce.parse('\\mathbb{Z}[x]').type.toString()).toBe('set'); }); - test('QuotientRing claims no element type (its elements are classes)', () => { - expect(ce.parse('\\mathbb{Z}_n').type.toString()).toBe('set'); + test('QuotientRing elements are classes: value over ℤ, unknown over other bases', () => { + expect(ce.parse('\\mathbb{Z}_n').type.toString()).toBe('set'); expect(ce.parse('\\mathbb{Q}_p').type.toString()).toBe('set'); }); }); @@ -331,7 +331,7 @@ describe('Route parity: raw MathJSON reaches the same dispatch', () => { structural: true, }); expect(e.json).toEqual(['Subscript', 'Integers', 'n']); - expect(e.type.toString()).toBe('set'); + expect(e.type.toString()).toBe('set'); expect(e.type.toString()).toBe( ce.box(['QuotientRing', 'Integers', 'n']).type.toString() );