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
11 changes: 7 additions & 4 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# This workflow will install Python dependencies, run tests and lint with the supported versions of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
name: Python application
on:
Expand All @@ -11,12 +11,15 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.14"]
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: "3.10"
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/test-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,18 @@ on:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.14"]

steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: "3.9"
python-version: ${{ matrix.python-version }}

- name: Install Dependencies
run: |
Expand Down
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Changelog

## 1.10.0-4 (package version 1.14.0.post4)

The Git tags of this fork (`1.10.0-x`) and the package version differ: tags `1.10.0-2` and `1.10.0-3` already declare version `1.14.0`, because upstream 1.14.0 was merged between `1.10.0-1` and `1.10.0-2`. Tag `1.10.0-4` declares `1.14.0.post4`, so the installed version keeps increasing (`pip show mathjson-solver`).

| Git tag | Package version |
|---|---|
| 1.10.0-1 | 1.10.0 |
| 1.10.0-2 | 1.14.0 |
| 1.10.0-3 | 1.14.0 |
| 1.10.0-4 | 1.14.0.post4 |

### Breaking changes
- **`["Log", x]` is now the base-10 logarithm** (it was the natural logarithm). This aligns the solver with the CortexJS Compute Engine used by the front end. Consumers must migrate stored `["Log", x]` expressions to `["Ln", x]` to keep the previous behaviour.
- **Python >= 3.11 is required** (`math.cbrt`).
- Out-of-domain and non-finite results now raise instead of returning a value (see below).

### Added
- `["Log", x, b]`: logarithm of `x` in base `b`.
- `MathJSONDomainError` (subclass of `ValueError`, exported from `mathjson_solver`). It is the inner exception (`MathJSONException.e`) when arguments are outside the mathematical domain of an operator:
- `Log`, `Ln`, `Log2`, `Log10` with `x <= 0`; `Log` with base `<= 0` or base `== 1`.
- `Sqrt` of a negative number.
- `Power` with a negative base and a non-integer exponent. (`Power(0, negative)` still raises `ZeroDivisionError`.)
- `Root` of a negative number with an even or fractional index.
- `Root` supports odd roots of negative numbers: `["Root", -8, 3]` is `-2` and `["Root", -32, 5]` is `-2`. `∛` uses `math.cbrt`, and an exact integer root is returned exactly on every platform: `["Root", 27, 3]` is `3.0`, although glibc's `cbrt(27)` is `3.0000000000000004`.
- Overflow handling: `OverflowError` is converted to `MathJSONException`. A float result that is infinite raises `OverflowError`, and a NaN result raises `MathJSONDomainError`, e.g. `["Multiply", 1e300, 1e300]`, `["Power", 10, 400]`. The check applies to float results of any construct only; booleans, strings, lists and dates are unaffected.
- `ExponentialE` evaluates to `math.e`. Parameters and `Constants` with the same name take precedence. `Pi` is intentionally not provided.
- `create_solver(parameters, strict=False)`. With `strict=True`, an expression whose head is not a supported operator raises `MathJSONException` (caused by `NotImplementedError`) instead of being returned unchanged. The default behaviour is unchanged.
- `extract_variables` knows `Ln` and `Rational`, and does not report `ExponentialE` as a variable.

### Changed
- `Power`, `Sqrt`, `Root` and `Square` use `math.pow` (never produce complex numbers) and return floats, e.g. `["Power", 2, 3]` is `8.0` (equal to `8`).

### Packaging / CI
- `setup.cfg` and `setup.py` version set to `1.14.0.post4` (tag `1.10.0-4`).
- GitHub workflows test on Python 3.11 and 3.14 with `actions/setup-python@v5`.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ except MathJSONException:

Left unhandled, the exception will look like `MathJSONException("Problem in Divide. ['Divide', 1, 0]. division by zero")`.

The original error is available as `e.e`. Arguments outside an operator's domain (e.g. `["Log", 0]`, `["Sqrt", -4]`) give a `MathJSONDomainError` (a `ValueError` subclass) and overflowing results give an `OverflowError`.

By default, expressions with an unknown operator are returned unchanged. Use `create_solver(parameters, strict=True)` to raise a `MathJSONException` (caused by `NotImplementedError`) instead.


