diff --git a/src/mathjson_solver/__main__.py b/src/mathjson_solver/__main__.py index 63cdd5e..c714827 100644 --- a/src/mathjson_solver/__main__.py +++ b/src/mathjson_solver/__main__.py @@ -1,145 +1,13 @@ import numbers import sys -import itertools -from typing import Union, Any, TypeAlias +from typing import Any as _Any from collections.abc import Callable, Iterable from functools import reduce -from fractions import Fraction import math from copy import deepcopy -from statistics import ( - median, - variance, - stdev, - pvariance, - pstdev, - mode, - quantiles, - covariance, - correlation, -) -import datetime - -from ._common import ( - is_numeric, - _try_parse_datetime, - has_matching_sublist, - comparison_safe_converter, - comparison_safe_converter_for_pairs, -) -from ._array_helpers import ( - _MultiplyByScalar, - _MultiplyByArray, - _AddScalar, - _SubtractScalar, - _AddArray, - _SubtractArray, - _CumulativeProduct, - _CumulativeSum, -) -from ._interpolation import ( - find_interpolation_bounds_indexes, - find_interpolation_bounds_2indexes, - linear_interpolate, -) -from ._number_theory import ( - _MAX_NUMBER_THEORY_MAGNITUDE, - _MAX_NTH_PRIME, - _MAX_PRIME_PI, - _is_prime, - _factor_integer, - _divisors, - _totient, - _integer_nth_root, - _is_perfect_power, - _extended_gcd, - _carmichael_lambda, - _jacobi_symbol, - _multiplicative_order, - _primitive_root, - _chinese_remainder, - _lucas_l, - _bernoulli, - _continued_fraction, - _from_continued_fraction, - _digits_in_base, - _is_figurate, -) -from ._special_functions import _erf_inv, _lambert_w, _agm, _elliptic_k_e -from ._combinatorics import _subfactorial, _bell_number - -NUMPY_AVAILABLE = False -try: - import numpy as np - - NUMPY_AVAILABLE = True -except ImportError: - pass - -RE2_AVAILABLE = False -try: - import re2 - - RE2_AVAILABLE = True - # No public "re2.Pattern" name is exported; derive the actual - # compiled-pattern type at import time rather than hardcoding - # re2's internal class path. - _RE2_PATTERN_TYPE = type(re2.compile("")) -except ImportError: - _RE2_PATTERN_TYPE = None - -# Hard cap on the result length of string operations whose size is -# otherwise controlled directly by a user-supplied count/length -# argument (StringRepeat, PadStart, PadEnd) - closes the same -# memory/time exhaustion shape that Loop/Random/arbitrary-size Repeat -# are excluded for elsewhere in this solver, but here it's cheaply and -# exactly bounded by a single length check before any allocation. -_MAX_STRING_REPEAT_LENGTH = 100_000 - -# Hard cap on PolynomialFit's degree - the normal-equations system it -# builds is (degree+1) x (degree+1), solved by O(degree^3) Gaussian -# elimination, so an unbounded user-supplied degree is a compute-cost -# vector the same way an unbounded string length is above. -_MAX_POLYFIT_DEGREE = 50 - -# A MathJSON expression, in this solver's list-based (not dictionary- -# based) representation: a literal (string, number, bool, or None), or -# a compound expression - a list whose first element is a construct -# name and whose remaining elements are themselves MathJSONExpression -# values (e.g. ["Add", 1, ["Multiply", 2, "x"]]). Used only for the -# small public API surface (`create_solver`, `extract_variables`, -# `translate_v1_mathjson`) - the ~300 per-construct functions inside -# `create_mathjson_solver` all take/return arbitrary runtime values -# (a construct can produce a `Fraction`, a `datetime.timedelta`, a -# compiled regex pattern, ...) and stay untyped rather than annotate -# each one with `Any` for no real type-safety benefit. -MathJSONExpression: TypeAlias = Union[ - str, int, float, bool, None, list["MathJSONExpression"] -] - - -class MathJSONException(Exception): - """Exception for MathJSON processing issues""" - - def __init__( - self, - e: Exception, - expr: MathJSONExpression, - *args: Any, - **kwargs: Any, - ) -> None: - super().__init__(args) - self.e = e - self.expr = expr - self.construct = kwargs.get("mathjson_construct", "MathJSON") - - def __str__(self) -> str: - if hasattr(self.e, "message"): - m = self.e.message - else: - m = str(self.e) - return f"Problem in {self.construct}. {self.expr}. {m}" +from ._common import comparison_safe_converter +from ._exceptions import MathJSONExpression, MathJSONException # def requires_array(func): # def inner1(*args, **kwargs): @@ -176,2578 +44,299 @@ def translate_v1_mathjson(expr: MathJSONExpression) -> MathJSONExpression: return expr -_VALID_REGEX_FLAGS = set("ims") - - -def Arr(f, c, solver_parameters, s): - return s - - -def Add(f, c, solver_parameters, s): - l_res = [] - tmp = 0 - for i, x in enumerate(s[1:]): - res = f(x, c) - if is_numeric(res): - res = float(res) - if i == 0: - tmp = res - else: - # Handle datetime string + timedelta - if isinstance(res, datetime.timedelta): - tmp = _try_parse_datetime(tmp) - elif isinstance(tmp, datetime.timedelta): - res = _try_parse_datetime(res) - try: - tmp = tmp + res - except TypeError: - pass - - # Convert datetime result back to string - if isinstance(tmp, (datetime.datetime, datetime.date)): - return tmp.isoformat() - return tmp - - # tmp = 0 - # for i, x in enumerate(l): - # if i == 0: - # tmp = x - # else: - # tmp = tmp + x - # return tmp - - -# def Sum(s): -# l_res = [] -# for x in s[1:]: -# res = f(x, c) -# if isinstance(res, list): -# l_res.append(sum([xx for xx in res[1:]])) -# else: -# l_res.append(res) -# return sum(l_res) - - -def Sum(f, c, solver_parameters, s): - l_res = ["Array"] - for x in s[1:]: - res = f(x, c) - if isinstance(res, list): - l_res.append( - Add(f, c, solver_parameters, ["Array"] + [xx for xx in res[1:]]) - ) - else: - l_res.append(res) - return Add(f, c, solver_parameters, l_res) - - -def Subtract(f, c, solver_parameters, s): - values = [f(x, c) for x in s[1:]] - # Convert datetime strings if we're dealing with timedelta - converted = [] - for i, v in enumerate(values): - if isinstance(v, datetime.timedelta): - converted.append(v) - elif any(isinstance(other, datetime.timedelta) for other in values): - converted.append(_try_parse_datetime(v)) - else: - converted.append(v) - result = reduce(lambda a, b: a - b, converted) - if isinstance(result, (datetime.datetime, datetime.date)): - return result.isoformat() - return result - - -def Max(f, c, solver_parameters, s): - args = s[1:] - if len(args) == 1: - if isinstance(args[0], str): - return max([f(x, c) for x in f(args[0], c) if is_numeric(f(x, c))]) - else: - return max([f(x, c) for x in args[0][1:] if is_numeric(f(x, c))]) - else: - # CortexJS-style variadic form: ["Max", a, b, c, ...] - return max([f(x, c) for x in args]) - - -def Min(f, c, solver_parameters, s): - args = s[1:] - if len(args) == 1: - if isinstance(args[0], str): - return min([f(x, c) for x in f(args[0], c) if is_numeric(f(x, c))]) - else: - return min([f(x, c) for x in args[0][1:] if is_numeric(f(x, c))]) - else: - # CortexJS-style variadic form: ["Min", a, b, c, ...] - return min([f(x, c) for x in args]) - - -def Average(f, c, solver_parameters, s): - if isinstance(s[1], str): - # A reference to "answer" has been passed - s_ = [float(f(x, c)) for x in f(s[1], c) if is_numeric(f(x, c))] - else: - s_ = [float(f(x, c)) for x in s[1][1:] if is_numeric(f(x, c))] - try: - return sum(s_) / len(s_) - except ZeroDivisionError: - return None - - -def Median(f, c, solver_parameters, s): - if isinstance(s[1], str): - return median([f(x, c) for x in f(s[1], c) if is_numeric(f(x, c))]) - else: - return median([f(x, c) for x in s[1][1:] if is_numeric(f(x, c))]) - - -def Length(f, c, solver_parameters, s): - if isinstance(s[1], str): - return len([x for x in f(s[1], c)][1:]) - else: - return len([x for x in s[1][1:]]) - - -def Clamp(f, c, solver_parameters, s): - """ - ["Clamp", value] or ["Clamp", value, lower, upper] - Bounds `value` between `lower` (default -1) and `upper` (default 1), - matching CortexJS `Clamp`. - """ - value = f(s[1], c) - lower = f(s[2], c) if len(s) > 2 else -1 - upper = f(s[3], c) if len(s) > 3 else 1 - return max(lower, min(upper, value)) - - -def _arr_vals(f, c, solver_parameters, s): - lst = f(s[1], c) - if not (isinstance(lst, list) and lst[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - return [f(x, c) for x in lst[1:]] - - -def First(f, c, solver_parameters, s): - return _arr_vals(f, c, solver_parameters, s)[0] - - -def Second(f, c, solver_parameters, s): - return _arr_vals(f, c, solver_parameters, s)[1] - - -def Third(f, c, solver_parameters, s): - return _arr_vals(f, c, solver_parameters, s)[2] - - -def Last(f, c, solver_parameters, s): - return _arr_vals(f, c, solver_parameters, s)[-1] - - -def Rest(f, c, solver_parameters, s): - return ["Array"] + _arr_vals(f, c, solver_parameters, s)[1:] - - -def Most(f, c, solver_parameters, s): - return ["Array"] + _arr_vals(f, c, solver_parameters, s)[:-1] - - -def Reverse(f, c, solver_parameters, s): - return ["Array"] + list(reversed(_arr_vals(f, c, solver_parameters, s))) - - -def Sort(f, c, solver_parameters, s): - return ["Array"] + sorted(_arr_vals(f, c, solver_parameters, s)) - - -def IsEmpty(f, c, solver_parameters, s): - lst = f(s[1], c) - if not (isinstance(lst, list) and lst[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - return len(lst) <= 1 - - -def Range(f, c, solver_parameters, s): - """ - CortexJS-compatible `Range`: - ["Range", upper] -> 1..upper (inclusive) - ["Range", lower, upper] -> lower..upper (inclusive) - ["Range", lower, upper, step] -> lower..upper (inclusive), stepped - Distinct from `GenerateRange`, which is 0-indexed and exclusive at - the upper end. - """ - if len(s) == 2: - return ["Array"] + list(range(1, int(f(s[1], c)) + 1)) - elif len(s) == 3: - lo, hi = int(f(s[1], c)), int(f(s[2], c)) - return ["Array"] + list(range(lo, hi + 1)) - else: - lo, hi, step = ( - int(f(s[1], c)), - int(f(s[2], c)), - int(f(s[3], c)), - ) - return ["Array"] + list(range(lo, hi + 1 if step > 0 else hi - 1, step)) - - -def Join(f, c, solver_parameters, s): - """ - ["Join", array1, array2, ...] - Concatenates the given arrays. - """ - result = ["Array"] - for arg in s[1:]: - lst = f(arg, c) - if not (isinstance(lst, list) and lst[0] == "Array"): - raise ValueError("All parameters must be arrays.") - result += [f(x, c) for x in lst[1:]] - return result - - -def Unique(f, c, solver_parameters, s): - seen = [] - for x in _arr_vals(f, c, solver_parameters, s): - if x not in seen: - seen.append(x) - return ["Array"] + seen - - -def Zip(f, c, solver_parameters, s): - lists = [[f(x, c) for x in f(arg, c)[1:]] for arg in s[1:]] - return ["Array"] + [["Array", a, b] for a, b in zip(*lists)] - - -def At(f, c, solver_parameters, s): - """ - ["At", array, index] - 1-indexed element access (CortexJS `At`), with negative indexes - counting from the end. - """ - vals = _arr_vals(f, c, solver_parameters, s) - idx = int(f(s[2], c)) - if idx > 0: - return vals[idx - 1] - else: - return vals[idx] - - -def Take(f, c, solver_parameters, s): - """ - ["Take", array, n] - First `n` elements if n >= 0; last `n` elements if n < 0. - Distinct from `Slice`, which takes an explicit [start, end) - range instead of a count. - """ - vals = _arr_vals(f, c, solver_parameters, s) - n = int(f(s[2], c)) - if n >= 0: - return ["Array"] + vals[:n] - return ["Array"] + vals[n:] - - -def Drop(f, c, solver_parameters, s): - """ - ["Drop", array, n] - All elements except the first `n` if n >= 0; except the - last `n` if n < 0. - """ - vals = _arr_vals(f, c, solver_parameters, s) - n = int(f(s[2], c)) - if n >= 0: - return ["Array"] + vals[n:] - return ["Array"] + vals[:n] - - -def TakeWhile(f, c, solver_parameters, s): - """ - ["TakeWhile", array, predicate] - Elements from the start, up to (excluding) the first one - for which `predicate` is false. - """ - result = [] - for v in _arr_vals(f, c, solver_parameters, s): - if not _apply_fn(f, c, solver_parameters, s[2], [v]): - break - result.append(v) - return ["Array"] + result - - -def DropWhile(f, c, solver_parameters, s): - """ - ["DropWhile", array, predicate] - Remaining elements from (and including) the first one for - which `predicate` is false. - """ - vals = _arr_vals(f, c, solver_parameters, s) - i = 0 - while i < len(vals) and _apply_fn(f, c, solver_parameters, s[2], [vals[i]]): - i += 1 - return ["Array"] + vals[i:] - - -def Contains(f, c, solver_parameters, s): - """ - ["Contains", array, value] - Whether `value` occurs in `array`. CortexJS argument order - (collection first) - the existing `In` takes them the - other way round (`["In", value, collection]`). - """ - return f(s[2], c) in _arr_vals(f, c, solver_parameters, s) - - -def IndexOf(f, c, solver_parameters, s): - """ - ["IndexOf", array, value] - 1-indexed position of the first occurrence of `value`, or - None if it doesn't occur. - """ - vals = _arr_vals(f, c, solver_parameters, s) - value = f(s[2], c) - try: - return vals.index(value) + 1 - except ValueError: - return None - - -def IndexWhere(f, c, solver_parameters, s): - """ - ["IndexWhere", array, predicate] - 1-indexed position of the first element for which - `predicate` is true, or None if none match. - """ - for i, v in enumerate(_arr_vals(f, c, solver_parameters, s)): - if _apply_fn(f, c, solver_parameters, s[2], [v]): - return i + 1 - return None - - -def Find(f, c, solver_parameters, s): - """ - ["Find", array, predicate] - The first element for which `predicate` is true, or None - if none match. - """ - for v in _arr_vals(f, c, solver_parameters, s): - if _apply_fn(f, c, solver_parameters, s[2], [v]): - return v - return None - - -def CountIf(f, c, solver_parameters, s): - """ - ["CountIf", array, predicate] - Count of elements for which `predicate` is true. - """ - return sum( - 1 - for v in _arr_vals(f, c, solver_parameters, s) - if _apply_fn(f, c, solver_parameters, s[2], [v]) - ) - - -def Position(f, c, solver_parameters, s): - """ - ["Position", array, predicate] - Array of the 1-indexed positions of every element for - which `predicate` is true. - """ - return ["Array"] + [ - i + 1 - for i, v in enumerate(_arr_vals(f, c, solver_parameters, s)) - if _apply_fn(f, c, solver_parameters, s[2], [v]) - ] - - -def RotateLeft(f, c, solver_parameters, s): - """ - ["RotateLeft", array, n] - Circularly shifts `array` left by `n` positions. - """ - vals = _arr_vals(f, c, solver_parameters, s) - if not vals: - return ["Array"] - n = int(f(s[2], c)) % len(vals) - return ["Array"] + vals[n:] + vals[:n] - - -def RotateRight(f, c, solver_parameters, s): - """ - ["RotateRight", array, n] - Circularly shifts `array` right by `n` positions. - """ - vals = _arr_vals(f, c, solver_parameters, s) - if not vals: - return ["Array"] - n = int(f(s[2], c)) % len(vals) - if n == 0: - return ["Array"] + vals - return ["Array"] + vals[-n:] + vals[:-n] - +from ._arithmetic_constructs import ( + Add, + Sum, + Subtract, + Max, + Min, + Average, + Median, + Clamp, + Int, + Float, + Floor, + Ceil, + Numerator, + Denominator, + Rational, + Product, +) +from ._array_arithmetic_constructs import ( + MultiplyByScalar, + MultiplyByArray, + AddScalar, + SubtractScalar, + AddArray, + SubtractArray, + GenerateRange, + AtIndex, + Slice, + CumulativeProduct, + CumulativeSum, + Interp, + FindIntervalIndex, + TrapezoidalIntegrate, +) +from ._collection_constructs import ( + Arr, + Length, + First, + Second, + Third, + Last, + Rest, + Most, + Reverse, + Sort, + IsEmpty, + Range, + Join, + Unique, + Zip, + At, + Take, + Drop, + TakeWhile, + DropWhile, + Contains, + IndexOf, + IndexWhere, + Find, + CountIf, + Position, + RotateLeft, + RotateRight, + MaxBy, + MinBy, + ArgMax, + ArgMin, + Ordering, + FlatMap, + Scan, + Differences, + Dedup, + Insert, + DeleteAt, + ReplaceAt, + Partition, + Chunk, + GroupBy, + ChunkBy, + Tally, + Union, + Intersection, + SetMinus, + SymmetricDifference, + Any, + All, + In, + Not_in, + Contains_any_of, + Contains_all_of, + Contains_none_of, + HasMatchingSublist, + Appended, +) +from ._combinatorics_constructs import ( + Fibonacci, + Multinomial, + Subfactorial, + BellNumber, + PowerSet, + Permutations, + Combinations, + CartesianProduct, +) +from ._comparison_constructs import ( + Not, + IsDefined, + IsUndefined, + Greater, + GreaterEqual, + Less, + LessEqual, + IdenticallyEqual, + Congruent, + BooleanAnd, + BooleanOr, +) +from ._control_flow_constructs import ( + Constants, + Switch, + StrictSwitch, + Which, + Variable, + Function, +) +from ._core_constructs import ( + Head, + Tail, + Hold, + Type, + IsSame, +) +from ._datetime_constructs import ( + Strptime, + Strftime, + Now, + Today, + TimeDeltaDays, + TimeDeltaMinutes, + TimeDeltaHours, + TimeDeltaWeeks, +) +from ._functional_constructs import ( + Fold, + Map, + StrictMap, + Filter, + Reduce, +) +from ._number_theory_constructs import ( + IsPrime, + FactorInteger, + PrimeFactors, + PrimeNu, + PrimeOmega, + Radical, + IsSquareFree, + Divisors, + Sigma0, + Sigma1, + SigmaMinus1, + DivisorSigma, + Totient, + IsPerfectPower, + NthPrime, + NextPrime, + PrimePi, + ExtendedGCD, + ChineseRemainder, + CarmichaelLambda, + JacobiSymbol, + MultiplicativeOrder, + PrimitiveRoot, + LucasL, + BernoulliB, + ContinuedFraction, + FromContinuedFraction, + IntegerDigits, + DigitCount, + DigitSum, + FromDigits, + IsSquare, + IsTriangular, + IsPentagonal, + IsOctahedral, + IsCenteredSquare, + IsPerfect, + IsAbundant, + IsHappy, +) +from ._special_function_constructs import ( + Beta, + Factorial2, + ErfInv, + LambertW, + AGM, + EllipticK, + EllipticE, +) +from ._statistics_constructs import ( + Variance, + StandardDeviation, + PopulationVariance, + PopulationStandardDeviation, + Mode, + Quartiles, + InterquartileRange, + Covariance, + Correlation, + Skewness, + Kurtosis, + LinearRegression, + PolynomialFit, +) +from ._string_constructs import ( + Str, + String, + StringJoin, + Utf8, + Utf16, + UnicodeScalars, + StringFrom, + Characters, + StringSplit, + StringReplace, + StringCompare, + IntegerString, + DigitsFrom, + NumberFrom, + StringRepeat, + PadStart, + PadEnd, + RegExp, + IsMatch, + StringMatch, + StringMatchAll, +) +from ._trig_constructs import ( + Sin, + Arcsin, + Cos, + Arccos, + Tan, + Arctan, + Pi, +) -def MaxBy(f, c, solver_parameters, s): - """ - ["MaxBy", array, function] - The element of `array` for which `function(element)` is - largest. - """ - return max( - _arr_vals(f, c, solver_parameters, s), - key=lambda v: _apply_fn(f, c, solver_parameters, s[2], [v]), - ) +def If(f, c, solver_parameters, s): + if len(s) < 3: + raise ValueError("Wrong parameters for 'If'") -def MinBy(f, c, solver_parameters, s): - """ - ["MinBy", array, function] - The element of `array` for which `function(element)` is - smallest. - """ - return min( - _arr_vals(f, c, solver_parameters, s), - key=lambda v: _apply_fn(f, c, solver_parameters, s[2], [v]), + # Detect the CortexJS flat form: ["If", cond, then] or + # ["If", cond, then, else]. In the Python pair-form below, + # s[1] is always a [condition, value] pair whose first + # element (the condition) is itself a MathJSON construct + # call, e.g. ["Equal", 1, 0]. A CortexJS flat condition is + # either not a list at all, or is itself such a construct + # call (its own first element is a *known construct name*). + # Requiring a known name - rather than any string - keeps a + # Python-form condition that is a bare parameter reference, + # e.g. ["If", ["my_flag", "yes"], "no"], from being + # misdetected as CortexJS form. + is_cortexjs_form = not isinstance(s[1], list) or ( + bool(s[1]) and isinstance(s[1][0], str) and s[1][0] in constructs ) - -def ArgMax(f, c, solver_parameters, s): - """ - ["ArgMax", array] - 1-indexed position of the largest element. - """ - vals = _arr_vals(f, c, solver_parameters, s) - return max(range(len(vals)), key=lambda i: vals[i]) + 1 - - -def ArgMin(f, c, solver_parameters, s): - """ - ["ArgMin", array] - 1-indexed position of the smallest element. - """ - vals = _arr_vals(f, c, solver_parameters, s) - return min(range(len(vals)), key=lambda i: vals[i]) + 1 - - -def Ordering(f, c, solver_parameters, s): - """ - ["Ordering", array] - Array of the 1-indexed positions that would put `array` - in ascending order. - """ - vals = _arr_vals(f, c, solver_parameters, s) - return ["Array"] + [i + 1 for i in sorted(range(len(vals)), key=lambda i: vals[i])] - - -def FlatMap(f, c, solver_parameters, s): - """ - ["FlatMap", array, function] - Applies `function` to each element (as with `Map`) and - flattens one level of the results - each result that is - itself an array is spliced in, others are kept as-is - - into a single array. - """ - result = ["Array"] - for v in _arr_vals(f, c, solver_parameters, s): - mapped = _apply_fn(f, c, solver_parameters, s[2], [v]) - if isinstance(mapped, list) and mapped and mapped[0] == "Array": - result += mapped[1:] + if is_cortexjs_form: + if len(s) not in (3, 4): + raise ValueError("Wrong parameters for 'If'") + if f(s[1], c): + return f(s[2], c) + elif len(s) == 4: + return f(s[3], c) else: - result.append(mapped) - return result - - -def Scan(f, c, solver_parameters, s): - """ - ["Scan", array, function] or ["Scan", array, function, initial] - Like `Reduce`'s CortexJS form (`function` applied as - `function(accumulator, current)`), but returns an array of - every intermediate accumulator value, including the seed, - instead of just the final one. - """ - vals = _arr_vals(f, c, solver_parameters, s) - fn_expr = s[2] - if len(s) == 4: - acc = f(s[3], c) - remaining = vals - else: - if not vals: - raise ValueError("'Scan' on an empty collection requires an initial value.") - acc = vals[0] - remaining = vals[1:] - result = ["Array", acc] - for v in remaining: - acc = _apply_fn(f, c, solver_parameters, fn_expr, [acc, v]) - result.append(acc) - return result - - -def Differences(f, c, solver_parameters, s): - """ - ["Differences", array] - Array of successive differences: element[i+1] - element[i]. - """ - vals = _arr_vals(f, c, solver_parameters, s) - return ["Array"] + [b - a for a, b in zip(vals, vals[1:])] - - -def Fold(f, c, solver_parameters, s): - """ - ["Fold", function, array] or ["Fold", function, array, initial] - The function-first form of `Reduce`'s CortexJS calling - convention (`["Reduce", array, function, ...]`) - same - behaviour, arguments swapped. - """ - if len(s) == 3: - return Reduce(f, c, solver_parameters, ["Reduce", s[2], s[1]]) - return Reduce(f, c, solver_parameters, ["Reduce", s[2], s[1], s[3]]) - - -def Dedup(f, c, solver_parameters, s): - """ - ["Dedup", array] - Removes only *consecutive* duplicate elements - distinct - from `Unique`, which removes every duplicate regardless of - position. - """ - result = [] - for v in _arr_vals(f, c, solver_parameters, s): - if not result or result[-1] != v: - result.append(v) - return ["Array"] + result - - -def Insert(f, c, solver_parameters, s): - """ - ["Insert", array, index, value] - Inserts `value` at the 1-indexed `index` (CortexJS - convention, matching `At`); negative indexes count from - the end. - """ - vals = _arr_vals(f, c, solver_parameters, s) - idx = int(f(s[2], c)) - value = f(s[3], c) - vals.insert(idx - 1 if idx > 0 else idx, value) - return ["Array"] + vals - - -def DeleteAt(f, c, solver_parameters, s): - """ - ["DeleteAt", array, index] - Removes the element at the 1-indexed `index`; negative - indexes count from the end. - """ - vals = _arr_vals(f, c, solver_parameters, s) - idx = int(f(s[2], c)) - del vals[idx - 1 if idx > 0 else idx] - return ["Array"] + vals - - -def ReplaceAt(f, c, solver_parameters, s): - """ - ["ReplaceAt", array, index, value] - Replaces the element at the 1-indexed `index` with - `value`; negative indexes count from the end. - """ - vals = _arr_vals(f, c, solver_parameters, s) - idx = int(f(s[2], c)) - value = f(s[3], c) - vals[idx - 1 if idx > 0 else idx] = value - return ["Array"] + vals - - -def Partition(f, c, solver_parameters, s): - """ - ["Partition", array, size] - Splits `array` into consecutive chunks of length `size` - (the last chunk may be shorter). Distinct from `Chunk`, - which instead takes the number of groups to split into. - """ - vals = _arr_vals(f, c, solver_parameters, s) - size = int(f(s[2], c)) - if size <= 0: - raise ValueError("'Partition' size must be a positive integer.") - return ["Array"] + [ - ["Array"] + vals[i : i + size] for i in range(0, len(vals), size) - ] - - -def Chunk(f, c, solver_parameters, s): - """ - ["Chunk", array, n] - Splits `array` into `n` roughly equal-sized consecutive - groups. - """ - vals = _arr_vals(f, c, solver_parameters, s) - n = int(f(s[2], c)) - if n <= 0: - raise ValueError("'Chunk' group count must be a positive integer.") - base, extra = divmod(len(vals), n) - result, start = [], 0 - for i in range(n): - size = base + (1 if i < extra else 0) - result.append(["Array"] + vals[start : start + size]) - start += size - return ["Array"] + result - + return None # CortexJS: Nothing, no else and condition false -def GroupBy(f, c, solver_parameters, s): - """ - ["GroupBy", array, function] - Groups elements of `array` by `function(element)`, in - order of first appearance of each key. Returns an array of - [key, group] pairs - there's no dedicated `Dictionary` - type in this solver. - """ - order = [] - groups = {} - for v in _arr_vals(f, c, solver_parameters, s): - k = _apply_fn(f, c, solver_parameters, s[2], [v]) - hk = tuple(k) if isinstance(k, list) else k - if hk not in groups: - order.append((hk, k)) - groups[hk] = [] - groups[hk].append(v) - return ["Array"] + [["Array", k, ["Array"] + groups[hk]] for hk, k in order] + for x in s[1:-1]: + if len(x) != 2: + raise ValueError("Wrong if or elif in 'If'") + try: + if f(x[0], c): + try: + return f(x[1], c) + except MathJSONException: + # Branch failed, try next condition + continue + except MathJSONException: + return f(s[-1], c) # return default value (else) - -def ChunkBy(f, c, solver_parameters, s): - """ - ["ChunkBy", array, function] - Splits `array` into consecutive runs sharing the same - `function(element)` key - unlike `GroupBy`, runs are not - merged across non-adjacent occurrences of the same key. - """ - vals = _arr_vals(f, c, solver_parameters, s) - if not vals: - return ["Array"] - result = [] - current = [vals[0]] - current_key = _apply_fn(f, c, solver_parameters, s[2], [vals[0]]) - for v in vals[1:]: - k = _apply_fn(f, c, solver_parameters, s[2], [v]) - if k == current_key: - current.append(v) - else: - result.append(["Array"] + current) - current, current_key = [v], k - result.append(["Array"] + current) - return ["Array"] + result - - -def Tally(f, c, solver_parameters, s): - """ - ["Tally", array] - Counts occurrences of each distinct element, in order of - first appearance. Returns an array of [value, count] - pairs. - """ - order = [] - counts = {} - for v in _arr_vals(f, c, solver_parameters, s): - hv = tuple(v) if isinstance(v, list) else v - if hv not in counts: - order.append((hv, v)) - counts[hv] = 0 - counts[hv] += 1 - return ["Array"] + [["Array", v, counts[hv]] for hv, v in order] - - -def _arr_vals_of(f, c, solver_parameters, expr): - """ - Like `_arr_vals`, but for an arbitrary expression instead - of assuming the array is at `s[1]` - needed by the - variadic set operations below. - """ - lst = f(expr, c) - if not (isinstance(lst, list) and lst[0] == "Array"): - raise ValueError("Parameter must be an array.") - return [f(x, c) for x in lst[1:]] - - -def Union(f, c, solver_parameters, s): - """ - ["Union", array1, array2, ...] - Distinct elements appearing in any of the given arrays, in - order of first appearance across the arguments - (left to right). Arrays stand in for CortexJS's `Set` - here - there's no dedicated set type in this solver. - """ - result = [] - for arg in s[1:]: - for v in _arr_vals_of(f, c, solver_parameters, arg): - if v not in result: - result.append(v) - return ["Array"] + result - - -def Intersection(f, c, solver_parameters, s): - """ - ["Intersection", array1, array2, ...] - Distinct elements common to every given array, in the - order they first appear in `array1`. - """ - arrays = [_arr_vals_of(f, c, solver_parameters, arg) for arg in s[1:]] - if not arrays: - return ["Array"] - result = [] - for v in arrays[0]: - if v not in result and all(v in arr for arr in arrays[1:]): - result.append(v) - return ["Array"] + result - - -def SetMinus(f, c, solver_parameters, s): - """ - ["SetMinus", array1, array2] - Distinct elements of `array1` that don't occur in - `array2`. - """ - a = _arr_vals_of(f, c, solver_parameters, s[1]) - b = _arr_vals_of(f, c, solver_parameters, s[2]) - result = [] - for v in a: - if v not in b and v not in result: - result.append(v) - return ["Array"] + result - - -def SymmetricDifference(f, c, solver_parameters, s): - """ - ["SymmetricDifference", array1, array2] - Distinct elements that occur in exactly one of - `array1`/`array2`: `array1`'s exclusive elements first - (in `array1`'s order), then `array2`'s (in `array2`'s - order). - """ - a = _arr_vals_of(f, c, solver_parameters, s[1]) - b = _arr_vals_of(f, c, solver_parameters, s[2]) - result = [] - for v in a: - if v not in b and v not in result: - result.append(v) - for v in b: - if v not in a and v not in result: - result.append(v) - return ["Array"] + result - - -def IsPrime(f, c, solver_parameters, s): - return _is_prime(f(s[1], c)) - - -# --- Number theory --- - - -def FactorInteger(f, c, solver_parameters, s): - return ["Array"] + [["Array", p, e] for p, e in _factor_integer(f(s[1], c))] - - -def PrimeFactors(f, c, solver_parameters, s): - return ["Array"] + [p for p, _ in _factor_integer(f(s[1], c))] - - -def PrimeNu(f, c, solver_parameters, s): - return len(_factor_integer(f(s[1], c))) - - -def PrimeOmega(f, c, solver_parameters, s): - return sum(e for _, e in _factor_integer(f(s[1], c))) - - -def Radical(f, c, solver_parameters, s): - return math.prod(p for p, _ in _factor_integer(f(s[1], c))) - - -def IsSquareFree(f, c, solver_parameters, s): - return all(e == 1 for _, e in _factor_integer(f(s[1], c))) - - -def Divisors(f, c, solver_parameters, s): - return ["Array"] + _divisors(f(s[1], c)) - - -def Sigma0(f, c, solver_parameters, s): - return len(_divisors(f(s[1], c))) - - -def Sigma1(f, c, solver_parameters, s): - return sum(_divisors(f(s[1], c))) - - -def SigmaMinus1(f, c, solver_parameters, s): - return sum(Fraction(1, d) for d in _divisors(f(s[1], c))) - - -def DivisorSigma(f, c, solver_parameters, s): - k = int(f(s[2], c)) - return sum(d**k for d in _divisors(f(s[1], c))) - - -def Totient(f, c, solver_parameters, s): - return _totient(f(s[1], c)) - - -def IsPerfectPower(f, c, solver_parameters, s): - return _is_perfect_power(f(s[1], c)) - - -def NthPrime(f, c, solver_parameters, s): - """ - ["NthPrime", n] - The n-th prime number (1-indexed: NthPrime(1) = 2). - Capped at _MAX_NTH_PRIME - a naive forward search gets - slow well before Python's own integer limits do. - """ - n = int(f(s[1], c)) - if not (1 <= n <= _MAX_NTH_PRIME): - raise ValueError(f"'NthPrime' n must be between 1 and {_MAX_NTH_PRIME}.") - count, candidate = 0, 1 - while count < n: - candidate += 1 - if _is_prime(candidate): - count += 1 - return candidate - - -def NextPrime(f, c, solver_parameters, s): - """ - ["NextPrime", n] or ["NextPrime", n, k] - The smallest prime greater than n, or the k-th such - prime. Both the starting value's magnitude and k are - capped, for the same reason as NthPrime. - """ - start = int(f(s[1], c)) - k = int(f(s[2], c)) if len(s) > 2 else 1 - if abs(start) > _MAX_NUMBER_THEORY_MAGNITUDE: - raise ValueError( - f"'NextPrime' starting value is limited to " - f"{_MAX_NUMBER_THEORY_MAGNITUDE} in absolute value." - ) - if not (1 <= k <= _MAX_NTH_PRIME): - raise ValueError(f"'NextPrime' k must be between 1 and {_MAX_NTH_PRIME}.") - count, candidate = 0, start - while count < k: - candidate += 1 - if _is_prime(candidate): - count += 1 - return candidate - - -def PrimePi(f, c, solver_parameters, s): - """ - ["PrimePi", n] - pi(n): the count of primes <= n. Capped at - _MAX_PRIME_PI - cost scales with n itself, not sqrt(n). - """ - n = int(f(s[1], c)) - if not (0 <= n <= _MAX_PRIME_PI): - raise ValueError(f"'PrimePi' n must be between 0 and {_MAX_PRIME_PI}.") - return sum(1 for k in range(2, n + 1) if _is_prime(k)) - - -def ExtendedGCD(f, c, solver_parameters, s): - return ["Array"] + list(_extended_gcd(int(f(s[1], c)), int(f(s[2], c)))) - - -def ChineseRemainder(f, c, solver_parameters, s): - """ - ["ChineseRemainder", remainders, moduli] - Solves the system x = remainders[i] (mod moduli[i]) via - the Chinese Remainder Theorem. Moduli must be pairwise - coprime. - """ - remainders = _arr_vals_of(f, c, solver_parameters, s[1]) - moduli = _arr_vals_of(f, c, solver_parameters, s[2]) - return _chinese_remainder(remainders, moduli) - - -def CarmichaelLambda(f, c, solver_parameters, s): - return _carmichael_lambda(f(s[1], c)) - - -def JacobiSymbol(f, c, solver_parameters, s): - return _jacobi_symbol(f(s[1], c), f(s[2], c)) - - -def MultiplicativeOrder(f, c, solver_parameters, s): - return _multiplicative_order(f(s[1], c), f(s[2], c)) - - -def PrimitiveRoot(f, c, solver_parameters, s): - return _primitive_root(f(s[1], c)) - - -def LucasL(f, c, solver_parameters, s): - return _lucas_l(f(s[1], c)) - - -def BernoulliB(f, c, solver_parameters, s): - return _bernoulli(f(s[1], c)) - - -def ContinuedFraction(f, c, solver_parameters, s): - x = f(s[1], c) - max_terms = int(f(s[2], c)) if len(s) > 2 else 20 - return ["Array"] + _continued_fraction(x, max_terms) - - -def FromContinuedFraction(f, c, solver_parameters, s): - return _from_continued_fraction(_arr_vals(f, c, solver_parameters, s)) - - -def IntegerDigits(f, c, solver_parameters, s): - base = int(f(s[2], c)) if len(s) > 2 else 10 - return ["Array"] + _digits_in_base(f(s[1], c), base) - - -def DigitCount(f, c, solver_parameters, s): - base = int(f(s[2], c)) if len(s) > 2 else 10 - return len(_digits_in_base(f(s[1], c), base)) - - -def DigitSum(f, c, solver_parameters, s): - base = int(f(s[2], c)) if len(s) > 2 else 10 - return sum(_digits_in_base(f(s[1], c), base)) - - -def FromDigits(f, c, solver_parameters, s): - digits = _arr_vals(f, c, solver_parameters, s) - base = int(f(s[2], c)) if len(s) > 2 else 10 - result = 0 - for d in digits: - result = result * base + int(d) - return result - - -def IsSquare(f, c, solver_parameters, s): - n = int(f(s[1], c)) - return n >= 0 and math.isqrt(n) ** 2 == n - - -def IsTriangular(f, c, solver_parameters, s): - return _is_figurate(f(s[1], c), lambda k: k * (k + 1) // 2, 0) - - -def IsPentagonal(f, c, solver_parameters, s): - return _is_figurate(f(s[1], c), lambda k: k * (3 * k - 1) // 2, 1) - - -def IsOctahedral(f, c, solver_parameters, s): - return _is_figurate(f(s[1], c), lambda k: k * (2 * k * k + 1) // 3, 1) - - -def IsCenteredSquare(f, c, solver_parameters, s): - return _is_figurate(f(s[1], c), lambda k: 2 * k * (k - 1) + 1, 1) - - -def IsPerfect(f, c, solver_parameters, s): - n = f(s[1], c) - return sum(_divisors(n)[:-1]) == n - - -def IsAbundant(f, c, solver_parameters, s): - n = f(s[1], c) - return sum(_divisors(n)[:-1]) > n - - -def IsHappy(f, c, solver_parameters, s): - n = int(f(s[1], c)) - seen = set() - while n != 1 and n not in seen: - seen.add(n) - n = sum(int(d) ** 2 for d in str(n)) - return n == 1 - - -# --- Special functions --- - - -def Beta(f, c, solver_parameters, s): - a, b = f(s[1], c), f(s[2], c) - return math.gamma(a) * math.gamma(b) / math.gamma(a + b) - - -def Factorial2(f, c, solver_parameters, s): - n = int(f(s[1], c)) - result = 1 - while n > 1: - result *= n - n -= 2 - return result - - -def ErfInv(f, c, solver_parameters, s): - return _erf_inv(f(s[1], c)) - - -def LambertW(f, c, solver_parameters, s): - return _lambert_w(f(s[1], c)) - - -def AGM(f, c, solver_parameters, s): - return _agm(f(s[1], c), f(s[2], c)) - - -def EllipticK(f, c, solver_parameters, s): - return _elliptic_k_e(f(s[1], c))[0] - - -def EllipticE(f, c, solver_parameters, s): - return _elliptic_k_e(f(s[1], c))[1] - - -# --- Combinatorics (trivial subset - no explosion risk) --- - - -def Fibonacci(f, c, solver_parameters, s): - n = int(f(s[1], c)) - a, b = 0, 1 - for _ in range(n): - a, b = b, a + b - return a - - -def Multinomial(f, c, solver_parameters, s): - ks = [int(k) for k in _arr_vals(f, c, solver_parameters, s)] - return math.factorial(sum(ks)) // math.prod(math.factorial(k) for k in ks) - - -def Subfactorial(f, c, solver_parameters, s): - return _subfactorial(f(s[1], c)) - - -def BellNumber(f, c, solver_parameters, s): - return _bell_number(f(s[1], c)) - - -# --- Combinatorics: enumeration (no output-size limit - see -# create_mathjson_solver's `blacklist` parameter to disable -# these for untrusted expression sources) --- - - -def PowerSet(f, c, solver_parameters, s): - """ - ["PowerSet", array] - All subsets of `array` (including the empty set and the - full set itself), as an array of arrays. No output-size - limit: a set of n elements has 2^n subsets. - """ - vals = _arr_vals(f, c, solver_parameters, s) - result = ["Array"] - for r in range(len(vals) + 1): - for combo in itertools.combinations(vals, r): - result.append(["Array"] + list(combo)) - return result - - -def Permutations(f, c, solver_parameters, s): - """ - ["Permutations", array] or ["Permutations", array, k] - All ordered arrangements of `k` elements from `array` - (default k = len(array)), as an array of arrays. No - output-size limit. - """ - vals = _arr_vals(f, c, solver_parameters, s) - k = int(f(s[2], c)) if len(s) > 2 else len(vals) - return ["Array"] + [["Array"] + list(p) for p in itertools.permutations(vals, k)] - - -def Combinations(f, c, solver_parameters, s): - """ - ["Combinations", array, k] - All unordered k-element subsets of `array`, as an array - of arrays. No output-size limit. - """ - vals = _arr_vals(f, c, solver_parameters, s) - k = int(f(s[2], c)) - return ["Array"] + [ - ["Array"] + list(combo) for combo in itertools.combinations(vals, k) - ] - - -def CartesianProduct(f, c, solver_parameters, s): - """ - ["CartesianProduct", array1, array2, ...] - All ordered tuples with one element drawn from each - array, as an array of arrays. No output-size limit: the - result has len(array1) * len(array2) * ... elements. - """ - lists = [_arr_vals_of(f, c, solver_parameters, arg) for arg in s[1:]] - return ["Array"] + [["Array"] + list(combo) for combo in itertools.product(*lists)] - - -# --- Core: structural introspection --- -# -# Unlike almost everything else on CortexJS's Core reference -# page (CAS functions, mutable state, LaTeX serialization - -# all out of scope, see docs), these don't need a type -# system or a rendering surface: they work directly on the -# raw, unevaluated MathJSON tree every construct already -# receives as `s`, the same mechanism RegExp already uses to -# peek at its pattern argument without evaluating it. - - -def Head(f, c, solver_parameters, s): - expr = s[1] - return expr[0] if isinstance(expr, list) and expr else None - - -def Tail(f, c, solver_parameters, s): - expr = s[1] - return ["Array"] + expr[1:] if isinstance(expr, list) else ["Array"] - - -def Hold(f, c, solver_parameters, s): - return s[1] - - -def Type(f, c, solver_parameters, s): - value = f(s[1], c) - if isinstance(value, bool): - return "boolean" - if isinstance(value, numbers.Number): - return "number" - if isinstance(value, str): - return "string" - if isinstance(value, list) and value and value[0] == "Array": - return "array" - if value is None: - return "nothing" - return type(value).__name__ - - -def IsSame(f, c, solver_parameters, s): - """ - ["IsSame", expr1, expr2] - Whether expr1 and expr2 are structurally identical *as - written* - same shape, literals, and order - compared - without evaluating either side. Distinct from `Equal`/ - `StrictEqual` (which compare evaluated *values*) and - from `IdenticallyEqual` (a stricter same-type - `StrictEqual`, also over evaluated values): this is a - pre-evaluation, syntactic check. CortexJS distinguishes - `IsSame` from `Same` by canonical-form normalization - (e.g. treating `x+y` and `y+x` as the same); this solver - has no such canonicalization step, so both map to the - same plain structural comparison here. - """ - return s[1] == s[2] - - -def Variance(f, c, solver_parameters, s): - return variance(_arr_vals(f, c, solver_parameters, s)) - - -def StandardDeviation(f, c, solver_parameters, s): - return stdev(_arr_vals(f, c, solver_parameters, s)) - - -def PopulationVariance(f, c, solver_parameters, s): - return pvariance(_arr_vals(f, c, solver_parameters, s)) - - -def PopulationStandardDeviation(f, c, solver_parameters, s): - return pstdev(_arr_vals(f, c, solver_parameters, s)) - - -def Mode(f, c, solver_parameters, s): - """ - ["Mode", array] - The most frequently occurring value. Ties go to whichever - value appears first in `array` (matching Python's - `statistics.mode`). - """ - return mode(_arr_vals(f, c, solver_parameters, s)) - - -def Quartiles(f, c, solver_parameters, s): - """ - ["Quartiles", array] - The three points (Q1, Q2/median, Q3) that divide `array` - into four equal-sized groups, using - `statistics.quantiles`' default ("exclusive") method. - """ - return ["Array"] + quantiles(_arr_vals(f, c, solver_parameters, s), n=4) - - -def InterquartileRange(f, c, solver_parameters, s): - """ - ["InterquartileRange", array] - Q3 - Q1. - """ - q1, _, q3 = quantiles(_arr_vals(f, c, solver_parameters, s), n=4) - return q3 - q1 - - -def Covariance(f, c, solver_parameters, s): - """ - ["Covariance", array1, array2] - Sample covariance of two equal-length collections. - """ - return covariance( - _arr_vals_of(f, c, solver_parameters, s[1]), - _arr_vals_of(f, c, solver_parameters, s[2]), - ) - - -def Correlation(f, c, solver_parameters, s): - """ - ["Correlation", array1, array2] - The Pearson correlation coefficient of two equal-length - collections. - """ - return correlation( - _arr_vals_of(f, c, solver_parameters, s[1]), - _arr_vals_of(f, c, solver_parameters, s[2]), - ) - - -def Skewness(f, c, solver_parameters, s): - """ - ["Skewness", array] - Sample skewness (the adjusted Fisher-Pearson standardized - moment coefficient - matches Excel's SKEW and - scipy.stats.skew(..., bias=False)): a measure of the - asymmetry of the data's distribution. Requires at least - 3 data points. - """ - vals = _arr_vals(f, c, solver_parameters, s) - n = len(vals) - if n < 3: - raise ValueError("'Skewness' requires at least 3 data points.") - m = sum(vals) / n - s_dev = stdev(vals) - if s_dev == 0: - raise ValueError("'Skewness' is undefined when all values are equal.") - m3 = sum((x - m) ** 3 for x in vals) / n - return (n**2 / ((n - 1) * (n - 2))) * m3 / s_dev**3 - - -def Kurtosis(f, c, solver_parameters, s): - """ - ["Kurtosis", array] - Sample excess kurtosis (the adjusted Fisher-Pearson - estimator - matches Excel's KURT and - scipy.stats.kurtosis(..., bias=False, fisher=True)): a - measure of the "tailedness" of the data's distribution - (0 for a normal distribution). Requires at least 4 data - points. - """ - vals = _arr_vals(f, c, solver_parameters, s) - n = len(vals) - if n < 4: - raise ValueError("'Kurtosis' requires at least 4 data points.") - m = sum(vals) / n - m2 = sum((x - m) ** 2 for x in vals) / n - m4 = sum((x - m) ** 4 for x in vals) / n - if m2 == 0: - raise ValueError("'Kurtosis' is undefined when all values are equal.") - g2 = m4 / m2**2 - 3 - return ((n - 1) / ((n - 2) * (n - 3))) * ((n + 1) * g2 + 6) - - -def LinearRegression(f, c, solver_parameters, s): - """ - ["LinearRegression", x_array, y_array] - The least-squares linear fit y = slope*x + intercept, - returned as ["Array", slope, intercept]. - """ - x = _arr_vals_of(f, c, solver_parameters, s[1]) - y = _arr_vals_of(f, c, solver_parameters, s[2]) - if len(x) != len(y): - raise ValueError("Both arrays must be of the same length.") - n = len(x) - if n < 2: - raise ValueError("'LinearRegression' requires at least 2 data points.") - mx, my = sum(x) / n, sum(y) / n - sxy = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y)) - sxx = sum((xi - mx) ** 2 for xi in x) - if sxx == 0: - raise ValueError("'LinearRegression' is undefined when all x values are equal.") - slope = sxy / sxx - intercept = my - slope * mx - return ["Array", slope, intercept] - - -def _solve_linear_system(f, c, solver_parameters, matrix, vector): - """ - Solves `matrix @ x = vector` via Gaussian elimination - with partial pivoting - a pure-Python solver (no numpy - dependency) for the small, dense normal-equations system - `PolynomialFit` builds. Less numerically stable than a - QR-based solver for high degrees or badly-scaled data, - but exact enough for the modest degrees this is capped - at. Raises ValueError if the system is singular. - """ - n = len(matrix) - rows = [list(row) + [vector[i]] for i, row in enumerate(matrix)] - for col in range(n): - pivot = max(range(col, n), key=lambda r: abs(rows[r][col])) - if abs(rows[pivot][col]) < 1e-12: - raise ValueError( - "System is singular - check for duplicate x values " - "or too few distinct points for the requested degree." - ) - rows[col], rows[pivot] = rows[pivot], rows[col] - for r in range(n): - if r != col: - factor = rows[r][col] / rows[col][col] - for cc in range(col, n + 1): - rows[r][cc] -= factor * rows[col][cc] - return [rows[i][n] / rows[i][i] for i in range(n)] - - -def PolynomialFit(f, c, solver_parameters, s): - """ - ["PolynomialFit", x_array, y_array, degree] - The least-squares polynomial fit of the given `degree` - (0 to _MAX_POLYFIT_DEGREE), via the normal equations - solved with Gaussian elimination - no numpy dependency, - but less numerically stable for high degrees or - badly-scaled x values than a QR-based solver (e.g. - numpy.polyfit) would be; keep degrees modest for - well-conditioned results. Returns - ["Array", c0, c1, ..., cd] representing - `c0 + c1*x + c2*x^2 + ... + cd*x^d` (lowest degree - first - the opposite order from numpy.polyfit). - """ - x = _arr_vals_of(f, c, solver_parameters, s[1]) - y = _arr_vals_of(f, c, solver_parameters, s[2]) - if len(x) != len(y): - raise ValueError("Both arrays must be of the same length.") - degree = int(f(s[3], c)) - if not (0 <= degree <= _MAX_POLYFIT_DEGREE): - raise ValueError( - f"'PolynomialFit' degree must be between 0 and " f"{_MAX_POLYFIT_DEGREE}." - ) - if len(x) < degree + 1: - raise ValueError("'PolynomialFit' needs at least degree + 1 data points.") - power_sums = [sum(xi**k for xi in x) for k in range(2 * degree + 1)] - matrix = [[power_sums[i + j] for j in range(degree + 1)] for i in range(degree + 1)] - vector = [sum((xi**k) * yi for xi, yi in zip(x, y)) for k in range(degree + 1)] - return ["Array"] + _solve_linear_system(f, c, solver_parameters, matrix, vector) - - -def Any(f, c, solver_parameters, s): - evaluated = f(s[1], c) - if isinstance(evaluated, list) and evaluated[0] == "Array": - return any([f(x, c) for x in evaluated[1:]]) - raise ValueError("Parameter 1 must be an array.") - - -def All(f, c, solver_parameters, s): - evaluated = f(s[1], c) - if isinstance(evaluated, list) and evaluated[0] == "Array": - return all([f(x, c) for x in evaluated[1:]]) - raise ValueError("Parameter 1 must be an array.") - - -def Int(f, c, solver_parameters, s): - try: - return int(f(s[1], c)) - except ValueError: - return int(float(f(s[1], c))) - - -def Float(f, c, solver_parameters, s): - return float(f(s[1], c)) - - -def Floor(f, c, solver_parameters, s): - return math.floor(f(s[1], c)) - - -def Ceil(f, c, solver_parameters, s): - return math.ceil(f(s[1], c)) - - -def Constants(f, c, solver_parameters, s): - for x in s[1:-1]: - try: - c[x[0]] = f(x[1], c) - except Exception: - c[x[0]] = None return f(s[-1], c) -def Switch(f, c, solver_parameters, s): - expression = f(s[1], c) - for x in s[3:]: - if len(x) != 2: - raise ValueError("Case of 'Switch' should have exactly two parameters") - if comparison_safe_converter(expression) == comparison_safe_converter( - f(x[0], c) - ): - return f(x[1], c) - else: - return f(s[2], c) - - -def StrictSwitch(f, c, solver_parameters, s): - expression = f(s[1], c) - for x in s[3:]: - if len(x) != 2: - raise ValueError( - "Case of 'StrictSwitch' should have exactly two parameters" - ) - if expression == f(x[0], c): - return f(x[1], c) - else: - return f(s[2], c) - - -def If(f, c, solver_parameters, s): - if len(s) < 3: - raise ValueError("Wrong parameters for 'If'") - - # Detect the CortexJS flat form: ["If", cond, then] or - # ["If", cond, then, else]. In the Python pair-form below, - # s[1] is always a [condition, value] pair whose first - # element (the condition) is itself a MathJSON construct - # call, e.g. ["Equal", 1, 0]. A CortexJS flat condition is - # either not a list at all, or is itself such a construct - # call (its own first element is a *known construct name*). - # Requiring a known name - rather than any string - keeps a - # Python-form condition that is a bare parameter reference, - # e.g. ["If", ["my_flag", "yes"], "no"], from being - # misdetected as CortexJS form. - is_cortexjs_form = not isinstance(s[1], list) or ( - bool(s[1]) and isinstance(s[1][0], str) and s[1][0] in constructs - ) - - if is_cortexjs_form: - if len(s) not in (3, 4): - raise ValueError("Wrong parameters for 'If'") - if f(s[1], c): - return f(s[2], c) - elif len(s) == 4: - return f(s[3], c) - else: - return None # CortexJS: Nothing, no else and condition false - - for x in s[1:-1]: - if len(x) != 2: - raise ValueError("Wrong if or elif in 'If'") - try: - if f(x[0], c): - try: - return f(x[1], c) - except MathJSONException: - # Branch failed, try next condition - continue - except MathJSONException: - return f(s[-1], c) # return default value (else) - - return f(s[-1], c) - - -def Which(f, c, solver_parameters, s): - """ - ["Which", cond1, expr1, cond2, expr2, ..., condN, exprN] - CortexJS multi-branch conditional: evaluates each `cond` in - order and returns the `expr` paired with the first truthy - one. Unlike 'Switch' (Python's value-equality dispatch), - each `cond` here is itself a boolean expression, not a - value to compare against - and unlike `If`'s pair form, - the pairs are flat (cond, expr as separate arguments, not - nested as [cond, expr]). Returns None (CortexJS `Nothing`) - if no condition matches. - """ - args = s[1:] - if len(args) % 2 != 0: - raise ValueError( - "'Which' requires an even number of parameters " "(condition, value, ...)" - ) - for i in range(0, len(args), 2): - if f(args[i], c): - return f(args[i + 1], c) - return None - - -def In(f, c, solver_parameters, s): - if len(s) != 3: - raise ValueError("Wrong parameters for 'In'") - if isinstance(s[2], list) and s[2][0] == "Array": - return f(s[1], c) in [f(x, c) for x in s[2][1:]] - - elif isinstance(s[2], str): - return f(s[1], c) in f(s[2], c) - else: - raise ValueError("Wrong parameters for 'In'. Parameter 2 must be a list.") - - -def Not_in(f, c, solver_parameters, s): - return not In(f, c, solver_parameters, s) - - -def Contains_any_of(f, c, solver_parameters, s): - if isinstance(s[1], list) and s[1][0] == "Array": - list1 = [f(x, c) for x in s[1][1:]] - elif isinstance(s[1], str): - list1 = f(s[1], c) - - if isinstance(s[2], list) and s[2][0] == "Array": - list2 = [f(x, c) for x in s[2][1:]] - elif isinstance(s[2], str): - list2 = f(s[2], c) - - if any(x in list1 for x in list2): - return True - return False - - -def Contains_all_of(f, c, solver_parameters, s): - if isinstance(s[1], list) and s[1][0] == "Array": - list1 = [f(x, c) for x in s[1][1:]] - elif isinstance(s[1], str): - list1 = f(s[1], c) - - if isinstance(s[2], list) and s[2][0] == "Array": - list2 = [f(x, c) for x in s[2][1:]] - elif isinstance(s[2], str): - list2 = f(s[2], c) - - if all(x in list1 for x in list2): - return True - return False - - -def Contains_none_of(f, c, solver_parameters, s): - return not Contains_any_of(f, c, solver_parameters, s) - - -def Str(f, c, solver_parameters, s): - if len(s) < 2: - raise ValueError("Wrong parameters for 'Str'") - return f"{f(s[1])}" - - -def String(f, c, solver_parameters, s): - """ - ["String", value1, value2, ...] - Concatenates the default string representation of each - argument. Distinct from `Str` (single argument) and from - `StringJoin` (joins the elements of a single array). - """ - return "".join(str(f(x, c)) for x in s[1:]) - - -def StringJoin(f, c, solver_parameters, s): - """ - ["StringJoin", array] or ["StringJoin", array, separator] - Joins the (stringified) elements of `array` with - `separator` (default ""). Distinct from `Join`, which - concatenates multiple arrays together. - """ - values = [str(v) for v in _arr_vals(f, c, solver_parameters, s)] - separator = f(s[2], c) if len(s) > 2 else "" - return separator.join(values) - - -def Utf8(f, c, solver_parameters, s): - """ - ["Utf8", string] - List of UTF-8 byte values representing `string`. - """ - return ["Array"] + list(f(s[1], c).encode("utf-8")) - - -def Utf16(f, c, solver_parameters, s): - """ - ["Utf16", string] - List of UTF-16 code units representing `string` (one - array element per 16-bit code unit, matching how - JavaScript/CortexJS model a string). - """ - encoded = f(s[1], c).encode("utf-16-le") - return ["Array"] + [ - encoded[i] | (encoded[i + 1] << 8) for i in range(0, len(encoded), 2) - ] - - -def UnicodeScalars(f, c, solver_parameters, s): - """ - ["UnicodeScalars", string] - List of Unicode scalar (code point) values in `string`. - """ - return ["Array"] + [ord(ch) for ch in f(s[1], c)] - - -def StringFrom(f, c, solver_parameters, s): - """ - ["StringFrom", array, encoding] - Converts `array` (a list of code units, as produced by - `Utf8`/`Utf16`/`UnicodeScalars`) back into a string. - `encoding` is one of "utf-8", "utf-16", "unicode-scalars". - """ - values = _arr_vals(f, c, solver_parameters, s) - encoding = f(s[2], c) - if encoding == "utf-8": - return bytes(int(v) for v in values).decode("utf-8") - elif encoding == "utf-16": - raw = b"".join(int(v).to_bytes(2, "little") for v in values) - return raw.decode("utf-16-le") - elif encoding == "unicode-scalars": - return "".join(chr(int(v)) for v in values) - else: - raise ValueError(f"Unknown encoding: {encoding!r}") - - -def Characters(f, c, solver_parameters, s): - """ - ["Characters", string] - Splits `string` into a list of its characters. Approximated - at the Unicode code-point level (Python `str` iteration) - rather than true extended grapheme clusters (Unicode - Annex #29), which would need a dependency this solver - doesn't otherwise require - a multi-codepoint grapheme - (e.g. an emoji with a modifier) is split into its - constituent code points rather than kept whole. - """ - return ["Array"] + list(f(s[1], c)) - - -def StringSplit(f, c, solver_parameters, s): - """ - ["StringSplit", string] or ["StringSplit", string, separator] - Splits on whitespace if no `separator` is given, else on - the literal `separator` string. - """ - string = f(s[1], c) - if len(s) > 2: - return ["Array"] + string.split(f(s[2], c)) - return ["Array"] + string.split() - - -def StringReplace(f, c, solver_parameters, s): - """ - ["StringReplace", string, target, replacement] - Replaces every occurrence of the literal substring - `target` with `replacement` (not pattern-based - see - `IsMatch`/`StringMatch` for pattern matching). - """ - return f(s[1], c).replace(f(s[2], c), f(s[3], c)) - - -def StringCompare(f, c, solver_parameters, s): - """ - ["StringCompare", string1, string2] - -1, 0, or 1 depending on whether `string1` sorts before, - equal to, or after `string2` by code-point sequence. - """ - a, b = f(s[1], c), f(s[2], c) - return (a > b) - (a < b) - - -def IntegerString(f, c, solver_parameters, s): - """ - ["IntegerString", integer] or ["IntegerString", integer, base] - String representation of `integer` in `base` (default 10, - 2-36). - """ - value = int(f(s[1], c)) - base = int(f(s[2], c)) if len(s) > 2 else 10 - if base == 10: - return str(value) - if not (2 <= base <= 36): - raise ValueError("'IntegerString' base must be between 2 and 36.") - digits = "0123456789abcdefghijklmnopqrstuvwxyz" - negative = value < 0 - value = abs(value) - if value == 0: - digits_out = "0" - else: - digits_out = "" - while value: - value, rem = divmod(value, base) - digits_out = digits[rem] + digits_out - return ("-" if negative else "") + digits_out - - -def DigitsFrom(f, c, solver_parameters, s): - """ - ["DigitsFrom", string] or ["DigitsFrom", string, base] - Parses `string` as an integer in `base` (default 10). - """ - string = f(s[1], c) - base = int(f(s[2], c)) if len(s) > 2 else 10 - return int(string, base) - - -def NumberFrom(f, c, solver_parameters, s): - """ - ["NumberFrom", string] - Parses `string` as a number - integer, decimal, or - scientific notation. - """ - string = f(s[1], c).strip() - try: - return int(string) - except ValueError: - return float(string) - - -def StringRepeat(f, c, solver_parameters, s): - """ - ["StringRepeat", string, n] - Concatenates `n` copies of `string`. Capped at - `_MAX_STRING_REPEAT_LENGTH` characters, since `n` is a - user-controlled value that would otherwise make this an - unbounded memory-exhaustion primitive. - """ - string = f(s[1], c) - n = int(f(s[2], c)) - if n < 0: - raise ValueError("'StringRepeat' count must be non-negative.") - if len(string) * n > _MAX_STRING_REPEAT_LENGTH: - raise ValueError( - f"'StringRepeat' result would exceed the " - f"{_MAX_STRING_REPEAT_LENGTH}-character limit." - ) - return string * n - - -def _pad(f, c, solver_parameters, string, length, pad, prepend): - if length > _MAX_STRING_REPEAT_LENGTH: - raise ValueError( - f"Pad length must not exceed " f"{_MAX_STRING_REPEAT_LENGTH} characters." - ) - if len(string) >= length or not pad: - return string - needed = length - len(string) - full_pad = (pad * (needed // len(pad) + 1))[:needed] - return full_pad + string if prepend else string + full_pad - - -def PadStart(f, c, solver_parameters, s): - """ - ["PadStart", string, length] or - ["PadStart", string, length, pad] - Pads `string` on the left to `length` characters using - `pad` (default a single space), truncating `pad` as - needed to fit exactly. - """ - pad = f(s[3], c) if len(s) > 3 else " " - return _pad(f, c, solver_parameters, f(s[1], c), int(f(s[2], c)), pad, prepend=True) - - -def PadEnd(f, c, solver_parameters, s): - """ - ["PadEnd", string, length] or ["PadEnd", string, length, pad] - Pads `string` on the right to `length` characters using - `pad` (default a single space), truncating `pad` as - needed to fit exactly. - """ - pad = f(s[3], c) if len(s) > 3 else " " - return _pad( - f, c, solver_parameters, f(s[1], c), int(f(s[2], c)), pad, prepend=False - ) - - -def _require_re2(f, c, solver_parameters): - if not RE2_AVAILABLE: - raise ImportError( - "RegExp/IsMatch/StringMatch/StringMatchAll require " - "'google-re2'. Install with " - "'pip install mathjson-solver[regex]' " - "(or 'pip install google-re2')." - ) - - -def _compile_regex(f, c, solver_parameters, pattern_str, flags_str=""): - if not set(flags_str) <= _VALID_REGEX_FLAGS: - raise ValueError( - f"Unsupported regex flag(s) in {flags_str!r}; only " - f"'i', 'm', 's' are supported." - ) - prefixed = f"(?{flags_str})" + pattern_str if flags_str else pattern_str - try: - return re2.compile(prefixed) - except re2.error as e: - raise ValueError(f"Invalid pattern {pattern_str!r}: {e}") from e - - -def _resolve_pattern(f, c, solver_parameters, expr): - """ - Evaluates `expr` and returns a compiled RE2 pattern - - unchanged if it's already one (e.g. produced by a nested - `RegExp` call), else compiled fresh (no flags) if it's a - plain string. - - Uses RE2 rather than Python's `re`: RE2 guarantees - linear-time matching (no catastrophic backtracking is - possible, by construction of the engine), at the cost of - not supporting backreferences or lookaround - a - deliberate trade-off for a solver that evaluates - untrusted expressions with no execution budget of its own. - """ - _require_re2(f, c, solver_parameters) - value = f(expr, c) - if isinstance(value, _RE2_PATTERN_TYPE): - return value - if isinstance(value, str): - return _compile_regex(f, c, solver_parameters, value) - raise ValueError("Expected a pattern string or a RegExp value.") - - -def RegExp(f, c, solver_parameters, s): - """ - ["RegExp", pattern] or ["RegExp", pattern, flags] - Compiles `pattern` (RE2 syntax - see `_resolve_pattern`) - into a reusable pattern value, for passing to - `IsMatch`/`StringMatch`/`StringMatchAll`. `flags` is a - string made up of "i" (case-insensitive), "m" (multiline - anchors), "s" (dot matches newline). - """ - _require_re2(f, c, solver_parameters) - pattern_str = f(s[1], c) - flags_str = f(s[2], c) if len(s) > 2 else "" - return _compile_regex(f, c, solver_parameters, pattern_str, flags_str) - - -def IsMatch(f, c, solver_parameters, s): - """ - ["IsMatch", string, pattern] - Whether `string` contains a match for `pattern` (a plain - pattern string, or a `RegExp` value) anywhere within it. - """ - pattern = _resolve_pattern(f, c, solver_parameters, s[2]) - return pattern.search(f(s[1], c)) is not None - - -def _match_record(f, c, solver_parameters, m): - return [ - "Array", - m.group(0), - m.start() + 1, # 1-indexed, matching this solver's - m.end(), # other CortexJS-compat index - # conventions (At, Range, ...) - ["Array"] + list(m.groups()), - ] - - -def StringMatch(f, c, solver_parameters, s): - """ - ["StringMatch", string, pattern] - The first match of `pattern` in `string`, as - ["Array", matched_text, start, end, ["Array", group1, ...]] - (1-indexed `start`, exclusive `end`), or None if there's - no match. - """ - pattern = _resolve_pattern(f, c, solver_parameters, s[2]) - m = pattern.search(f(s[1], c)) - return _match_record(f, c, solver_parameters, m) if m else None - - -def StringMatchAll(f, c, solver_parameters, s): - """ - ["StringMatchAll", string, pattern] - All non-overlapping matches of `pattern` in `string`, - each in the same shape as `StringMatch`. - """ - pattern = _resolve_pattern(f, c, solver_parameters, s[2]) - return ["Array"] + [ - _match_record(f, c, solver_parameters, m) for m in pattern.finditer(f(s[1], c)) - ] - - -def Not(f, c, solver_parameters, s): - return not f(s[1]) - - -def _apply_fn(f, c, solver_parameters, fn_expr, args): - """ - Apply a "function" argument (as used by Map, Filter, and the - CortexJS form of Reduce) to positional `args`. Supports two - conventions: - - - CortexJS `["Function", body, param1, param2, ...]`. If no - parameter names are given, `args` are bound to the - anonymous placeholders "_" (only when there is a single - argument) and "_1", "_2", ... (always), for use inside - `body`. - - The existing "call template" convention: - `[function_name, ...]`, applied as - `f([function_name] + args, c)`. - """ - if isinstance(fn_expr, list) and fn_expr and fn_expr[0] == "Function": - body = fn_expr[1] - params = fn_expr[2:] - local_c = dict(c) - if params: - for name, value in zip(params, args): - local_c[name] = value - else: - if len(args) == 1: - local_c["_"] = args[0] - for i, value in enumerate(args, start=1): - local_c[f"_{i}"] = value - return f(body, local_c) - elif isinstance(fn_expr, list) and fn_expr: - function_name = fn_expr[0] - return f([function_name] + list(args), c) - else: - raise ValueError( - "Wrong function parameter: expected a call template " - "(e.g. ['Square']) or a ['Function', body, ...] expression." - ) - - -def Map(f, c, solver_parameters, s): - """ - ["Map", list, function, more parameters] - The `function` must accept at least one parameter. That is for the current loop element. - The `more parameters` are for any additional parameters that function might have. - `function` can also be a CortexJS ["Function", body, ...params] expression. - """ - z = f(s[1], c) - if isinstance(z, list): - retlist = ["Array"] - for x in z[1:]: - try: - retlist.append(_apply_fn(f, c, solver_parameters, s[2], [x] + s[3:])) - except MathJSONException: - retlist.append(x) - return retlist - - -def StrictMap(f, c, solver_parameters, s): - """ - ["Map", list, function, more parameters] - The `function` must accept at least one parameter. That is for the current loop element. - The `more parameters` are for any additional parameters that function might have. - `function` can also be a CortexJS ["Function", body, ...params] expression. - """ - z = f(s[1], c) - if isinstance(z, list): - retlist = ["Array"] - for x in z[1:]: - retlist.append(_apply_fn(f, c, solver_parameters, s[2], [x] + s[3:])) - return retlist - - -def Filter(f, c, solver_parameters, s): - """ - ["Filter", list, function, more parameters] - The `function` must accept at least one parameter. That is for the current loop element. - The `more parameters` are for any additional parameters that function might have. - `function` can also be a CortexJS ["Function", body, ...params] expression. - """ - z = f(s[1], c) - if isinstance(z, list): - retlist = ["Array"] - for x in z[1:]: - if _apply_fn(f, c, solver_parameters, s[2], [x] + s[3:]): - retlist.append(x) - return retlist - - -def HasMatchingSublist(f, c, solver_parameters, s): - """ - ["HasMatchingSublist", list, required_match_count, position, contiguous, function, more parameters] - """ - the_list = f(s[1], c)[1:] - required_match_count = f(s[2], c) - position = f(s[3], c) - contiguous = f(s[4], c) - conditions = [] - - for i, x in enumerate(the_list): - the_function_name = s[5][0] - ss = [the_function_name, x] + s[6:] - conditions.append(f(ss, c)) - pass - - return has_matching_sublist( - my_list=the_list, - required_match_count=required_match_count, - position=position, - contiguous=contiguous, - conditions=conditions, - ) - - -def Strptime(f, c, solver_parameters, s): - datetime_str = f(s[1], c) - parameters = f(s[2], c) - return datetime.datetime.strptime(datetime_str, parameters).isoformat() - - -def Strftime(f, c, solver_parameters, s): - dt = _try_parse_datetime(f(s[1], c)) - if not isinstance(dt, (datetime.datetime, datetime.date)): - raise ValueError(f"Strftime: could not parse input as datetime: {dt!r}") - parameters = f(s[2], c) - return dt.strftime(parameters) - - -def Now(f, c, solver_parameters, s): - return datetime.datetime.now().isoformat() - - -def Today(f, c, solver_parameters, s): - return datetime.date.today().isoformat() - - -def TimeDeltaDays(f, c, solver_parameters, s): - return datetime.timedelta(days=f(s[1], c)) - - -def TimeDeltaMinutes(f, c, solver_parameters, s): - return datetime.timedelta(minutes=f(s[1], c)) - - -def TimeDeltaHours(f, c, solver_parameters, s): - return datetime.timedelta(hours=f(s[1], c)) - - -def TimeDeltaWeeks(f, c, solver_parameters, s): - return datetime.timedelta(weeks=f(s[1], c)) - - -# def Equal(s): -# a = comparison_safe_converter(f(s[1], c)) -# b = comparison_safe_converter(f(s[2], c)) -# return a == b -# if type(a) in [int, float, str]: -# a = f"{a}" -# elif type(a) in [bool, NoneType]: -# if a: -# a = True -# else: -# a = None - -# if is_numeric(a): - -# lambda s: f"{f(s[1], c)}" == f"{f(s[2], c)}", - - -def IsDefined(f, c, solver_parameters, s): - return s[1] in solver_parameters or s[1] in c - - -def IsUndefined(f, c, solver_parameters, s): - return not IsDefined(f, c, solver_parameters, s) - - -def Greater(f, c, solver_parameters, s): - v1, v2 = comparison_safe_converter_for_pairs(f(s[1], c), f(s[2], c)) - try: - return v1 > v2 - except TypeError: - return False - - -def GreaterEqual(f, c, solver_parameters, s): - v1, v2 = comparison_safe_converter_for_pairs(f(s[1], c), f(s[2], c)) - try: - return v1 >= v2 - except TypeError: - return False - - -def Less(f, c, solver_parameters, s): - v1, v2 = comparison_safe_converter_for_pairs(f(s[1], c), f(s[2], c)) - try: - return v1 < v2 - except TypeError: - return False - - -def LessEqual(f, c, solver_parameters, s): - v1, v2 = comparison_safe_converter_for_pairs(f(s[1], c), f(s[2], c)) - try: - return v1 <= v2 - except TypeError: - return False - - -def IdenticallyEqual(f, c, solver_parameters, s): - """ - ["IdenticallyEqual", a, b] - Stricter than `StrictEqual`: true only if `a` and `b` have - the same Python type *and* are equal - e.g. `1` and `1.0` - are `StrictEqual` but not `IdenticallyEqual`. - """ - a = f(s[1], c) - b = f(s[2], c) - return type(a) == type(b) and a == b - - -def Congruent(f, c, solver_parameters, s): - """ - ["Congruent", a, b, modulus] - Whether `a` and `b` are congruent modulo `modulus`, i.e. - `(a - b) % modulus == 0`. - """ - a = f(s[1], c) - b = f(s[2], c) - modulus = f(s[3], c) - return (a - b) % modulus == 0 - - -def _rational_parts(f, c, solver_parameters, s): - """ - Numerator/denominator pair for `s[1]`. If `s[1]` is itself - an unevaluated `["Rational", n, d]` expression, its - declared `n`/`d` are used exactly. Otherwise, the - evaluated value is reconstructed as the closest fraction - with a bounded denominator - a best-effort approximation, - not exact/symbolic, since this solver has no dedicated - rational-number type carried through arithmetic (see - `Rational`'s docstring). - """ - expr = s[1] - if isinstance(expr, list) and len(expr) == 3 and expr[0] == "Rational": - return int(f(expr[1], c)), int(f(expr[2], c)) - value = f(expr, c) - if isinstance(value, int): - return value, 1 - frac = Fraction(value).limit_denominator(10**6) - return frac.numerator, frac.denominator - - -def Numerator(f, c, solver_parameters, s): - return _rational_parts(f, c, solver_parameters, s)[0] - - -def Denominator(f, c, solver_parameters, s): - return _rational_parts(f, c, solver_parameters, s)[1] - - -def Rational(f, c, solver_parameters, s): - """ - ["Rational", numerator, denominator] - Evaluates to `numerator / denominator` as a plain float - - there's no exact rational-number type carried through - arithmetic in this solver, so precision beyond a float is - only preserved when `Numerator`/`Denominator` read this - expression directly, rather than its evaluated value. - """ - return f(s[1], c) / f(s[2], c) - - -def BooleanAnd(f, c, solver_parameters, s): - """ - Boolean AND operation. - ["And", condition1, condition2, ...] - """ - for x in s[1:]: - if not f(x, c): - return False - return True - - -def BooleanOr(f, c, solver_parameters, s): - """ - Boolean OR operation. - ["Or", condition1, condition2, ...] - """ - for x in s[1:]: - if f(x, c): - return True - return False - - -# sin, cos, tan, arcsin, arccos, arctan -def Sin(f, c, solver_parameters, s): - return math.sin(f(s[1], c)) - - -def Arcsin(f, c, solver_parameters, s): - return math.asin(f(s[1], c)) - - -def Cos(f, c, solver_parameters, s): - return math.cos(f(s[1], c)) - - -def Arccos(f, c, solver_parameters, s): - return math.acos(f(s[1], c)) - - -def Tan(f, c, solver_parameters, s): - return math.tan(f(s[1], c)) - - -def Arctan(f, c, solver_parameters, s): - return math.atan(f(s[1], c)) - - -def Pi(f, c, solver_parameters, s): - return math.pi - - -def Variable(f, c, solver_parameters, s): - """ - ["Variable", variable_name] - The `variable_name` must be a string. - """ - variable_name = s[1] - if variable_name in c: - return f(c[variable_name], c) - else: - raise KeyError(f"Variable '{variable_name}' is not defined") - - -def Function(f, c, solver_parameters, s): - """ - ["Function", body_expression, param_name1, param_name2, ...] - Defines a CortexJS-style lambda: `body_expression` is evaluated - with the given parameter names bound to whatever arguments it - is called with. `Function` expressions are meant to be passed - as the `function` argument of Map, Filter, and Reduce; if no - parameter names are given, the anonymous placeholders "_", - "_1", "_2", ... are used instead (see those functions). - Evaluating a ["Function", ...] expression outside of such a - context just returns it unevaluated. - """ - return s - - -def MultiplyByScalar(f, c, solver_parameters, s): - """ - ["MultiplyByScalar", array, scalar] - The `array` must be an array of numeric values. - The `scalar` is the number to multiply each element by. - """ - array = f(s[1], c) - scalar = f(s[2], c) - if not (isinstance(array, list) and array[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - array = [f(x, c) for x in array[1:]] - return ["Array"] + _MultiplyByScalar(array, scalar) - - -def MultiplyByArray(f, c, solver_parameters, s): - """ - ["MultiplyByArray", array1, array2] - The `array1` and `array2` must be arrays of the same length. - """ - array1 = f(s[1], c) - array2 = f(s[2], c) - if not (isinstance(array1, list) and array1[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - if not (isinstance(array2, list) and array2[0] == "Array"): - raise ValueError("Parameter 2 must be an array.") - array1 = [f(x, c) for x in array1[1:]] - array2 = [f(x, c) for x in array2[1:]] - if len(array1) != len(array2): - raise ValueError("Both arrays must be of the same length.") - return ["Array"] + _MultiplyByArray(array1, array2) - - -def AddScalar(f, c, solver_parameters, s): - """ - ["AddScalar", array, scalar] - The `array` must be an array of numeric values. - The `scalar` is the number to add to each element. - """ - array = f(s[1], c) - scalar = f(s[2], c) - if not (isinstance(array, list) and array[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - array = [f(x, c) for x in array[1:]] - return ["Array"] + _AddScalar(array, scalar) - - -def SubtractScalar(f, c, solver_parameters, s): - """ - ["SubtractScalar", array, scalar] - The `array` must be an array of numeric values. - The `scalar` is the number to subtract from each element. - """ - array = f(s[1], c) - scalar = f(s[2], c) - if not (isinstance(array, list) and array[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - array = [f(x, c) for x in array[1:]] - return ["Array"] + _SubtractScalar(array, scalar) - - -def AddArray(f, c, solver_parameters, s): - """ - ["AddArray", array1, array2] - The `array1` and `array2` must be arrays of the same length. - """ - array1 = f(s[1], c) - array2 = f(s[2], c) - if not (isinstance(array1, list) and array1[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - if not (isinstance(array2, list) and array2[0] == "Array"): - raise ValueError("Parameter 2 must be an array.") - array1 = [f(x, c) for x in array1[1:]] - array2 = [f(x, c) for x in array2[1:]] - if len(array1) != len(array2): - raise ValueError("Both arrays must be of the same length.") - return ["Array"] + _AddArray(array1, array2) - - -def SubtractArray(f, c, solver_parameters, s): - """ - ["SubtractArray", array1, array2] - The `array1` and `array2` must be arrays of the same length. - """ - array1 = f(s[1], c) - array2 = f(s[2], c) - if not (isinstance(array1, list) and array1[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - if not (isinstance(array2, list) and array2[0] == "Array"): - raise ValueError("Parameter 2 must be an array.") - array1 = [f(x, c) for x in array1[1:]] - array2 = [f(x, c) for x in array2[1:]] - if len(array1) != len(array2): - raise ValueError("Both arrays must be of the same length.") - return ["Array"] + _SubtractArray(array1, array2) - - -def GenerateRange(f, c, solver_parameters, s): - """ - ["GenerateRange", end] - or - ["GenerateRange", start, end, step] - The `start`, `end`, and `step` are numeric values. - """ - if len(s) == 2: - end = f(s[1], c) - start = 0 - step = 1 - elif len(s) == 4: - start = f(s[1], c) - end = f(s[2], c) - step = f(s[3], c) - else: - raise ValueError( - "GenerateRange requires either 1 or 3 parameters (end or start, end, step)." - ) - if step == 0: - raise ValueError("Step cannot be zero.") - if (start < end and step < 0) or (start > end and step > 0): - raise ValueError("Step direction is incorrect for the given range.") - result = ["Array"] - if start < end: - current = start - while current < end: - result.append(current) - current += step - else: - current = start - while current > end: - result.append(current) - current += step - return result - - -def AtIndex(f, c, solver_parameters, s): - """ - ["AtIndex", array, index] - The `array` must be an array of values. - The `index` is the index of the element to retrieve. - """ - array = f(s[1], c) - index = f(s[2], c) - if not (isinstance(array, list) and array[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - # array = [f(x, c) for x in array[1:]] - # return array[index] - array = [x for x in array[1:]] - return f(array[index], c) - - -def Slice(f, c, solver_parameters, s): - """ - ["Slice", array, start, end] - The `array` must be an array of values. - The `start` and `end` are the slice indices. - """ - array = f(s[1], c) - start = f(s[2], c) - end = f(s[3], c) - if not (isinstance(array, list) and array[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - array = [f(x, c) for x in array[1:]] - return ["Array"] + array[start:end] - - -def CumulativeProduct(f, c, solver_parameters, s): - """ - ["CumulativeProduct", array] - The `array` must be an array of numeric values. - """ - array = f(s[1], c) - if not (isinstance(array, list) and array[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - array = [f(x, c) for x in array[1:]] - return ["Array"] + _CumulativeProduct(array) - # return _CumulativeProduct(array) - - -def CumulativeSum(f, c, solver_parameters, s): - """ - ["CumulativeSum", array] - The `array` must be an array of numeric values. - """ - array = f(s[1], c) - if not (isinstance(array, list) and array[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - array = [f(x, c) for x in array[1:]] - return ["Array"] + _CumulativeSum(array) - # return - - -def Interp(f, c, solver_parameters, s): - """ - ["Interp", x_array, y_array, target_x] - The `x_array` and `y_array` must be arrays of the same length. - The `target_x` is the x value to interpolate for. - """ - x_array = f(s[1], c) - y_array = f(s[2], c) - target_x = f(s[3], c) - if not (isinstance(x_array, list) and x_array[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - if not (isinstance(y_array, list) and y_array[0] == "Array"): - raise ValueError("Parameter 2 must be an array.") - x_array = [f(x, c) for x in x_array[1:]] - y_array = [f(y, c) for y in y_array[1:]] - return linear_interpolate(x_array, y_array, target_x) - - -def FindIntervalIndex(f, c, solver_parameters, s): - """ - ["FindIntervalIndex", array, target_value] - The `array` must be an array of numeric values. - The `target_value` is the value to find the interval index for. - """ - array = f(s[1], c) - target_value = f(s[2], c) - if not (isinstance(array, list) and array[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - array = [f(x, c) for x in array[1:]] - zz = find_interpolation_bounds_2indexes(array, target_value) - return zz[0] - - -def TrapezoidalIntegrate(f, c, solver_parameters, s): - """ - ["TrapezoidalIntegrate", function_expression, start, end, n, variable] - """ - if not NUMPY_AVAILABLE: - raise ImportError( - "TrapezoidalIntegrate requires 'numpy'. Install with 'pip install numpy'." - ) - function_expression = s[1] - start = f(s[2], c) - end = f(s[3], c) - n = f(s[4], c) - variable = s[5] - - t = np.linspace(start, end, n + 1) - - # Calculate the integral using the trapezoidal rule - - values = [] - for x in t: - variable_name = variable[1] - variable_value = x - c[variable_name] = variable_value - values.append(f(function_expression, c)) - h = (end - start) / n - return h * (0.5 * values[0] + np.sum(values[1:-1]) + 0.5 * values[-1]) - - # return total_area - - -def Reduce(f, c, solver_parameters, s): - """ - Two calling conventions, disambiguated by argument count: - - CortexJS form (3 or 4 arguments): - ["Reduce", list, function] - ["Reduce", list, function, initial_value] - `function` is applied as `function(accumulator, current_item)` - on each element; without `initial_value`, the first element - seeds the accumulator. `function` can be a call template - (e.g. ["Add"]) or a ["Function", body, ...params] expression - (see `_apply_fn`). - - Original Python form (6 arguments): - ["Reduce", list, initial_value, function, str_name_of_accumulator, str_name_of_current, str_name_of_index] - """ - if len(s) <= 4: - the_list = f(s[1], c) - if not (isinstance(the_list, list) and the_list[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - elements = the_list[1:] - fn_expr = s[2] - - if len(s) == 4: - accumulator = f(s[3], c) - remaining = elements - else: - if not elements: - raise ValueError( - "'Reduce' on an empty collection requires an initial value." - ) - accumulator = f(elements[0], c) - remaining = elements[1:] - - for x in remaining: - accumulator = _apply_fn(f, c, solver_parameters, fn_expr, [accumulator, x]) - return accumulator - - the_list = f(s[1], c)[1:] - initial_value = f(s[2], c) - function_expression = s[3] - - _accumulator = s[4] - name_accumulator = _accumulator[1] - - _current = s[5] - name_current = _current[1] - - _index = s[6] - name_index = _index[1] - - c[name_accumulator] = initial_value - - for i, x in enumerate(the_list): - c[name_current] = x - c[name_index] = i - c[name_accumulator] = f(function_expression, c) - - return c[name_accumulator] - - -def Product(f, c, solver_parameters, s): - """ - ["Product", array] - Multiplies together the numeric elements of `array`. - """ - the_list = f(s[1], c) - if not (isinstance(the_list, list) and the_list[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - result = 1 - for x in the_list[1:]: - result *= f(x, c) - return result - - -def Appended(f, c, solver_parameters, s): - """ - ["Appended", array, value] - The `array` must be an array of values. - The `value` is the value to append to the array. - """ - array = f(s[1], c) - - if not (isinstance(array, list) and array[0] == "Array"): - raise ValueError("Parameter 1 must be an array.") - - value = f(s[2], c) - - array = [x for x in array[1:]] - array.append(value) - return ["Array"] + array - - constructs = { "Sum": Sum, "Add": Add, @@ -3120,7 +709,7 @@ def create_mathjson_solver( solver_parameters: dict[str, MathJSONExpression], legacy_v1: bool = False, blacklist: Iterable[str] | None = None, -) -> Callable[[MathJSONExpression], Any]: +) -> Callable[[MathJSONExpression], _Any]: """ `blacklist`, if given, is an iterable of construct names (e.g. `["PowerSet", "Permutations"]`) that this solver instance refuses to diff --git a/src/mathjson_solver/_arithmetic_constructs.py b/src/mathjson_solver/_arithmetic_constructs.py new file mode 100644 index 0000000..761de9c --- /dev/null +++ b/src/mathjson_solver/_arithmetic_constructs.py @@ -0,0 +1,219 @@ +""" +Basic arithmetic and numeric-conversion constructs: variadic +add/subtract/multiply-style reducers, rounding/truncation, and rational- +number construction. +""" + +from fractions import Fraction +import datetime +import math +from functools import reduce +from statistics import median +from ._common import ( + _try_parse_datetime, + is_numeric, +) + + +def Add(f, c, solver_parameters, s): + l_res = [] + tmp = 0 + for i, x in enumerate(s[1:]): + res = f(x, c) + if is_numeric(res): + res = float(res) + if i == 0: + tmp = res + else: + # Handle datetime string + timedelta + if isinstance(res, datetime.timedelta): + tmp = _try_parse_datetime(tmp) + elif isinstance(tmp, datetime.timedelta): + res = _try_parse_datetime(res) + try: + tmp = tmp + res + except TypeError: + pass + + # Convert datetime result back to string + if isinstance(tmp, (datetime.datetime, datetime.date)): + return tmp.isoformat() + return tmp + + # tmp = 0 + # for i, x in enumerate(l): + # if i == 0: + # tmp = x + # else: + # tmp = tmp + x + # return tmp + + +# def Sum(s): +# l_res = [] +# for x in s[1:]: +# res = f(x, c) +# if isinstance(res, list): +# l_res.append(sum([xx for xx in res[1:]])) +# else: +# l_res.append(res) +# return sum(l_res) + + +def Sum(f, c, solver_parameters, s): + l_res = ["Array"] + for x in s[1:]: + res = f(x, c) + if isinstance(res, list): + l_res.append( + Add(f, c, solver_parameters, ["Array"] + [xx for xx in res[1:]]) + ) + else: + l_res.append(res) + return Add(f, c, solver_parameters, l_res) + + +def Subtract(f, c, solver_parameters, s): + values = [f(x, c) for x in s[1:]] + # Convert datetime strings if we're dealing with timedelta + converted = [] + for i, v in enumerate(values): + if isinstance(v, datetime.timedelta): + converted.append(v) + elif any(isinstance(other, datetime.timedelta) for other in values): + converted.append(_try_parse_datetime(v)) + else: + converted.append(v) + result = reduce(lambda a, b: a - b, converted) + if isinstance(result, (datetime.datetime, datetime.date)): + return result.isoformat() + return result + + +def Max(f, c, solver_parameters, s): + args = s[1:] + if len(args) == 1: + if isinstance(args[0], str): + return max([f(x, c) for x in f(args[0], c) if is_numeric(f(x, c))]) + else: + return max([f(x, c) for x in args[0][1:] if is_numeric(f(x, c))]) + else: + # CortexJS-style variadic form: ["Max", a, b, c, ...] + return max([f(x, c) for x in args]) + + +def Min(f, c, solver_parameters, s): + args = s[1:] + if len(args) == 1: + if isinstance(args[0], str): + return min([f(x, c) for x in f(args[0], c) if is_numeric(f(x, c))]) + else: + return min([f(x, c) for x in args[0][1:] if is_numeric(f(x, c))]) + else: + # CortexJS-style variadic form: ["Min", a, b, c, ...] + return min([f(x, c) for x in args]) + + +def Average(f, c, solver_parameters, s): + if isinstance(s[1], str): + # A reference to "answer" has been passed + s_ = [float(f(x, c)) for x in f(s[1], c) if is_numeric(f(x, c))] + else: + s_ = [float(f(x, c)) for x in s[1][1:] if is_numeric(f(x, c))] + try: + return sum(s_) / len(s_) + except ZeroDivisionError: + return None + + +def Median(f, c, solver_parameters, s): + if isinstance(s[1], str): + return median([f(x, c) for x in f(s[1], c) if is_numeric(f(x, c))]) + else: + return median([f(x, c) for x in s[1][1:] if is_numeric(f(x, c))]) + + +def Clamp(f, c, solver_parameters, s): + """ + ["Clamp", value] or ["Clamp", value, lower, upper] + Bounds `value` between `lower` (default -1) and `upper` (default 1), + matching CortexJS `Clamp`. + """ + value = f(s[1], c) + lower = f(s[2], c) if len(s) > 2 else -1 + upper = f(s[3], c) if len(s) > 3 else 1 + return max(lower, min(upper, value)) + + +def Int(f, c, solver_parameters, s): + try: + return int(f(s[1], c)) + except ValueError: + return int(float(f(s[1], c))) + + +def Float(f, c, solver_parameters, s): + return float(f(s[1], c)) + + +def Floor(f, c, solver_parameters, s): + return math.floor(f(s[1], c)) + + +def Ceil(f, c, solver_parameters, s): + return math.ceil(f(s[1], c)) + + +def _rational_parts(f, c, solver_parameters, s): + """ + Numerator/denominator pair for `s[1]`. If `s[1]` is itself + an unevaluated `["Rational", n, d]` expression, its + declared `n`/`d` are used exactly. Otherwise, the + evaluated value is reconstructed as the closest fraction + with a bounded denominator - a best-effort approximation, + not exact/symbolic, since this solver has no dedicated + rational-number type carried through arithmetic (see + `Rational`'s docstring). + """ + expr = s[1] + if isinstance(expr, list) and len(expr) == 3 and expr[0] == "Rational": + return int(f(expr[1], c)), int(f(expr[2], c)) + value = f(expr, c) + if isinstance(value, int): + return value, 1 + frac = Fraction(value).limit_denominator(10**6) + return frac.numerator, frac.denominator + + +def Numerator(f, c, solver_parameters, s): + return _rational_parts(f, c, solver_parameters, s)[0] + + +def Denominator(f, c, solver_parameters, s): + return _rational_parts(f, c, solver_parameters, s)[1] + + +def Rational(f, c, solver_parameters, s): + """ + ["Rational", numerator, denominator] + Evaluates to `numerator / denominator` as a plain float - + there's no exact rational-number type carried through + arithmetic in this solver, so precision beyond a float is + only preserved when `Numerator`/`Denominator` read this + expression directly, rather than its evaluated value. + """ + return f(s[1], c) / f(s[2], c) + + +def Product(f, c, solver_parameters, s): + """ + ["Product", array] + Multiplies together the numeric elements of `array`. + """ + the_list = f(s[1], c) + if not (isinstance(the_list, list) and the_list[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + result = 1 + for x in the_list[1:]: + result *= f(x, c) + return result diff --git a/src/mathjson_solver/_array_arithmetic_constructs.py b/src/mathjson_solver/_array_arithmetic_constructs.py new file mode 100644 index 0000000..65beccb --- /dev/null +++ b/src/mathjson_solver/_array_arithmetic_constructs.py @@ -0,0 +1,280 @@ +""" +Array-as-numeric-vector arithmetic and calculus constructs (element-wise +scalar/array ops, interpolation, numerical integration). +""" + +from ._array_helpers import ( + _AddArray, + _AddScalar, + _CumulativeProduct, + _CumulativeSum, + _MultiplyByArray, + _MultiplyByScalar, + _SubtractArray, + _SubtractScalar, +) +from ._interpolation import ( + find_interpolation_bounds_2indexes, + linear_interpolate, +) + +NUMPY_AVAILABLE = False +try: + import numpy as np + + NUMPY_AVAILABLE = True +except ImportError: + pass + + +def MultiplyByScalar(f, c, solver_parameters, s): + """ + ["MultiplyByScalar", array, scalar] + The `array` must be an array of numeric values. + The `scalar` is the number to multiply each element by. + """ + array = f(s[1], c) + scalar = f(s[2], c) + if not (isinstance(array, list) and array[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + array = [f(x, c) for x in array[1:]] + return ["Array"] + _MultiplyByScalar(array, scalar) + + +def MultiplyByArray(f, c, solver_parameters, s): + """ + ["MultiplyByArray", array1, array2] + The `array1` and `array2` must be arrays of the same length. + """ + array1 = f(s[1], c) + array2 = f(s[2], c) + if not (isinstance(array1, list) and array1[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + if not (isinstance(array2, list) and array2[0] == "Array"): + raise ValueError("Parameter 2 must be an array.") + array1 = [f(x, c) for x in array1[1:]] + array2 = [f(x, c) for x in array2[1:]] + if len(array1) != len(array2): + raise ValueError("Both arrays must be of the same length.") + return ["Array"] + _MultiplyByArray(array1, array2) + + +def AddScalar(f, c, solver_parameters, s): + """ + ["AddScalar", array, scalar] + The `array` must be an array of numeric values. + The `scalar` is the number to add to each element. + """ + array = f(s[1], c) + scalar = f(s[2], c) + if not (isinstance(array, list) and array[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + array = [f(x, c) for x in array[1:]] + return ["Array"] + _AddScalar(array, scalar) + + +def SubtractScalar(f, c, solver_parameters, s): + """ + ["SubtractScalar", array, scalar] + The `array` must be an array of numeric values. + The `scalar` is the number to subtract from each element. + """ + array = f(s[1], c) + scalar = f(s[2], c) + if not (isinstance(array, list) and array[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + array = [f(x, c) for x in array[1:]] + return ["Array"] + _SubtractScalar(array, scalar) + + +def AddArray(f, c, solver_parameters, s): + """ + ["AddArray", array1, array2] + The `array1` and `array2` must be arrays of the same length. + """ + array1 = f(s[1], c) + array2 = f(s[2], c) + if not (isinstance(array1, list) and array1[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + if not (isinstance(array2, list) and array2[0] == "Array"): + raise ValueError("Parameter 2 must be an array.") + array1 = [f(x, c) for x in array1[1:]] + array2 = [f(x, c) for x in array2[1:]] + if len(array1) != len(array2): + raise ValueError("Both arrays must be of the same length.") + return ["Array"] + _AddArray(array1, array2) + + +def SubtractArray(f, c, solver_parameters, s): + """ + ["SubtractArray", array1, array2] + The `array1` and `array2` must be arrays of the same length. + """ + array1 = f(s[1], c) + array2 = f(s[2], c) + if not (isinstance(array1, list) and array1[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + if not (isinstance(array2, list) and array2[0] == "Array"): + raise ValueError("Parameter 2 must be an array.") + array1 = [f(x, c) for x in array1[1:]] + array2 = [f(x, c) for x in array2[1:]] + if len(array1) != len(array2): + raise ValueError("Both arrays must be of the same length.") + return ["Array"] + _SubtractArray(array1, array2) + + +def GenerateRange(f, c, solver_parameters, s): + """ + ["GenerateRange", end] + or + ["GenerateRange", start, end, step] + The `start`, `end`, and `step` are numeric values. + """ + if len(s) == 2: + end = f(s[1], c) + start = 0 + step = 1 + elif len(s) == 4: + start = f(s[1], c) + end = f(s[2], c) + step = f(s[3], c) + else: + raise ValueError( + "GenerateRange requires either 1 or 3 parameters (end or start, end, step)." + ) + if step == 0: + raise ValueError("Step cannot be zero.") + if (start < end and step < 0) or (start > end and step > 0): + raise ValueError("Step direction is incorrect for the given range.") + result = ["Array"] + if start < end: + current = start + while current < end: + result.append(current) + current += step + else: + current = start + while current > end: + result.append(current) + current += step + return result + + +def AtIndex(f, c, solver_parameters, s): + """ + ["AtIndex", array, index] + The `array` must be an array of values. + The `index` is the index of the element to retrieve. + """ + array = f(s[1], c) + index = f(s[2], c) + if not (isinstance(array, list) and array[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + # array = [f(x, c) for x in array[1:]] + # return array[index] + array = [x for x in array[1:]] + return f(array[index], c) + + +def Slice(f, c, solver_parameters, s): + """ + ["Slice", array, start, end] + The `array` must be an array of values. + The `start` and `end` are the slice indices. + """ + array = f(s[1], c) + start = f(s[2], c) + end = f(s[3], c) + if not (isinstance(array, list) and array[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + array = [f(x, c) for x in array[1:]] + return ["Array"] + array[start:end] + + +def CumulativeProduct(f, c, solver_parameters, s): + """ + ["CumulativeProduct", array] + The `array` must be an array of numeric values. + """ + array = f(s[1], c) + if not (isinstance(array, list) and array[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + array = [f(x, c) for x in array[1:]] + return ["Array"] + _CumulativeProduct(array) + # return _CumulativeProduct(array) + + +def CumulativeSum(f, c, solver_parameters, s): + """ + ["CumulativeSum", array] + The `array` must be an array of numeric values. + """ + array = f(s[1], c) + if not (isinstance(array, list) and array[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + array = [f(x, c) for x in array[1:]] + return ["Array"] + _CumulativeSum(array) + # return + + +def Interp(f, c, solver_parameters, s): + """ + ["Interp", x_array, y_array, target_x] + The `x_array` and `y_array` must be arrays of the same length. + The `target_x` is the x value to interpolate for. + """ + x_array = f(s[1], c) + y_array = f(s[2], c) + target_x = f(s[3], c) + if not (isinstance(x_array, list) and x_array[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + if not (isinstance(y_array, list) and y_array[0] == "Array"): + raise ValueError("Parameter 2 must be an array.") + x_array = [f(x, c) for x in x_array[1:]] + y_array = [f(y, c) for y in y_array[1:]] + return linear_interpolate(x_array, y_array, target_x) + + +def FindIntervalIndex(f, c, solver_parameters, s): + """ + ["FindIntervalIndex", array, target_value] + The `array` must be an array of numeric values. + The `target_value` is the value to find the interval index for. + """ + array = f(s[1], c) + target_value = f(s[2], c) + if not (isinstance(array, list) and array[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + array = [f(x, c) for x in array[1:]] + zz = find_interpolation_bounds_2indexes(array, target_value) + return zz[0] + + +def TrapezoidalIntegrate(f, c, solver_parameters, s): + """ + ["TrapezoidalIntegrate", function_expression, start, end, n, variable] + """ + if not NUMPY_AVAILABLE: + raise ImportError( + "TrapezoidalIntegrate requires 'numpy'. Install with 'pip install numpy'." + ) + function_expression = s[1] + start = f(s[2], c) + end = f(s[3], c) + n = f(s[4], c) + variable = s[5] + + t = np.linspace(start, end, n + 1) + + # Calculate the integral using the trapezoidal rule + + values = [] + for x in t: + variable_name = variable[1] + variable_value = x + c[variable_name] = variable_value + values.append(f(function_expression, c)) + h = (end - start) / n + return h * (0.5 * values[0] + np.sum(values[1:-1]) + 0.5 * values[-1]) + + # return total_area diff --git a/src/mathjson_solver/_collection_constructs.py b/src/mathjson_solver/_collection_constructs.py new file mode 100644 index 0000000..308419d --- /dev/null +++ b/src/mathjson_solver/_collection_constructs.py @@ -0,0 +1,707 @@ +""" +Array/list/collection constructs: element access, searching, +transformation, grouping, and set algebra (over Array - no dedicated Set +type). +""" + +from ._common import ( + has_matching_sublist, +) +from ._construct_shared import _apply_fn, _arr_vals, _arr_vals_of + + +def Arr(f, c, solver_parameters, s): + return s + + +def Length(f, c, solver_parameters, s): + if isinstance(s[1], str): + return len([x for x in f(s[1], c)][1:]) + else: + return len([x for x in s[1][1:]]) + + +def First(f, c, solver_parameters, s): + return _arr_vals(f, c, solver_parameters, s)[0] + + +def Second(f, c, solver_parameters, s): + return _arr_vals(f, c, solver_parameters, s)[1] + + +def Third(f, c, solver_parameters, s): + return _arr_vals(f, c, solver_parameters, s)[2] + + +def Last(f, c, solver_parameters, s): + return _arr_vals(f, c, solver_parameters, s)[-1] + + +def Rest(f, c, solver_parameters, s): + return ["Array"] + _arr_vals(f, c, solver_parameters, s)[1:] + + +def Most(f, c, solver_parameters, s): + return ["Array"] + _arr_vals(f, c, solver_parameters, s)[:-1] + + +def Reverse(f, c, solver_parameters, s): + return ["Array"] + list(reversed(_arr_vals(f, c, solver_parameters, s))) + + +def Sort(f, c, solver_parameters, s): + return ["Array"] + sorted(_arr_vals(f, c, solver_parameters, s)) + + +def IsEmpty(f, c, solver_parameters, s): + lst = f(s[1], c) + if not (isinstance(lst, list) and lst[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + return len(lst) <= 1 + + +def Range(f, c, solver_parameters, s): + """ + CortexJS-compatible `Range`: + ["Range", upper] -> 1..upper (inclusive) + ["Range", lower, upper] -> lower..upper (inclusive) + ["Range", lower, upper, step] -> lower..upper (inclusive), stepped + Distinct from `GenerateRange`, which is 0-indexed and exclusive at + the upper end. + """ + if len(s) == 2: + return ["Array"] + list(range(1, int(f(s[1], c)) + 1)) + elif len(s) == 3: + lo, hi = int(f(s[1], c)), int(f(s[2], c)) + return ["Array"] + list(range(lo, hi + 1)) + else: + lo, hi, step = ( + int(f(s[1], c)), + int(f(s[2], c)), + int(f(s[3], c)), + ) + return ["Array"] + list(range(lo, hi + 1 if step > 0 else hi - 1, step)) + + +def Join(f, c, solver_parameters, s): + """ + ["Join", array1, array2, ...] + Concatenates the given arrays. + """ + result = ["Array"] + for arg in s[1:]: + lst = f(arg, c) + if not (isinstance(lst, list) and lst[0] == "Array"): + raise ValueError("All parameters must be arrays.") + result += [f(x, c) for x in lst[1:]] + return result + + +def Unique(f, c, solver_parameters, s): + seen = [] + for x in _arr_vals(f, c, solver_parameters, s): + if x not in seen: + seen.append(x) + return ["Array"] + seen + + +def Zip(f, c, solver_parameters, s): + lists = [[f(x, c) for x in f(arg, c)[1:]] for arg in s[1:]] + return ["Array"] + [["Array", a, b] for a, b in zip(*lists)] + + +def At(f, c, solver_parameters, s): + """ + ["At", array, index] + 1-indexed element access (CortexJS `At`), with negative indexes + counting from the end. + """ + vals = _arr_vals(f, c, solver_parameters, s) + idx = int(f(s[2], c)) + if idx > 0: + return vals[idx - 1] + else: + return vals[idx] + + +def Take(f, c, solver_parameters, s): + """ + ["Take", array, n] + First `n` elements if n >= 0; last `n` elements if n < 0. + Distinct from `Slice`, which takes an explicit [start, end) + range instead of a count. + """ + vals = _arr_vals(f, c, solver_parameters, s) + n = int(f(s[2], c)) + if n >= 0: + return ["Array"] + vals[:n] + return ["Array"] + vals[n:] + + +def Drop(f, c, solver_parameters, s): + """ + ["Drop", array, n] + All elements except the first `n` if n >= 0; except the + last `n` if n < 0. + """ + vals = _arr_vals(f, c, solver_parameters, s) + n = int(f(s[2], c)) + if n >= 0: + return ["Array"] + vals[n:] + return ["Array"] + vals[:n] + + +def TakeWhile(f, c, solver_parameters, s): + """ + ["TakeWhile", array, predicate] + Elements from the start, up to (excluding) the first one + for which `predicate` is false. + """ + result = [] + for v in _arr_vals(f, c, solver_parameters, s): + if not _apply_fn(f, c, solver_parameters, s[2], [v]): + break + result.append(v) + return ["Array"] + result + + +def DropWhile(f, c, solver_parameters, s): + """ + ["DropWhile", array, predicate] + Remaining elements from (and including) the first one for + which `predicate` is false. + """ + vals = _arr_vals(f, c, solver_parameters, s) + i = 0 + while i < len(vals) and _apply_fn(f, c, solver_parameters, s[2], [vals[i]]): + i += 1 + return ["Array"] + vals[i:] + + +def Contains(f, c, solver_parameters, s): + """ + ["Contains", array, value] + Whether `value` occurs in `array`. CortexJS argument order + (collection first) - the existing `In` takes them the + other way round (`["In", value, collection]`). + """ + return f(s[2], c) in _arr_vals(f, c, solver_parameters, s) + + +def IndexOf(f, c, solver_parameters, s): + """ + ["IndexOf", array, value] + 1-indexed position of the first occurrence of `value`, or + None if it doesn't occur. + """ + vals = _arr_vals(f, c, solver_parameters, s) + value = f(s[2], c) + try: + return vals.index(value) + 1 + except ValueError: + return None + + +def IndexWhere(f, c, solver_parameters, s): + """ + ["IndexWhere", array, predicate] + 1-indexed position of the first element for which + `predicate` is true, or None if none match. + """ + for i, v in enumerate(_arr_vals(f, c, solver_parameters, s)): + if _apply_fn(f, c, solver_parameters, s[2], [v]): + return i + 1 + return None + + +def Find(f, c, solver_parameters, s): + """ + ["Find", array, predicate] + The first element for which `predicate` is true, or None + if none match. + """ + for v in _arr_vals(f, c, solver_parameters, s): + if _apply_fn(f, c, solver_parameters, s[2], [v]): + return v + return None + + +def CountIf(f, c, solver_parameters, s): + """ + ["CountIf", array, predicate] + Count of elements for which `predicate` is true. + """ + return sum( + 1 + for v in _arr_vals(f, c, solver_parameters, s) + if _apply_fn(f, c, solver_parameters, s[2], [v]) + ) + + +def Position(f, c, solver_parameters, s): + """ + ["Position", array, predicate] + Array of the 1-indexed positions of every element for + which `predicate` is true. + """ + return ["Array"] + [ + i + 1 + for i, v in enumerate(_arr_vals(f, c, solver_parameters, s)) + if _apply_fn(f, c, solver_parameters, s[2], [v]) + ] + + +def RotateLeft(f, c, solver_parameters, s): + """ + ["RotateLeft", array, n] + Circularly shifts `array` left by `n` positions. + """ + vals = _arr_vals(f, c, solver_parameters, s) + if not vals: + return ["Array"] + n = int(f(s[2], c)) % len(vals) + return ["Array"] + vals[n:] + vals[:n] + + +def RotateRight(f, c, solver_parameters, s): + """ + ["RotateRight", array, n] + Circularly shifts `array` right by `n` positions. + """ + vals = _arr_vals(f, c, solver_parameters, s) + if not vals: + return ["Array"] + n = int(f(s[2], c)) % len(vals) + if n == 0: + return ["Array"] + vals + return ["Array"] + vals[-n:] + vals[:-n] + + +def MaxBy(f, c, solver_parameters, s): + """ + ["MaxBy", array, function] + The element of `array` for which `function(element)` is + largest. + """ + return max( + _arr_vals(f, c, solver_parameters, s), + key=lambda v: _apply_fn(f, c, solver_parameters, s[2], [v]), + ) + + +def MinBy(f, c, solver_parameters, s): + """ + ["MinBy", array, function] + The element of `array` for which `function(element)` is + smallest. + """ + return min( + _arr_vals(f, c, solver_parameters, s), + key=lambda v: _apply_fn(f, c, solver_parameters, s[2], [v]), + ) + + +def ArgMax(f, c, solver_parameters, s): + """ + ["ArgMax", array] + 1-indexed position of the largest element. + """ + vals = _arr_vals(f, c, solver_parameters, s) + return max(range(len(vals)), key=lambda i: vals[i]) + 1 + + +def ArgMin(f, c, solver_parameters, s): + """ + ["ArgMin", array] + 1-indexed position of the smallest element. + """ + vals = _arr_vals(f, c, solver_parameters, s) + return min(range(len(vals)), key=lambda i: vals[i]) + 1 + + +def Ordering(f, c, solver_parameters, s): + """ + ["Ordering", array] + Array of the 1-indexed positions that would put `array` + in ascending order. + """ + vals = _arr_vals(f, c, solver_parameters, s) + return ["Array"] + [i + 1 for i in sorted(range(len(vals)), key=lambda i: vals[i])] + + +def FlatMap(f, c, solver_parameters, s): + """ + ["FlatMap", array, function] + Applies `function` to each element (as with `Map`) and + flattens one level of the results - each result that is + itself an array is spliced in, others are kept as-is - + into a single array. + """ + result = ["Array"] + for v in _arr_vals(f, c, solver_parameters, s): + mapped = _apply_fn(f, c, solver_parameters, s[2], [v]) + if isinstance(mapped, list) and mapped and mapped[0] == "Array": + result += mapped[1:] + else: + result.append(mapped) + return result + + +def Scan(f, c, solver_parameters, s): + """ + ["Scan", array, function] or ["Scan", array, function, initial] + Like `Reduce`'s CortexJS form (`function` applied as + `function(accumulator, current)`), but returns an array of + every intermediate accumulator value, including the seed, + instead of just the final one. + """ + vals = _arr_vals(f, c, solver_parameters, s) + fn_expr = s[2] + if len(s) == 4: + acc = f(s[3], c) + remaining = vals + else: + if not vals: + raise ValueError("'Scan' on an empty collection requires an initial value.") + acc = vals[0] + remaining = vals[1:] + result = ["Array", acc] + for v in remaining: + acc = _apply_fn(f, c, solver_parameters, fn_expr, [acc, v]) + result.append(acc) + return result + + +def Differences(f, c, solver_parameters, s): + """ + ["Differences", array] + Array of successive differences: element[i+1] - element[i]. + """ + vals = _arr_vals(f, c, solver_parameters, s) + return ["Array"] + [b - a for a, b in zip(vals, vals[1:])] + + +def Dedup(f, c, solver_parameters, s): + """ + ["Dedup", array] + Removes only *consecutive* duplicate elements - distinct + from `Unique`, which removes every duplicate regardless of + position. + """ + result = [] + for v in _arr_vals(f, c, solver_parameters, s): + if not result or result[-1] != v: + result.append(v) + return ["Array"] + result + + +def Insert(f, c, solver_parameters, s): + """ + ["Insert", array, index, value] + Inserts `value` at the 1-indexed `index` (CortexJS + convention, matching `At`); negative indexes count from + the end. + """ + vals = _arr_vals(f, c, solver_parameters, s) + idx = int(f(s[2], c)) + value = f(s[3], c) + vals.insert(idx - 1 if idx > 0 else idx, value) + return ["Array"] + vals + + +def DeleteAt(f, c, solver_parameters, s): + """ + ["DeleteAt", array, index] + Removes the element at the 1-indexed `index`; negative + indexes count from the end. + """ + vals = _arr_vals(f, c, solver_parameters, s) + idx = int(f(s[2], c)) + del vals[idx - 1 if idx > 0 else idx] + return ["Array"] + vals + + +def ReplaceAt(f, c, solver_parameters, s): + """ + ["ReplaceAt", array, index, value] + Replaces the element at the 1-indexed `index` with + `value`; negative indexes count from the end. + """ + vals = _arr_vals(f, c, solver_parameters, s) + idx = int(f(s[2], c)) + value = f(s[3], c) + vals[idx - 1 if idx > 0 else idx] = value + return ["Array"] + vals + + +def Partition(f, c, solver_parameters, s): + """ + ["Partition", array, size] + Splits `array` into consecutive chunks of length `size` + (the last chunk may be shorter). Distinct from `Chunk`, + which instead takes the number of groups to split into. + """ + vals = _arr_vals(f, c, solver_parameters, s) + size = int(f(s[2], c)) + if size <= 0: + raise ValueError("'Partition' size must be a positive integer.") + return ["Array"] + [ + ["Array"] + vals[i : i + size] for i in range(0, len(vals), size) + ] + + +def Chunk(f, c, solver_parameters, s): + """ + ["Chunk", array, n] + Splits `array` into `n` roughly equal-sized consecutive + groups. + """ + vals = _arr_vals(f, c, solver_parameters, s) + n = int(f(s[2], c)) + if n <= 0: + raise ValueError("'Chunk' group count must be a positive integer.") + base, extra = divmod(len(vals), n) + result, start = [], 0 + for i in range(n): + size = base + (1 if i < extra else 0) + result.append(["Array"] + vals[start : start + size]) + start += size + return ["Array"] + result + + +def GroupBy(f, c, solver_parameters, s): + """ + ["GroupBy", array, function] + Groups elements of `array` by `function(element)`, in + order of first appearance of each key. Returns an array of + [key, group] pairs - there's no dedicated `Dictionary` + type in this solver. + """ + order = [] + groups = {} + for v in _arr_vals(f, c, solver_parameters, s): + k = _apply_fn(f, c, solver_parameters, s[2], [v]) + hk = tuple(k) if isinstance(k, list) else k + if hk not in groups: + order.append((hk, k)) + groups[hk] = [] + groups[hk].append(v) + return ["Array"] + [["Array", k, ["Array"] + groups[hk]] for hk, k in order] + + +def ChunkBy(f, c, solver_parameters, s): + """ + ["ChunkBy", array, function] + Splits `array` into consecutive runs sharing the same + `function(element)` key - unlike `GroupBy`, runs are not + merged across non-adjacent occurrences of the same key. + """ + vals = _arr_vals(f, c, solver_parameters, s) + if not vals: + return ["Array"] + result = [] + current = [vals[0]] + current_key = _apply_fn(f, c, solver_parameters, s[2], [vals[0]]) + for v in vals[1:]: + k = _apply_fn(f, c, solver_parameters, s[2], [v]) + if k == current_key: + current.append(v) + else: + result.append(["Array"] + current) + current, current_key = [v], k + result.append(["Array"] + current) + return ["Array"] + result + + +def Tally(f, c, solver_parameters, s): + """ + ["Tally", array] + Counts occurrences of each distinct element, in order of + first appearance. Returns an array of [value, count] + pairs. + """ + order = [] + counts = {} + for v in _arr_vals(f, c, solver_parameters, s): + hv = tuple(v) if isinstance(v, list) else v + if hv not in counts: + order.append((hv, v)) + counts[hv] = 0 + counts[hv] += 1 + return ["Array"] + [["Array", v, counts[hv]] for hv, v in order] + + +def Union(f, c, solver_parameters, s): + """ + ["Union", array1, array2, ...] + Distinct elements appearing in any of the given arrays, in + order of first appearance across the arguments + (left to right). Arrays stand in for CortexJS's `Set` + here - there's no dedicated set type in this solver. + """ + result = [] + for arg in s[1:]: + for v in _arr_vals_of(f, c, solver_parameters, arg): + if v not in result: + result.append(v) + return ["Array"] + result + + +def Intersection(f, c, solver_parameters, s): + """ + ["Intersection", array1, array2, ...] + Distinct elements common to every given array, in the + order they first appear in `array1`. + """ + arrays = [_arr_vals_of(f, c, solver_parameters, arg) for arg in s[1:]] + if not arrays: + return ["Array"] + result = [] + for v in arrays[0]: + if v not in result and all(v in arr for arr in arrays[1:]): + result.append(v) + return ["Array"] + result + + +def SetMinus(f, c, solver_parameters, s): + """ + ["SetMinus", array1, array2] + Distinct elements of `array1` that don't occur in + `array2`. + """ + a = _arr_vals_of(f, c, solver_parameters, s[1]) + b = _arr_vals_of(f, c, solver_parameters, s[2]) + result = [] + for v in a: + if v not in b and v not in result: + result.append(v) + return ["Array"] + result + + +def SymmetricDifference(f, c, solver_parameters, s): + """ + ["SymmetricDifference", array1, array2] + Distinct elements that occur in exactly one of + `array1`/`array2`: `array1`'s exclusive elements first + (in `array1`'s order), then `array2`'s (in `array2`'s + order). + """ + a = _arr_vals_of(f, c, solver_parameters, s[1]) + b = _arr_vals_of(f, c, solver_parameters, s[2]) + result = [] + for v in a: + if v not in b and v not in result: + result.append(v) + for v in b: + if v not in a and v not in result: + result.append(v) + return ["Array"] + result + + +def Any(f, c, solver_parameters, s): + evaluated = f(s[1], c) + if isinstance(evaluated, list) and evaluated[0] == "Array": + return any([f(x, c) for x in evaluated[1:]]) + raise ValueError("Parameter 1 must be an array.") + + +def All(f, c, solver_parameters, s): + evaluated = f(s[1], c) + if isinstance(evaluated, list) and evaluated[0] == "Array": + return all([f(x, c) for x in evaluated[1:]]) + raise ValueError("Parameter 1 must be an array.") + + +def In(f, c, solver_parameters, s): + if len(s) != 3: + raise ValueError("Wrong parameters for 'In'") + if isinstance(s[2], list) and s[2][0] == "Array": + return f(s[1], c) in [f(x, c) for x in s[2][1:]] + + elif isinstance(s[2], str): + return f(s[1], c) in f(s[2], c) + else: + raise ValueError("Wrong parameters for 'In'. Parameter 2 must be a list.") + + +def Not_in(f, c, solver_parameters, s): + return not In(f, c, solver_parameters, s) + + +def Contains_any_of(f, c, solver_parameters, s): + if isinstance(s[1], list) and s[1][0] == "Array": + list1 = [f(x, c) for x in s[1][1:]] + elif isinstance(s[1], str): + list1 = f(s[1], c) + + if isinstance(s[2], list) and s[2][0] == "Array": + list2 = [f(x, c) for x in s[2][1:]] + elif isinstance(s[2], str): + list2 = f(s[2], c) + + if any(x in list1 for x in list2): + return True + return False + + +def Contains_all_of(f, c, solver_parameters, s): + if isinstance(s[1], list) and s[1][0] == "Array": + list1 = [f(x, c) for x in s[1][1:]] + elif isinstance(s[1], str): + list1 = f(s[1], c) + + if isinstance(s[2], list) and s[2][0] == "Array": + list2 = [f(x, c) for x in s[2][1:]] + elif isinstance(s[2], str): + list2 = f(s[2], c) + + if all(x in list1 for x in list2): + return True + return False + + +def Contains_none_of(f, c, solver_parameters, s): + return not Contains_any_of(f, c, solver_parameters, s) + + +def HasMatchingSublist(f, c, solver_parameters, s): + """ + ["HasMatchingSublist", list, required_match_count, position, contiguous, function, more parameters] + """ + the_list = f(s[1], c)[1:] + required_match_count = f(s[2], c) + position = f(s[3], c) + contiguous = f(s[4], c) + conditions = [] + + for i, x in enumerate(the_list): + the_function_name = s[5][0] + ss = [the_function_name, x] + s[6:] + conditions.append(f(ss, c)) + pass + + return has_matching_sublist( + my_list=the_list, + required_match_count=required_match_count, + position=position, + contiguous=contiguous, + conditions=conditions, + ) + + +def Appended(f, c, solver_parameters, s): + """ + ["Appended", array, value] + The `array` must be an array of values. + The `value` is the value to append to the array. + """ + array = f(s[1], c) + + if not (isinstance(array, list) and array[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + + value = f(s[2], c) + + array = [x for x in array[1:]] + array.append(value) + return ["Array"] + array diff --git a/src/mathjson_solver/_combinatorics_constructs.py b/src/mathjson_solver/_combinatorics_constructs.py new file mode 100644 index 0000000..0454a9f --- /dev/null +++ b/src/mathjson_solver/_combinatorics_constructs.py @@ -0,0 +1,87 @@ +""" +Combinatorial constructs. +""" + +import itertools +import math +from ._combinatorics import _bell_number, _subfactorial +from ._construct_shared import _arr_vals, _arr_vals_of + +# --- Combinatorics (trivial subset - no explosion risk) --- + + +def Fibonacci(f, c, solver_parameters, s): + n = int(f(s[1], c)) + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return a + + +def Multinomial(f, c, solver_parameters, s): + ks = [int(k) for k in _arr_vals(f, c, solver_parameters, s)] + return math.factorial(sum(ks)) // math.prod(math.factorial(k) for k in ks) + + +def Subfactorial(f, c, solver_parameters, s): + return _subfactorial(f(s[1], c)) + + +def BellNumber(f, c, solver_parameters, s): + return _bell_number(f(s[1], c)) + + +# --- Combinatorics: enumeration (no output-size limit - see +# create_mathjson_solver's `blacklist` parameter to disable +# these for untrusted expression sources) --- + + +def PowerSet(f, c, solver_parameters, s): + """ + ["PowerSet", array] + All subsets of `array` (including the empty set and the + full set itself), as an array of arrays. No output-size + limit: a set of n elements has 2^n subsets. + """ + vals = _arr_vals(f, c, solver_parameters, s) + result = ["Array"] + for r in range(len(vals) + 1): + for combo in itertools.combinations(vals, r): + result.append(["Array"] + list(combo)) + return result + + +def Permutations(f, c, solver_parameters, s): + """ + ["Permutations", array] or ["Permutations", array, k] + All ordered arrangements of `k` elements from `array` + (default k = len(array)), as an array of arrays. No + output-size limit. + """ + vals = _arr_vals(f, c, solver_parameters, s) + k = int(f(s[2], c)) if len(s) > 2 else len(vals) + return ["Array"] + [["Array"] + list(p) for p in itertools.permutations(vals, k)] + + +def Combinations(f, c, solver_parameters, s): + """ + ["Combinations", array, k] + All unordered k-element subsets of `array`, as an array + of arrays. No output-size limit. + """ + vals = _arr_vals(f, c, solver_parameters, s) + k = int(f(s[2], c)) + return ["Array"] + [ + ["Array"] + list(combo) for combo in itertools.combinations(vals, k) + ] + + +def CartesianProduct(f, c, solver_parameters, s): + """ + ["CartesianProduct", array1, array2, ...] + All ordered tuples with one element drawn from each + array, as an array of arrays. No output-size limit: the + result has len(array1) * len(array2) * ... elements. + """ + lists = [_arr_vals_of(f, c, solver_parameters, arg) for arg in s[1:]] + return ["Array"] + [["Array"] + list(combo) for combo in itertools.product(*lists)] diff --git a/src/mathjson_solver/_comparison_constructs.py b/src/mathjson_solver/_comparison_constructs.py new file mode 100644 index 0000000..9936002 --- /dev/null +++ b/src/mathjson_solver/_comparison_constructs.py @@ -0,0 +1,114 @@ +""" +Comparison, equality, definedness, and boolean-logic constructs. +""" + +from ._common import ( + comparison_safe_converter_for_pairs, +) + + +def Not(f, c, solver_parameters, s): + return not f(s[1]) + + +# def Equal(s): +# a = comparison_safe_converter(f(s[1], c)) +# b = comparison_safe_converter(f(s[2], c)) +# return a == b +# if type(a) in [int, float, str]: +# a = f"{a}" +# elif type(a) in [bool, NoneType]: +# if a: +# a = True +# else: +# a = None + +# if is_numeric(a): + +# lambda s: f"{f(s[1], c)}" == f"{f(s[2], c)}", + + +def IsDefined(f, c, solver_parameters, s): + return s[1] in solver_parameters or s[1] in c + + +def IsUndefined(f, c, solver_parameters, s): + return not IsDefined(f, c, solver_parameters, s) + + +def Greater(f, c, solver_parameters, s): + v1, v2 = comparison_safe_converter_for_pairs(f(s[1], c), f(s[2], c)) + try: + return v1 > v2 + except TypeError: + return False + + +def GreaterEqual(f, c, solver_parameters, s): + v1, v2 = comparison_safe_converter_for_pairs(f(s[1], c), f(s[2], c)) + try: + return v1 >= v2 + except TypeError: + return False + + +def Less(f, c, solver_parameters, s): + v1, v2 = comparison_safe_converter_for_pairs(f(s[1], c), f(s[2], c)) + try: + return v1 < v2 + except TypeError: + return False + + +def LessEqual(f, c, solver_parameters, s): + v1, v2 = comparison_safe_converter_for_pairs(f(s[1], c), f(s[2], c)) + try: + return v1 <= v2 + except TypeError: + return False + + +def IdenticallyEqual(f, c, solver_parameters, s): + """ + ["IdenticallyEqual", a, b] + Stricter than `StrictEqual`: true only if `a` and `b` have + the same Python type *and* are equal - e.g. `1` and `1.0` + are `StrictEqual` but not `IdenticallyEqual`. + """ + a = f(s[1], c) + b = f(s[2], c) + return type(a) == type(b) and a == b + + +def Congruent(f, c, solver_parameters, s): + """ + ["Congruent", a, b, modulus] + Whether `a` and `b` are congruent modulo `modulus`, i.e. + `(a - b) % modulus == 0`. + """ + a = f(s[1], c) + b = f(s[2], c) + modulus = f(s[3], c) + return (a - b) % modulus == 0 + + +def BooleanAnd(f, c, solver_parameters, s): + """ + Boolean AND operation. + ["And", condition1, condition2, ...] + """ + for x in s[1:]: + if not f(x, c): + return False + return True + + +def BooleanOr(f, c, solver_parameters, s): + """ + Boolean OR operation. + ["Or", condition1, condition2, ...] + """ + for x in s[1:]: + if f(x, c): + return True + return False diff --git a/src/mathjson_solver/_construct_shared.py b/src/mathjson_solver/_construct_shared.py new file mode 100644 index 0000000..d5ac4d1 --- /dev/null +++ b/src/mathjson_solver/_construct_shared.py @@ -0,0 +1,62 @@ +""" +Shared, `f`/`c`-dependent utility functions called from constructs in more +than one topic module (unlike the pure helpers in _common.py etc., these +need to recursively evaluate MathJSON sub-expressions via `f`). +""" + + +def _arr_vals(f, c, solver_parameters, s): + lst = f(s[1], c) + if not (isinstance(lst, list) and lst[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + return [f(x, c) for x in lst[1:]] + + +def _arr_vals_of(f, c, solver_parameters, expr): + """ + Like `_arr_vals`, but for an arbitrary expression instead + of assuming the array is at `s[1]` - needed by the + variadic set operations below. + """ + lst = f(expr, c) + if not (isinstance(lst, list) and lst[0] == "Array"): + raise ValueError("Parameter must be an array.") + return [f(x, c) for x in lst[1:]] + + +def _apply_fn(f, c, solver_parameters, fn_expr, args): + """ + Apply a "function" argument (as used by Map, Filter, and the + CortexJS form of Reduce) to positional `args`. Supports two + conventions: + + - CortexJS `["Function", body, param1, param2, ...]`. If no + parameter names are given, `args` are bound to the + anonymous placeholders "_" (only when there is a single + argument) and "_1", "_2", ... (always), for use inside + `body`. + - The existing "call template" convention: + `[function_name, ...]`, applied as + `f([function_name] + args, c)`. + """ + if isinstance(fn_expr, list) and fn_expr and fn_expr[0] == "Function": + body = fn_expr[1] + params = fn_expr[2:] + local_c = dict(c) + if params: + for name, value in zip(params, args): + local_c[name] = value + else: + if len(args) == 1: + local_c["_"] = args[0] + for i, value in enumerate(args, start=1): + local_c[f"_{i}"] = value + return f(body, local_c) + elif isinstance(fn_expr, list) and fn_expr: + function_name = fn_expr[0] + return f([function_name] + list(args), c) + else: + raise ValueError( + "Wrong function parameter: expected a call template " + "(e.g. ['Square']) or a ['Function', body, ...] expression." + ) diff --git a/src/mathjson_solver/_control_flow_constructs.py b/src/mathjson_solver/_control_flow_constructs.py new file mode 100644 index 0000000..b8e4df2 --- /dev/null +++ b/src/mathjson_solver/_control_flow_constructs.py @@ -0,0 +1,96 @@ +""" +Control-flow constructs (Constants, Switch, StrictSwitch, Which) plus +Variable/Function, which share their scoping concerns. `If` itself stays in +__main__.py - it inspects the module-level `constructs` dict directly to +detect CortexJS vs. Python calling convention, and moving it here would +create a circular import back to __main__. +""" + +from ._common import ( + comparison_safe_converter, +) + + +def Constants(f, c, solver_parameters, s): + for x in s[1:-1]: + try: + c[x[0]] = f(x[1], c) + except Exception: + c[x[0]] = None + return f(s[-1], c) + + +def Switch(f, c, solver_parameters, s): + expression = f(s[1], c) + for x in s[3:]: + if len(x) != 2: + raise ValueError("Case of 'Switch' should have exactly two parameters") + if comparison_safe_converter(expression) == comparison_safe_converter( + f(x[0], c) + ): + return f(x[1], c) + else: + return f(s[2], c) + + +def StrictSwitch(f, c, solver_parameters, s): + expression = f(s[1], c) + for x in s[3:]: + if len(x) != 2: + raise ValueError( + "Case of 'StrictSwitch' should have exactly two parameters" + ) + if expression == f(x[0], c): + return f(x[1], c) + else: + return f(s[2], c) + + +def Which(f, c, solver_parameters, s): + """ + ["Which", cond1, expr1, cond2, expr2, ..., condN, exprN] + CortexJS multi-branch conditional: evaluates each `cond` in + order and returns the `expr` paired with the first truthy + one. Unlike 'Switch' (Python's value-equality dispatch), + each `cond` here is itself a boolean expression, not a + value to compare against - and unlike `If`'s pair form, + the pairs are flat (cond, expr as separate arguments, not + nested as [cond, expr]). Returns None (CortexJS `Nothing`) + if no condition matches. + """ + args = s[1:] + if len(args) % 2 != 0: + raise ValueError( + "'Which' requires an even number of parameters " "(condition, value, ...)" + ) + for i in range(0, len(args), 2): + if f(args[i], c): + return f(args[i + 1], c) + return None + + +def Variable(f, c, solver_parameters, s): + """ + ["Variable", variable_name] + The `variable_name` must be a string. + """ + variable_name = s[1] + if variable_name in c: + return f(c[variable_name], c) + else: + raise KeyError(f"Variable '{variable_name}' is not defined") + + +def Function(f, c, solver_parameters, s): + """ + ["Function", body_expression, param_name1, param_name2, ...] + Defines a CortexJS-style lambda: `body_expression` is evaluated + with the given parameter names bound to whatever arguments it + is called with. `Function` expressions are meant to be passed + as the `function` argument of Map, Filter, and Reduce; if no + parameter names are given, the anonymous placeholders "_", + "_1", "_2", ... are used instead (see those functions). + Evaluating a ["Function", ...] expression outside of such a + context just returns it unevaluated. + """ + return s diff --git a/src/mathjson_solver/_core_constructs.py b/src/mathjson_solver/_core_constructs.py new file mode 100644 index 0000000..5d9c476 --- /dev/null +++ b/src/mathjson_solver/_core_constructs.py @@ -0,0 +1,64 @@ +""" +Core structural-introspection constructs (Head, Tail, Hold, Type, IsSame) - +these inspect or return expressions without evaluating them the way most +constructs do. +""" + +import numbers + +# --- Core: structural introspection --- +# +# Unlike almost everything else on CortexJS's Core reference +# page (CAS functions, mutable state, LaTeX serialization - +# all out of scope, see docs), these don't need a type +# system or a rendering surface: they work directly on the +# raw, unevaluated MathJSON tree every construct already +# receives as `s`, the same mechanism RegExp already uses to +# peek at its pattern argument without evaluating it. + + +def Head(f, c, solver_parameters, s): + expr = s[1] + return expr[0] if isinstance(expr, list) and expr else None + + +def Tail(f, c, solver_parameters, s): + expr = s[1] + return ["Array"] + expr[1:] if isinstance(expr, list) else ["Array"] + + +def Hold(f, c, solver_parameters, s): + return s[1] + + +def Type(f, c, solver_parameters, s): + value = f(s[1], c) + if isinstance(value, bool): + return "boolean" + if isinstance(value, numbers.Number): + return "number" + if isinstance(value, str): + return "string" + if isinstance(value, list) and value and value[0] == "Array": + return "array" + if value is None: + return "nothing" + return type(value).__name__ + + +def IsSame(f, c, solver_parameters, s): + """ + ["IsSame", expr1, expr2] + Whether expr1 and expr2 are structurally identical *as + written* - same shape, literals, and order - compared + without evaluating either side. Distinct from `Equal`/ + `StrictEqual` (which compare evaluated *values*) and + from `IdenticallyEqual` (a stricter same-type + `StrictEqual`, also over evaluated values): this is a + pre-evaluation, syntactic check. CortexJS distinguishes + `IsSame` from `Same` by canonical-form normalization + (e.g. treating `x+y` and `y+x` as the same); this solver + has no such canonicalization step, so both map to the + same plain structural comparison here. + """ + return s[1] == s[2] diff --git a/src/mathjson_solver/_datetime_constructs.py b/src/mathjson_solver/_datetime_constructs.py new file mode 100644 index 0000000..c94a851 --- /dev/null +++ b/src/mathjson_solver/_datetime_constructs.py @@ -0,0 +1,46 @@ +""" +Date/time constructs. +""" + +import datetime +from ._common import ( + _try_parse_datetime, +) + + +def Strptime(f, c, solver_parameters, s): + datetime_str = f(s[1], c) + parameters = f(s[2], c) + return datetime.datetime.strptime(datetime_str, parameters).isoformat() + + +def Strftime(f, c, solver_parameters, s): + dt = _try_parse_datetime(f(s[1], c)) + if not isinstance(dt, (datetime.datetime, datetime.date)): + raise ValueError(f"Strftime: could not parse input as datetime: {dt!r}") + parameters = f(s[2], c) + return dt.strftime(parameters) + + +def Now(f, c, solver_parameters, s): + return datetime.datetime.now().isoformat() + + +def Today(f, c, solver_parameters, s): + return datetime.date.today().isoformat() + + +def TimeDeltaDays(f, c, solver_parameters, s): + return datetime.timedelta(days=f(s[1], c)) + + +def TimeDeltaMinutes(f, c, solver_parameters, s): + return datetime.timedelta(minutes=f(s[1], c)) + + +def TimeDeltaHours(f, c, solver_parameters, s): + return datetime.timedelta(hours=f(s[1], c)) + + +def TimeDeltaWeeks(f, c, solver_parameters, s): + return datetime.timedelta(weeks=f(s[1], c)) diff --git a/src/mathjson_solver/_exceptions.py b/src/mathjson_solver/_exceptions.py new file mode 100644 index 0000000..d557a0e --- /dev/null +++ b/src/mathjson_solver/_exceptions.py @@ -0,0 +1,46 @@ +""" +The public exception type and expression type alias, kept in their own +leaf module so both __main__.py and the construct topic modules that +need `MathJSONException` (e.g. _functional_constructs.py's `Map`) can +import it without a circular dependency back to __main__. +""" + +from typing import Union, Any, TypeAlias + +# A MathJSON expression, in this solver's list-based (not dictionary- +# based) representation: a literal (string, number, bool, or None), or +# a compound expression - a list whose first element is a construct +# name and whose remaining elements are themselves MathJSONExpression +# values (e.g. ["Add", 1, ["Multiply", 2, "x"]]). Used only for the +# small public API surface (`create_solver`, `extract_variables`, +# `translate_v1_mathjson`) - the ~300 per-construct functions all +# take/return arbitrary runtime values (a construct can produce a +# `Fraction`, a `datetime.timedelta`, a compiled regex pattern, ...) +# and stay untyped rather than annotate each one with `Any` for no +# real type-safety benefit. +MathJSONExpression: TypeAlias = Union[ + str, int, float, bool, None, list["MathJSONExpression"] +] + + +class MathJSONException(Exception): + """Exception for MathJSON processing issues""" + + def __init__( + self, + e: Exception, + expr: MathJSONExpression, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(args) + self.e = e + self.expr = expr + self.construct = kwargs.get("mathjson_construct", "MathJSON") + + def __str__(self) -> str: + if hasattr(self.e, "message"): + m = self.e.message + else: + m = str(self.e) + return f"Problem in {self.construct}. {self.expr}. {m}" diff --git a/src/mathjson_solver/_functional_constructs.py b/src/mathjson_solver/_functional_constructs.py new file mode 100644 index 0000000..faceadb --- /dev/null +++ b/src/mathjson_solver/_functional_constructs.py @@ -0,0 +1,130 @@ +""" +Higher-order constructs (Map, Filter, Reduce, Fold, ...) built on +`_apply_fn`, which applies a CortexJS `Function` expression or call- +template to positional arguments. +""" + +from ._construct_shared import _apply_fn +from ._exceptions import MathJSONException + + +def Fold(f, c, solver_parameters, s): + """ + ["Fold", function, array] or ["Fold", function, array, initial] + The function-first form of `Reduce`'s CortexJS calling + convention (`["Reduce", array, function, ...]`) - same + behaviour, arguments swapped. + """ + if len(s) == 3: + return Reduce(f, c, solver_parameters, ["Reduce", s[2], s[1]]) + return Reduce(f, c, solver_parameters, ["Reduce", s[2], s[1], s[3]]) + + +def Map(f, c, solver_parameters, s): + """ + ["Map", list, function, more parameters] + The `function` must accept at least one parameter. That is for the current loop element. + The `more parameters` are for any additional parameters that function might have. + `function` can also be a CortexJS ["Function", body, ...params] expression. + """ + z = f(s[1], c) + if isinstance(z, list): + retlist = ["Array"] + for x in z[1:]: + try: + retlist.append(_apply_fn(f, c, solver_parameters, s[2], [x] + s[3:])) + except MathJSONException: + retlist.append(x) + return retlist + + +def StrictMap(f, c, solver_parameters, s): + """ + ["Map", list, function, more parameters] + The `function` must accept at least one parameter. That is for the current loop element. + The `more parameters` are for any additional parameters that function might have. + `function` can also be a CortexJS ["Function", body, ...params] expression. + """ + z = f(s[1], c) + if isinstance(z, list): + retlist = ["Array"] + for x in z[1:]: + retlist.append(_apply_fn(f, c, solver_parameters, s[2], [x] + s[3:])) + return retlist + + +def Filter(f, c, solver_parameters, s): + """ + ["Filter", list, function, more parameters] + The `function` must accept at least one parameter. That is for the current loop element. + The `more parameters` are for any additional parameters that function might have. + `function` can also be a CortexJS ["Function", body, ...params] expression. + """ + z = f(s[1], c) + if isinstance(z, list): + retlist = ["Array"] + for x in z[1:]: + if _apply_fn(f, c, solver_parameters, s[2], [x] + s[3:]): + retlist.append(x) + return retlist + + +def Reduce(f, c, solver_parameters, s): + """ + Two calling conventions, disambiguated by argument count: + + CortexJS form (3 or 4 arguments): + ["Reduce", list, function] + ["Reduce", list, function, initial_value] + `function` is applied as `function(accumulator, current_item)` + on each element; without `initial_value`, the first element + seeds the accumulator. `function` can be a call template + (e.g. ["Add"]) or a ["Function", body, ...params] expression + (see `_apply_fn`). + + Original Python form (6 arguments): + ["Reduce", list, initial_value, function, str_name_of_accumulator, str_name_of_current, str_name_of_index] + """ + if len(s) <= 4: + the_list = f(s[1], c) + if not (isinstance(the_list, list) and the_list[0] == "Array"): + raise ValueError("Parameter 1 must be an array.") + elements = the_list[1:] + fn_expr = s[2] + + if len(s) == 4: + accumulator = f(s[3], c) + remaining = elements + else: + if not elements: + raise ValueError( + "'Reduce' on an empty collection requires an initial value." + ) + accumulator = f(elements[0], c) + remaining = elements[1:] + + for x in remaining: + accumulator = _apply_fn(f, c, solver_parameters, fn_expr, [accumulator, x]) + return accumulator + + the_list = f(s[1], c)[1:] + initial_value = f(s[2], c) + function_expression = s[3] + + _accumulator = s[4] + name_accumulator = _accumulator[1] + + _current = s[5] + name_current = _current[1] + + _index = s[6] + name_index = _index[1] + + c[name_accumulator] = initial_value + + for i, x in enumerate(the_list): + c[name_current] = x + c[name_index] = i + c[name_accumulator] = f(function_expression, c) + + return c[name_accumulator] diff --git a/src/mathjson_solver/_number_theory_constructs.py b/src/mathjson_solver/_number_theory_constructs.py new file mode 100644 index 0000000..3a69da2 --- /dev/null +++ b/src/mathjson_solver/_number_theory_constructs.py @@ -0,0 +1,257 @@ +""" +Number-theory constructs, backed by the algorithms in _number_theory.py. +""" + +from fractions import Fraction +import math +from ._number_theory import ( + _MAX_NTH_PRIME, + _MAX_NUMBER_THEORY_MAGNITUDE, + _MAX_PRIME_PI, + _bernoulli, + _carmichael_lambda, + _chinese_remainder, + _continued_fraction, + _digits_in_base, + _divisors, + _extended_gcd, + _factor_integer, + _from_continued_fraction, + _is_figurate, + _is_perfect_power, + _is_prime, + _jacobi_symbol, + _lucas_l, + _multiplicative_order, + _primitive_root, + _totient, +) +from ._construct_shared import _arr_vals, _arr_vals_of + + +def IsPrime(f, c, solver_parameters, s): + return _is_prime(f(s[1], c)) + + +# --- Number theory --- + + +def FactorInteger(f, c, solver_parameters, s): + return ["Array"] + [["Array", p, e] for p, e in _factor_integer(f(s[1], c))] + + +def PrimeFactors(f, c, solver_parameters, s): + return ["Array"] + [p for p, _ in _factor_integer(f(s[1], c))] + + +def PrimeNu(f, c, solver_parameters, s): + return len(_factor_integer(f(s[1], c))) + + +def PrimeOmega(f, c, solver_parameters, s): + return sum(e for _, e in _factor_integer(f(s[1], c))) + + +def Radical(f, c, solver_parameters, s): + return math.prod(p for p, _ in _factor_integer(f(s[1], c))) + + +def IsSquareFree(f, c, solver_parameters, s): + return all(e == 1 for _, e in _factor_integer(f(s[1], c))) + + +def Divisors(f, c, solver_parameters, s): + return ["Array"] + _divisors(f(s[1], c)) + + +def Sigma0(f, c, solver_parameters, s): + return len(_divisors(f(s[1], c))) + + +def Sigma1(f, c, solver_parameters, s): + return sum(_divisors(f(s[1], c))) + + +def SigmaMinus1(f, c, solver_parameters, s): + return sum(Fraction(1, d) for d in _divisors(f(s[1], c))) + + +def DivisorSigma(f, c, solver_parameters, s): + k = int(f(s[2], c)) + return sum(d**k for d in _divisors(f(s[1], c))) + + +def Totient(f, c, solver_parameters, s): + return _totient(f(s[1], c)) + + +def IsPerfectPower(f, c, solver_parameters, s): + return _is_perfect_power(f(s[1], c)) + + +def NthPrime(f, c, solver_parameters, s): + """ + ["NthPrime", n] + The n-th prime number (1-indexed: NthPrime(1) = 2). + Capped at _MAX_NTH_PRIME - a naive forward search gets + slow well before Python's own integer limits do. + """ + n = int(f(s[1], c)) + if not (1 <= n <= _MAX_NTH_PRIME): + raise ValueError(f"'NthPrime' n must be between 1 and {_MAX_NTH_PRIME}.") + count, candidate = 0, 1 + while count < n: + candidate += 1 + if _is_prime(candidate): + count += 1 + return candidate + + +def NextPrime(f, c, solver_parameters, s): + """ + ["NextPrime", n] or ["NextPrime", n, k] + The smallest prime greater than n, or the k-th such + prime. Both the starting value's magnitude and k are + capped, for the same reason as NthPrime. + """ + start = int(f(s[1], c)) + k = int(f(s[2], c)) if len(s) > 2 else 1 + if abs(start) > _MAX_NUMBER_THEORY_MAGNITUDE: + raise ValueError( + f"'NextPrime' starting value is limited to " + f"{_MAX_NUMBER_THEORY_MAGNITUDE} in absolute value." + ) + if not (1 <= k <= _MAX_NTH_PRIME): + raise ValueError(f"'NextPrime' k must be between 1 and {_MAX_NTH_PRIME}.") + count, candidate = 0, start + while count < k: + candidate += 1 + if _is_prime(candidate): + count += 1 + return candidate + + +def PrimePi(f, c, solver_parameters, s): + """ + ["PrimePi", n] + pi(n): the count of primes <= n. Capped at + _MAX_PRIME_PI - cost scales with n itself, not sqrt(n). + """ + n = int(f(s[1], c)) + if not (0 <= n <= _MAX_PRIME_PI): + raise ValueError(f"'PrimePi' n must be between 0 and {_MAX_PRIME_PI}.") + return sum(1 for k in range(2, n + 1) if _is_prime(k)) + + +def ExtendedGCD(f, c, solver_parameters, s): + return ["Array"] + list(_extended_gcd(int(f(s[1], c)), int(f(s[2], c)))) + + +def ChineseRemainder(f, c, solver_parameters, s): + """ + ["ChineseRemainder", remainders, moduli] + Solves the system x = remainders[i] (mod moduli[i]) via + the Chinese Remainder Theorem. Moduli must be pairwise + coprime. + """ + remainders = _arr_vals_of(f, c, solver_parameters, s[1]) + moduli = _arr_vals_of(f, c, solver_parameters, s[2]) + return _chinese_remainder(remainders, moduli) + + +def CarmichaelLambda(f, c, solver_parameters, s): + return _carmichael_lambda(f(s[1], c)) + + +def JacobiSymbol(f, c, solver_parameters, s): + return _jacobi_symbol(f(s[1], c), f(s[2], c)) + + +def MultiplicativeOrder(f, c, solver_parameters, s): + return _multiplicative_order(f(s[1], c), f(s[2], c)) + + +def PrimitiveRoot(f, c, solver_parameters, s): + return _primitive_root(f(s[1], c)) + + +def LucasL(f, c, solver_parameters, s): + return _lucas_l(f(s[1], c)) + + +def BernoulliB(f, c, solver_parameters, s): + return _bernoulli(f(s[1], c)) + + +def ContinuedFraction(f, c, solver_parameters, s): + x = f(s[1], c) + max_terms = int(f(s[2], c)) if len(s) > 2 else 20 + return ["Array"] + _continued_fraction(x, max_terms) + + +def FromContinuedFraction(f, c, solver_parameters, s): + return _from_continued_fraction(_arr_vals(f, c, solver_parameters, s)) + + +def IntegerDigits(f, c, solver_parameters, s): + base = int(f(s[2], c)) if len(s) > 2 else 10 + return ["Array"] + _digits_in_base(f(s[1], c), base) + + +def DigitCount(f, c, solver_parameters, s): + base = int(f(s[2], c)) if len(s) > 2 else 10 + return len(_digits_in_base(f(s[1], c), base)) + + +def DigitSum(f, c, solver_parameters, s): + base = int(f(s[2], c)) if len(s) > 2 else 10 + return sum(_digits_in_base(f(s[1], c), base)) + + +def FromDigits(f, c, solver_parameters, s): + digits = _arr_vals(f, c, solver_parameters, s) + base = int(f(s[2], c)) if len(s) > 2 else 10 + result = 0 + for d in digits: + result = result * base + int(d) + return result + + +def IsSquare(f, c, solver_parameters, s): + n = int(f(s[1], c)) + return n >= 0 and math.isqrt(n) ** 2 == n + + +def IsTriangular(f, c, solver_parameters, s): + return _is_figurate(f(s[1], c), lambda k: k * (k + 1) // 2, 0) + + +def IsPentagonal(f, c, solver_parameters, s): + return _is_figurate(f(s[1], c), lambda k: k * (3 * k - 1) // 2, 1) + + +def IsOctahedral(f, c, solver_parameters, s): + return _is_figurate(f(s[1], c), lambda k: k * (2 * k * k + 1) // 3, 1) + + +def IsCenteredSquare(f, c, solver_parameters, s): + return _is_figurate(f(s[1], c), lambda k: 2 * k * (k - 1) + 1, 1) + + +def IsPerfect(f, c, solver_parameters, s): + n = f(s[1], c) + return sum(_divisors(n)[:-1]) == n + + +def IsAbundant(f, c, solver_parameters, s): + n = f(s[1], c) + return sum(_divisors(n)[:-1]) > n + + +def IsHappy(f, c, solver_parameters, s): + n = int(f(s[1], c)) + seen = set() + while n != 1 and n not in seen: + seen.add(n) + n = sum(int(d) ** 2 for d in str(n)) + return n == 1 diff --git a/src/mathjson_solver/_special_function_constructs.py b/src/mathjson_solver/_special_function_constructs.py new file mode 100644 index 0000000..d3cc988 --- /dev/null +++ b/src/mathjson_solver/_special_function_constructs.py @@ -0,0 +1,43 @@ +""" +Special-function constructs, backed by the iterative algorithms in +_special_functions.py. +""" + +import math +from ._special_functions import _agm, _elliptic_k_e, _erf_inv, _lambert_w + +# --- Special functions --- + + +def Beta(f, c, solver_parameters, s): + a, b = f(s[1], c), f(s[2], c) + return math.gamma(a) * math.gamma(b) / math.gamma(a + b) + + +def Factorial2(f, c, solver_parameters, s): + n = int(f(s[1], c)) + result = 1 + while n > 1: + result *= n + n -= 2 + return result + + +def ErfInv(f, c, solver_parameters, s): + return _erf_inv(f(s[1], c)) + + +def LambertW(f, c, solver_parameters, s): + return _lambert_w(f(s[1], c)) + + +def AGM(f, c, solver_parameters, s): + return _agm(f(s[1], c), f(s[2], c)) + + +def EllipticK(f, c, solver_parameters, s): + return _elliptic_k_e(f(s[1], c))[0] + + +def EllipticE(f, c, solver_parameters, s): + return _elliptic_k_e(f(s[1], c))[1] diff --git a/src/mathjson_solver/_statistics_constructs.py b/src/mathjson_solver/_statistics_constructs.py new file mode 100644 index 0000000..6f073df --- /dev/null +++ b/src/mathjson_solver/_statistics_constructs.py @@ -0,0 +1,215 @@ +""" +Descriptive-statistics and regression constructs. +""" + +from statistics import ( + correlation, + covariance, + mode, + pstdev, + pvariance, + quantiles, + stdev, + variance, +) +from ._construct_shared import _arr_vals, _arr_vals_of + +# Hard cap on PolynomialFit's degree - the normal-equations system it +# builds is (degree+1) x (degree+1), solved by O(degree^3) Gaussian +# elimination, so an unbounded user-supplied degree is a compute-cost +# vector the same way an unbounded string length is above. +_MAX_POLYFIT_DEGREE = 50 + + +def Variance(f, c, solver_parameters, s): + return variance(_arr_vals(f, c, solver_parameters, s)) + + +def StandardDeviation(f, c, solver_parameters, s): + return stdev(_arr_vals(f, c, solver_parameters, s)) + + +def PopulationVariance(f, c, solver_parameters, s): + return pvariance(_arr_vals(f, c, solver_parameters, s)) + + +def PopulationStandardDeviation(f, c, solver_parameters, s): + return pstdev(_arr_vals(f, c, solver_parameters, s)) + + +def Mode(f, c, solver_parameters, s): + """ + ["Mode", array] + The most frequently occurring value. Ties go to whichever + value appears first in `array` (matching Python's + `statistics.mode`). + """ + return mode(_arr_vals(f, c, solver_parameters, s)) + + +def Quartiles(f, c, solver_parameters, s): + """ + ["Quartiles", array] + The three points (Q1, Q2/median, Q3) that divide `array` + into four equal-sized groups, using + `statistics.quantiles`' default ("exclusive") method. + """ + return ["Array"] + quantiles(_arr_vals(f, c, solver_parameters, s), n=4) + + +def InterquartileRange(f, c, solver_parameters, s): + """ + ["InterquartileRange", array] + Q3 - Q1. + """ + q1, _, q3 = quantiles(_arr_vals(f, c, solver_parameters, s), n=4) + return q3 - q1 + + +def Covariance(f, c, solver_parameters, s): + """ + ["Covariance", array1, array2] + Sample covariance of two equal-length collections. + """ + return covariance( + _arr_vals_of(f, c, solver_parameters, s[1]), + _arr_vals_of(f, c, solver_parameters, s[2]), + ) + + +def Correlation(f, c, solver_parameters, s): + """ + ["Correlation", array1, array2] + The Pearson correlation coefficient of two equal-length + collections. + """ + return correlation( + _arr_vals_of(f, c, solver_parameters, s[1]), + _arr_vals_of(f, c, solver_parameters, s[2]), + ) + + +def Skewness(f, c, solver_parameters, s): + """ + ["Skewness", array] + Sample skewness (the adjusted Fisher-Pearson standardized + moment coefficient - matches Excel's SKEW and + scipy.stats.skew(..., bias=False)): a measure of the + asymmetry of the data's distribution. Requires at least + 3 data points. + """ + vals = _arr_vals(f, c, solver_parameters, s) + n = len(vals) + if n < 3: + raise ValueError("'Skewness' requires at least 3 data points.") + m = sum(vals) / n + s_dev = stdev(vals) + if s_dev == 0: + raise ValueError("'Skewness' is undefined when all values are equal.") + m3 = sum((x - m) ** 3 for x in vals) / n + return (n**2 / ((n - 1) * (n - 2))) * m3 / s_dev**3 + + +def Kurtosis(f, c, solver_parameters, s): + """ + ["Kurtosis", array] + Sample excess kurtosis (the adjusted Fisher-Pearson + estimator - matches Excel's KURT and + scipy.stats.kurtosis(..., bias=False, fisher=True)): a + measure of the "tailedness" of the data's distribution + (0 for a normal distribution). Requires at least 4 data + points. + """ + vals = _arr_vals(f, c, solver_parameters, s) + n = len(vals) + if n < 4: + raise ValueError("'Kurtosis' requires at least 4 data points.") + m = sum(vals) / n + m2 = sum((x - m) ** 2 for x in vals) / n + m4 = sum((x - m) ** 4 for x in vals) / n + if m2 == 0: + raise ValueError("'Kurtosis' is undefined when all values are equal.") + g2 = m4 / m2**2 - 3 + return ((n - 1) / ((n - 2) * (n - 3))) * ((n + 1) * g2 + 6) + + +def LinearRegression(f, c, solver_parameters, s): + """ + ["LinearRegression", x_array, y_array] + The least-squares linear fit y = slope*x + intercept, + returned as ["Array", slope, intercept]. + """ + x = _arr_vals_of(f, c, solver_parameters, s[1]) + y = _arr_vals_of(f, c, solver_parameters, s[2]) + if len(x) != len(y): + raise ValueError("Both arrays must be of the same length.") + n = len(x) + if n < 2: + raise ValueError("'LinearRegression' requires at least 2 data points.") + mx, my = sum(x) / n, sum(y) / n + sxy = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y)) + sxx = sum((xi - mx) ** 2 for xi in x) + if sxx == 0: + raise ValueError("'LinearRegression' is undefined when all x values are equal.") + slope = sxy / sxx + intercept = my - slope * mx + return ["Array", slope, intercept] + + +def _solve_linear_system(f, c, solver_parameters, matrix, vector): + """ + Solves `matrix @ x = vector` via Gaussian elimination + with partial pivoting - a pure-Python solver (no numpy + dependency) for the small, dense normal-equations system + `PolynomialFit` builds. Less numerically stable than a + QR-based solver for high degrees or badly-scaled data, + but exact enough for the modest degrees this is capped + at. Raises ValueError if the system is singular. + """ + n = len(matrix) + rows = [list(row) + [vector[i]] for i, row in enumerate(matrix)] + for col in range(n): + pivot = max(range(col, n), key=lambda r: abs(rows[r][col])) + if abs(rows[pivot][col]) < 1e-12: + raise ValueError( + "System is singular - check for duplicate x values " + "or too few distinct points for the requested degree." + ) + rows[col], rows[pivot] = rows[pivot], rows[col] + for r in range(n): + if r != col: + factor = rows[r][col] / rows[col][col] + for cc in range(col, n + 1): + rows[r][cc] -= factor * rows[col][cc] + return [rows[i][n] / rows[i][i] for i in range(n)] + + +def PolynomialFit(f, c, solver_parameters, s): + """ + ["PolynomialFit", x_array, y_array, degree] + The least-squares polynomial fit of the given `degree` + (0 to _MAX_POLYFIT_DEGREE), via the normal equations + solved with Gaussian elimination - no numpy dependency, + but less numerically stable for high degrees or + badly-scaled x values than a QR-based solver (e.g. + numpy.polyfit) would be; keep degrees modest for + well-conditioned results. Returns + ["Array", c0, c1, ..., cd] representing + `c0 + c1*x + c2*x^2 + ... + cd*x^d` (lowest degree + first - the opposite order from numpy.polyfit). + """ + x = _arr_vals_of(f, c, solver_parameters, s[1]) + y = _arr_vals_of(f, c, solver_parameters, s[2]) + if len(x) != len(y): + raise ValueError("Both arrays must be of the same length.") + degree = int(f(s[3], c)) + if not (0 <= degree <= _MAX_POLYFIT_DEGREE): + raise ValueError( + f"'PolynomialFit' degree must be between 0 and " f"{_MAX_POLYFIT_DEGREE}." + ) + if len(x) < degree + 1: + raise ValueError("'PolynomialFit' needs at least degree + 1 data points.") + power_sums = [sum(xi**k for xi in x) for k in range(2 * degree + 1)] + matrix = [[power_sums[i + j] for j in range(degree + 1)] for i in range(degree + 1)] + vector = [sum((xi**k) * yi for xi, yi in zip(x, y)) for k in range(degree + 1)] + return ["Array"] + _solve_linear_system(f, c, solver_parameters, matrix, vector) diff --git a/src/mathjson_solver/_string_constructs.py b/src/mathjson_solver/_string_constructs.py new file mode 100644 index 0000000..9ed56e0 --- /dev/null +++ b/src/mathjson_solver/_string_constructs.py @@ -0,0 +1,364 @@ +""" +String and regex (RE2-backed) constructs. +""" + +from ._construct_shared import _arr_vals + +RE2_AVAILABLE = False +try: + import re2 + + RE2_AVAILABLE = True + # No public "re2.Pattern" name is exported; derive the actual + # compiled-pattern type at import time rather than hardcoding + # re2's internal class path. + _RE2_PATTERN_TYPE = type(re2.compile("")) +except ImportError: + _RE2_PATTERN_TYPE = None + + +# Hard cap on the result length of string operations whose size is +# otherwise controlled directly by a user-supplied count/length +# argument (StringRepeat, PadStart, PadEnd) - closes the same +# memory/time exhaustion shape that Loop/Random/arbitrary-size Repeat +# are excluded for elsewhere in this solver, but here it's cheaply and +# exactly bounded by a single length check before any allocation. +_MAX_STRING_REPEAT_LENGTH = 100_000 + + +_VALID_REGEX_FLAGS = set("ims") + + +def Str(f, c, solver_parameters, s): + if len(s) < 2: + raise ValueError("Wrong parameters for 'Str'") + return f"{f(s[1])}" + + +def String(f, c, solver_parameters, s): + """ + ["String", value1, value2, ...] + Concatenates the default string representation of each + argument. Distinct from `Str` (single argument) and from + `StringJoin` (joins the elements of a single array). + """ + return "".join(str(f(x, c)) for x in s[1:]) + + +def StringJoin(f, c, solver_parameters, s): + """ + ["StringJoin", array] or ["StringJoin", array, separator] + Joins the (stringified) elements of `array` with + `separator` (default ""). Distinct from `Join`, which + concatenates multiple arrays together. + """ + values = [str(v) for v in _arr_vals(f, c, solver_parameters, s)] + separator = f(s[2], c) if len(s) > 2 else "" + return separator.join(values) + + +def Utf8(f, c, solver_parameters, s): + """ + ["Utf8", string] + List of UTF-8 byte values representing `string`. + """ + return ["Array"] + list(f(s[1], c).encode("utf-8")) + + +def Utf16(f, c, solver_parameters, s): + """ + ["Utf16", string] + List of UTF-16 code units representing `string` (one + array element per 16-bit code unit, matching how + JavaScript/CortexJS model a string). + """ + encoded = f(s[1], c).encode("utf-16-le") + return ["Array"] + [ + encoded[i] | (encoded[i + 1] << 8) for i in range(0, len(encoded), 2) + ] + + +def UnicodeScalars(f, c, solver_parameters, s): + """ + ["UnicodeScalars", string] + List of Unicode scalar (code point) values in `string`. + """ + return ["Array"] + [ord(ch) for ch in f(s[1], c)] + + +def StringFrom(f, c, solver_parameters, s): + """ + ["StringFrom", array, encoding] + Converts `array` (a list of code units, as produced by + `Utf8`/`Utf16`/`UnicodeScalars`) back into a string. + `encoding` is one of "utf-8", "utf-16", "unicode-scalars". + """ + values = _arr_vals(f, c, solver_parameters, s) + encoding = f(s[2], c) + if encoding == "utf-8": + return bytes(int(v) for v in values).decode("utf-8") + elif encoding == "utf-16": + raw = b"".join(int(v).to_bytes(2, "little") for v in values) + return raw.decode("utf-16-le") + elif encoding == "unicode-scalars": + return "".join(chr(int(v)) for v in values) + else: + raise ValueError(f"Unknown encoding: {encoding!r}") + + +def Characters(f, c, solver_parameters, s): + """ + ["Characters", string] + Splits `string` into a list of its characters. Approximated + at the Unicode code-point level (Python `str` iteration) + rather than true extended grapheme clusters (Unicode + Annex #29), which would need a dependency this solver + doesn't otherwise require - a multi-codepoint grapheme + (e.g. an emoji with a modifier) is split into its + constituent code points rather than kept whole. + """ + return ["Array"] + list(f(s[1], c)) + + +def StringSplit(f, c, solver_parameters, s): + """ + ["StringSplit", string] or ["StringSplit", string, separator] + Splits on whitespace if no `separator` is given, else on + the literal `separator` string. + """ + string = f(s[1], c) + if len(s) > 2: + return ["Array"] + string.split(f(s[2], c)) + return ["Array"] + string.split() + + +def StringReplace(f, c, solver_parameters, s): + """ + ["StringReplace", string, target, replacement] + Replaces every occurrence of the literal substring + `target` with `replacement` (not pattern-based - see + `IsMatch`/`StringMatch` for pattern matching). + """ + return f(s[1], c).replace(f(s[2], c), f(s[3], c)) + + +def StringCompare(f, c, solver_parameters, s): + """ + ["StringCompare", string1, string2] + -1, 0, or 1 depending on whether `string1` sorts before, + equal to, or after `string2` by code-point sequence. + """ + a, b = f(s[1], c), f(s[2], c) + return (a > b) - (a < b) + + +def IntegerString(f, c, solver_parameters, s): + """ + ["IntegerString", integer] or ["IntegerString", integer, base] + String representation of `integer` in `base` (default 10, + 2-36). + """ + value = int(f(s[1], c)) + base = int(f(s[2], c)) if len(s) > 2 else 10 + if base == 10: + return str(value) + if not (2 <= base <= 36): + raise ValueError("'IntegerString' base must be between 2 and 36.") + digits = "0123456789abcdefghijklmnopqrstuvwxyz" + negative = value < 0 + value = abs(value) + if value == 0: + digits_out = "0" + else: + digits_out = "" + while value: + value, rem = divmod(value, base) + digits_out = digits[rem] + digits_out + return ("-" if negative else "") + digits_out + + +def DigitsFrom(f, c, solver_parameters, s): + """ + ["DigitsFrom", string] or ["DigitsFrom", string, base] + Parses `string` as an integer in `base` (default 10). + """ + string = f(s[1], c) + base = int(f(s[2], c)) if len(s) > 2 else 10 + return int(string, base) + + +def NumberFrom(f, c, solver_parameters, s): + """ + ["NumberFrom", string] + Parses `string` as a number - integer, decimal, or + scientific notation. + """ + string = f(s[1], c).strip() + try: + return int(string) + except ValueError: + return float(string) + + +def StringRepeat(f, c, solver_parameters, s): + """ + ["StringRepeat", string, n] + Concatenates `n` copies of `string`. Capped at + `_MAX_STRING_REPEAT_LENGTH` characters, since `n` is a + user-controlled value that would otherwise make this an + unbounded memory-exhaustion primitive. + """ + string = f(s[1], c) + n = int(f(s[2], c)) + if n < 0: + raise ValueError("'StringRepeat' count must be non-negative.") + if len(string) * n > _MAX_STRING_REPEAT_LENGTH: + raise ValueError( + f"'StringRepeat' result would exceed the " + f"{_MAX_STRING_REPEAT_LENGTH}-character limit." + ) + return string * n + + +def _pad(f, c, solver_parameters, string, length, pad, prepend): + if length > _MAX_STRING_REPEAT_LENGTH: + raise ValueError( + f"Pad length must not exceed " f"{_MAX_STRING_REPEAT_LENGTH} characters." + ) + if len(string) >= length or not pad: + return string + needed = length - len(string) + full_pad = (pad * (needed // len(pad) + 1))[:needed] + return full_pad + string if prepend else string + full_pad + + +def PadStart(f, c, solver_parameters, s): + """ + ["PadStart", string, length] or + ["PadStart", string, length, pad] + Pads `string` on the left to `length` characters using + `pad` (default a single space), truncating `pad` as + needed to fit exactly. + """ + pad = f(s[3], c) if len(s) > 3 else " " + return _pad(f, c, solver_parameters, f(s[1], c), int(f(s[2], c)), pad, prepend=True) + + +def PadEnd(f, c, solver_parameters, s): + """ + ["PadEnd", string, length] or ["PadEnd", string, length, pad] + Pads `string` on the right to `length` characters using + `pad` (default a single space), truncating `pad` as + needed to fit exactly. + """ + pad = f(s[3], c) if len(s) > 3 else " " + return _pad( + f, c, solver_parameters, f(s[1], c), int(f(s[2], c)), pad, prepend=False + ) + + +def _require_re2(f, c, solver_parameters): + if not RE2_AVAILABLE: + raise ImportError( + "RegExp/IsMatch/StringMatch/StringMatchAll require " + "'google-re2'. Install with " + "'pip install mathjson-solver[regex]' " + "(or 'pip install google-re2')." + ) + + +def _compile_regex(f, c, solver_parameters, pattern_str, flags_str=""): + if not set(flags_str) <= _VALID_REGEX_FLAGS: + raise ValueError( + f"Unsupported regex flag(s) in {flags_str!r}; only " + f"'i', 'm', 's' are supported." + ) + prefixed = f"(?{flags_str})" + pattern_str if flags_str else pattern_str + try: + return re2.compile(prefixed) + except re2.error as e: + raise ValueError(f"Invalid pattern {pattern_str!r}: {e}") from e + + +def _resolve_pattern(f, c, solver_parameters, expr): + """ + Evaluates `expr` and returns a compiled RE2 pattern - + unchanged if it's already one (e.g. produced by a nested + `RegExp` call), else compiled fresh (no flags) if it's a + plain string. + + Uses RE2 rather than Python's `re`: RE2 guarantees + linear-time matching (no catastrophic backtracking is + possible, by construction of the engine), at the cost of + not supporting backreferences or lookaround - a + deliberate trade-off for a solver that evaluates + untrusted expressions with no execution budget of its own. + """ + _require_re2(f, c, solver_parameters) + value = f(expr, c) + if isinstance(value, _RE2_PATTERN_TYPE): + return value + if isinstance(value, str): + return _compile_regex(f, c, solver_parameters, value) + raise ValueError("Expected a pattern string or a RegExp value.") + + +def RegExp(f, c, solver_parameters, s): + """ + ["RegExp", pattern] or ["RegExp", pattern, flags] + Compiles `pattern` (RE2 syntax - see `_resolve_pattern`) + into a reusable pattern value, for passing to + `IsMatch`/`StringMatch`/`StringMatchAll`. `flags` is a + string made up of "i" (case-insensitive), "m" (multiline + anchors), "s" (dot matches newline). + """ + _require_re2(f, c, solver_parameters) + pattern_str = f(s[1], c) + flags_str = f(s[2], c) if len(s) > 2 else "" + return _compile_regex(f, c, solver_parameters, pattern_str, flags_str) + + +def IsMatch(f, c, solver_parameters, s): + """ + ["IsMatch", string, pattern] + Whether `string` contains a match for `pattern` (a plain + pattern string, or a `RegExp` value) anywhere within it. + """ + pattern = _resolve_pattern(f, c, solver_parameters, s[2]) + return pattern.search(f(s[1], c)) is not None + + +def _match_record(f, c, solver_parameters, m): + return [ + "Array", + m.group(0), + m.start() + 1, # 1-indexed, matching this solver's + m.end(), # other CortexJS-compat index + # conventions (At, Range, ...) + ["Array"] + list(m.groups()), + ] + + +def StringMatch(f, c, solver_parameters, s): + """ + ["StringMatch", string, pattern] + The first match of `pattern` in `string`, as + ["Array", matched_text, start, end, ["Array", group1, ...]] + (1-indexed `start`, exclusive `end`), or None if there's + no match. + """ + pattern = _resolve_pattern(f, c, solver_parameters, s[2]) + m = pattern.search(f(s[1], c)) + return _match_record(f, c, solver_parameters, m) if m else None + + +def StringMatchAll(f, c, solver_parameters, s): + """ + ["StringMatchAll", string, pattern] + All non-overlapping matches of `pattern` in `string`, + each in the same shape as `StringMatch`. + """ + pattern = _resolve_pattern(f, c, solver_parameters, s[2]) + return ["Array"] + [ + _match_record(f, c, solver_parameters, m) for m in pattern.finditer(f(s[1], c)) + ] diff --git a/src/mathjson_solver/_trig_constructs.py b/src/mathjson_solver/_trig_constructs.py new file mode 100644 index 0000000..f767126 --- /dev/null +++ b/src/mathjson_solver/_trig_constructs.py @@ -0,0 +1,34 @@ +""" +Trigonometric constructs and the Pi constant. +""" + +import math + + +# sin, cos, tan, arcsin, arccos, arctan +def Sin(f, c, solver_parameters, s): + return math.sin(f(s[1], c)) + + +def Arcsin(f, c, solver_parameters, s): + return math.asin(f(s[1], c)) + + +def Cos(f, c, solver_parameters, s): + return math.cos(f(s[1], c)) + + +def Arccos(f, c, solver_parameters, s): + return math.acos(f(s[1], c)) + + +def Tan(f, c, solver_parameters, s): + return math.tan(f(s[1], c)) + + +def Arctan(f, c, solver_parameters, s): + return math.atan(f(s[1], c)) + + +def Pi(f, c, solver_parameters, s): + return math.pi