diff --git a/src/eval.ts b/src/eval.ts index e5f8472d..182f058e 100644 --- a/src/eval.ts +++ b/src/eval.ts @@ -5,7 +5,7 @@ import {total} from '@mathigon/core'; -import {gcd, isBetween, lcm} from '@mathigon/fermat'; +import {gcd, isBetween, lcm, nearlyEquals} from '@mathigon/fermat'; import {SpecialFunction} from './symbols'; const OPERATORS = ['add', 'sub', 'mul', 'div', 'sup'] as const; @@ -37,6 +37,12 @@ export const hasZero = (a: Interval) => contains(a, 0); // ----------------------------------------------------------------------------- // Standard Evaluation +export const evaluateRel: Record<'='|'<'|'>', (...args: number[]) => boolean> = { + '=': (a, b) => nearlyEquals(a, b), + '<': (a, b) => a < b, + '>': (a, b) => a > b +}; + export const evaluate: Record number> = { add: (...args) => args.reduce((a, b) => a + b, 0), sub: (...args) => (args.length > 1) ? args[0] - args[1] : -args[0], @@ -119,6 +125,12 @@ function intervalMod(a: Interval, m = TWO_PI): Interval { // ----------------------------------------------------------------------------- // Interval Evaluation +export const intervalRel: Record<'='|'<'|'>', (...args: Interval[]) => boolean> = { + '=': (a, b) => (contains(a, b[0]) && contains(a, b[1])) || (contains(b, a[0]) && contains(b, a[1])), + '<': (a, b) => a[1] < b[0], + '>': (a, b) => a[1] < b[0] +}; + export const interval: Record Interval> = { add: (...args) => int(total(args.map(a => a[0])), total(args.map(a => a[1]))), sub: (a, b) => b !== undefined ? int(a[0] - b[1], a[1] - b[0]) : int(-a[1], -a[0]), diff --git a/src/functions.ts b/src/functions.ts index 3d0651b9..b44024fd 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -5,10 +5,10 @@ import {flatten, isOneOf, join, repeat, unique, words} from '@mathigon/core'; -import {evaluate, interval, Interval} from './eval'; +import {evaluate, evaluateRel, interval, Interval, intervalRel} from './eval'; import {collapseTerm} from './parser'; -import {BRACKETS, escape, isSpecialFunction, VOICE_STRINGS} from './symbols'; -import {CustomFunction, ExprElement, ExprMap, ExprNumber, MathMLMap, VarMap} from './elements'; +import {BRACKETS, escape, isSpecialFunction, SpecialFunction, VOICE_STRINGS} from './symbols'; +import {ExprElement, ExprMap, ExprNumber, MathMLMap, VarMap} from './elements'; import {ExprError} from './errors'; @@ -40,12 +40,29 @@ function supVoice(a: string) { export class ExprFunction extends ExprElement { + private operator?: 'add'|'sub'|'mul'|'div'|'sup'|SpecialFunction; constructor(readonly fn: string, readonly args: ExprElement[] = []) { super(); + this.operator = fn === '+' ? 'add' : fn === '−' ? 'sub' : + '*·×'.includes(fn) ? 'mul' : fn === '/' ? 'div' : fn === 'sup' ? 'sup' : + isSpecialFunction(fn) ? fn : undefined; } evaluate(vars: VarMap = {}) { + if (this.fn === '{') { + // Piecewise functions + for (let i = 0; i < this.args.length; i += 1) { + if (this.args[i + 1].evaluate(vars)) return this.args[i].evaluate(); + } + return NaN; + } else if (this.fn === '(') { + return this.args[0].evaluate(vars); + } else if (this.fn === '[') { + // TODO Evaluate matrices + return NaN; + } + const args = this.args.map(a => a.evaluate(vars)); if (this.fn in vars) { @@ -55,17 +72,42 @@ export class ExprFunction extends ExprElement { throw ExprError.uncallableExpression(this.fn); } - if (this.fn === '+') return evaluate.add(...args); - if (this.fn === '−') return evaluate.sub(...args); - if (['*', '·', '×'].includes(this.fn)) return evaluate.mul(...args); - if (this.fn === '/') return evaluate.div(...args); - if (this.fn === 'sup') return evaluate.sup(...args); - if (isSpecialFunction(this.fn)) return evaluate[this.fn](...args); - if (this.fn === '(') return args[0]; + if ('=<>'.includes(this.fn)) return evaluateRel[this.fn as '='|'<'|'>'](...args) ? 1 : 0; + // TODO: evaluate underover functions + // if (this.fn === 'underover') { + // if (this.args[0].toString() === '∑') { + // let sum = 0; + // for (let i = args[1]; i < args[2]; i++) { + // sum += i; + // } + // return sum; + // } + // if (this.args[0].toString() === '∏') { + // let prod = 1; + // for (let i = args[1]; i < args[2]; i++) { + // prod *= i; + // } + // return prod; + // } + // } + // + if (this.operator) return evaluate[this.operator](...args); throw ExprError.undefinedFunction(this.fn); } interval(vars: VarMap = {}): Interval { + if (this.fn === '{') { + for (let i = 0; i < this.args.length; i += 1) { + if (this.args[i + 1].interval(vars)[0]) return this.args[i].interval(vars); + } + return [NaN, NaN]; + } else if (this.fn === '(') { + return this.args[0].interval(vars); + } else if (this.fn === '[') { + // TODO Evaluate matrices + return [NaN, NaN]; + } + const args = this.args.map(a => a.interval(vars)); if (this.fn in vars) { @@ -76,13 +118,8 @@ export class ExprFunction extends ExprElement { throw ExprError.uncallableExpression(this.fn); } - if (this.fn === '+') return interval.add(...args); - if (this.fn === '−') return interval.sub(...args); - if (['*', '·', '×'].includes(this.fn)) return interval.mul(...args); - if (this.fn === '/') return interval.div(...args); - if (this.fn === 'sup') return interval.sup(...args); - if (isSpecialFunction(this.fn)) return interval[this.fn](...args); - if (this.fn === '(') return args[0]; + if ('=<>'.includes(this.fn)) return intervalRel[this.fn as '='|'<'|'>'](...args) ? [1, 1] : [0, 0]; + if (this.operator) return interval[this.operator](...args); throw ExprError.undefinedFunction(this.fn); } @@ -134,8 +171,9 @@ export class ExprFunction extends ExprElement { } toMathML(custom: MathMLMap = {}) { - const args = this.args.map(a => a.toMathML(custom)); - const argsF = this.args.map((a, i) => addMFence(a, this.fn, args[i])); + // Remove matrix/piecewise row breaks by filtering semi-colons. + const args = this.args.filter(a => a.toString() !== ';').map(a => a.toMathML(custom)); + const argsF = this.args.filter(a => a.toString() !== ';').map((a, i) => addMFence(a, this.fn, args[i])); if (this.fn in custom) { const argsX = args.map((a, i) => ({ @@ -169,7 +207,7 @@ export class ExprFunction extends ExprElement { if (this.fn === 'sqrt') return `${argsF[0]}`; if (isOneOf(this.fn, '/', 'root')) { - // Fractions or square roots don't have brackets around their arguments + // Fractions or roots don't have brackets around their arguments const el = (this.fn === '/' ? 'mfrac' : 'mroot'); const args1 = this.args.map((a, i) => addMRow(a, args[i])); return `<${el}>${args1.join('')}`; @@ -182,16 +220,21 @@ export class ExprFunction extends ExprElement { return `${args1.join('')}`; } - if (this.fn === 'subsup') { + if (this.fn === 'subsup' || this.fn === 'underover') { const args1 = [addMRow(this.args[0], argsF[0]), addMRow(this.args[1], args[1]), addMRow(this.args[2], args[2])]; - return `${args1.join('')}`; + return `${args1.join('')}`; } - if (isOneOf(this.fn, '(', '[', '{')) { + if (this.fn === '(') { return `${argsF.join(COMMA)}`; } + if (isOneOf(this.fn, '[', '{')) { + const rows = this.args.filter(r => r.toString() === ';').length + 1; + return `${argsF.join('')}`; + } + if (isOneOf(this.fn, '!', '%')) { return `${argsF[0]}${this.fn}`; } @@ -229,6 +272,8 @@ export class ExprFunction extends ExprElement { // Maybe `open bracket ${joined} close bracket` ? if (this.fn === 'sqrt') return `square root of ${joined}`; + if (this.fn === 'root' && args[1] === '3') return `cubic root of ${args[0]}`; + if (this.fn === 'root' && args[1] !== '3') return `${args[1]}th root of ${[args[0]]}`; if (this.fn === '%') return `${joined} percent`; if (this.fn === '!') return `${joined} factorial`; if (this.fn === '/') return `${args[0]} over ${args[1]}`; @@ -237,7 +282,17 @@ export class ExprFunction extends ExprElement { if (this.fn === 'sub') return joined; if (this.fn === 'subsup') return `${args[0]} ${args[1]} ${supVoice(args[2])}`; if (this.fn === 'sup') return `${args[0]} ${supVoice(args[1])}`; - + if (this.fn === 'underover') { + let symbol = '?'; + if (args[0] === '∑') { + symbol = 'sum'; + } else if (args[0] === '∫') { + symbol = 'integral'; + } else if (args[0] === '∏') { + symbol = 'product'; + } + return `${symbol} from ${args[1]} to ${args[2]} of`; + } if (VOICE_STRINGS[this.fn]) return args.join(` ${VOICE_STRINGS[this.fn]} `); // TODO Implement other cases diff --git a/src/parser.ts b/src/parser.ts index e2c65fe3..b90ed6c4 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -130,6 +130,8 @@ function findBinaryFunction(tokens: ExprElement[], fn: string) { if (isOperator(tokens[0], fn)) throw ExprError.startOperator(tokens[0]); if (isOperator(last(tokens), fn)) throw ExprError.endOperator(last(tokens)); + const mUnderOver = ['∑', '∏', '∫']; + for (let i = 1; i < tokens.length - 1; ++i) { if (!isOperator(tokens[i], fn)) continue; const token = tokens[i] as ExprOperator; @@ -137,7 +139,8 @@ function findBinaryFunction(tokens: ExprElement[], fn: string) { const a = tokens[i - 1]; const b = tokens[i + 1]; - if (a instanceof ExprOperator) { + // Sigma is ExprOperator, next to '_' also an operator + if (a instanceof ExprOperator && !mUnderOver.includes(a.o)) { throw ExprError.consecutiveOperators(a.o, token.o); } if (b instanceof ExprOperator) { @@ -151,7 +154,8 @@ function findBinaryFunction(tokens: ExprElement[], fn: string) { if (c instanceof ExprOperator) throw ExprError.consecutiveOperators(token2.o, c.o); const args = [removeBrackets(a), removeBrackets(b), removeBrackets(c)]; if (token.o === '^') [args[1], args[2]] = [args[2], args[1]]; - tokens.splice(i - 1, 5, new ExprFunction('subsup', args)); + const mathMLFn = mUnderOver.includes(a.toString()) ? 'underover' : 'subsup'; + tokens.splice(i - 1, 5, new ExprFunction(mathMLFn, args)); i -= 4; } else { @@ -191,16 +195,26 @@ export function matchBrackets(tokens: ExprElement[], context?: {variables?: stri } const closed = stack.pop(); + if (closed === undefined) continue; const term = last(stack); const lastTerm = last(term); const isFn = isOperator(t, ')') && lastTerm instanceof ExprIdentifier && !safeVariables.includes(lastTerm.i); - const fnName = isFn ? (term.pop() as ExprIdentifier).i : (closed![0] as ExprOperator).o; + const fnName = isFn ? (term.pop() as ExprIdentifier).i : (closed[0] as ExprOperator).o; // Support multiple arguments for function calls. - const args = splitArray(closed!.slice(1), a => isOperator(a, ',')); + const withinBrackets = closed.slice(1); + const args = splitArray(withinBrackets, a => isOperator(a, ', ;')); + + // Conditionally re-add semicolon row break markers for matrices + for (let i = 0; i < withinBrackets.length; i++) { + if (withinBrackets[i].toString() === ';' && isOperator(t, '] }')) { + args.splice(i - 1, 0, [withinBrackets[i]]); + } + } + term.push(new ExprFunction(fnName, args.map(prepareTerm))); } else if (isOperator(t, '( [ {')) { diff --git a/src/symbols.ts b/src/symbols.ts index 7c5f5523..3a23d6a6 100644 --- a/src/symbols.ts +++ b/src/symbols.ts @@ -112,7 +112,7 @@ const UPPERCASE = ALPHABET.toUpperCase().split(''); const GREEK = Object.values(SPECIAL_IDENTIFIERS); export const IDENTIFIER_SYMBOLS = [...LOWERCASE, ...UPPERCASE, ...GREEK, '$']; -const SIMPLE_SYMBOLS = '|()[]{}÷,!<>=*/+-–−~^_…°•∥⊥\'∠:%∼△'; +const SIMPLE_SYMBOLS = '|()[]{}÷,;!<>=*/+-–−~^_…°•∥⊥\'∠:%∼△'; const COMPLEX_SYMBOLS = Object.values(SPECIAL_OPERATORS); export const OPERATOR_SYMBOLS = [...SIMPLE_SYMBOLS, ...COMPLEX_SYMBOLS]; diff --git a/test/evaluate-test.ts b/test/evaluate-test.ts index a0ed0a15..ee463613 100644 --- a/test/evaluate-test.ts +++ b/test/evaluate-test.ts @@ -27,6 +27,8 @@ tape('Functions', (test) => { test.equal(value('2 ^ 3'), 8); test.equal(value('4 / 2'), 2); test.equal(value('sqrt(81)'), 9); + test.equal(value('root(27, 3)'), 3); + test.equal(value('root(81, 4)'), 3); test.equal(Math.round(value('sin(pi)')), 0); test.end(); }); diff --git a/test/mathml-test.ts b/test/mathml-test.ts index 0c5f6e37..3e9f492d 100644 --- a/test/mathml-test.ts +++ b/test/mathml-test.ts @@ -128,8 +128,6 @@ tape('Roots', (test) => { tape('Groupings', (test) => { test.equal(mathML('(a+b)'), 'a+b'); - test.equal(mathML('{a+b}'), - 'a+b'); test.equal(mathML('abs(a+b)'), 'a+b'); test.equal(mathML('a,b,c'), @@ -144,3 +142,31 @@ tape('Groupings', (test) => { 'eiτ=1'); test.end(); }); + +tape('Matrices and Piecewise', (test) => { + test.equal(mathML('[a, b, c]'), + 'abc'); + test.equal(mathML('[a, b; c, d]'), + 'abcd'); + test.equal(mathML('[a; b; c]'), + 'abc'); + test.equal(mathML('{a, b, c}'), + 'abc'); + test.equal(mathML('{a, b; c, d}'), + 'abcd'); + test.equal(mathML('{a; b; c}'), + 'abc'); + test.equal(mathML('{a+b}'), + 'a+b'); + test.end(); +}); + +tape('Under Over', (test) => { + test.equal(mathML('∑_(i = 0)^2 i'), + 'i=02i'); + test.equal(mathML('∫_a^b c'), + 'abc'); + test.equal(mathML('∫_0^1 xdx'), + '01xdx'); + test.end(); +}); diff --git a/test/voice-test.ts b/test/voice-test.ts index e204281c..c6aa539b 100644 --- a/test/voice-test.ts +++ b/test/voice-test.ts @@ -13,6 +13,8 @@ const voice = (src: string) => Expression.parse(src).toVoice(); tape('Basic Voice', (test) => { test.equal(voice('sqrt(5)'), 'square root of 5'); + test.equal(voice('root(27, 3)'), 'cubic root of 27'); + test.equal(voice('root(256, 4)'), '4th root of 256'); test.equal(voice('a * b'), '_a_ times _b_'); test.equal(voice('(a * b)'), '_a_ times _b_'); test.equal(voice('a^b'), '_a_ to the power of _b_'); @@ -23,5 +25,14 @@ tape('Basic Voice', (test) => { test.equal(voice('a/b'), '_a_ over _b_'); test.equal(voice('a//b'), '_a_ divided by _b_'); test.equal(voice('(a + b)/cc'), '_a_ plus _b_ over cc'); + test.equal(voice('∑_(i=1)^(10)'), 'sum from _i_ equals 1 to 10 of'); + test.equal(voice('∑_(i=1)^(10)i'), 'sum from _i_ equals 1 to 10 of _i_'); + test.equal(voice('∑_(i=1)^(10)i + 1'), 'sum from _i_ equals 1 to 10 of _i_ plus 1'); + test.equal(voice('∏_(i=1)^(10)'), 'product from _i_ equals 1 to 10 of'); + test.equal(voice('∏_(i=1)^(10)i'), 'product from _i_ equals 1 to 10 of _i_'); + test.equal(voice('∏_(i=1)^(10)i + 1'), 'product from _i_ equals 1 to 10 of _i_ plus 1'); + test.equal(voice('∫_(i=1)^(10)'), 'integral from _i_ equals 1 to 10 of'); + test.equal(voice('∫_(i=1)^(10)i'), 'integral from _i_ equals 1 to 10 of _i_'); + test.equal(voice('∫_(i=1)^(10)i + 1'), 'integral from _i_ equals 1 to 10 of _i_ plus 1'); test.end(); });