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
9 changes: 9 additions & 0 deletions app/context/symbolic/comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from lambdafeedback.slr_parsing import ExprNode

from app.context.operators import COMPARISON_OPERATORS
from app.context.symbolic.inequalities import InequalityComparison, check_inequality_equivalence, inequality_bounds
from app.context.symbolic.numerical import compare_numerically, matches_written_precision
from app.evaluation_parameters import EvaluationParameters
from app.types import SubstitutionList, Substitutions
Expand Down Expand Up @@ -132,6 +133,14 @@ def check_equality(
if uses_written_precision(criterion, parameters):
return check_written_precision(criterion, lhs_expr, rhs_expr, parameters)

# Subtracting relations raises a TypeError, so they must not reach the `lhs_expr - rhs_expr` comparison below
lhs_is_inequality = inequality_bounds(lhs_expr) is not None
rhs_is_inequality = inequality_bounds(rhs_expr) is not None
if lhs_is_inequality and rhs_is_inequality:
return check_inequality_equivalence(lhs_expr, rhs_expr, parameters) is InequalityComparison.TRUE
elif lhs_is_inequality or rhs_is_inequality:
return False

if isinstance(lhs_expr, Equality) and not isinstance(rhs_expr, Equality):
return False
elif not isinstance(lhs_expr, Equality) and isinstance(rhs_expr, Equality):
Expand Down
87 changes: 87 additions & 0 deletions app/context/symbolic/graphs/equivalence.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from app.context.graph_helpers import attach_terminal_criterion, outcome_tag
from app.context.symbolic.comparison import check_equality, unsimplified_with_unsplittable
from app.context.symbolic.graphs.set_equivalence import add_set_equivalence_nodes, as_sets
from app.context.symbolic.inequalities import check_inequality_equivalence, inequality_bounds
from app.evaluation_parameters import EvaluationParameters
from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators

Expand Down Expand Up @@ -113,6 +114,90 @@ def add_equality_equivalence_nodes(
)


def _inequality_equivalence(label: str, parameters: EvaluationParameters) -> Evaluate:
def inequality_equivalence(_response: Any) -> ReachedCriteria:
res = parameters.reserved_expressions["response"]
ans = parameters.reserved_expressions["answer"]
result = check_inequality_equivalence(res, ans, parameters)
return {label + "_" + result.name: None}
return inequality_equivalence


def add_inequality_equivalence_nodes(
graph: CriteriaGraph, END: OutputNode, parameters: EvaluationParameters, label: str, lhs: str, rhs: str
) -> None:
"""Adds the nodes that check if the inequalities (or `!=` relations) `lhs` and `rhs` are equivalent to `graph`."""
graph.add_evaluation_node(
label,
summary=label,
details="Checks if " + str(lhs) + " is an equivalent inequality to " + str(rhs) + ".",
evaluate=_inequality_equivalence(label, parameters)
)
attach_terminal_criterion(
graph,
label,
label + "_TRUE",
END,
summary=str(lhs) + " is equivalent to " + str(rhs),
details=str(lhs) + " is an equivalent inequality to " + str(rhs) + ".",
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("INEQUALITIES_EQUIVALENT")
)
attach_terminal_criterion(
graph,
label,
label + "_FALSE",
END,
summary=str(lhs) + " is not equivalent to " + str(rhs),
details=str(lhs) + " is not an equivalent inequality to " + str(rhs) + ".",
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("INEQUALITIES_NOT_EQUIVALENT")
)
attach_terminal_criterion(
graph,
label,
label + "_UNKNOWN",
END,
summary="Cannot determine if " + str(lhs) + " is equivalent to " + str(rhs),
details="Cannot determine if " + str(lhs) + " is an equivalent inequality to " + str(rhs) + ".",
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("INEQUALITY_EQUIVALENCE_UNKNOWN")
)
attach_terminal_criterion(
graph,
label,
label + "_WRONG_DIRECTION",
END,
summary=str(lhs) + " is the opposite inequality to " + str(rhs),
details=str(lhs) + " points in the opposite direction to " + str(rhs) + ".",
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("INEQUALITIES_WRONG_DIRECTION")
)
attach_terminal_criterion(
graph,
label,
label + "_STRICTNESS_MISMATCH",
END,
summary=str(lhs) + " has a different strictness to " + str(rhs),
details=str(lhs) + " uses a strict/non-strict inequality where " + str(rhs) + " does not.",
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("INEQUALITY_STRICTNESS_MISMATCH")
)
attach_terminal_criterion(
graph,
label,
label + "_RESPONSE_NOT_INEQUALITY",
END,
summary=str(lhs) + " is an expression, not an inequality.",
details=str(lhs) + " is an expression, not an inequality.",
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("RESPONSE_NOT_INEQUALITY")
)
attach_terminal_criterion(
graph,
label,
label + "_ANSWER_NOT_INEQUALITY",
END,
summary=str(rhs) + " is an expression, not an inequality.",
details=str(rhs) + " is an expression, not an inequality.",
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("ANSWER_NOT_INEQUALITY")
)


def add_mathematical_equivalence_nodes(
graph: CriteriaGraph, END: OutputNode, criterion: ExprNode, parameters: EvaluationParameters, label: str, lhs: str, rhs: str
) -> None:
Expand Down Expand Up @@ -196,6 +281,8 @@ def criterion_equality_node(criterion: ExprNode, parameters: EvaluationParameter

if use_set_equivalence is True:
add_set_equivalence_nodes(graph, END, criterion, parameters, label, lhs, rhs, response_list, answer_list)
elif inequality_bounds(res) is not None or inequality_bounds(ans) is not None:
add_inequality_equivalence_nodes(graph, END, parameters, label, lhs, rhs)
elif isinstance(res, Equality) or isinstance(ans, Equality):
add_equality_equivalence_nodes(graph, END, parameters, label, lhs, rhs)
else:
Expand Down
179 changes: 179 additions & 0 deletions app/context/symbolic/inequalities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Equivalence of inequalities (`x > 5`, `1 < x < 5`) and not-equal relations (`x != 5`) in the answer and response."""

from __future__ import annotations

from enum import Enum

from sympy import And, Basic, GreaterThan, LessThan, Ne, StrictGreaterThan, StrictLessThan, simplify
from sympy.core.relational import Relational

from app.evaluation_parameters import EvaluationParameters

# Order relations (chainable as `1 < x < 5`).
ORDER_RELATIONS = (StrictLessThan, LessThan, StrictGreaterThan, GreaterThan)
# Non-order relations handled by the same machinery but never chained.
RELATIONS = ORDER_RELATIONS + (Ne,)


class InequalityComparison(Enum):
"""The outcome of comparing a response relation to an answer relation; the names are the criteria tag suffixes."""
TRUE = "equivalent"
FALSE = "not equivalent, e.g. a zero ratio, a different number of bounds or `!=` against an order operator"
UNKNOWN = "undecidable, the ratio is not a constant or its sign is unknown"
WRONG_DIRECTION = "the ratio is a negative constant, so the relations describe opposite regions"
STRICTNESS_MISMATCH = "the ratio is a positive constant but one relation is strict and the other is not"
RESPONSE_NOT_INEQUALITY = "the answer is a relation but the response is not"
ANSWER_NOT_INEQUALITY = "the response is a relation but the answer is not"


def inequality_bounds(expr: Basic) -> list[Relational] | None:
"""
The list of relation parts if `expr` is a single relation (`x < 5`, `x != 5`) or a conjunction
of order inequalities (a chained inequality such as `1 < x < 5`), otherwise None.
"""
if isinstance(expr, RELATIONS):
return [expr]
if isinstance(expr, And) and expr.args and all(isinstance(arg, ORDER_RELATIONS) for arg in expr.args):
return list(expr.args)
return None


def _constant_ratio(difference_res: Basic, difference_ans: Basic, constants: set[str]) -> Basic | None:
"""`difference_res / difference_ans` simplified, or None if it contains symbols that are not constants."""
ratio = simplify(difference_res / difference_ans)
if not {str(s) for s in ratio.free_symbols}.issubset(constants):
return None
return ratio


def _compare_not_equal(res: Relational, ans: Relational, constants: set[str]) -> InequalityComparison:
"""
`f != g` is equivalent to `p != q` when `(f - g) / (p - q)` simplifies to a
non-zero constant. Direction and strictness do not apply to `!=`.
"""
difference_res = simplify(res.lhs - res.rhs)
difference_ans = simplify(ans.lhs - ans.rhs)
if difference_res == 0 and difference_ans == 0:
return InequalityComparison.TRUE
if difference_res == 0 or difference_ans == 0:
return InequalityComparison.UNKNOWN
ratio = _constant_ratio(difference_res, difference_ans, constants)
if ratio is None:
return InequalityComparison.UNKNOWN
if ratio.is_zero:
return InequalityComparison.FALSE
if ratio.is_positive or ratio.is_negative:
return InequalityComparison.TRUE
return InequalityComparison.UNKNOWN


def _normalise(relation: Relational) -> tuple[Basic, str]:
"""`relation` rewritten as `D REL 0` with `REL` either `<` or `<=`, returned as `(D, REL)`."""
difference = relation.lhs - relation.rhs
operator = relation.rel_op
if operator in (">", ">="):
difference = -difference
operator = "<" if operator == ">" else "<="
return simplify(difference), operator


def _compare_single_inequality(res: Relational, ans: Relational, constants: set[str]) -> InequalityComparison:
"""
Compare one response relation to one answer relation.

For order operators each side `f REL g` is rewritten as `D REL 0` (all terms
moved to one side) and normalised to `<` or `<=` by negating `D` when the
operator is `>` or `>=`; the two are equivalent when `D_res / D_ans`
simplifies to a positive constant and the normalised operators match. `!=` is
delegated to `_compare_not_equal`; `!=` against an order operator is never
equivalent.
"""
res_is_not_equal = res.rel_op == "!="
ans_is_not_equal = ans.rel_op == "!="
if res_is_not_equal != ans_is_not_equal:
return InequalityComparison.FALSE
if res_is_not_equal and ans_is_not_equal:
return _compare_not_equal(res, ans, constants)

try:
difference_res, operator_res = _normalise(res)
difference_ans, operator_ans = _normalise(ans)
except TypeError:
return InequalityComparison.UNKNOWN

# `difference` is `lhs - rhs`, so it is zero when an inequality compares an
# expression to itself, e.g. `x <= x`. Such an inequality is always true (or
# always false for `<` / `>`), and a zero `difference_ans` would make the
# ratio below a division by zero, so handle these cases up front.
if difference_res == 0 and difference_ans == 0:
if operator_res == operator_ans:
return InequalityComparison.TRUE
return InequalityComparison.STRICTNESS_MISMATCH
if difference_res == 0 or difference_ans == 0:
return InequalityComparison.UNKNOWN

ratio = _constant_ratio(difference_res, difference_ans, constants)
if ratio is None:
return InequalityComparison.UNKNOWN
if ratio.is_zero:
return InequalityComparison.FALSE
if ratio.is_positive:
if operator_res == operator_ans:
return InequalityComparison.TRUE
return InequalityComparison.STRICTNESS_MISMATCH
if ratio.is_negative:
return InequalityComparison.WRONG_DIRECTION
return InequalityComparison.UNKNOWN


def _compare_chained_inequalities(
res_bounds: list[Relational], ans_bounds: list[Relational], constants: set[str]
) -> InequalityComparison:
"""
Compare two chained inequalities (`1 < x < 5`) bound by bound. The response's
two bound inequalities are matched against the answer's two in either pairing;
equivalent only when some pairing makes both bounds equivalent.
"""
res_lower, res_upper = res_bounds
saw_unknown = False
saw_strictness = False
for ans_first, ans_second in ((ans_bounds[0], ans_bounds[1]), (ans_bounds[1], ans_bounds[0])):
first = _compare_single_inequality(res_lower, ans_first, constants)
second = _compare_single_inequality(res_upper, ans_second, constants)
if first is InequalityComparison.TRUE and second is InequalityComparison.TRUE:
return InequalityComparison.TRUE
if first is InequalityComparison.UNKNOWN or second is InequalityComparison.UNKNOWN:
saw_unknown = True
elif {first, second} <= {InequalityComparison.TRUE, InequalityComparison.STRICTNESS_MISMATCH}:
saw_strictness = True
if saw_strictness:
return InequalityComparison.STRICTNESS_MISMATCH
if saw_unknown:
return InequalityComparison.UNKNOWN
return InequalityComparison.FALSE


def check_inequality_equivalence(res: Basic, ans: Basic, parameters: EvaluationParameters) -> InequalityComparison:
"""
Check whether the response relation `res` is equivalent to the answer
relation `ans`. Both may be a single `sympy` relation (an order operator or
`!=`) or a two-part chained order inequality (`1 < x < 5`, parsed as an `And`
of two relations); a chain is compared to another chain bound by bound, and a
chain is never equivalent to a single relation.
"""
res_bounds = inequality_bounds(res)
ans_bounds = inequality_bounds(ans)
if res_bounds is None and ans_bounds is None:
return InequalityComparison.FALSE
if res_bounds is None:
return InequalityComparison.RESPONSE_NOT_INEQUALITY
if ans_bounds is None:
return InequalityComparison.ANSWER_NOT_INEQUALITY
if len(res_bounds) != len(ans_bounds):
return InequalityComparison.FALSE

constants = set(parameters.parsing_parameters.constants)

if len(res_bounds) == 1:
return _compare_single_inequality(res_bounds[0], ans_bounds[0], constants)
return _compare_chained_inequalities(res_bounds, ans_bounds, constants)
2 changes: 2 additions & 0 deletions app/docs/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Each context package exposes its `context` (a `Context`) from its `__init__.py`.
- `symbolic`: Comparison of symbolic expressions that cannot be reduced to numerical values.
- `equality`: Comparison of mathematical equalities (with the extra complexities that come with equivalence of equalities compared to equality of expressions).
- `inequality`: Same as `equality` except for mathematical inequalities (which will require different choices when it comes to what can be considered equivalence). It might be appropriate to combine `equality` and `inequality` into one context (called `statements` or similar).

**Current implementation:** inequality answer/response equivalence is handled *inside* the `symbolic` context, parallel to equality equivalence. `criterion_equality_node` (`app/context/symbolic/graphs/equivalence.py`) adds the inequality equivalence nodes when either reserved expression parses to a `sympy` relation (`inequality_bounds`), and `check_inequality_equivalence` (`app/context/symbolic/inequalities.py`) rewrites both sides as `D REL 0` and checks that `D_response / D_answer` is a positive constant with matching strictness. The outcome is an `InequalityComparison`, whose name is the criterion tag suffix. Order operators `<`, `<=`, `>`, `>=` are parsed into relations by `parse_expression` in `lambdafeedback.expression_parsing` (lambda-feedback-packages#13). A two-operator single-direction chain (`1 < x < 5`) is parsed into `And(<ineq>, <ineq>)`; the two response bounds are matched against the two answer bounds in either pairing, reusing `_compare_single_inequality`. Longer or mixed-direction chains are rejected by the parser. `!=` (or `≠`, normalised to `!=`) parses to `Ne`; `_compare_single_inequality` delegates the both-`!=` case to `_compare_not_equal` (non-zero-constant ratio, no direction/strictness) and treats `!=` against an order operator as not equivalent. `!=` cannot be chained. Moving this into a dedicated `statements` context remains future work.
- `collection`: Comparison of collections (e.g. sets, lists or intervals of the number line). Likely to consist mostly of code for handling comparison of individual elements using the other contexts, and configuring what counts as equivalence between different collections.

##### `symbolic` Criteria commands and grammar
Expand Down
14 changes: 13 additions & 1 deletion app/docs/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ The `criteria` parameter reserves `response` and `answer` as keywords that will

##### Available criteria

**Note:** In the table below EXPRESSION is used to denote some mathematical expression, i.e. a string that contains mathematical symbols and operators, but no equal signs `=` or inequality signs `>`, '<'.
**Note:** In the table below EXPRESSION is used to denote some mathematical expression, i.e. a string that contains mathematical symbols and operators, but no equal signs `=` or inequality signs `>`, '<'. (A whole-response inequality such as `2x - 10 >= 0` is still supported when the answer is also an inequality — see *Inequalities in the answer and response* below.)

| Name | Syntax | Description | Example |
|-------|:-------------------------------|:------------------------------------|:--------------------|
Expand Down Expand Up @@ -326,6 +326,18 @@ The example given in the example problem set uses an EXPRESSION response area th
Some examples of expressions that are accepted as correct:
`x^2-5\*y^2-7=0` $x^2-5y^2-7=0$, `x^2 = 5y^2+7` $x^2=5y^2+7$, `2x^2 = 10y^2+14` $2x^2=10y^2+14=0$.

#### Inequalities in the answer and response

There is (limited) support for using inequalities in the response and answer. If the answer is `p REL q` and the response is `f REL' g`, where `REL` and `REL'` are order operators (`<`, `<=`, `>`, `>=`), the function rewrites each side as `D REL 0` (moving all terms to one side and flipping `>`/`>=` to `<`/`<=`) and checks that `D_response / D_answer` simplifies to a **positive** constant *and* that the two relations have the same strictness. `<` and `<=` are treated as different.

For example, with answer `2x - 10 >= 0` (`strict_syntax` false, `elementary_functions` true), the responses `x >= 5`, `5 <= x`, `4x - 20 >= 0` and `10 - 2x <= 0` are accepted, while `x > 5` is rejected (wrong strictness) and `x <= 5` is rejected (opposite direction).

Two-part chained inequalities that point in one direction (e.g. `1 < x < 5` or `5 >= x > 1`) are also supported, in the answer and/or the response. Each chain is split into its lower- and upper-bound inequality and the bounds are compared with the rule above. For example, with answer `1 < x < 5` the responses `5 > x > 1`, `0 < x - 1 < 4` and `2 < 2x < 10` are accepted, while `1 <= x < 5` is rejected (wrong strictness on the lower bound).

Not-equal, `!=` (or `≠`), is supported as a single relation. `f != g` is equivalent to `p != q` when `(f - g) / (p - q)` simplifies to a non-zero constant (direction and strictness do not apply). For example, with answer `x != 5` the responses `5 != x`, `2x != 10` and `x - 5 != 0` are accepted; `x = 5` is not.

**Note:** `!=` cannot be chained (`x != y != 5`) or combined with order operators (`1 < x != 5`). Chains of three or more operators (`1 <= x <= y <= 5`) and mixed-direction chains (`1 < x > 5`) are not supported. A response that expands to a set of inequalities (e.g. via `plus_minus`) is not supported.

#### Checking the value of an expression or a physical quantity

If the parameter `physical_quantity` is set to true, the evaluation function can handle expressions that describe physical quantities. Which units are permitted and how they should be written depends on the `units_string` and `strictness` parameters respectively.
Expand Down
Loading
Loading