Skip to content
Open
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
259 changes: 256 additions & 3 deletions app/context/symbolic.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
from copy import deepcopy
from sympy import Add, Pow, Mul, Equality, pi, im, I, N, oo
from sympy import Add, Pow, Mul, Equality, pi, im, I, N, oo, simplify
from sympy import re as real_part
from sympy import StrictLessThan, LessThan, StrictGreaterThan, GreaterThan, Ne, And

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


def inequality_bounds(expr):
"""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, RELATION_TYPES):
return [expr]
if isinstance(expr, And) and expr.args and all(
isinstance(arg, INEQUALITY_TYPES) for arg in expr.args
):
return list(expr.args)
return None

from ..utility.expression_utilities import (
default_parameters,
Expand Down Expand Up @@ -117,7 +136,16 @@ def do_comparison(comparison_symbol, expression):

def check_equality(criterion, parameters_dict, local_substitutions=[]):
lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions)
if isinstance(lhs_expr, Equality) and not isinstance(rhs_expr, Equality):
lhs_is_inequality = inequality_bounds(lhs_expr) is not None
rhs_is_inequality = inequality_bounds(rhs_expr) is not None
if lhs_is_inequality or rhs_is_inequality:
# Subtracting relational / And objects raises, so these cases must be
# intercepted before the generic `lhs_expr - rhs_expr` comparison below.
if lhs_is_inequality and rhs_is_inequality:
result = check_inequality_equivalence(lhs_expr, rhs_expr, parameters_dict) is True
else:
result = False
elif isinstance(lhs_expr, Equality) and not isinstance(rhs_expr, Equality):
result = False
elif not isinstance(lhs_expr, Equality) and isinstance(rhs_expr, Equality):
result = False
Expand Down Expand Up @@ -188,6 +216,153 @@ def check_order(criterion, parameters_dict, local_substitutions=[]):
return result


def _compare_not_equal(res, ans, constants):
"""
`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 `!=`.

Returns True, False or None (undecidable).
"""
difference_res = simplify(res.lhs - res.rhs)
difference_ans = simplify(ans.lhs - ans.rhs)
if difference_res == 0 and difference_ans == 0:
return True
if difference_res == 0 or difference_ans == 0:
return None
ratio = simplify(difference_res / difference_ans)
if not {str(s) for s in ratio.free_symbols}.issubset(constants):
return None
if ratio.is_zero:
return False
if ratio.is_positive or ratio.is_negative:
return True
return None


def _compare_single_inequality(res, ans, constants):
"""
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.

Returns one of: True, False, None (undecidable), "WRONG_DIRECTION" (negative
constant ratio) or "STRICTNESS_MISMATCH" (positive ratio but `<` vs `<=`).
"""
res_is_not_equal = res.rel_op == "!="
ans_is_not_equal = ans.rel_op == "!="
if res_is_not_equal != ans_is_not_equal:
return False
if res_is_not_equal and ans_is_not_equal:
return _compare_not_equal(res, ans, constants)

def normalise(relation):
difference = relation.lhs - relation.rhs
operator = relation.rel_op
if operator in (">", ">="):
difference = -difference
operator = "<" if operator == ">" else "<="
return simplify(difference), operator

try:
difference_res, operator_res = normalise(res)
difference_ans, operator_ans = normalise(ans)
except Exception:
return None

# `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 True
return "STRICTNESS_MISMATCH"
if difference_res == 0 or difference_ans == 0:
return None

ratio = simplify(difference_res / difference_ans)
if not {str(s) for s in ratio.free_symbols}.issubset(constants):
return None
if ratio.is_zero:
return False
if ratio.is_positive:
if operator_res == operator_ans:
return True
return "STRICTNESS_MISMATCH"
if ratio.is_negative:
return "WRONG_DIRECTION"
return None


def _compare_chained_inequalities(res_bounds, ans_bounds, constants):
"""
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_none = 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 True and second is True:
return True
if first is None or second is None:
saw_none = True
elif {first, second} <= {True, "STRICTNESS_MISMATCH"}:
saw_strictness = True
if saw_strictness:
return "STRICTNESS_MISMATCH"
if saw_none:
return None
return False


def check_inequality_equivalence(res, ans, parameters_dict):
"""
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.

