Skip to content

Commit

Permalink
WIP: "Actual" symbolic evaluation
Browse files Browse the repository at this point in the history
  This PR is not intended for merging as-is, but serves as an
  alternate demonstration (vis-a-vis issue #2437) that essentially all
  the ingredients already exist in mathjs for evaluation in which all
  undefined variables evaluate to symbols, therefore possibly returning
  an expression (Node) rather than a concrete value (while still
  evaluating all the way to concrete values when possible).
  Moreover, mathematical manipulation of symbolic expressions can be
  supported without circularity and without modifying numerous source
  files.

  This PR does however depend on a small addition to typed-function.js,
  see josdejong/typed-function#125.

  See (or run) examples/symbolic_evaluation.mjs for further details on this.
  • Loading branch information
gwhitney committed Mar 9, 2022
1 parent 7c13b85 commit 82bb75a
Show file tree
Hide file tree
Showing 14 changed files with 151 additions and 25 deletions.
122 changes: 122 additions & 0 deletions examples/symbolic_evaluation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import math from '../src/defaultInstance.js'

math.SymbolNode.onUndefinedSymbol = (name, node) => node

math.typed.onMismatch = (name, args, signatures) => {
let nodeArg = false
for (const arg of args) {
if (math.isNode(arg)) {
nodeArg = true
break
}
}
if (nodeArg) {
const specialOps = { addScalar: 'add', multiplyScalar: 'multiply' }
if (name in specialOps) name = specialOps[name]
const maybeOp = math.OperatorNode.getOperator(name)
const newArgs = Array.from(args, arg => math.simplify.ensureNode(arg))
if (maybeOp) return new math.OperatorNode(maybeOp, name, newArgs)
return new math.FunctionNode(new math.SymbolNode(name), newArgs)
}

let argstr = args[0].toString()
for (let i = 1; i < args.length; ++i) {
argstr += `, ${args[i]}`
}

throw TypeError(`Typed function type mismatch for ${name} called with '${argstr}'`)
}

function mystringify (obj) {
let s = '{'
for (const key in obj) {
s += `${key}: ${obj[key]}, `
}
return s.slice(0, -2) + '}'
}

function logExample (expr, scope = {}) {
let header = `Evaluating: '${expr}'`
if (Object.keys(scope).length > 0) {
header += ` in scope ${mystringify(scope)}`
}
console.log(header)
let result
try {
result = math.evaluate(expr, scope)
if (math.isNode(result)) {
result = `Expression ${result.toString()}`
}
} catch (err) {
result = err.toString()
}
console.log(` --> ${result}`)
}

let point = 1
console.log(`${point++}. By just evaluating all unknown symbols to themselves, and
providing a typed-function handler that builds expression trees when there is
no matching signature, we implement full-fledged symbolic evaluation:`)
logExample('x*y + 3x - y + 2', { y: 7 })
console.log(`
${point++}. If all of the free variables have values, this evaluates
all the way to the numeric value:`)
logExample('x*y + 3x - y + 2', { x: 1, y: 7 })
console.log(`
${point++}. It works with matrices as well, for example.`)
logExample('[x^2 + 3x + x*y, y, 12]', { x: 2 })
logExample('[x^2 + 3x + x*y, y, 12]', { x: 2, y: 7 })
console.log(`(Note there are no fractions as in the simplifyConstant
version, since we are using ordinary 'math.evaluate()' in this approach.)
${point++}. However, to break a chain of automatic conversions that disrupts
this style of evaluation, it's necessary to remove the former conversion
from 'number' to 'string':`)
logExample('count(57)')
console.log(`(In develop, this returns 2, the length of the string representation
of 57. However, it turns out that with only very slight tweaks to "Unit,"
all tests pass without the automatic 'number' -> 'string' conversion,
suggesting it isn't really being used, or at least very little.
${point++}. This lets you more easily perform operations like symbolic differentiation:`)
logExample('derivative(sin(x) + exp(x) + x^3, x)')
console.log("(Note no quotes in the argument to 'derivative' -- it is directly\n" +
'operating on the expression, without any string values involved.)')

console.log(`
${point++}. Doing it this way respects assignment, since ordinary evaluate does:`)
logExample('f = x^2+2x*y; derivative(f,x)')
console.log(`
${point++}. You can also build up expressions incrementally and use the scope:`)
logExample('h1 = x^2+5x; h3 = h1 + h2; derivative(h3,x)', {
h2: math.evaluate('3x+7')
})
console.log(`
${point++}. Some kinks still remain at the moment. Scope values for the
variable of differentiation disrupt the results:`)
logExample('derivative(x^3 + x^2, x)')
logExample('derivative(x^3 + x^2, x)', { x: 1 })
console.log(`${''}(We'd like the latter evaluation to return the result of the
first differentiation, evaluated at 1, or namely 5. However, there is not (yet)
a concept in math.evaluate that 'derivative' creates a variable-binding
environment, blocking off the 'x' from being substituted via the outside
scope within its first argument. Implementing this may be slightly trickier
in this approach since ordinary 'evaluate' (in the absence of 'rawArgs'
markings) is an essentially "bottom-up" operation whereas 'math.resolve' is
more naturally a "top-down" operation. The point is you need to know you're
inside a 'derivative' or other binding environment at the time that you do
substitution.)
Also, unlike the simplifyConstant approach, derivative doesn't know to
'check' whether a contained variable actually depends on 'x', so the order
of assignments makes a big difference:`)
logExample('h3 = h1+h2; h1 = x^2+5x; derivative(h3,x)', {
h2: math.evaluate('3x+7')
})
console.log(`${''}(Here, 'h1' in the first assignment evaluates to a
SymbolNode('h1'), which ends up being part of the argument to the eventual
derivative call, and there's never anything to fill in the later definition
of 'h1', and as it's a different symbol, its derivative with respect to 'x'
is assumed to be 0.)
Nevertheless, such features could be implemented.`)
14 changes: 7 additions & 7 deletions src/core/function/typed.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,13 @@ export const createTyped = /* #__PURE__ */ factory('typed', dependencies, functi

return new Complex(x, 0)
}
}, {
from: 'number',
to: 'string',
convert: function (x) {
return x + ''
}
}, {
}, // {
// from: 'number',
// to: 'string',
// convert: function (x) {
// return x + ''
// }
/* }, */ {
from: 'BigNumber',
to: 'Complex',
convert: function (x) {
Expand Down
4 changes: 3 additions & 1 deletion src/expression/node/OperatorNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { isNode } from '../../utils/is.js'
import { map } from '../../utils/array.js'
import { escape } from '../../utils/string.js'
import { getSafeProperty, isSafeMethod } from '../../utils/customs.js'
import { getAssociativity, getPrecedence, isAssociativeWith, properties } from '../operators.js'
import { getAssociativity, getPrecedence, getOperator, isAssociativeWith, properties } from '../operators.js'
import { latexOperators } from '../../utils/latex.js'
import { factory } from '../../utils/factory.js'

Expand Down Expand Up @@ -613,5 +613,7 @@ export const createOperatorNode = /* #__PURE__ */ factory(name, dependencies, ({
return this.type + ':' + this.fn
}

OperatorNode.getOperator = getOperator

return OperatorNode
}, { isClass: true, isNode: true })
4 changes: 2 additions & 2 deletions src/expression/node/SymbolNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,13 @@ export const createSymbolNode = /* #__PURE__ */ factory(name, dependencies, ({ m
}
} else {
const isUnit = isValuelessUnit(name)

const me = this
return function (scope, args, context) {
return scope.has(name)
? scope.get(name)
: isUnit
? new Unit(null, name)
: SymbolNode.onUndefinedSymbol(name)
: SymbolNode.onUndefinedSymbol(name, me)
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/function/algebra/simplify.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ export const createSimplify = /* #__PURE__ */ factory(name, dependencies, (
simplify.defaultContext = defaultContext
simplify.realContext = realContext
simplify.positiveContext = positiveContext
simplify.ensureNode = simplifyConstant.ensureNode

function removeParens (node) {
return node.transform(function (node, path, parent) {
Expand Down
2 changes: 2 additions & 0 deletions src/function/algebra/simplify/simplifyConstant.js
Original file line number Diff line number Diff line change
Expand Up @@ -428,5 +428,7 @@ export const createSimplifyConstant = /* #__PURE__ */ factory(name, dependencies
}
}

simplifyConstant.ensureNode = _ensureNode

return simplifyConstant
})
8 changes: 1 addition & 7 deletions src/type/unit/Unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,7 @@ export const createUnitClass = /* #__PURE__ */ factory(name, dependencies, ({
this.units = u.units
this.dimensions = u.dimensions
} else {
this.units = [
{
unit: UNIT_NONE,
prefix: PREFIXES.NONE, // link to a list with supported prefixes
power: 0
}
]
this.units = []
this.dimensions = []
for (let i = 0; i < BASE_DIMENSIONS.length; i++) {
this.dimensions[i] = 0
Expand Down
5 changes: 5 additions & 0 deletions src/type/unit/function/unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export const createUnitFunction = /* #__PURE__ */ factory(name, dependencies, ({
return new Unit(value, unit)
},

'number | BigNumber | Fraction': function (value) {
// dimensionless
return new Unit(value)
},

'Array | Matrix': function (x) {
return deepMap(x, this)
}
Expand Down
4 changes: 2 additions & 2 deletions test/unit-tests/function/algebra/derivative.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ describe('derivative', function () {
it('should throw error for incorrect argument types', function () {
assert.throws(function () {
derivative('42', '42')
}, /TypeError: Unexpected type of argument in function derivative \(expected: string or SymbolNode or number or boolean, actual: ConstantNode, index: 1\)/)
}, /TypeError: Unexpected type of argument in function derivative \(expected: string or SymbolNode or boolean, actual: ConstantNode, index: 1\)/)

assert.throws(function () {
derivative('[1, 2; 3, 4]', 'x')
Expand All @@ -268,7 +268,7 @@ describe('derivative', function () {
it('should throw error if incorrect number of arguments', function () {
assert.throws(function () {
derivative('x + 2')
}, /TypeError: Too few arguments in function derivative \(expected: string or SymbolNode or number or boolean, index: 1\)/)
}, /TypeError: Too few arguments in function derivative \(expected: string or SymbolNode or boolean, index: 1\)/)

assert.throws(function () {
derivative('x + 2', 'x', {}, true, 42)
Expand Down
2 changes: 1 addition & 1 deletion test/unit-tests/function/matrix/count.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('count', function () {

it('should throw an error if called with an invalid number of arguments', function () {
assert.throws(function () { count() }, /TypeError: Too few arguments/)
assert.throws(function () { count(1, 2) }, /TypeError: Too many arguments/)
assert.throws(function () { count('1', 2) }, /TypeError: Too many arguments/)
})

it('should throw an error if called with invalid type of arguments', function () {
Expand Down
2 changes: 1 addition & 1 deletion test/unit-tests/function/matrix/diag.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ describe('diag', function () {

it('should throw an error in case of wrong number of arguments', function () {
assert.throws(function () { math.diag() }, /TypeError: Too few arguments/)
assert.throws(function () { math.diag([], 2, 3, 4) }, /TypeError: Too many arguments/)
assert.throws(function () { math.diag([], 3, 'dense', 4) }, /TypeError: Too many arguments/)
})

it('should throw an error in case of invalid type of arguments', function () {
Expand Down
2 changes: 1 addition & 1 deletion test/unit-tests/function/unit/to.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe('to', function () {

it('should throw an error if called with a number', function () {
assert.throws(function () { math.to(5, unit('m')) }, TypeError)
assert.throws(function () { math.to(unit('5cm'), 2) }, /SyntaxError: "2" contains no units/)
assert.throws(function () { math.to(unit('5cm'), 2) }, TypeError)
})

it('should throw an error if called with a string', function () {
Expand Down
4 changes: 2 additions & 2 deletions test/unit-tests/type/matrix/function/matrix.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,11 @@ describe('matrix', function () {
})

it('should throw an error if called with too many arguments', function () {
assert.throws(function () { matrix([], 3, 3, 7) }, /TypeError: Too many arguments/)
assert.throws(function () { matrix([], 'dense', 'number', 7) }, /TypeError: Too many arguments/)
})

it('should throw an error when called with an invalid storage format', function () {
assert.throws(function () { math.matrix([], 1) }, /TypeError: Unknown matrix type "1"/)
assert.throws(function () { math.matrix([], '1') }, /TypeError: Unknown matrix type "1"/)
})

it('should throw an error when called with an unknown storage format', function () {
Expand Down
2 changes: 1 addition & 1 deletion test/unit-tests/type/matrix/function/sparse.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('sparse', function () {
})

it('should throw an error if called with too many arguments', function () {
assert.throws(function () { sparse([], 3, 3) }, /TypeError: Too many arguments/)
assert.throws(function () { sparse([], 'number', 3) }, /TypeError: Too many arguments/)
})

it('should LaTeX matrix', function () {
Expand Down

0 comments on commit 82bb75a

Please sign in to comment.