Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [2.3.0] - Unreleased

### Changed

- The evaluator no longer copies its whole variable scope on every recursive evaluation step - only the constructs that actually introduce a new binding (`Constants`, `Reduce`'s legacy accumulator form, `TrapezoidalIntegrate`) copy it, and only their own copy. Measured 2-3x faster on arithmetic- and `Reduce`-heavy expressions; no behavior change.

### Fixed

- **`extract_variables` treated unrecognized constructs' arguments as free variables.** `["Color", "red"]` would report `"red"` as a parameter to supply, even though it's a literal argument to a construct this solver doesn't implement - not a variable reference. `extract_variables` now mirrors `f()`'s own fallback for an unrecognized construct (return the expression unchanged, never evaluating or substituting into its arguments): it's treated as opaque data, contributing no free variables, rather than recursed into. Applies to any unimplemented construct (`Color`, `Quantity`, etc.), not just specific names. The same treatment was extended to `Head`/`Tail`/`Hold`/`IsSame`/`Same` (new in this release, see below), which likewise never evaluate their arguments.
Expand Down
19 changes: 13 additions & 6 deletions src/mathjson_solver/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from collections.abc import Callable, Iterable
from functools import reduce
import math
from copy import deepcopy

from ._common import comparison_safe_converter
from ._exceptions import MathJSONExpression, MathJSONException
Expand Down Expand Up @@ -732,11 +731,9 @@ def create_mathjson_solver(
blacklist = frozenset(blacklist) if blacklist else frozenset()

def f(s, *args):
if args:
c = deepcopy(args[0])
else:
c = {}
# c = deepcopy(kwargs.get("c", {}))
# Leaf values never touch `c` at all - check them before doing
# anything with it, since leaves vastly outnumber interior nodes
# in any real expression tree.
if isinstance(s, numbers.Number):
return s
# CortexJS represents the boolean literals as the bare symbols
Expand All @@ -748,6 +745,16 @@ def f(s, *args):
# solver parameter or local variable.
if s == "True" or s == "False":
return s == "True"
# `c` is passed through by reference, not copied, here or at any
# of the call sites below - almost every construct only reads
# it. The few constructs that introduce new bindings (Constants,
# Reduce's legacy accumulator form, TrapezoidalIntegrate) are
# responsible for making their own `c = dict(c)` copy before
# writing into it, the same way `_apply_fn` already does for
# `Function` - so a written binding is visible to whatever that
# construct evaluates internally, but never leaks back into the
# caller's scope once the construct returns.
c = args[0] if args else {}
if isinstance(s, list):
if not s:
# Empty equation given - []
Expand Down
3 changes: 3 additions & 0 deletions src/mathjson_solver/_array_arithmetic_constructs.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,9 @@ def TrapezoidalIntegrate(f, c, solver_parameters, s):

# Calculate the integral using the trapezoidal rule

# Bind into a copy, not the caller's `c` - see `f`'s own comment on
# this convention.
c = dict(c)
values = []
for x in t:
variable_name = variable[1]
Expand Down
4 changes: 4 additions & 0 deletions src/mathjson_solver/_control_flow_constructs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@


def Constants(f, c, solver_parameters, s):
# Bind into a copy, not the caller's `c` - these bindings must be
# visible to the rest of this Constants block but not leak back out
# once it returns (see `f`'s own comment on this convention).
c = dict(c)
for x in s[1:-1]:
try:
c[x[0]] = f(x[1], c)
Expand Down
3 changes: 3 additions & 0 deletions src/mathjson_solver/_functional_constructs.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ def Reduce(f, c, solver_parameters, s):
_index = s[6]
name_index = _index[1]

# Bind into a copy, not the caller's `c` - see `f`'s own comment on
# this convention.
c = dict(c)
c[name_accumulator] = initial_value

for i, x in enumerate(the_list):
Expand Down
123 changes: 123 additions & 0 deletions tests/test_scope_isolation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""
Regression tests for `c`-scope isolation.

`f()` passes `c` through by reference rather than copying it on every
call; the handful of constructs that introduce new bindings
(`Constants`, `Reduce`'s legacy accumulator form, `TrapezoidalIntegrate`)
are responsible for copying it themselves before writing into it (see
`_apply_fn`, which has always done this for `Function`). These tests
exist specifically to catch a leak in either direction - a binding
escaping into the caller's scope, or two sibling scopes contaminating
each other - since the existing per-construct tests weren't written to
catch that failure mode.
"""

import sys
import os
import pytest

NUMPY_AVAILABLE = False
try:
import numpy # noqa: F401

NUMPY_AVAILABLE = True
except ImportError:
pass

sys.path.append(os.path.join(os.path.dirname(__file__), "../src/"))

from mathjson_solver import create_solver


def test_constants_binding_does_not_shadow_solver_parameter_afterward():
solver = create_solver({"x": 5})
expression = ["Add", ["Constants", ["x", 999], "x"], "x"]
assert solver(expression) == 1004


def test_constants_binding_does_not_leak_to_sibling():
solver = create_solver({})
expression = ["List", ["Constants", ["temp", 1], "temp"], ["IsDefined", "temp"]]
assert solver(expression) == ["Array", 1, False]


def test_sibling_constants_blocks_do_not_cross_contaminate():
solver = create_solver({})
expression = [
"Add",
["Constants", ["x", 1], "x"],
["Constants", ["x", 2], "x"],
]
assert solver(expression) == 3


def test_nested_constants_inner_binding_does_not_leak_to_outer():
solver = create_solver({})
expression = [
"Constants",
["x", 1],
["Add", ["Constants", ["x", 100], "x"], "x"],
]
# Inner Constants shadows x=100 for its own body (100 + 1 = 101);
# outer x=1 is untouched by the inner block having run.
assert solver(expression) == 101


def test_reduce_legacy_form_bindings_do_not_leak():
solver = create_solver({})
expression = [
"List",
[
"Reduce",
["Array", 1, 2, 3],
0,
["Add", ["Variable", "accumulator"], ["Variable", "current_item"]],
["Variable", "accumulator"],
["Variable", "current_item"],
["Variable", "index"],
],
["IsDefined", "accumulator"],
["IsDefined", "current_item"],
["IsDefined", "index"],
]
assert solver(expression) == ["Array", 6, False, False, False]


def test_sibling_reduce_legacy_calls_do_not_cross_contaminate():
solver = create_solver({})
expression = [
"Add",
[
"Reduce",
["Array", 1, 2, 3],
0,
["Add", ["Variable", "accumulator"], ["Variable", "current_item"]],
["Variable", "accumulator"],
["Variable", "current_item"],
["Variable", "index"],
],
[
"Reduce",
["Array", 10, 20],
0,
["Add", ["Variable", "accumulator"], ["Variable", "current_item"]],
["Variable", "accumulator"],
["Variable", "current_item"],
["Variable", "index"],
],
]
assert solver(expression) == 36


@pytest.mark.skipif(not NUMPY_AVAILABLE, reason="NumPy not available")
def test_trapezoidal_integrate_variable_binding_does_not_leak():
solver = create_solver({})
expression = [
"List",
["TrapezoidalIntegrate", ["Multiply", "t", "t"], 0, 1, 1000, ["Variable", "t"]],
["IsDefined", "t"],
]
result = solver(expression)
assert result[0] == "Array"
assert abs(result[1] - 1 / 3) < 1e-3
assert result[2] is False
Loading