diff --git a/README.md b/README.md index 3242edc..d2dd3b9 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ value (bug fixes only). Each package builds on its own: ```bash -cd units && poetry build +cd physical_quantities && poetry build ``` During development, sibling dependencies resolve from the monorepo as path diff --git a/criteria/CHANGELOG.md b/criteria/CHANGELOG.md index 3884b17..04b1702 100644 --- a/criteria/CHANGELOG.md +++ b/criteria/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog: lambdafeedback-criteria +## 0.4.0 (unreleased; pre-release 0.4.0rc1, tag v0.4.0-rc.1) + +The version moves with `lambdafeedback-expression-parsing` 0.4.0, since all four `lambdafeedback-*` packages share one version. + ## 0.3.0 (unreleased; pre-release 0.3.0rc4, tag v0.3.0-rc.4) The first public release, after the extraction refactor. The import path is now `lambdafeedback.criteria` (was `criteria`). There was no 0.2.0 release: all four `lambdafeedback-*` packages share one version, and the first release is 0.3.0. Supports Python 3.12 to 3.14 (3.11 is not supported); `__version__` gives the installed version. diff --git a/criteria/pyproject.toml b/criteria/pyproject.toml index 719ecf6..e6377de 100644 --- a/criteria/pyproject.toml +++ b/criteria/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "lambdafeedback-criteria" -version = "0.3.0rc4" +version = "0.4.0rc1" description = "Criteria parser and criteria graphs that turn evaluations into feedback, for Lambda Feedback evaluation functions." readme = "README.md" license = "MIT" @@ -25,7 +25,7 @@ classifiers = [ ] requires-python = ">=3.12" dependencies = [ - "lambdafeedback-slr-parsing (>=0.3.0rc4,<0.4.0)", + "lambdafeedback-slr-parsing (>=0.4.0rc1,<0.5.0)", ] [project.urls] diff --git a/expression_parsing/CHANGELOG.md b/expression_parsing/CHANGELOG.md index a9cf8fc..c0cdbb3 100644 --- a/expression_parsing/CHANGELOG.md +++ b/expression_parsing/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog: lambdafeedback-expression-parsing +## 0.4.0 (unreleased; pre-release 0.4.0rc1, tag v0.4.0-rc.1) + +New features on top of 0.3.0: relational operators, chained equalities, reading the precision a number was written to, and big-operator task symbols. + +### Added + +- (since 0.4.0rc1) `parse_expression` reads inequalities and `!=`. `x < 5`, `x <= 5`, `x > 5` and `x >= 5` give the unevaluated SymPy relations, `x != 5` gives `Ne(x, 5)`, and a chain of two inequalities pointing the same way (`1 < x <= 5`) gives `And` of both relations. As with `=`, each operand is parsed as a whole expression, and is simplified only with `simplify`. `≠`, `≤` and `≥` are written as `!=`, `<=` and `>=` (`UNICODE_OPERATORS`). These raise `ExpressionParsingError`: more than two inequalities (`a < b < c < d`), a chain pointing both ways (`1 < x > 5`), `=` or `!=` combined with another relation (`1 < x != 5`), and a relation missing an operand (`x <`). `!=` used to be read as a factorial followed by `=`, so `x != 5` was `Eq(factorial(x), 5)`, and `x <= 5` failed to parse. `split_relations(expr, error)` (exported, with `RELATIONAL_OPERATORS`) returns the operands and the operators between them, checked by the rules above. It replaces `split_equation` in `parse_expression`; `parse_latex` still splits on `=` only, as latex2sympy2_extended reads `\leq` and the others itself. From compareExpressions#276. +- (since 0.4.0rc1) A chain of equalities: `x = y = z` gives `And(Eq(x, y), Eq(y, z))` from both `parse_expression` and `parse_latex`, where more than one `=` used to raise "An expression can contain at most one '='". A single `=` still gives a bare `Equality`. `=` in a chain can't be mixed with another relation (`x = y < z`), and an empty side (`x = = y`) raises `ExpressionParsingError` (`LatexParseError` from `parse_latex`). From compareExpressions#278. +- (since 0.4.0rc1) `read_written_number(string)` reads the precision a number was written to, for grading a response to a number of significant figures or decimal places. It returns a `WrittenNumber` (its `integer` and `fraction` digits, `has_point` and `exponent`), or `None` unless `string` is exactly one decimal number. `.significant_figures` doesn't count leading zeros, and counts trailing zeros only after a written point (`540` has 2, `540.` has 3, `92.00` has 4). `.decimal_places` subtracts the exponent (`5.02e4` has 0, `5e-3` has 3). It's more lenient than `is_number`, since it reads what a learner wrote: a `+` sign, leading zeros and a trailing point are allowed. The exponent is written `e`, `E`, `*10^` or `*10**`. `round_to_significant_figures(value, figures)` returns a float, and `round_to_decimal_places(value, places)` returns a string that keeps its trailing zeros. They come from compareExpressions#273 and #277, which only read an `e`/`E` exponent; grading with them stays in compareExpressions. + +### Fixed + +- (since 0.4.0rc1) A task symbol whose LaTeX starts with a big operator (`\sum`, `\int`, `\prod`, `\lim`, `\bigcup`, `\bigcap`; `BIG_OPERATOR_PREFIXES` in `sympy_parsing`) wraps its argument when applied: with `infsum` as `\sum_{n=1}^{\infty}`, `infsum((-1)^n/n^2)` is an undefined function `infsum` of the summand, which `sympy_to_latex` prints as the operator followed by it. It was the product `infsum*((-1)^n/n^2)`, so the operator was printed inside the fraction, or followed by `\cdot`. Not applied (`2*infsum`), it is still the task's symbol. From compareExpressions#261. + ## 0.3.0 (unreleased; pre-release 0.3.0rc4, tag v0.3.0-rc.4) The first public release. Further tidy-up after the 0.2.0 extraction: previewing a response and mapping failures to learner-facing feedback text are app-layer concerns, so they move out of this package. `__version__` gives the installed version. diff --git a/expression_parsing/pyproject.toml b/expression_parsing/pyproject.toml index 908c5b6..3d9c5f3 100644 --- a/expression_parsing/pyproject.toml +++ b/expression_parsing/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "lambdafeedback-expression-parsing" -version = "0.3.0rc4" +version = "0.4.0rc1" description = "Safe SymPy parsing and preprocessing of learner-written mathematical expressions, for Lambda Feedback evaluation functions." readme = "README.md" license = "MIT" @@ -27,7 +27,7 @@ classifiers = [ ] requires-python = ">=3.12" dependencies = [ - "lambdafeedback-slr-parsing (>=0.3.0rc4,<0.4.0)", + "lambdafeedback-slr-parsing (>=0.4.0rc1,<0.5.0)", "sympy (>=1.14,<2.0)", "latex2sympy2-extended[antlr4-13-2] (>=1.11.0,<2.0)", ] diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/__init__.py b/expression_parsing/src/lambdafeedback/expression_parsing/__init__.py index ca69a56..12445e3 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/__init__.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/__init__.py @@ -39,21 +39,27 @@ from .latex import MAX_LATEX_NESTING, extract_latex, latex_symbols, parse_latex, sanitise_latex, sympy_to_latex from .notation import NotationWarning, notation_warnings from .numbers import ( + WrittenNumber, compute_relative_tolerance_from_significant_decimals, generate_arbitrary_number_pattern_matcher, is_complex_number_on_cartesian_form, is_complex_number_on_exponential_form, is_number, plain_number_end, + read_written_number, + round_to_decimal_places, + round_to_significant_figures, ) from .params import Convention, ExpressionParams, SymbolSpec, parse_symbol_assumptions from .preprocessing import ( + RELATIONAL_OPERATORS, convert_absolute_notation, convert_bracket_notation, create_expression_set, has_matching_brackets, is_multiple_answers_wrapper, preprocess_expression, + split_relations, ) from .substitution import ( FunctionName, @@ -80,6 +86,7 @@ "MAX_NUMBER_EXPONENT", "MAX_PRODUCT_TERMS", "MAX_SIMPLIFY_POWER", + "RELATIONAL_OPERATORS", "UNEVALUATED_SUM", "UNICODE_OPERATORS", "AbsoluteValueNotationError", @@ -95,6 +102,7 @@ "SymbolSpec", "SympyParsingConfig", "UnknownConventionError", + "WrittenNumber", "__version__", "apply_convention", "compute_relative_tolerance_from_significant_decimals", @@ -119,7 +127,11 @@ "parse_symbol_assumptions", "plain_number_end", "preprocess_expression", + "read_written_number", + "round_to_decimal_places", + "round_to_significant_figures", "sanitise_latex", + "split_relations", "substitute", "substitute_input_symbols", "substitutions_sort_key", diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/latex.py b/expression_parsing/src/lambdafeedback/expression_parsing/latex.py index ec8e776..b924de3 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/latex.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/latex.py @@ -362,8 +362,8 @@ def text(parsed: Any) -> str: for expression in responses: sides = split_equation(expression, LatexParseError) try: - if len(sides) == 2: - # An equation is written "lhs = rhs", as parse_expression reads it, with each + if len(sides) > 1: + # An equation is written "lhs = rhs" (or "x = y = z"), as parse_expression reads it, with each # side parsed separately ("Eq(x, 2)" would not survive parse_expression with # elementary_functions, where the E of Eq is Euler's number). parsed_responses.add(" = ".join(text(_latex2sympy(side)) for side in sides)) diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/numbers.py b/expression_parsing/src/lambdafeedback/expression_parsing/numbers.py index a8c92a8..4ae7275 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/numbers.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/numbers.py @@ -10,6 +10,7 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import dataclass DEFAULT_SIGNIFICANT_FIGURES = 2 @@ -182,6 +183,76 @@ def matcher(comp_string: str) -> bool: return matcher +@dataclass(frozen=True) +class WrittenNumber: + """A number as written, digit for digit: what its precision is depends on how it was written. + + ``92`` and ``92.00`` are the same value written to 2 and 4 significant + figures; ``540`` has 2 and ``540.`` (with a point) 3. + """ + + integer: str + """The digits before the point (may be empty, as in ``.5``).""" + fraction: str + """The digits after the point (may be empty).""" + has_point: bool + exponent: int = 0 + + @property + def significant_figures(self) -> int: + """Leading zeros don't count; trailing zeros count only after a written point. Zero has 1.""" + digits = (self.integer + self.fraction).lstrip("0") + if not digits: + return 1 + if not self.has_point: + digits = digits.rstrip("0") + return len(digits) or 1 + + @property + def decimal_places(self) -> int: + """The digits after the point, less the exponent: ``5.02e4`` has 0, ``5e-3`` has 3.""" + return max(len(self.fraction) - self.exponent, 0) + + +def read_written_number(string: str) -> WrittenNumber | None: + """``string`` as a :class:`WrittenNumber`, if it is exactly one decimal number; ``None`` otherwise. + + More lenient than :func:`is_number`, since a learner's precision is read + from what they wrote: a ``+`` sign, leading zeros and a trailing point + (``540.``) are allowed. The exponent is written as in :func:`is_number` + (``e``, ``E``, ``*10^`` or ``*10**``), with an optional sign. + """ + string = string.strip() + index = 1 if string[:1] in ("+", "-") else 0 + integer_end = _digits_end(string, index) + has_point = string.startswith(".", integer_end) + fraction_end = _digits_end(string, integer_end + 1) if has_point else integer_end + integer, fraction = string[index:integer_end], string[integer_end + 1 : fraction_end] + if not integer and not fraction: + return None + exponent = 0 + if fraction_end < len(string): + mark = next((mark for mark in _EXPONENT_MARKS if string.startswith(mark, fraction_end)), None) + if mark is None: + return None + exponent_text = string[fraction_end + len(mark) :] + digits = exponent_text[1:] if exponent_text[:1] in ("+", "-") else exponent_text + if not digits or _digits_end(digits, 0) != len(digits): + return None + exponent = int(exponent_text) + return WrittenNumber(integer, fraction, has_point, exponent) + + +def round_to_significant_figures(value: float, figures: int) -> float: + """``value`` rounded to ``figures`` significant figures (``3.14159`` to 3 is ``3.14``).""" + return float(f"{value:.{figures}g}") + + +def round_to_decimal_places(value: float, places: int) -> str: + """``value`` rounded to ``places`` decimal places, as a string to keep trailing zeros (``0`` to 2 is ``0.00``).""" + return f"{value:.{places}f}" + + def compute_relative_tolerance_from_significant_decimals(string: str) -> float: """``5 * 10**-n`` for a number written with ``n`` significant figures (at least 2); 0 for non-numbers.""" string = string.strip() diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/preprocessing.py b/expression_parsing/src/lambdafeedback/expression_parsing/preprocessing.py index 1525a4b..38d7a1e 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/preprocessing.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/preprocessing.py @@ -19,13 +19,53 @@ def split_equation(expr: str, error: type[ExpressionParsingError]) -> list[str]: - """``[expr]``, or ``[lhs, rhs]`` for an equation; raises ``error`` for more than one ``=``.""" + """``[expr]``, or the sides of an equation (``x = y = z`` has three); raises ``error`` for an empty side.""" sides = expr.split("=") - if len(sides) > 2: - raise error(f"An expression can contain at most one '=': {expr}", expression=expr) + if len(sides) > 1 and any(not side.strip() for side in sides): + raise error(f"A relation needs an expression on each side: {expr}", expression=expr) return sides +RELATIONAL_OPERATORS = ("<=", ">=", "!=", "<", ">", "=") +"""The relations a response may state, two-character operators first (scanned in this order).""" + + +def _relational_operator_at(expr: str, index: int) -> str | None: + return next((operator for operator in RELATIONAL_OPERATORS if expr.startswith(operator, index)), None) + + +def split_relations(expr: str, error: type[ExpressionParsingError]) -> tuple[list[str], list[str]]: + """The operands of ``expr`` and the relational operators between them: ``1 < x <= 5`` gives + ``(["1 ", " x ", " 5"], ["<", "<="])``, and an expression ``([expr], [])``. + + Allowed are one relation (``x = 1``, ``x < 1``, ``x != 1``), a chain of + equalities (``x = y = z``) or two order relations pointing the same way + (``1 < x <= 5``). Raises ``error`` for anything else, and for a relation + missing an operand (``x <``, ``x = = y``). + """ + operands, operators = [], [] + start = index = 0 + while index < len(expr): + operator = _relational_operator_at(expr, index) + if operator is None: + index += 1 + continue + operands.append(expr[start:index]) + operators.append(operator) + index = start = index + len(operator) + operands.append(expr[start:]) + if len(operators) > 1 and set(operators) != {"="}: + if "=" in operators or "!=" in operators: + raise error(f"'=' and '!=' can't be combined with another relation: {expr}", expression=expr) + if len(operators) > 2: + raise error(f"Only two inequalities can be chained, as in 1 < x < 5: {expr}", expression=expr) + if operators[0][0] != operators[1][0]: + raise error(f"A chain of inequalities must point one way, as in 1 < x < 5: {expr}", expression=expr) + if operators and any(not operand.strip() for operand in operands): + raise error(f"A relation needs an expression on each side: {expr}", expression=expr) + return operands, operators + + def has_matching_brackets(expr: str) -> bool: """True if every ``(``, ``[``, ``{`` is closed by the same kind, correctly nested. diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/symbol_tables.py b/expression_parsing/src/lambdafeedback/expression_parsing/symbol_tables.py index 1c44a63..7f2aec9 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/symbol_tables.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/symbol_tables.py @@ -142,6 +142,9 @@ def _with_upper_case(entries: list[tuple[str, list[str]]]) -> tuple[SymbolAliase "∙": "*", # BULLET OPERATOR "÷": "/", # DIVISION SIGN "∕": "/", # DIVISION SLASH + "≠": "!=", # NOT EQUAL TO + "≤": "<=", # LESS-THAN OR EQUAL TO + "≥": ">=", # GREATER-THAN OR EQUAL TO } ) """Unicode operators (as typed on phones or pasted from documents) and their ASCII forms.""" diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/sympy_parsing.py b/expression_parsing/src/lambdafeedback/expression_parsing/sympy_parsing.py index c99e033..d64a0ff 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/sympy_parsing.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/sympy_parsing.py @@ -8,13 +8,37 @@ from dataclasses import dataclass, field, replace from functools import cached_property from io import StringIO +from itertools import pairwise from keyword import iskeyword from token import NAME, OP from tokenize import TokenError, generate_tokens from typing import Any import sympy -from sympy import Basic, Chi, E, Equality, Function, I, Lambda, Set, Sum, Symbol, Tuple, beta, gamma, summation, zeta +from sympy import ( + And, + Basic, + Chi, + E, + Equality, + Function, + Ge, + Gt, + I, + Lambda, + Le, + Lt, + Ne, + Set, + Sum, + Symbol, + Tuple, + beta, + gamma, + summation, + zeta, +) +from sympy.core.function import UndefinedFunction from sympy.parsing.sympy_parser import ( _token_splittable, auto_number, @@ -35,7 +59,7 @@ from .conventions import apply_convention from .errors import ExpressionParsingError, SymbolAssumptionError from .params import ExpressionParams -from .preprocessing import convert_bracket_notation, create_expression_set, split_equation +from .preprocessing import convert_bracket_notation, create_expression_set, split_relations from .substitution import ( Substitution, elementary_function_substitutions, @@ -95,6 +119,62 @@ def sympy_symbols(symbols: Iterable[str]) -> dict[str, Symbol]: return {name: Symbol(name) for name in symbols} +BIG_OPERATOR_PREFIXES = (r"\sum", r"\int", r"\prod", r"\lim", r"\bigcup", r"\bigcap") +"""LaTeX commands that make a task symbol a prefix operator (see :func:`prefix_operator`).""" + + +def _print_prefix_operator(self: Any, printer: Any) -> str: + # Read from a class attribute, so the function pickles (a closure over the LaTeX wouldn't). + return f"{self._latex_prefix} {printer.doprint(self.args[0])}" + + +def prefix_operator(name: str, latex: str) -> UndefinedFunction: + """An undefined function for a task symbol whose LaTeX is a big operator (``\\sum_{n=1}^{\\infty}``). + + As a ``Symbol``, ``infsum(x)`` would be read as ``infsum*x``, and the + operator printed inside fractions or followed by ``\\cdot``. As a function + it wraps its argument, and prints as ``latex`` followed by it. + """ + return UndefinedFunction(name, _latex_prefix=latex, _latex=_print_prefix_operator) + + +_OPERATOR_SUFFIX = "__operator" + + +def _prefix_operators(params: ExpressionParams) -> dict[str, UndefinedFunction]: + """``{code__operator: prefix_operator(code, latex)}`` for each task symbol whose LaTeX starts with a big operator. + + The code itself stays a ``Symbol``: :func:`_applied_prefix_operators` + swaps in the operator only where it is applied. + """ + from .latex import extract_latex # imported here: latex imports this module + + operators = {} + for code, spec in params.symbols.items(): + if spec.latex is None: + continue + latex = extract_latex(spec.latex).strip() + if latex.startswith(BIG_OPERATOR_PREFIXES): + operators[f"{code}{_OPERATOR_SUFFIX}"] = prefix_operator(code, latex) + return operators + + +def _applied_prefix_operators(tokens: list[Any], local_dict: dict[str, Any], global_dict: Any) -> list[Any]: + """Read a big-operator task symbol followed by ``(`` as its prefix operator (``infsum(x)``). + + Not applied to anything (``2*infsum``), it is still the task's symbol. + """ + result: list[Any] = [] + values = [value for _, value in tokens] + for index, (kind, value) in enumerate(tokens): + operator = f"{value}{_OPERATOR_SUFFIX}" + if kind == NAME and operator in local_dict and _following(values, index) == "(": + result.append((NAME, operator)) + else: + result.append((kind, value)) + return result + + UNEVALUATED_SUM = Function("sum") """A sum written without an index or limits (``sum(x^n)``): an undefined function of its summand. @@ -168,7 +248,7 @@ def __post_init__(self) -> None: params = self.params unsplittable = [*self.extra_unsplittable_symbols, *params.reserved_keywords] unsplittable += [code for code in params.symbols if len(code) > 1] - symbol_dict = {**_base_symbol_dict(params), **sympy_symbols(unsplittable)} + symbol_dict = {**_base_symbol_dict(params), **sympy_symbols(unsplittable), **_prefix_operators(params)} constants: set[str] = set() for symbol, assumption in (*self.extra_symbol_assumptions, *params.symbol_assumptions): # Assumptions become Symbol keyword arguments; SymPy stores any identifier. @@ -235,7 +315,14 @@ def transformations(self) -> tuple[Any, ...]: """ # _keywords_as_names goes after auto_symbol (which leaves keywords alone, and # would wrap our Symbol('in') again), before the symbol splitting. - leading = (lambda_notation, auto_symbol, _keywords_as_names, repeated_decimals, auto_number) + leading = ( + lambda_notation, + auto_symbol, + _keywords_as_names, + _applied_prefix_operators, + repeated_decimals, + auto_number, + ) transformations: tuple[Any, ...] if self.params.strict_syntax: transformations = (*leading, function_exponentiation) @@ -354,15 +441,34 @@ def _substitute_names(expr: str, config: SympyParsingConfig) -> str: return " ".join(substitute(expr, sorted_substitutions(substitutions)).split()) +def _relation(operator: str, lhs: Any, rhs: Any) -> Basic: + """The unevaluated SymPy relation ``lhs operator rhs``.""" + if operator == "=": + return Equality(lhs, rhs, evaluate=False) + elif operator == "!=": + return Ne(lhs, rhs, evaluate=False) + elif operator == "<": + return Lt(lhs, rhs, evaluate=False) + elif operator == "<=": + return Le(lhs, rhs, evaluate=False) + elif operator == ">": + return Gt(lhs, rhs, evaluate=False) + elif operator == ">=": + return Ge(lhs, rhs, evaluate=False) + raise ValueError(f"Not a relational operator: {operator!r}") + + def _evaluate(expr: str, config: SympyParsingConfig, transformations: tuple[Any, ...]) -> Basic: symbol_dict = dict(config.symbol_dict) - sides = split_equation(expr, ExpressionParsingError) + operands, operators = split_relations(expr, ExpressionParsingError) parsed: Any - if len(sides) == 2: - # Each side as if it were a whole expression: unevaluated unless simplifying + if operators: + # Each operand as if it were a whole expression: unevaluated unless simplifying # (evaluating 9**9**9 = x would never finish). - lhs, rhs = (_parse_side(side, config, transformations, symbol_dict) for side in sides) - parsed = Equality(lhs, rhs, evaluate=False) + sides = [_parse_side(operand, config, transformations, symbol_dict) for operand in operands] + relations = [_relation(operator, *pair) for operator, pair in zip(operators, pairwise(sides), strict=True)] + # A chain (1 < x < 5) states each relation between neighbours. + parsed = relations[0] if len(relations) == 1 else And(*relations, evaluate=False) else: parsed = _parse_side(expr, config, transformations, symbol_dict) if not isinstance(parsed, Basic): diff --git a/expression_parsing/tests/test_regressions.py b/expression_parsing/tests/test_regressions.py index 9605956..c305b06 100644 --- a/expression_parsing/tests/test_regressions.py +++ b/expression_parsing/tests/test_regressions.py @@ -9,6 +9,7 @@ import pytest from sympy import ( Abs, + And, Eq, Function, Integral, @@ -130,9 +131,10 @@ def test_capital_e_after_implicit_multiplication(self): assert str(parse_expression("2E", parsing_params())) == "2*E" assert str(parse_expression("xE", parsing_params())) == "E*x" - def test_chained_equalities_are_rejected(self): - with pytest.raises(ValueError, match="="): - parse_expression("a=b=c", parsing_params()) + def test_chained_equalities_are_each_equation(self): + # Was silently Eq(a, b), then rejected; a chain since compareExpressions#278. + a, b, c = Symbol("a"), Symbol("b"), Symbol("c") + assert parse_expression("a=b=c", parsing_params()) == And(Eq(a, b), Eq(b, c)) def test_arithmetic_on_sets_is_rejected_in_strict_syntax(self): with warnings.catch_warnings(): @@ -211,10 +213,13 @@ def test_simplify_keeps_an_equation(self): # Simplifying the Eq as a whole turned "x = x" into "True". assert parse_latex("x = x", {}, simplify=True) == "x = x" - def test_more_than_one_equals_sign_is_rejected(self): - with pytest.raises(LatexParseError, match="=") as info: - parse_latex("x = y = 2", {}) - assert info.value.expression == "x = y = 2" + def test_chained_equality_keeps_every_side(self): + assert parse_latex("x = y = 2", {}) == "x = y = 2" + + def test_empty_side_is_rejected(self): + with pytest.raises(LatexParseError, match="on each side") as info: + parse_latex("x = = 2", {}) + assert info.value.expression == "x = = 2" class TestPublicReleaseReview: @@ -903,6 +908,61 @@ def test_integrate_with_elementary_functions(self, expr, expected): assert self.parse(expr, elementary_functions=True) == expected +_BIG_OPERATOR_SYMBOLS = {"infsum": {"latex": r"\sum_{n=1}^{\infty}"}, "pi": {"latex": r"\pi"}} + + +class TestBigOperatorSymbols: + """A task symbol with big-operator LaTeX (``infsum`` as ``\\sum``) was a Symbol, so ``infsum(x)`` was ``infsum*x``.""" + + def parse(self, response): + params = ExpressionParams.from_dict({"elementary_functions": True, "symbols": _BIG_OPERATOR_SYMBOLS}) + expr = parse_expression(preprocess_expression(response, params), SympyParsingConfig.from_params(params)) + return expr, params + + def test_symbol_is_a_function_of_its_argument(self): + expr, _ = self.parse("infsum((-1)^n / n^2)") + assert expr.func.__name__ == "infsum" + + @pytest.mark.parametrize( + ("response", "latex"), + [ + ("infsum((-1)^n / n^2)", r"\sum_{n=1}^{\infty} \frac{\left(-1\right)^{n}}{n^{2}}"), + ( + "infsum((4/n^2)*(-1)^n*cos(nx))", + r"\sum_{n=1}^{\infty} \frac{4 \cdot \left(-1\right)^{n} \cdot \cos{\left(n \cdot x \right)}}{n^{2}}", + ), + ( + "((pi^2)/(3))+infsum((4/n^2)*(-1)^n*cos(nx))", + r"\frac{\pi^{2}}{3} + \sum_{n=1}^{\infty} \frac{4 \cdot \left(-1\right)^{n} \cdot \cos{\left(n \cdot x \right)}}{n^{2}}", + ), + ], + ) + def test_prints_the_operator_before_its_argument(self, response, latex): + expr, params = self.parse(response) + assert sympy_to_latex(expr, params.symbols, {"mul_symbol": "dot"}) == latex + + @pytest.mark.parametrize("latex", [r"\int_0^1", r"\prod_{k=1}^{n}", r"\lim_{x \to 0}", r"$\bigcup_i$"]) + def test_every_big_operator_is_a_prefix(self, latex): + params = ExpressionParams.from_dict({"symbols": {"op": {"latex": latex}}}) + assert parse_expression("op(x)", SympyParsingConfig.from_params(params)).func.__name__ == "op" + + def test_not_applied_it_is_still_the_symbol(self): + expr, _ = self.parse("2*infsum + infsum(x)") + assert expr.has(Symbol("infsum")) + assert any(term.func.__name__ == "infsum" for term in expr.args) + + def test_other_symbols_stay_symbols(self): + params = ExpressionParams.from_dict({"symbols": {"ab": {"latex": r"\alpha_b"}}}) + assert parse_expression("ab", SympyParsingConfig.from_params(params)) == Symbol("ab") + + def test_config_round_trips_through_pickle(self): + import pickle + + params = ExpressionParams.from_dict({"symbols": _BIG_OPERATOR_SYMBOLS}) + config = pickle.loads(pickle.dumps(SympyParsingConfig.from_params(params))) + assert sympy_to_latex(parse_expression("infsum(x)", config), params.symbols) == r"\sum_{n=1}^{\infty} x" + + def test_parsing_logs_its_decisions_at_debug_only(caplog): import logging diff --git a/expression_parsing/tests/test_relations.py b/expression_parsing/tests/test_relations.py new file mode 100644 index 0000000..afe52df --- /dev/null +++ b/expression_parsing/tests/test_relations.py @@ -0,0 +1,132 @@ +"""Relations in a response: equations, inequalities and ``!=`` (from compareExpressions#276).""" + +import pytest +from sympy import And, Equality, GreaterThan, LessThan, Ne, StrictGreaterThan, StrictLessThan, Symbol + +from lambdafeedback.expression_parsing import ( + ExpressionParams, + ExpressionParsingError, + LatexParseError, + SympyParsingConfig, + parse_expression, + parse_latex, + preprocess_expression, + split_relations, +) + +x = Symbol("x") + + +def parse(expr, **params): + expression_params = ExpressionParams.from_dict({"elementary_functions": True, **params}) + preprocessed = preprocess_expression(expr, expression_params) + return parse_expression(preprocessed, SympyParsingConfig.from_params(expression_params)) + + +@pytest.mark.parametrize( + ("expr", "expected_type"), + [ + ("x > 5", StrictGreaterThan), + ("x < 5", StrictLessThan), + ("x >= 5", GreaterThan), + ("x <= 5", LessThan), + ("5 < x", StrictLessThan), + ("2*x - 10 >= 0", GreaterThan), + ("2x - 10 > 0", StrictGreaterThan), + ("x>=5", GreaterThan), + ("x ≤ 5", LessThan), + ("x ≥ 5", GreaterThan), + ], +) +def test_inequality(expr, expected_type): + assert isinstance(parse(expr), expected_type) + + +@pytest.mark.parametrize( + ("expr", "part_types"), + [ + ("1 < x < 5", (StrictLessThan, StrictLessThan)), + ("1 <= x <= 5", (LessThan, LessThan)), + ("5 >= x >= 1", (GreaterThan, GreaterThan)), + ("5 >= x > 1", (GreaterThan, StrictGreaterThan)), + ("0 < x - 1 <= 4", (StrictLessThan, LessThan)), + ], +) +def test_chained_inequality_is_both_relations(expr, part_types): + parsed = parse(expr) + assert isinstance(parsed, And) + assert len(parsed.args) == 2 + assert {type(arg) for arg in parsed.args} == set(part_types) + + +@pytest.mark.parametrize("expr", ["x != 5", "5 != x", "x - 5 != 0", "x ≠ 5", "x!=5"]) +def test_not_equal(expr): + # `!=` was read as factorial and `=`: `x != 5` was Eq(factorial(x), 5). + assert isinstance(parse(expr), Ne) + + +@pytest.mark.parametrize("expr", ["x = 5", "2*x**2 = 10*y**2 + 14"]) +def test_equation_is_still_an_equality(expr): + assert isinstance(parse(expr), Equality) + + +def test_operands_are_left_unevaluated_unless_simplifying(): + assert parse("x < 2 + 3").rhs != 5 + assert parse("x < 2 + 3", simplify=True).rhs == 5 + + +@pytest.mark.parametrize( + ("expr", "message"), + [ + ("1 < x > 5", "point one way"), + ("a < b > c", "point one way"), + ("1 <= x <= y <= 5", "two inequalities"), + ("a < b < c < d", "two inequalities"), + ("x != y != 5", "can't be combined"), + ("1 < x != 5", "can't be combined"), + ("x < y = 5", "can't be combined"), + ("x = y < 5", "can't be combined"), + ("x = y != 5", "can't be combined"), + ("x <", "on each side"), + ("< 5", "on each side"), + ("x = = y", "on each side"), + ("x = y =", "on each side"), + ], +) +def test_rejected(expr, message): + with pytest.raises(ExpressionParsingError, match=message): + parse(expr) + + +@pytest.mark.parametrize("expr", ["x = y = z", "x=y=z"]) +def test_chained_equality_is_each_equation(expr): + # From compareExpressions#278: more than one "=" used to be rejected. + y, z = Symbol("y"), Symbol("z") + assert parse(expr) == And(Equality(x, y), Equality(y, z)) + + +def test_long_chained_equality(): + parsed = parse("a = b = c = d") + assert isinstance(parsed, And) + assert len(parsed.args) == 3 + assert all(isinstance(arg, Equality) for arg in parsed.args) + + +def test_latex_chained_equality_parses_through(): + y, z = Symbol("y"), Symbol("z") + assert parse(parse_latex("x = y = z", {})) == And(Equality(x, y), Equality(y, z)) + + +def test_latex_empty_side_is_rejected(): + with pytest.raises(LatexParseError, match="on each side"): + parse_latex("x = = y", {}) + + +def test_split_relations(): + assert split_relations("1 < x <= 5", ExpressionParsingError) == (["1 ", " x ", " 5"], ["<", "<="]) + assert split_relations("x + 1", ExpressionParsingError) == (["x + 1"], []) + + +def test_latex_inequality_parses_through(): + # parse_latex writes the relation as text, which parse_expression then reads. + assert parse(parse_latex(r"x \leq 5", {})) == LessThan(x, 5) diff --git a/expression_parsing/tests/test_written_number.py b/expression_parsing/tests/test_written_number.py new file mode 100644 index 0000000..8185314 --- /dev/null +++ b/expression_parsing/tests/test_written_number.py @@ -0,0 +1,111 @@ +"""Reading the precision a number was written to (from compareExpressions#273 and #277).""" + +import pytest + +from lambdafeedback.expression_parsing import ( + WrittenNumber, + read_written_number, + round_to_decimal_places, + round_to_significant_figures, +) + + +@pytest.mark.parametrize( + ("string", "expected"), + [ + ("92.00", WrittenNumber("92", "00", True)), + ("92", WrittenNumber("92", "", False)), + ("0.0032", WrittenNumber("0", "0032", True)), + ("540", WrittenNumber("540", "", False)), + ("540.", WrittenNumber("540", "", True)), + (".5", WrittenNumber("", "5", True)), + ("-3.14", WrittenNumber("3", "14", True)), + ("+3.14", WrittenNumber("3", "14", True)), + (" 3.14 ", WrittenNumber("3", "14", True)), + ("007", WrittenNumber("007", "", False)), + ("5.02e4", WrittenNumber("5", "02", True, 4)), + ("5.02E+4", WrittenNumber("5", "02", True, 4)), + ("5e-3", WrittenNumber("5", "", False, -3)), + ("5.02*10^4", WrittenNumber("5", "02", True, 4)), + ("5.02*10**-4", WrittenNumber("5", "02", True, -4)), + ("0", WrittenNumber("0", "", False)), + ], +) +def test_reads_a_written_number(string, expected): + assert read_written_number(string) == expected + + +@pytest.mark.parametrize("string", ["", "two", ".", "-", "--3.14", "3.14e", "3.14e-", "3.1.4", "3x", "1/2", "3.14 m"]) +def test_rejects_anything_else(string): + assert read_written_number(string) is None + + +@pytest.mark.parametrize( + ("string", "expected"), + [ + ("92.00", 4), + ("92", 2), + ("0.0032", 2), + ("540", 2), + ("540.", 3), + ("0", 1), + ("0.00", 1), + ("007", 1), + ("5.02e4", 3), + ("-3.14", 3), + ], +) +def test_significant_figures(string, expected): + assert read_written_number(string).significant_figures == expected + + +@pytest.mark.parametrize( + ("string", "expected"), + [ + ("3.14", 2), + ("3.140", 3), + ("92.00", 2), + ("0.0032", 4), + ("540", 0), + ("540.", 0), + ("0", 0), + ("5.02e4", 0), + ("5e-3", 3), + ("1.5e-3", 4), + ("1.5*10^-3", 4), + ], +) +def test_decimal_places(string, expected): + assert read_written_number(string).decimal_places == expected + + +@pytest.mark.parametrize( + ("value", "figures", "expected"), + [ + (0, 3, 0.0), + (3.14159, 3, 3.14), + (3.14159, 6, 3.14159), + (540, 2, 540.0), + (0.0032, 2, 0.0032), + (50200, 3, 50200.0), + (-3.14159, 3, -3.14), + ], +) +def test_round_to_significant_figures(value, figures, expected): + assert round_to_significant_figures(value, figures) == pytest.approx(expected) + + +@pytest.mark.parametrize( + ("value", "places", "expected"), + [ + (0, 2, "0.00"), + (92, 2, "92.00"), + (3.14159, 2, "3.14"), + (3.14159, 4, "3.1416"), + (3.146, 2, "3.15"), + (2.7, 0, "3"), + (-3.14159, 2, "-3.14"), + ], +) +def test_round_to_decimal_places(value, places, expected): + assert round_to_decimal_places(value, places) == expected diff --git a/physical_quantities/CHANGELOG.md b/physical_quantities/CHANGELOG.md index 601f98d..1a6cb8b 100644 --- a/physical_quantities/CHANGELOG.md +++ b/physical_quantities/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog: lambdafeedback-physical-quantities +## 0.4.0 (unreleased; pre-release 0.4.0rc1, tag v0.4.0-rc.1) + +The version moves with `lambdafeedback-expression-parsing` 0.4.0, since all four `lambdafeedback-*` packages share one version. + ## 0.3.0 (unreleased; pre-release 0.3.0rc4, tag v0.3.0-rc.4) The first public release. This is a further tidy-up after the 0.2.0 extraction: the package now only does unit conversion. Package metadata (MIT licence, README, URLs) is in place for PyPI. Python 3.13 and later is supported, now that `lambdafeedback-expression-parsing` no longer depends on latex2sympy2. SymPy 1.14 or later is required (was 1.12): 1.12 predates Python 3.12, and a configuration with a declared function (`symbol_assumptions` `function`) can only be pickled from 1.14. diff --git a/physical_quantities/pyproject.toml b/physical_quantities/pyproject.toml index 74e3d49..db1782a 100644 --- a/physical_quantities/pyproject.toml +++ b/physical_quantities/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "lambdafeedback-physical-quantities" -version = "0.3.0rc4" +version = "0.4.0rc1" description = "Parse physical quantities such as 9.81 m/s^2 into value and unit, with SI conversion and dimensional analysis." readme = "README.md" license = "MIT" @@ -25,8 +25,8 @@ classifiers = [ ] requires-python = ">=3.12" dependencies = [ - "lambdafeedback-slr-parsing (>=0.3.0rc4,<0.4.0)", - "lambdafeedback-expression-parsing (>=0.3.0rc4,<0.4.0)", + "lambdafeedback-slr-parsing (>=0.4.0rc1,<0.5.0)", + "lambdafeedback-expression-parsing (>=0.4.0rc1,<0.5.0)", "sympy (>=1.14,<2.0)", ] diff --git a/poetry.lock b/poetry.lock index 772deff..2d74f1b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1278,7 +1278,7 @@ type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] [[package]] name = "lambdafeedback-criteria" -version = "0.3.0rc4" +version = "0.4.0rc1" description = "Criteria parser and criteria graphs that turn evaluations into feedback, for Lambda Feedback evaluation functions." optional = false python-versions = ">=3.12" @@ -1287,7 +1287,7 @@ files = [] develop = true [package.dependencies] -lambdafeedback-slr-parsing = ">=0.3.0rc4,<0.4.0" +lambdafeedback-slr-parsing = ">=0.4.0rc1,<0.5.0" [package.source] type = "directory" @@ -1295,7 +1295,7 @@ url = "criteria" [[package]] name = "lambdafeedback-expression-parsing" -version = "0.3.0rc4" +version = "0.4.0rc1" description = "Safe SymPy parsing and preprocessing of learner-written mathematical expressions, for Lambda Feedback evaluation functions." optional = false python-versions = ">=3.12" @@ -1304,7 +1304,7 @@ files = [] develop = true [package.dependencies] -lambdafeedback-slr-parsing = ">=0.3.0rc4,<0.4.0" +lambdafeedback-slr-parsing = ">=0.4.0rc1,<0.5.0" latex2sympy2-extended = {version = ">=1.11.0,<2.0", extras = ["antlr4-13-2"]} sympy = ">=1.14,<2.0" @@ -1314,7 +1314,7 @@ url = "expression_parsing" [[package]] name = "lambdafeedback-physical-quantities" -version = "0.3.0rc4" +version = "0.4.0rc1" description = "Parse physical quantities such as 9.81 m/s^2 into value and unit, with SI conversion and dimensional analysis." optional = false python-versions = ">=3.12" @@ -1323,8 +1323,8 @@ files = [] develop = true [package.dependencies] -lambdafeedback-expression-parsing = ">=0.3.0rc4,<0.4.0" -lambdafeedback-slr-parsing = ">=0.3.0rc4,<0.4.0" +lambdafeedback-expression-parsing = ">=0.4.0rc1,<0.5.0" +lambdafeedback-slr-parsing = ">=0.4.0rc1,<0.5.0" sympy = ">=1.14,<2.0" [package.source] @@ -1333,7 +1333,7 @@ url = "physical_quantities" [[package]] name = "lambdafeedback-slr-parsing" -version = "0.3.0rc4" +version = "0.4.0rc1" description = "Dependency-free SLR(1) parser generator for Lambda Feedback evaluation functions." optional = false python-versions = ">=3.12" diff --git a/slr_parsing/CHANGELOG.md b/slr_parsing/CHANGELOG.md index 77b1c66..232b53b 100644 --- a/slr_parsing/CHANGELOG.md +++ b/slr_parsing/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog: lambdafeedback-slr-parsing +## 0.4.0 (unreleased; pre-release 0.4.0rc1, tag v0.4.0-rc.1) + +The version moves with `lambdafeedback-expression-parsing` 0.4.0, since all four `lambdafeedback-*` packages share one version. + ## 0.3.0 (unreleased; pre-release 0.3.0rc4, tag v0.3.0-rc.4) The first public release, after the extraction refactor. The import path is now `lambdafeedback.slr_parsing` (was `slr_parsing`). There was no 0.2.0 release: all four `lambdafeedback-*` packages share one version, and the first release is 0.3.0. Supports Python 3.12 to 3.14 (3.11 is not supported); `__version__` gives the installed version. diff --git a/slr_parsing/pyproject.toml b/slr_parsing/pyproject.toml index e3e1c9e..a7b7385 100644 --- a/slr_parsing/pyproject.toml +++ b/slr_parsing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "lambdafeedback-slr-parsing" -version = "0.3.0rc4" +version = "0.4.0rc1" description = "Dependency-free SLR(1) parser generator for Lambda Feedback evaluation functions." readme = "README.md" license = "MIT" diff --git a/tools/parity/expected_changes.json b/tools/parity/expected_changes.json index b540324..880264f 100644 --- a/tools/parity/expected_changes.json +++ b/tools/parity/expected_changes.json @@ -116,8 +116,11 @@ "value": "ERROR" }, "expr.parse|assumptions|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|assumptions|x – 1": { "reason": "expression_parsing: Unicode minus and en dash are read as '-' (normalise_unicode_operators); was the product x*−*1", @@ -142,8 +145,11 @@ "value": "ERROR" }, "expr.parse|complex|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|complex|x – 1": { "reason": "expression_parsing: Unicode minus and en dash are read as '-' (normalise_unicode_operators); was the product x*−*1", @@ -175,8 +181,11 @@ } }, "expr.parse|default|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|default|x – 1": { "reason": "expression_parsing: Unicode minus and en dash are read as '-' (normalise_unicode_operators); was the product x*−*1", @@ -208,8 +217,11 @@ "value": "ERROR" }, "expr.parse|elementary|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|elementary|x – 1": { "reason": "expression_parsing: Unicode minus and en dash are read as '-' (normalise_unicode_operators); was the product x*−*1", @@ -234,8 +246,11 @@ "value": "ERROR" }, "expr.parse|implicit_higher|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|implicit_higher|x – 1": { "reason": "expression_parsing: Unicode minus and en dash are read as '-' (normalise_unicode_operators); was the product x*−*1", @@ -256,8 +271,11 @@ "value": "ERROR" }, "expr.parse|strict|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|strict|as": { "reason": "expression_parsing: Python keywords (as, in, is, ...) are read as symbols; in strict syntax 'as' is one symbol (was a*s)", @@ -296,8 +314,11 @@ "value": "ERROR" }, "expr.parse|symbols|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|symbols|lam + lambda": { "reason": "expression_parsing: task symbols are normalised once (lambda -> lamda) in ExpressionParams, so every path maps the task's lambda to lamda and prints it with the task's LaTeX",