## How to run tests
Make sure you have `pytest` installed. Then `cd` into project directory and run:
Expand Down
16 changes: 14 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* [Root](#root-and-square-root)
* [Sqrt](#root-and-square-root)
* [Exp](#exponents-and-logarithms)
* [Ln](#exponents-and-logarithms)
* [Log](#exponents-and-logarithms)
* [Log2](#exponents-and-logarithms)
* [Log10](#exponents-and-logarithms)
Expand Down Expand Up @@ -139,27 +140,38 @@ Performs a division.
```

#### Square and Power
`Power` raises a number to given power. `Square` is a special case of `Power`.
`Power` raises a number to given power. `Square` is a special case of `Power`. A negative base with a non-integer exponent is undefined and raises a `MathJSONDomainError`.
```python
["Power", 2, 3] # 2^3=8
["Power", 10, -3] # 10^-3=0.001
["Square", 4] # 4^2=16
```

#### Root and square root
`Root` also supports odd roots of negative numbers. Even or fractional roots of negative numbers (and `Sqrt` of a negative number) raise a `MathJSONDomainError`.
```python
["Root", 9, 2] # √9=3.0
["Root", 8, 3] # ∛8=2.0
["Root", -8, 3] # ∛-8=-2.0
["Root", -16, 4] # MathJSONDomainError
["Sqrt", 9] # √9=3.0
```

#### Exponents and logarithms
`Log` is the base-10 logarithm (as in CortexJS Compute Engine); use `Ln` for the natural logarithm. `Log` accepts an optional base as the second argument. Arguments outside the domain (`x <= 0`, base `<= 0` or base `== 1`) raise a `MathJSONDomainError`. `ExponentialE` evaluates to _e_ unless a parameter or constant with that name is defined.
```python
["Exp", 2] # e^2≅7.389
["Log", 2.7183] # ln(2.7183)≅1.0000
["Ln", 2.7183] # ln(2.7183)≅1.0000
["Ln", "ExponentialE"] # ln(e)=1.0
["Log", 1000] # log10(1000)=3.0
["Log", 8, 2] # log2(8)=3.0
["Log2", 8] # log2(8)=3.0
["Log10", 1000] # log10(1000)=3.0
```

#### Out-of-range results
Floating-point results that overflow to infinity raise a `MathJSONException` caused by an `OverflowError`; undefined (NaN) results raise one caused by a `MathJSONDomainError`.

### Conditionality

#### Equality, greater than, less than
Expand Down
4 changes: 2 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[metadata]
name = mathjson-solver
version = 1.14.0
version = 1.14.0.post4
author = Martins Mednis
author_email = mrt@mednis.info
description = Utilities for MathJSON evaluation
Expand All @@ -18,7 +18,7 @@ classifiers =
package_dir =
= src
packages = find:
python_requires = >=3.6
python_requires = >=3.11

[options.packages.find]
where = src
Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

setuptools.setup(
name="mathjson-solver",
version="1.14.0",
version="1.14.0.post4",
author="Martins Mednis",
author_email="mrt@mednis.info",
description="Utilities for MathJSON evaluation",
Expand All @@ -22,5 +22,5 @@
],
package_dir={"": "src"},
packages=setuptools.find_packages(where="src"),
python_requires=">=3.6",
python_requires=">=3.11",
)
2 changes: 1 addition & 1 deletion src/mathjson_solver/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
from .__main__ import create_mathjson_solver as create_solver
from .__main__ import MathJSONException, extract_variables
from .__main__ import MathJSONException, MathJSONDomainError, extract_variables
110 changes: 97 additions & 13 deletions src/mathjson_solver/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ def __str__(self):
return f"Problem in {self.construct}. {self.expr}. {m}"


class MathJSONDomainError(ValueError):
"""The arguments are outside the mathematical domain of the operator"""


def is_numeric(x):
try:
float(x)
Expand Down Expand Up @@ -149,7 +153,7 @@ def has_sublist2(
return count >= required_match_count


def create_mathjson_solver(solver_parameters):
def create_mathjson_solver(solver_parameters, strict=False):
def f(s, *args):
if args:
c = deepcopy(args[0])
Expand Down Expand Up @@ -529,6 +533,69 @@ def safe_float(val):
except TypeError:
return False

def domain_safe(fn, *args):
"""Call `fn`; math domain errors become MathJSONDomainError"""
try:
return fn(*args)
except ValueError as e:
raise MathJSONDomainError(str(e))

def Power(s):
x = f(s[1], c)
y = f(s[2], c)
if x == 0 and y < 0:
# Keep the division-by-zero error that pow() raised
raise ZeroDivisionError("0 cannot be raised to a negative power")
return domain_safe(math.pow, x, y)

def positive_root(x, n, r):
y = math.cbrt(x) if n == 3 else domain_safe(math.pow, x, r)
# cbrt and pow(x, 1/n) can be one ulp off an exact root:
# give exact roots back exactly
if (
math.isfinite(y)
and isinstance(n, (int, float))
and float(n).is_integer()
and n > 0
):
k = round(y)
if k ** int(n) == x:
return float(k)
return y

def Root(s):
x = f(s[1], c)
n = f(s[2], c)
r = 1.0 / n
if x < 0:
# Only odd roots of negative numbers are real
is_odd = (
isinstance(n, (int, float))
and float(n).is_integer()
and n % 2 == 1
)
if not is_odd:
raise MathJSONDomainError(
"Even or fractional root of a negative number"
)
return -positive_root(-x, n, r)
return positive_root(x, n, r)

def Log(s):
x = f(s[1], c)
if x <= 0:
raise MathJSONDomainError(
"Logarithm of a non-positive number"
)
if len(s) < 3:
return math.log10(x)
b = f(s[2], c)
if b <= 0 or b == 1:
raise MathJSONDomainError(
"Logarithm base must be positive and not equal to 1"
)
return math.log(x, b)

constructs = {
"Sum": Sum,
"Add": Add,
Expand All @@ -545,15 +612,15 @@ def safe_float(val):
"Divide": lambda s: f(s[1], c) / f(s[2], c),
"Rational": lambda s: f(s[1], c) / f(s[2], c),
"Negate": lambda s: -f(s[1], c),
"Power": lambda s: pow(f(s[1], c), f(s[2], c)),
"Root": lambda s: pow(f(s[1], c), 1.0 / f(s[2], c)),
"Sqrt": lambda s: pow(f(s[1], c), 1.0 / 2),
"Square": lambda s: pow(f(s[1], c), 2),
"Power": Power,
"Root": Root,
"Sqrt": lambda s: domain_safe(math.pow, f(s[1], c), 1.0 / 2),
"Square": lambda s: domain_safe(math.pow, f(s[1], c), 2),
"Exp": lambda s: math.exp(f(s[1], c)),
"Log": lambda s: math.log(f(s[1], c)),
"Ln": lambda s: math.log(f(s[1], c)),
"Log2": lambda s: math.log2(f(s[1], c)),
"Log10": lambda s: math.log10(f(s[1], c)),
"Log": Log,
"Ln": lambda s: domain_safe(math.log, f(s[1], c)),
"Log2": lambda s: domain_safe(math.log2, f(s[1], c)),
"Log10": lambda s: domain_safe(math.log10, f(s[1], c)),
"Less": lambda s: Compare(operator.lt, s, c),
"Greater": lambda s: Compare(operator.gt, s, c),
"Equal": lambda s: Compare(operator.eq, s, c),
Expand Down Expand Up @@ -603,7 +670,13 @@ def safe_float(val):
}
if s[0] in constructs:
try:
return constructs[s[0]](s)
res = constructs[s[0]](s)
# Floats must be finite: inf is overflow, nan is undefined
if isinstance(res, float) and not math.isfinite(res):
if math.isnan(res):
raise MathJSONDomainError("Result is not a number")
raise OverflowError("Result is too large")
return res

# except RecursionError:
# return s[0]
Expand All @@ -616,10 +689,15 @@ def safe_float(val):
raise MathJSONException(e, s, mathjson_construct=s[0])
except ZeroDivisionError as e:
raise MathJSONException(e, s, mathjson_construct=s[0])
except OverflowError as e:
raise MathJSONException(e, s, mathjson_construct=s[0])
else:
# raise MathJSONException(
# NotImplementedError(f"'{s[0]}' is not supported"), s
# )
if strict and isinstance(s[0], str):
raise MathJSONException(
NotImplementedError(f"'{s[0]}' is not supported"),
s,
mathjson_construct=s[0],
)
return s
elif s in solver_parameters:
try:
Expand All @@ -628,6 +706,9 @@ def safe_float(val):
return solver_parameters[s]
elif s in c:
return f(c[s], c)
elif s == "ExponentialE":
# Parameters and context take precedence over the constant
return math.e
else:
# raise KeyError(f"Parameter '{s}' is not defined")
return s
Expand All @@ -652,8 +733,10 @@ def extract_variables(
"Root",
"Sqrt",
"Square",
"Rational",
"Exp",
"Log",
"Ln",
"Log2",
"Log10",
"Equal",
Expand Down Expand Up @@ -696,6 +779,7 @@ def extract_variables(
"TimeDeltaHours",
"TimeDeltaMinutes",
"TimeDeltaDays",
"ExponentialE",
]
if isinstance(s, str):
if s in ignore_list:
Expand Down
Loading
Loading