Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions criteria/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions criteria/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]
Expand Down
14 changes: 14 additions & 0 deletions expression_parsing/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions expression_parsing/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)",
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -80,6 +86,7 @@
"MAX_NUMBER_EXPONENT",
"MAX_PRODUCT_TERMS",
"MAX_SIMPLIFY_POWER",
"RELATIONAL_OPERATORS",
"UNEVALUATED_SUM",
"UNICODE_OPERATORS",
"AbsoluteValueNotationError",
Expand All @@ -95,6 +102,7 @@
"SymbolSpec",
"SympyParsingConfig",
"UnknownConventionError",
"WrittenNumber",
"__version__",
"apply_convention",
"compute_relative_tolerance_from_significant_decimals",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass

DEFAULT_SIGNIFICANT_FIGURES = 2

Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading
Loading