Returns one of:
True - equivalent
False - not equivalent (e.g. zero ratio, different arity,
`!=` vs an order operator)
"WRONG_DIRECTION" - ratio is a negative constant (opposite region)
"STRICTNESS_MISMATCH" - positive-constant ratio but `<` vs `<=` differ
"RESPONSE_NOT_INEQUALITY" - the response is not a relation, the answer is
"ANSWER_NOT_INEQUALITY" - the response is a relation, the answer is not
None - undecidable (non-constant/unknown-sign ratio)
"""
res_bounds = inequality_bounds(res)
ans_bounds = inequality_bounds(ans)
if res_bounds is None and ans_bounds is not None:
return "RESPONSE_NOT_INEQUALITY"
if res_bounds is not None and ans_bounds is None:
return "ANSWER_NOT_INEQUALITY"
if res_bounds is None and ans_bounds is None:
return False
if len(res_bounds) != len(ans_bounds):
return False

constants = set(parameters_dict["parsing_parameters"].get("constants", set()))

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)


def check_proportionality(criterion, parameters_dict, local_substitutions=[]):
lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions)
result = None
Expand Down Expand Up @@ -355,6 +530,20 @@ def equality_equivalence(unused_input):
label+"_FALSE": None
}

def inequality_equivalence(unused_input):
res = parameters_dict["reserved_expressions"]["response"]
ans = parameters_dict["reserved_expressions"]["answer"]
result = check_inequality_equivalence(res, ans, parameters_dict)
result_to_tag = {
True: label+"_TRUE",
False: label+"_FALSE",
"WRONG_DIRECTION": label+"_WRONG_DIRECTION",
"STRICTNESS_MISMATCH": label+"_STRICTNESS_MISMATCH",
"RESPONSE_NOT_INEQUALITY": label+"_RESPONSE_NOT_INEQUALITY",
"ANSWER_NOT_INEQUALITY": label+"_ANSWER_NOT_INEQUALITY",
}
return {result_to_tag.get(result, label+"_UNKNOWN"): None}

graph = CriteriaGraph(label)
END = CriteriaGraph.END
graph.add_node(END)
Expand Down Expand Up @@ -396,7 +585,8 @@ def same_symbols(unused_input):

res = parameters_dict["reserved_expressions"]["response"]
ans = parameters_dict["reserved_expressions"]["answer"]
use_equality_equivalence = isinstance(res, Equality) or isinstance(ans, Equality)
use_inequality_equivalence = inequality_bounds(res) is not None or inequality_bounds(ans) is not None
use_equality_equivalence = (isinstance(res, Equality) or isinstance(ans, Equality)) and not use_inequality_equivalence

# TODO: Make checking set equivalence its own context that calls symbolic comparisons instead
if use_set_equivalence is True:
Expand Down Expand Up @@ -484,6 +674,69 @@ def same_symbols(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_NOT_EXPRESSION")
)
graph.attach(label+"_EQUALITY_NOT_EXPRESSION", END.label)
elif use_inequality_equivalence:
graph.add_evaluation_node(
label,
summary=label,
details="Checks if "+str(lhs)+" is an equivalent inequality to "+str(rhs)+".",
evaluate=inequality_equivalence
)
graph.attach(
label,
label+"_TRUE",
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")
)
graph.attach(label+"_TRUE", END.label)
graph.attach(
label,
label+"_FALSE",
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")
)
graph.attach(label+"_FALSE", END.label)
graph.attach(
label,
label+"_UNKNOWN",
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")
)
graph.attach(label+"_UNKNOWN", END.label)
graph.attach(
label,
label+"_WRONG_DIRECTION",
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")
)
graph.attach(label+"_WRONG_DIRECTION", END.label)
graph.attach(
label,
label+"_STRICTNESS_MISMATCH",
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")
)
graph.attach(label+"_STRICTNESS_MISMATCH", END.label)
graph.attach(
label,
label+"_RESPONSE_NOT_INEQUALITY",
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")
)
graph.attach(label+"_RESPONSE_NOT_INEQUALITY", END.label)
graph.attach(
label,
label+"_ANSWER_NOT_INEQUALITY",
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")
)
graph.attach(label+"_ANSWER_NOT_INEQUALITY", END.label)
else:
graph.add_evaluation_node(
label,
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 @@ There are currently two different contexts:
- `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` picks the `inequality_equivalence` branch (flag `use_inequality_equivalence`) when either reserved expression parses to a `sympy` order relation, and `check_inequality_equivalence` rewrites both sides as `D REL 0` and checks that `D_response / D_answer` is a positive constant with matching strictness. Order operators `<`, `<=`, `>`, `>=` are parsed into relations by `parse_expression` (`app/utility/expression_utilities.py`). A two-operator single-direction chain (`1 < x < 5`) is parsed into `And(<ineq>, <ineq>)`; `check_inequality_equivalence` (via `inequality_bounds`) matches the two response bounds against the two answer bounds in either pairing, reusing `_compare_single_inequality`. Longer or mixed-direction chains are rejected. `!=` (or `≠`, normalised to `!=` in `parse_expression`) 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 @@ -288,6 +288,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
7 changes: 7 additions & 0 deletions app/feedback/symbolic.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
"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.",
"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,
"INEQUALITIES_NOT_EQUIVALENT": "The response is not the expected inequality.",
"INEQUALITY_EQUIVALENCE_UNKNOWN": "Cannot determine if the given inequality is equivalent to the expected inequality.",
"INEQUALITIES_WRONG_DIRECTION": "The response is the opposite inequality to the one expected.",
"INEQUALITY_STRICTNESS_MISMATCH": "The response has the wrong strictness (`<` vs `<=`, or `>` vs `>=`).",
"WITHIN_TOLERANCE": None, # "The difference between the response the answer is within specified error tolerance.",
"NOT_NUMERICAL": None, # "The expression cannot be evaluated numerically.",
}[tag]
Expand Down
Loading
Loading