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.equalities import EqualityComparison, check_equality_equivalence, is_equality_chain
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
Expand Down Expand Up @@ -141,6 +142,14 @@ def check_equality(
elif lhs_is_inequality or rhs_is_inequality:
return False

# Chained equalities are `And` objects, so they must be intercepted for the same reason
lhs_is_equality_chain = is_equality_chain(lhs_expr)
rhs_is_equality_chain = is_equality_chain(rhs_expr)
if lhs_is_equality_chain and rhs_is_equality_chain:
return check_equality_equivalence(lhs_expr, rhs_expr, parameters) is EqualityComparison.TRUE
elif lhs_is_equality_chain or rhs_is_equality_chain:
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
171 changes: 171 additions & 0 deletions app/context/symbolic/equalities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""Equivalence of equalities (`x = y`) and chained equalities (`x = y = z`) in the answer and response."""

from __future__ import annotations

from enum import Enum
from itertools import permutations

from sympy import And, Basic, Equality, Matrix, Symbol, linear_eq_to_matrix

from app.evaluation_parameters import EvaluationParameters

# Chains longer than this are not searched exhaustively; see `_compare_chained_equalities`.
MAX_CHAINED_EQUALITY_PARTS = 8


class EqualityComparison(Enum):
"""The outcome of comparing a response equality to an answer equality; the names are the criteria tag suffixes."""
TRUE = "equivalent"
FALSE = "not equivalent"
UNKNOWN = "undecidable, a chain too long to search"
EXPRESSION_NOT_EQUALITY = "the answer is an equality but the response is not"
EQUALITY_NOT_EXPRESSION = "the response is an equality but the answer is not"
CHAIN_NOT_EQUALITY = "the response is a chained equality but the answer is a single equality"
EQUALITY_NOT_CHAIN = "the response is a single equality but the answer is a chained equality"


def equality_parts(expr: Basic) -> list[Equality] | None:
"""
The list of equality parts if `expr` is a single equality (`x = y`) or a chained equality
(`x = y = z`, parsed as a conjunction of the equalities between adjacent operands), otherwise None.
Disjoint from `inequality_bounds`, which never matches a conjunction of equalities.
"""
if isinstance(expr, Equality):
return [expr]
if isinstance(expr, And) and expr.args and all(isinstance(arg, Equality) for arg in expr.args):
return list(expr.args)
return None


def is_equality_chain(expr: Basic) -> bool:
"""True if `expr` is a chained equality (`x = y = z`), not a single equality."""
return isinstance(expr, And) and equality_parts(expr) is not None


def _compare_single_equality(res_eq: Equality, ans_eq: Equality, constants: set[str]) -> bool:
"""
Check whether two equalities describe the same relation, i.e. whether their
residuals (`lhs - rhs`) differ only by a constant factor. Where one residual
simplifies to zero the other is examined on its own, since the ratio would be
a division by zero.
"""
res_difference = (res_eq.args[0] - res_eq.args[1]).simplify()
ans_difference = (ans_eq.args[0] - ans_eq.args[1]).simplify()
if res_difference == 0:
symbols_in_equality_ratio = ans_difference.free_symbols
elif ans_difference == 0:
symbols_in_equality_ratio = res_difference.free_symbols
else:
equality_ratio = (res_eq.args[0] - res_eq.args[1]) / (ans_eq.args[0] - ans_eq.args[1])
symbols_in_equality_ratio = equality_ratio.simplify().free_symbols
return {str(s) for s in symbols_in_equality_ratio}.issubset(constants)


def _chain_residuals(parts: list[Equality]) -> list[Basic]:
return [part.args[0] - part.args[1] for part in parts]


def _augmented_matrix(residuals: list[Basic], symbols: list[Symbol]) -> Matrix | None:
"""
The coefficient matrix of `residuals` in `symbols` augmented with the
constant terms, or None if any residual is not affine in `symbols` or if the
coefficients are not purely numeric.
"""
try:
coefficients, constant_terms = linear_eq_to_matrix(residuals, symbols)
except ValueError:
return None
matrix = coefficients.row_join(constant_terms)
if matrix.free_symbols:
return None
return matrix


def _compare_chained_equalities_by_solution_set(res_parts: list[Equality], ans_parts: list[Equality]) -> bool | None:
"""
Compare two chained equalities by the solution set of the system their parts
describe. A chain asserts that all of its operands are equal to one another,
so the order in which the parts are written carries no meaning and the
adjacent differences of a permuted chain are different expressions that
nonetheless constrain exactly the same values: `y = x = z` gives `y - x` and
`x - z` where `x = y = z` gives `x - y` and `y - z`, and each pair spans the
other. Two affine systems have the same solution set exactly when their
augmented matrices have equal rank and stacking them adds no rank.

Returns None when the residuals are not affine with numeric coefficients, in
which case the caller falls back to comparing the parts pairwise.
"""
res_residuals = _chain_residuals(res_parts)
ans_residuals = _chain_residuals(ans_parts)
symbols = sorted(set().union(*[residual.free_symbols for residual in res_residuals + ans_residuals]), key=str)
res_matrix = _augmented_matrix(res_residuals, symbols)
ans_matrix = _augmented_matrix(ans_residuals, symbols)
if res_matrix is None or ans_matrix is None:
return None
res_rank = res_matrix.rank()
ans_rank = ans_matrix.rank()
return res_rank == ans_rank == res_matrix.col_join(ans_matrix).rank()


def _compare_chained_equalities(res_parts: list[Equality], ans_parts: list[Equality], constants: set[str]) -> bool | None:
"""
Compare two chained equalities (`x = y = z`). Affine chains are compared by
solution set; anything else (for instance a chain scaled by a symbolic
constant, whose coefficients are not numeric) falls back to matching the
parts one-to-one with the same constant-ratio test used for a single
equality. Returns None for non-affine chains too long to search
exhaustively.
"""
result = _compare_chained_equalities_by_solution_set(res_parts, ans_parts)
if result is not None:
return result
if len(res_parts) != len(ans_parts):
return False
if len(res_parts) > MAX_CHAINED_EQUALITY_PARTS:
return None
for permuted_ans_parts in permutations(ans_parts):
if all(
_compare_single_equality(res_part, ans_part, constants)
for res_part, ans_part in zip(res_parts, permuted_ans_parts)
):
return True
return False


def check_equality_equivalence(res: Basic, ans: Basic, parameters: EvaluationParameters) -> EqualityComparison:
"""
Check whether the response equality `res` is equivalent to the answer
equality `ans`. Both may be a single `sympy` equality (`x = y`) or a chained
equality (`x = y = z`, parsed as an `And` of the equalities between adjacent
operands); a chain is compared to another chain by solution set.
"""
res_parts = equality_parts(res)
ans_parts = equality_parts(ans)
if res_parts is None and ans_parts is None:
return EqualityComparison.FALSE
if res_parts is None:
return EqualityComparison.EXPRESSION_NOT_EQUALITY
if ans_parts is None:
return EqualityComparison.EQUALITY_NOT_EXPRESSION

constants = set(parameters.parsing_parameters.constants)

if len(res_parts) == 1 and len(ans_parts) == 1:
if _compare_single_equality(res_parts[0], ans_parts[0], constants):
return EqualityComparison.TRUE
return EqualityComparison.FALSE

# A chain is compared by solution set, which already accounts for a differing
# number of parts, so the counts only choose the feedback tag. A degenerate
# chain that states no more than a single equality (`x = y = y`) therefore
# still matches that equality.
result = _compare_chained_equalities(res_parts, ans_parts, constants)
if result is True:
return EqualityComparison.TRUE
if result is None:
return EqualityComparison.UNKNOWN
if len(res_parts) > len(ans_parts):
return EqualityComparison.CHAIN_NOT_EQUALITY
if len(res_parts) < len(ans_parts):
return EqualityComparison.EQUALITY_NOT_CHAIN
return EqualityComparison.FALSE
48 changes: 22 additions & 26 deletions app/context/symbolic/graphs/equivalence.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@

from typing import Any

from sympy import Equality

from lambdafeedback.criteria import CriteriaGraph, Evaluate, OutputNode, ReachedCriteria
from lambdafeedback.expression_parsing import parse_expression
from lambdafeedback.slr_parsing import ExprNode

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.equalities import check_equality_equivalence, equality_parts
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
Expand All @@ -18,31 +17,10 @@

def _equality_equivalence(label: str, parameters: EvaluationParameters) -> Evaluate:
def equality_equivalence(_response: Any) -> ReachedCriteria:
result = False
res = parameters.reserved_expressions["response"]
ans = parameters.reserved_expressions["answer"]

if (not isinstance(res, Equality)) and isinstance(ans, Equality):
return {
label + "_EXPRESSION_NOT_EQUALITY": None
}

if isinstance(res, Equality) and (not isinstance(ans, Equality)):
return {
label + "_EQUALITY_NOT_EXPRESSION": None
}

# TODO: Remove when criteria for checking proportionality is implemented
if isinstance(res, Equality) and isinstance(ans, Equality):
res_difference = (res.args[0] - res.args[1]).simplify()
if res_difference == 0:
symbols_in_equality_ratio = (ans.args[0] - ans.args[1]).simplify().free_symbols
elif (ans.args[0] - ans.args[1]).simplify() == 0:
symbols_in_equality_ratio = res_difference.free_symbols
else:
symbols_in_equality_ratio = ((res.args[0] - res.args[1]) / (ans.args[0] - ans.args[1])).simplify().free_symbols
result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters.parsing_parameters.constants)
return outcome_tag(label, result is True)
result = check_equality_equivalence(res, ans, parameters)
return {label + "_" + result.name: None}
return equality_equivalence


Expand Down Expand Up @@ -112,6 +90,24 @@ def add_equality_equivalence_nodes(
details=str(lhs) + " is an equality, not an expression.",
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_NOT_EXPRESSION")
)
attach_terminal_criterion(
graph,
label,
label + "_CHAIN_NOT_EQUALITY",
END,
summary=str(lhs) + " is a chained equality, not a single equality.",
details=str(lhs) + " is a chained equality, but a single equality was expected.",
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("CHAIN_NOT_EQUALITY")
)
attach_terminal_criterion(
graph,
label,
label + "_EQUALITY_NOT_CHAIN",
END,
summary=str(lhs) + " is a single equality, not a chained equality.",
details=str(lhs) + " is a single equality, but a chained equality was expected.",
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_NOT_CHAIN")
)


def _inequality_equivalence(label: str, parameters: EvaluationParameters) -> Evaluate:
Expand Down Expand Up @@ -283,7 +279,7 @@ def criterion_equality_node(criterion: ExprNode, parameters: EvaluationParameter
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):
elif equality_parts(res) is not None or equality_parts(ans) is not None:
add_equality_equivalence_nodes(graph, END, parameters, label, lhs, rhs)
else:
add_mathematical_equivalence_nodes(graph, END, criterion, parameters, label, lhs, rhs)
Expand Down
2 changes: 2 additions & 0 deletions app/docs/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ Each context package exposes its `context` (a `Context`) from its `__init__.py`.
- `numerical`: Comparison of expressions that can be evaluated to numerical values (e.g. expressions that are already numerical values or expressions only containing constants). Focuses on identifying if numerical values are greater than, less than, proportional to the expected answer or similar.
- `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).

**Current implementation:** `criterion_equality_node` (`app/context/symbolic/graphs/equivalence.py`) adds the equality equivalence nodes when either reserved expression parses to an equality, which `equality_parts` (`app/context/symbolic/equalities.py`) reports for both a bare `Equality` and a chained equality. `parse_expression` in `lambdafeedback.expression_parsing` (lambda-feedback-packages#13) parses `x = y = z` into `And(Eq(x, y), Eq(y, z))`, the conjunction of the equalities between adjacent operands. `check_equality_equivalence` keeps the original constant-ratio test (`_compare_single_equality`) for the single-equality-to-single-equality case, and otherwise compares chains by solution set: a chain asserts that all of its operands are equal, so a permuted chain yields different adjacent differences that span the same space, and `_compare_chained_equalities_by_solution_set` therefore compares the ranks of the augmented coefficient matrices rather than matching parts up. That needs residuals that are affine with numeric coefficients; anything else (for instance a chain scaled by a symbolic constant) falls back to matching the parts one-to-one over every permutation, bounded by `MAX_CHAINED_EQUALITY_PARTS`. The outcome is an `EqualityComparison`, whose name is the criterion tag suffix. Because the solution-set comparison already accounts for a differing number of parts, the part counts only select the `CHAIN_NOT_EQUALITY` / `EQUALITY_NOT_CHAIN` feedback tags, which leaves a degenerate chain such as `x = y = y` matching `x = y`. Equalities mixed with order operators in one expression (`x = y < z`) are rejected by the parser.
- `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.
Expand Down
6 changes: 6 additions & 0 deletions app/docs/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,12 @@ 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$.

Chained equalities of any length (e.g. `x = y = z`) are supported, in the answer and/or the response. A chain asserts that all of its operands are equal to one another, so it is parsed as the conjunction of the equalities between adjacent operands and compared by solution set. The order the operands are written in therefore carries no meaning: with answer `x = y = z` the responses `z = y = x`, `y = x = z`, `2x = 2y = 2z` and `x + 1 = y + 1 = z + 1` are all accepted, while `x = y = w` and `x = y = 5` are rejected. A degenerate chain that asserts no more than a single equality (e.g. `x = y = y`) matches that equality.

A chain is not equivalent to a single equality that omits one of its constraints: with answer `x = y = z` the response `x = y` is rejected, and vice versa. Every `=` must have an expression on both sides, so `x = = y` and `x =` are rejected as unparseable.

**Note:** equalities cannot be mixed with order operators in one expression (e.g. `x = y < z`), and chains whose parts are not affine are compared by matching the parts one-to-one instead of by solution set, which is order-insensitive but otherwise stricter.

#### 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.
Expand Down
2 changes: 2 additions & 0 deletions app/feedback/symbolic.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"EQUALITIES_EQUIVALENT": None,
"EQUALITIES_NOT_EQUIVALENT": "The response is not the expected equality.",
"EQUALITY_EQUIVALENCE_UNKNOWN": "Cannot determine if the given equality is equivalent to the expected equality.",
"CHAIN_NOT_EQUALITY": "The response was a chained equality but a single equality was expected.",
"EQUALITY_NOT_CHAIN": "The response was a single equality but a chained equality was expected.",
"RESPONSE_NOT_INEQUALITY": "The response was an expression but was expected to be an inequality.",
"ANSWER_NOT_INEQUALITY": "The response was an inequality but the answer is not, so they cannot be compared.",
"INEQUALITIES_EQUIVALENT": None,
Expand Down
Loading
Loading