Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Functions, (...args: number[]) => number> = {
add: (...args) => args.reduce((a, b) => a + b, 0),
sub: (...args) => (args.length > 1) ? args[0] - args[1] : -args[0],
Expand Down Expand Up @@ -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<Functions, (...args: Interval[]) => 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]),
Expand Down
103 changes: 79 additions & 24 deletions src/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -169,7 +207,7 @@ export class ExprFunction extends ExprElement {
if (this.fn === 'sqrt') return `<msqrt>${argsF[0]}</msqrt>`;

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('')}</${el}>`;
Expand All @@ -182,16 +220,21 @@ export class ExprFunction extends ExprElement {
return `<m${this.fn}>${args1.join('')}</m${this.fn}>`;
}

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 `<msubsup>${args1.join('')}</msubsup>`;
return `<m${this.fn}>${args1.join('')}</m${this.fn}>`;
}

if (isOneOf(this.fn, '(', '[', '{')) {
if (this.fn === '(') {
return `<mfenced open="${this.fn}" close="${BRACKETS[this.fn]}">${argsF.join(COMMA)}</mfenced>`;
}

if (isOneOf(this.fn, '[', '{')) {
const rows = this.args.filter(r => r.toString() === ';').length + 1;
return `<mfenced open="${this.fn}" close="${BRACKETS[this.fn]}" rows="${rows}">${argsF.join('')}</mfenced>`;
}

if (isOneOf(this.fn, '!', '%')) {
return `${argsF[0]}<mo value="${this.fn}" lspace="0">${this.fn}</mo>`;
}
Expand Down Expand Up @@ -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]}`;
Expand All @@ -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

Expand Down
22 changes: 18 additions & 4 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,17 @@ 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;

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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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, '( [ {')) {
Expand Down
2 changes: 1 addition & 1 deletion src/symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
2 changes: 2 additions & 0 deletions test/evaluate-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
30 changes: 28 additions & 2 deletions test/mathml-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,6 @@ tape('Roots', (test) => {
tape('Groupings', (test) => {
test.equal(mathML('(a+b)'),
'<mfenced open="(" close=")"><mi>a</mi><mo value="+">+</mo><mi>b</mi></mfenced>');
test.equal(mathML('{a+b}'),
'<mfenced open="{" close="}"><mi>a</mi><mo value="+">+</mo><mi>b</mi></mfenced>');
test.equal(mathML('abs(a+b)'),
'<mfenced open="|" close="|"><mi>a</mi><mo value="+">+</mo><mi>b</mi></mfenced>');
test.equal(mathML('a,b,c'),
Expand All @@ -144,3 +142,31 @@ tape('Groupings', (test) => {
'<msup><mi>e</mi><mrow><mi>i</mi><mi>τ</mi></mrow></msup><mo value="=">=</mo><mn>1</mn>');
test.end();
});

tape('Matrices and Piecewise', (test) => {
test.equal(mathML('[a, b, c]'),
'<mfenced open="[" close="]" rows="1"><mi>a</mi><mi>b</mi><mi>c</mi></mfenced>');
test.equal(mathML('[a, b; c, d]'),
'<mfenced open="[" close="]" rows="2"><mi>a</mi><mi>b</mi><mi>c</mi><mi>d</mi></mfenced>');
test.equal(mathML('[a; b; c]'),
'<mfenced open="[" close="]" rows="3"><mi>a</mi><mi>b</mi><mi>c</mi></mfenced>');
test.equal(mathML('{a, b, c}'),
'<mfenced open="{" close="}" rows="1"><mi>a</mi><mi>b</mi><mi>c</mi></mfenced>');
test.equal(mathML('{a, b; c, d}'),
'<mfenced open="{" close="}" rows="2"><mi>a</mi><mi>b</mi><mi>c</mi><mi>d</mi></mfenced>');
test.equal(mathML('{a; b; c}'),
'<mfenced open="{" close="}" rows="3"><mi>a</mi><mi>b</mi><mi>c</mi></mfenced>');
test.equal(mathML('{a+b}'),
'<mfenced open="{" close="}" rows="1"><mi>a</mi><mo value="+">+</mo><mi>b</mi></mfenced>');
test.end();
});

tape('Under Over', (test) => {
test.equal(mathML('∑_(i = 0)^2 i'),
'<munderover><mo value="∑">∑</mo><mrow><mi>i</mi><mo value="=">=</mo><mn>0</mn></mrow><mn>2</mn></munderover><mi>i</mi>');
test.equal(mathML('∫_a^b c'),
'<munderover><mo value="∫">∫</mo><mi>a</mi><mi>b</mi></munderover><mi>c</mi>');
test.equal(mathML('∫_0^1 xdx'),
'<munderover><mo value="∫">∫</mo><mn>0</mn><mn>1</mn></munderover><mi>xdx</mi>');
test.end();
});
11 changes: 11 additions & 0 deletions test/voice-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_');
Expand All @@ -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();
});