From a2bef834f124d9154bb54be3387cb2a8043de9c5 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Thu, 29 Oct 2026 13:29:08 +0000 Subject: [PATCH 1/6] Give feedback on response size mismatch instead of erroring Check the response shape against the answer before grading. Mismatches (too many/few entries, wrong nesting) now return is_correct False with feedback describing the difference, rather than raising an exception or an IndexError. Also fixes short responses being graded on a subset of the answer. Co-Authored-By: Claude Opus 5.5 --- app/Dockerfile | 1 + app/docs/user.md | 5 ++- app/evaluation.py | 17 ++++++++-- app/shape.py | 81 +++++++++++++++++++++++++++++++++++++++++++++++ app/shape_test.py | 56 ++++++++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 app/shape.py create mode 100644 app/shape_test.py diff --git a/app/Dockerfile b/app/Dockerfile index 7832b6e..8b8ef7b 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -10,6 +10,7 @@ RUN pip3 install -r requirements.txt # Copy the evaluation and testing scripts COPY evaluation.py ./app/ +COPY shape.py ./app/ COPY evaluation_test.py ./app/ # Copy Documentation diff --git a/app/docs/user.md b/app/docs/user.md index 9580963..624d45b 100644 --- a/app/docs/user.md +++ b/app/docs/user.md @@ -3,4 +3,7 @@ This function compares two symbolic expression arrays, with any level of nesting (2D, 3D, irregular shape, ...). Each cell is compared using the [`SymbolicEqual`](https://lambda-feedback.github.io/user-documentation/user_eval_function_docs/symbolicEqual/) evaluation function. ## Inputs -This function support all input parameters that [`SymbolicEqual`](https://lambda-feedback.github.io/user-documentation/user_eval_function_docs/symbolicEqual/) uses. \ No newline at end of file +This function support all input parameters that [`SymbolicEqual`](https://lambda-feedback.github.io/user-documentation/user_eval_function_docs/symbolicEqual/) uses. + +## Size mismatches +If the response does not have the same size or shape as the answer (e.g. too many or too few entries, or a list where a single expression was expected), the response is marked incorrect and the feedback explains the difference, e.g. "Your response has 2 rows and 3 columns, but the expected answer has 3 rows and 2 columns." No cells are graded in this case. diff --git a/app/evaluation.py b/app/evaluation.py index feb5a7f..9da12ee 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -4,6 +4,11 @@ from evaluation_function_utils.client import EvaluationFunctionClient from evaluation_function_utils.errors import EvaluationException +try: + from .shape import shape_feedback +except ImportError: + from shape import shape_feedback + client = EvaluationFunctionClient(env_path=".env") @@ -79,11 +84,19 @@ def evaluation_function(response, answer, params): to output the grading response. """ - if not (isinstance(response, list) and isinstance(answer, list)): + if not isinstance(answer, list): raise EvaluationException( - f"Response, Answer given of type {type(response)}, {type(answer)}: types unsupported" + f"Answer given of type {type(answer)}: type unsupported" ) + # Give feedback on a size/shape mismatch rather than failing during grading + size_feedback = shape_feedback(response, answer) + if size_feedback is not None: + return { + "is_correct": False, + "feedback": size_feedback + } + # Grade all cells recursively (to allow any shape input) detailed_feedback, feedback = recursive_grade(params, response, answer, [], [], []) diff --git a/app/shape.py b/app/shape.py new file mode 100644 index 0000000..40250b3 --- /dev/null +++ b/app/shape.py @@ -0,0 +1,81 @@ +def regular_shape(array): + """ + Return the shape of a rectangular array (list of lists ... of non-lists) + as a tuple, e.g. (2, 3) for a 2x3 matrix. A single entry has shape (). + Returns None if the array is irregular (ragged or mixed nesting). + """ + if not isinstance(array, list): + return () + if len(array) == 0: + return (0,) + + shapes = [regular_shape(x) for x in array] + if any(s is None for s in shapes) or any(s != shapes[0] for s in shapes): + return None + return (len(array),) + shapes[0] + + +def format_loc(loc): + return "(" + ",".join(str(i) for i in loc) + ")" + + +def plural(n, word, word_plural=None): + if n == 1: + return f"{n} {word}" + return f"{n} {word_plural or word + 's'}" + + +def entries(n): + return plural(n, "entry", "entries") + + +def describe_shape(shape): + if len(shape) == 1: + return entries(shape[0]) + if len(shape) == 2: + return f"{plural(shape[0], 'row')} and {plural(shape[1], 'column')}" + return "size " + " x ".join(str(n) for n in shape) + + +def find_mismatch(response, answer, loc): + """ + Return a message describing the first place where the response + does not have the same nested structure as the answer, or None. + """ + where = f"Entry {format_loc(loc)}" if loc else "Your response" + + if not isinstance(answer, list): + if isinstance(response, list): + return f"{where} should be a single expression, not a list." + return None + + if not isinstance(response, list): + return f"{where} should be a list of {entries(len(answer))}, not a single expression." + + if len(response) != len(answer): + return f"{where} has {entries(len(response))}, but should have {entries(len(answer))}." + + for i in range(len(answer)): + message = find_mismatch(response[i], answer[i], loc + [i + 1]) + if message is not None: + return message + + return None + + +def shape_feedback(response, answer): + """ + Return feedback explaining how the size of the response differs + from the answer, or None if they have the same shape. + """ + message = find_mismatch(response, answer, []) + if message is None: + return None + + response_shape = regular_shape(response) + answer_shape = regular_shape(answer) + if response_shape and answer_shape: + return f"Your response has {describe_shape(response_shape)}, "\ + f"but the expected answer has {describe_shape(answer_shape)}." + + return message diff --git a/app/shape_test.py b/app/shape_test.py new file mode 100644 index 0000000..76b3466 --- /dev/null +++ b/app/shape_test.py @@ -0,0 +1,56 @@ +import unittest + +try: + from .shape import shape_feedback, regular_shape +except ImportError: + from shape import shape_feedback, regular_shape + + +class TestShapeFeedback(unittest.TestCase): + + def test_same_shape(self): + self.assertIsNone(shape_feedback(["a", "b"], ["c", "d"])) + self.assertIsNone(shape_feedback([["a"], ["b", "c"]], [["x"], ["y", "z"]])) + + def test_regular_shape(self): + self.assertEqual(regular_shape(["a", "b", "c"]), (3,)) + self.assertEqual(regular_shape([["a", "b"], ["c", "d"]]), (2, 2)) + self.assertIsNone(regular_shape([["a"], ["b", "c"]])) + self.assertIsNone(regular_shape(["a", ["b"]])) + + def test_vector_too_long(self): + self.assertEqual( + shape_feedback(["a", "b", "c"], ["a", "b"]), + "Your response has 3 entries, but the expected answer has 2 entries.") + + def test_vector_too_short(self): + self.assertEqual( + shape_feedback(["a"], ["a", "b"]), + "Your response has 1 entry, but the expected answer has 2 entries.") + + def test_matrix_wrong_size(self): + self.assertEqual( + shape_feedback([["a", "b", "c"], ["d", "e", "f"]], [["a", "b"], ["c", "d"], ["e", "f"]]), + "Your response has 2 rows and 3 columns, but the expected answer has 3 rows and 2 columns.") + + def test_vector_for_matrix(self): + self.assertEqual( + shape_feedback(["a", "b", "c", "d"], [["a", "b"], ["c", "d"]]), + "Your response has 4 entries, but the expected answer has 2 rows and 2 columns.") + + def test_single_expression_for_list(self): + self.assertEqual( + shape_feedback("a", ["a", "b"]), + "Your response should be a list of 2 entries, not a single expression.") + + def test_irregular_mismatch_location(self): + self.assertEqual( + shape_feedback([["a"], ["b", "c", "d"]], [["a"], ["b", "c"]]), + "Entry (2) has 3 entries, but should have 2 entries.") + self.assertEqual( + shape_feedback(["a", ["b"]], ["a", "b"]), + "Entry (2) should be a single expression, not a list.") + + +if __name__ == "__main__": + unittest.main() From 9be872d29a08e87c90dbece25a6d567e3be0b771 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Thu, 29 Oct 2026 13:30:39 +0000 Subject: [PATCH 2/6] Test size mismatch feedback through evaluation_function Mock the symbolicEqual client at import so the tests run without AWS credentials, and check mismatches are reported without grading cells. Co-Authored-By: Claude Opus 5.5 --- app/evaluation_test.py | 65 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/app/evaluation_test.py b/app/evaluation_test.py index 4c045d5..6200bb0 100644 --- a/app/evaluation_test.py +++ b/app/evaluation_test.py @@ -1,13 +1,15 @@ import unittest +from unittest import mock -from evaluation_function_utils.client import MissingCredentials +# The real client needs AWS credentials on creation, so replace it with a +# mock while importing. Tests that reach symbolicEqual can't run without it. +with mock.patch("evaluation_function_utils.client.EvaluationFunctionClient"): + try: + from . import evaluation + except ImportError: + import evaluation -try: - from .evaluation import evaluation_function -except ImportError: - from evaluation import evaluation_function -except MissingCredentials: - pass +evaluation_function = evaluation.evaluation_function class TestEvaluationFunction(unittest.TestCase): @@ -29,8 +31,53 @@ class TestEvaluationFunction(unittest.TestCase): as it should. """ - def test_blank(self): - self.assertTrue(True) + def setUp(self): + evaluation.client.reset_mock() + + def assert_size_feedback(self, response, answer, feedback): + result = evaluation_function(response, answer, {}) + + self.assertEqual(result.get("is_correct"), False) + self.assertEqual(result.get("feedback"), feedback) + # Size mismatches are reported before any cell is graded + evaluation.client.invoke.assert_not_called() + + def test_vector_too_long(self): + self.assert_size_feedback( + ["a", "b", "c"], ["a", "b"], + "Your response has 3 entries, but the expected answer has 2 entries.") + + def test_vector_too_short(self): + self.assert_size_feedback( + ["a"], ["a", "b"], + "Your response has 1 entry, but the expected answer has 2 entries.") + + def test_matrix_wrong_size(self): + self.assert_size_feedback( + [["a", "b", "c"], ["d", "e", "f"]], [["a", "b"], ["c", "d"], ["e", "f"]], + "Your response has 2 rows and 3 columns, but the expected answer has 3 rows and 2 columns.") + + def test_single_expression_for_list(self): + self.assert_size_feedback( + "a", ["a", "b"], + "Your response should be a list of 2 entries, not a single expression.") + + def test_irregular_mismatch(self): + self.assert_size_feedback( + [["a"], ["b", "c", "d"]], [["a"], ["b", "c"]], + "Entry (2) has 3 entries, but should have 2 entries.") + + def test_same_size_is_graded(self): + evaluation.client.invoke.return_value = {"is_correct": True} + + result = evaluation_function([["a", "b"], ["c", "d"]], [["a", "b"], ["c", "d"]], {}) + + self.assertEqual(result.get("is_correct"), True) + self.assertEqual(evaluation.client.invoke.call_count, 4) + + def test_answer_not_list(self): + with self.assertRaises(evaluation.EvaluationException): + evaluation_function(["a"], "a", {}) """ NOTE: This function was converted to use the experimental From bbf648ba7922da74adc8ee1f854d10430744751b Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Thu, 29 Oct 2026 13:32:39 +0000 Subject: [PATCH 3/6] Only mock the client at import when credentials are missing Patch the client per test instead, so the tests work whether or not credentials are present and never replace a real client module-wide. Note in the readme that the healthcheck doesn't run evaluation_test.py. Co-Authored-By: Claude Opus 5.5 --- app/evaluation_test.py | 27 ++++++++++++++++++++------- readme.md | 2 +- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/app/evaluation_test.py b/app/evaluation_test.py index 6200bb0..b32c00d 100644 --- a/app/evaluation_test.py +++ b/app/evaluation_test.py @@ -1,13 +1,24 @@ import unittest from unittest import mock -# The real client needs AWS credentials on creation, so replace it with a -# mock while importing. Tests that reach symbolicEqual can't run without it. -with mock.patch("evaluation_function_utils.client.EvaluationFunctionClient"): +from evaluation_function_utils.client import MissingCredentials + + +def import_evaluation(): try: from . import evaluation except ImportError: import evaluation + return evaluation + + +# The real client needs AWS credentials on creation. Without them, import +# with a mock client so the tests below (which mock the client) can still run. +try: + evaluation = import_evaluation() +except MissingCredentials: + with mock.patch("evaluation_function_utils.client.EvaluationFunctionClient"): + evaluation = import_evaluation() evaluation_function = evaluation.evaluation_function @@ -32,7 +43,9 @@ class TestEvaluationFunction(unittest.TestCase): """ def setUp(self): - evaluation.client.reset_mock() + patcher = mock.patch.object(evaluation, "client") + self.client = patcher.start() + self.addCleanup(patcher.stop) def assert_size_feedback(self, response, answer, feedback): result = evaluation_function(response, answer, {}) @@ -40,7 +53,7 @@ def assert_size_feedback(self, response, answer, feedback): self.assertEqual(result.get("is_correct"), False) self.assertEqual(result.get("feedback"), feedback) # Size mismatches are reported before any cell is graded - evaluation.client.invoke.assert_not_called() + self.client.invoke.assert_not_called() def test_vector_too_long(self): self.assert_size_feedback( @@ -68,12 +81,12 @@ def test_irregular_mismatch(self): "Entry (2) has 3 entries, but should have 2 entries.") def test_same_size_is_graded(self): - evaluation.client.invoke.return_value = {"is_correct": True} + self.client.invoke.return_value = {"is_correct": True} result = evaluation_function([["a", "b"], ["c", "d"]], [["a", "b"], ["c", "d"]], {}) self.assertEqual(result.get("is_correct"), True) - self.assertEqual(evaluation.client.invoke.call_count, 4) + self.assertEqual(self.client.invoke.call_count, 4) def test_answer_not_list(self): with self.assertRaises(evaluation.EvaluationException): diff --git a/readme.md b/readme.md index ea1e128..1d90c30 100644 --- a/readme.md +++ b/readme.md @@ -61,7 +61,7 @@ config.json # Specifies the name of the evaluation function - Edit the `app/evaluation.py` file, which ultimately gets called when the function is given the `eval` command - Edit the `app/evaluation_test.py` file to add tests which get run: - Every time you commit to this repo, before the image is built and deployed - - Whenever the `healthcheck` command is supplied to the deployed function + - Note: the `healthcheck` command looks for `evaluation_tests.py` (plural), so these tests are not run by the deployed function's healthcheck - Edit the `app/docs/user.md` and `app/docs/dev.md` files to reflect your changes. These files are baked into the function's image and are made available using the `docs` command. This feature is used to display documentation on our [Documentation](https://lambda-feedback.github.io/Documentation/) website. --- From 6e8a68efdd952bcc3826e5b0416d85dc9c6988a9 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Thu, 29 Oct 2026 13:33:04 +0000 Subject: [PATCH 4/6] Revert readme note about the healthcheck Co-Authored-By: Claude Opus 5.5 --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 1d90c30..ea1e128 100644 --- a/readme.md +++ b/readme.md @@ -61,7 +61,7 @@ config.json # Specifies the name of the evaluation function - Edit the `app/evaluation.py` file, which ultimately gets called when the function is given the `eval` command - Edit the `app/evaluation_test.py` file to add tests which get run: - Every time you commit to this repo, before the image is built and deployed - - Note: the `healthcheck` command looks for `evaluation_tests.py` (plural), so these tests are not run by the deployed function's healthcheck + - Whenever the `healthcheck` command is supplied to the deployed function - Edit the `app/docs/user.md` and `app/docs/dev.md` files to reflect your changes. These files are baked into the function's image and are made available using the `docs` command. This feature is used to display documentation on our [Documentation](https://lambda-feedback.github.io/Documentation/) website. --- From 28c8213e9c588f5cbbc7739279203340e101a0e0 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Thu, 29 Oct 2026 13:43:36 +0000 Subject: [PATCH 5/6] Move remaining shape tests into evaluation_test.py Test size feedback through evaluation_function only, and remove the now-redundant shape_test.py. Co-Authored-By: Claude Opus 5.5 --- app/evaluation_test.py | 10 ++++++++ app/shape_test.py | 56 ------------------------------------------ 2 files changed, 10 insertions(+), 56 deletions(-) delete mode 100644 app/shape_test.py diff --git a/app/evaluation_test.py b/app/evaluation_test.py index b32c00d..2766760 100644 --- a/app/evaluation_test.py +++ b/app/evaluation_test.py @@ -70,6 +70,16 @@ def test_matrix_wrong_size(self): [["a", "b", "c"], ["d", "e", "f"]], [["a", "b"], ["c", "d"], ["e", "f"]], "Your response has 2 rows and 3 columns, but the expected answer has 3 rows and 2 columns.") + def test_vector_for_matrix(self): + self.assert_size_feedback( + ["a", "b", "c", "d"], [["a", "b"], ["c", "d"]], + "Your response has 4 entries, but the expected answer has 2 rows and 2 columns.") + + def test_list_for_single_expression(self): + self.assert_size_feedback( + ["a", ["b"]], ["a", "b"], + "Entry (2) should be a single expression, not a list.") + def test_single_expression_for_list(self): self.assert_size_feedback( "a", ["a", "b"], diff --git a/app/shape_test.py b/app/shape_test.py deleted file mode 100644 index 76b3466..0000000 --- a/app/shape_test.py +++ /dev/null @@ -1,56 +0,0 @@ -import unittest - -try: - from .shape import shape_feedback, regular_shape -except ImportError: - from shape import shape_feedback, regular_shape - - -class TestShapeFeedback(unittest.TestCase): - - def test_same_shape(self): - self.assertIsNone(shape_feedback(["a", "b"], ["c", "d"])) - self.assertIsNone(shape_feedback([["a"], ["b", "c"]], [["x"], ["y", "z"]])) - - def test_regular_shape(self): - self.assertEqual(regular_shape(["a", "b", "c"]), (3,)) - self.assertEqual(regular_shape([["a", "b"], ["c", "d"]]), (2, 2)) - self.assertIsNone(regular_shape([["a"], ["b", "c"]])) - self.assertIsNone(regular_shape(["a", ["b"]])) - - def test_vector_too_long(self): - self.assertEqual( - shape_feedback(["a", "b", "c"], ["a", "b"]), - "Your response has 3 entries, but the expected answer has 2 entries.") - - def test_vector_too_short(self): - self.assertEqual( - shape_feedback(["a"], ["a", "b"]), - "Your response has 1 entry, but the expected answer has 2 entries.") - - def test_matrix_wrong_size(self): - self.assertEqual( - shape_feedback([["a", "b", "c"], ["d", "e", "f"]], [["a", "b"], ["c", "d"], ["e", "f"]]), - "Your response has 2 rows and 3 columns, but the expected answer has 3 rows and 2 columns.") - - def test_vector_for_matrix(self): - self.assertEqual( - shape_feedback(["a", "b", "c", "d"], [["a", "b"], ["c", "d"]]), - "Your response has 4 entries, but the expected answer has 2 rows and 2 columns.") - - def test_single_expression_for_list(self): - self.assertEqual( - shape_feedback("a", ["a", "b"]), - "Your response should be a list of 2 entries, not a single expression.") - - def test_irregular_mismatch_location(self): - self.assertEqual( - shape_feedback([["a"], ["b", "c", "d"]], [["a"], ["b", "c"]]), - "Entry (2) has 3 entries, but should have 2 entries.") - self.assertEqual( - shape_feedback(["a", ["b"]], ["a", "b"]), - "Entry (2) should be a single expression, not a list.") - - -if __name__ == "__main__": - unittest.main() From 6c91f3e57e225fb53c4d146fdd6ba3be79e37a99 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Wed, 4 Nov 2026 10:08:32 +0000 Subject: [PATCH 6/6] Refactored to use numpy --- app/evaluation.py | 1 - app/evaluation_test.py | 10 ++++- app/shape.py | 86 ++++++++++++++++++++---------------------- 3 files changed, 50 insertions(+), 47 deletions(-) diff --git a/app/evaluation.py b/app/evaluation.py index 9da12ee..551b8b2 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -1,4 +1,3 @@ -import numpy as np import re from evaluation_function_utils.client import EvaluationFunctionClient diff --git a/app/evaluation_test.py b/app/evaluation_test.py index 2766760..eab13ae 100644 --- a/app/evaluation_test.py +++ b/app/evaluation_test.py @@ -83,7 +83,7 @@ def test_list_for_single_expression(self): def test_single_expression_for_list(self): self.assert_size_feedback( "a", ["a", "b"], - "Your response should be a list of 2 entries, not a single expression.") + "Your response should be an array of 2 entries.") def test_irregular_mismatch(self): self.assert_size_feedback( @@ -98,6 +98,14 @@ def test_same_size_is_graded(self): self.assertEqual(result.get("is_correct"), True) self.assertEqual(self.client.invoke.call_count, 4) + def test_same_shape_different_values_is_graded(self): + self.client.invoke.return_value = {"is_correct": True} + + result = evaluation_function([["a+b", "c"], ["d", "e"]], [["b+a", "c"], ["d", "e"]], {}) + + self.assertEqual(result.get("is_correct"), True) + self.assertEqual(self.client.invoke.call_count, 4) + def test_answer_not_list(self): with self.assertRaises(evaluation.EvaluationException): evaluation_function(["a"], "a", {}) diff --git a/app/shape.py b/app/shape.py index 40250b3..90555b8 100644 --- a/app/shape.py +++ b/app/shape.py @@ -1,22 +1,4 @@ -def regular_shape(array): - """ - Return the shape of a rectangular array (list of lists ... of non-lists) - as a tuple, e.g. (2, 3) for a 2x3 matrix. A single entry has shape (). - Returns None if the array is irregular (ragged or mixed nesting). - """ - if not isinstance(array, list): - return () - if len(array) == 0: - return (0,) - - shapes = [regular_shape(x) for x in array] - if any(s is None for s in shapes) or any(s != shapes[0] for s in shapes): - return None - return (len(array),) + shapes[0] - - -def format_loc(loc): - return "(" + ",".join(str(i) for i in loc) + ")" +import numpy as np def plural(n, word, word_plural=None): @@ -37,30 +19,23 @@ def describe_shape(shape): return "size " + " x ".join(str(n) for n in shape) -def find_mismatch(response, answer, loc): +def array_shape(array): """ - Return a message describing the first place where the response - does not have the same nested structure as the answer, or None. + Return the shape of a regular (rectangular) array, e.g. (2, 3) for a + 2x3 matrix, or None if the array is ragged. """ - where = f"Entry {format_loc(loc)}" if loc else "Your response" - - if not isinstance(answer, list): - if isinstance(response, list): - return f"{where} should be a single expression, not a list." + try: + return np.shape(array) + except ValueError: return None - if not isinstance(response, list): - return f"{where} should be a list of {entries(len(answer))}, not a single expression." - if len(response) != len(answer): - return f"{where} has {entries(len(response))}, but should have {entries(len(answer))}." - - for i in range(len(answer)): - message = find_mismatch(response[i], answer[i], loc + [i + 1]) - if message is not None: - return message - - return None +def entry_lengths(array): + """ + Return the length of each top-level entry, with -1 for an entry that + is a single expression rather than a list. + """ + return np.array([len(e) if isinstance(e, list) else -1 for e in array]) def shape_feedback(response, answer): @@ -68,14 +43,35 @@ def shape_feedback(response, answer): Return feedback explaining how the size of the response differs from the answer, or None if they have the same shape. """ - message = find_mismatch(response, answer, []) - if message is None: - return None + if not isinstance(response, list): + return f"Your response should be an array of {entries(len(answer))}." - response_shape = regular_shape(response) - answer_shape = regular_shape(answer) - if response_shape and answer_shape: + response_shape = array_shape(response) + answer_shape = array_shape(answer) + + if response_shape is not None and answer_shape is not None: + if response_shape == answer_shape: + return None return f"Your response has {describe_shape(response_shape)}, "\ f"but the expected answer has {describe_shape(answer_shape)}." - return message + # At least one of the arrays is ragged, so numpy can't give its shape + if len(response) != len(answer): + return f"Your response has {entries(len(response))}, "\ + f"but the expected answer has {entries(len(answer))}." + + mismatches = np.argwhere(entry_lengths(response) != entry_lengths(answer)).ravel() + if len(mismatches) == 0: + # The mismatch is more than one level deep + return "Your response does not have the same shape as the expected answer." + + i = mismatches[0] + response_length = int(entry_lengths(response)[i]) + answer_length = int(entry_lengths(answer)[i]) + where = f"Entry ({i + 1})" + + if answer_length == -1: + return f"{where} should be a single expression, not a list." + if response_length == -1: + return f"{where} should be an array of {entries(answer_length)}." + return f"{where} has {entries(response_length)}, but should have {entries(answer_length)}."