From e7fddece5eb4fafd94f3b234fd55202dfaab1031 Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Mon, 19 Oct 2026 13:09:44 -0400 Subject: [PATCH 1/2] CodeTailor: regroup Java puzzles into larger, labeled blocks CodeTailor's Java puzzles put most lines in their own block, so a puzzle is full of identical { and } blocks and of statements whose order does not matter, while the line grader accepts only one order (e.g. three constructor assignments in a different order were marked wrong). Regroup finished Java puzzles so an opening brace joins its header, each closing brace is labeled with what it closes (} // end getPrice), and consecutive same-indentation statements share a block. Paired distractors become a copy of the regrouped block with their lines swapped in. Puzzles with settled blocks are left as they are. Co-Authored-By: Claude Opus 5.5 --- .../personalized_parsons/end_to_end.py | 6 + .../java_puzzle_blocks.py | 187 ++++++++++++++++++ .../test_java_puzzle_blocks.py | 159 +++++++++++++++ 3 files changed, 352 insertions(+) create mode 100644 bases/rsptx/book_server_api/routers/personalized_parsons/java_puzzle_blocks.py create mode 100644 test/bases/rsptx/book_server_api/test_java_puzzle_blocks.py diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py b/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py index 9b327bb0a..7d7103fb5 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py @@ -8,6 +8,7 @@ from .generate_parsons_blocks import generate_Parsons_block from .get_parsons_code_distractors import generate_code_with_distrator from .get_personalized_solution import get_example_solution, get_fixed_code +from .java_puzzle_blocks import restructure_java_puzzle from .personalize_parsons import compare_code, personalize_Parsons_block from .subgoal_labels import ( attach_labels_to_code, @@ -103,6 +104,11 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level): final_Parsons_block, final_fixed_code, language ) + # One-line Java blocks leave the puzzle full of identical braces and of + # statements the line grader accepts in only one order, so regroup them. + if final_Parsons_block != "Correct_Code" and language == "java": + final_Parsons_block = restructure_java_puzzle(final_Parsons_block) + return ( final_fixed_code, final_Parsons_block, diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/java_puzzle_blocks.py b/bases/rsptx/book_server_api/routers/personalized_parsons/java_puzzle_blocks.py new file mode 100644 index 000000000..a158f8eb4 --- /dev/null +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/java_puzzle_blocks.py @@ -0,0 +1,187 @@ +""" +Regroup CodeTailor's Java Parsons puzzles into larger, less ambiguous blocks. + +CodeTailor builds most Java puzzles one line per block, so a puzzle is full of +identical ``{`` and ``}`` blocks and of statements whose order does not +matter (``this.name = name;`` / ``this.grade = grade;``), yet the line grader +accepts only one order. ``restructure_java_puzzle`` regroups a finished +puzzle so that: + +* an opening brace is in the same block as the header it opens, +* each closing brace is its own block, labeled with what it closes + (``} // end getPrice``, ``} // end if``, ``} // end class``), and +* consecutive statements at the same indentation share one block. A header + (``public int getPrice()``, ``if (...)``) always starts a new block. + +Paired distractors follow the block their correct lines end up in: the +distractor becomes a copy of that whole block with its own lines in place of +the correct ones. +""" + +import re + +_TAG_RE = re.compile(r"\s*#(paired|distractor)\s*$") +_CONTROL_RE = re.compile(r"(else\s+if|if|else|for|while|do|switch|try|catch|finally)\b") +_NAME_RE = re.compile(r"(\w+)\s*\(") +_CLASS_RE = re.compile(r"\bclass\s+(\w+)") + + +def _split_blocks(puzzle): + blocks, current = [], [] + for line in puzzle.split("\n"): + if line.strip() == "---": + blocks.append(current) + current = [] + else: + current.append(line) + blocks.append(current) + return [ + [ln.rstrip() for ln in b if ln.strip()] + for b in blocks + if any(ln.strip() for ln in b) + ] + + +def _tag(block): + match = _TAG_RE.search(block[-1]) + if not match: + return None, block + return match.group(1), block[:-1] + [block[-1][: match.start()].rstrip()] + + +def _indent(line): + return len(line) - len(line.lstrip()) + + +def _opens(line): + return line.strip().endswith("{") + + +def _closing_label(header, class_name): + """What a closing brace closes, from the header of the block it ends.""" + text = header.strip() + control = _CONTROL_RE.match(text.lstrip("} ").strip()) + if control: + word = re.sub(r"\s+", " ", control.group(1)) + return "end if" if word == "else if" else f"end {word}" + if _CLASS_RE.search(text): + return "end class" + name = _NAME_RE.search(text) + if name: + return ( + "end constructor" if name.group(1) == class_name else f"end {name.group(1)}" + ) + return None + + +def _brace_labels(lines): + """Map the index of each bare ``}`` line to its label.""" + class_match = next( + (_CLASS_RE.search(ln) for ln in lines if _CLASS_RE.search(ln)), None + ) + class_name = class_match.group(1) if class_match else None + labels, stack, last_header = {}, [], None + for i, line in enumerate(lines): + text = line.strip() + if text.startswith("}"): + header = stack.pop() if stack else None + if text == "}" and header: + label = _closing_label(header, class_name) + if label: + labels[i] = label + if _opens(line): # "} else {" + stack.append(text) + elif text == "{": + stack.append(last_header or "") + elif _opens(line): + stack.append(text) + if text not in ("{", "}"): + last_header = text + return labels + + +def restructure_java_puzzle(puzzle): + """Regroup a ``---``-separated Java puzzle (see the module docstring). + Puzzles with settled blocks are returned unchanged: their fixed lines + cannot share a block with lines the student has to place.""" + if "#settled" in puzzle: + return puzzle + blocks = _split_blocks(puzzle) + if not blocks: + return puzzle + + # Units: a solution block that has a paired distractor stays whole, so + # the distractor can stand in for it; any other block splits into lines. + solution, distractors, unpaired = [], {}, [] + for block in blocks: + tag, lines = _tag(block) + if tag == "paired" and solution: + distractors.setdefault(len(solution) - 1, []).append(lines) + elif tag: + unpaired.append(block) + else: + solution.append(lines) + split_units = [] + for idx, lines in enumerate(solution): + if idx in distractors: + split_units.append((lines, idx)) + else: + split_units.extend(([ln], None) for ln in lines) + + flat = [ln for lines, _ in split_units for ln in lines] + labels = _brace_labels(flat) + + # Group the units: [{"lines": [...], "units": [(start, lines, idx)]}] + groups, pos = [], 0 + for n, (lines, idx) in enumerate(split_units): + first = lines[0].strip() + next_first = ( + split_units[n + 1][0][0].strip() if n + 1 < len(split_units) else "" + ) + is_header = _opens(lines[-1]) or next_first == "{" + current = groups[-1] if groups else None + if first == "{" and current is not None and current["kind"] == "header": + current["units"].append((pos, lines, idx)) + current["kind"] = "opened" + elif first.startswith("}"): + text = lines[0] + if len(lines) == 1 and pos in labels and "//" not in text: + lines = [f"{text} // {labels[pos]}"] + groups.append( + { + "kind": "header" if _opens(lines[-1]) else "closed", + "units": [(pos, lines, idx)], + } + ) + elif is_header: + groups.append( + { + "kind": "opened" if _opens(lines[-1]) else "header", + "units": [(pos, lines, idx)], + } + ) + elif ( + current is not None + and current["kind"] == "statement" + and _indent(current["units"][-1][1][-1]) == _indent(lines[0]) + ): + current["units"].append((pos, lines, idx)) + else: + groups.append({"kind": "statement", "units": [(pos, lines, idx)]}) + pos += len(split_units[n][0]) + + out = [] + for group in groups: + correct = [ln for _, lines, _ in group["units"] for ln in lines] + out.append(correct) + for k, (_, _, idx) in enumerate(group["units"]): + for wrong in distractors.get(idx, []) if idx is not None else []: + swapped = [ + ln + for j, (_, lines, _) in enumerate(group["units"]) + for ln in (wrong if j == k else lines) + ] + swapped[-1] += " #paired" + out.append(swapped) + out.extend(unpaired) + return "\n---\n".join("\n".join(block) for block in out) + "\n" diff --git a/test/bases/rsptx/book_server_api/test_java_puzzle_blocks.py b/test/bases/rsptx/book_server_api/test_java_puzzle_blocks.py new file mode 100644 index 000000000..cc9798710 --- /dev/null +++ b/test/bases/rsptx/book_server_api/test_java_puzzle_blocks.py @@ -0,0 +1,159 @@ +""" +Tests for restructure_java_puzzle, which regroups CodeTailor's one-line-per-block +Java puzzles: opening braces join their header, closing braces are labeled, and +same-indentation statements share a block, with paired distractors following the +block their correct lines end up in. +""" + +from rsptx.book_server_api.routers.personalized_parsons.java_puzzle_blocks import ( + restructure_java_puzzle, +) + + +def _blocks(puzzle): + return [b.strip("\n") for b in puzzle.split("---")] + + +# CodeTailor's "Solution" puzzle for a constructor problem: one line per +# block, and a paired distractor built from the student's code. +_STUDENT = """\ + public class Student +--- + { +--- + private String name; +--- + private int grade; +--- + public Student(String name, int grade) +--- + { +--- + this.name = name; + this.grade = grade; +--- + name = name; + grade = grade; #paired +--- + } +--- + public static void main(String[] args) +--- + { +--- + Student s = new Student("Ana", 10); +--- + System.out.println(s.name); +--- + } +--- + } +""" + + +def test_braces_join_headers_and_closing_braces_are_labeled(): + blocks = _blocks(restructure_java_puzzle(_STUDENT)) + assert blocks[0] == " public class Student\n {" + assert " public Student(String name, int grade)\n {" in blocks + assert " } // end constructor" in blocks + assert " } // end main" in blocks + assert blocks[-1] == " } // end class" + + +def test_same_indentation_statements_share_a_block(): + blocks = _blocks(restructure_java_puzzle(_STUDENT)) + assert " private String name;\n private int grade;" in blocks + assert ( + ' Student s = new Student("Ana", 10);\n' + " System.out.println(s.name);" + ) in blocks + + +def test_paired_distractor_follows_its_block(): + blocks = _blocks(restructure_java_puzzle(_STUDENT)) + correct = blocks.index( + " this.name = name;\n this.grade = grade;" + ) + assert blocks[correct + 1] == ( + " name = name;\n grade = grade; #paired" + ) + + +def test_distractor_is_swapped_into_a_merged_block(): + puzzle = """\ +public class A +--- +{ +--- + void f() +--- + { +--- + int x = 1; +--- + int y = 2; +--- + int y = 3; #paired +--- + } +--- +} +""" + blocks = _blocks(restructure_java_puzzle(puzzle)) + i = blocks.index(" int x = 1;\n int y = 2;") + assert blocks[i + 1] == " int x = 1;\n int y = 3; #paired" + assert " } // end f" in blocks + + +def test_control_structures_are_labeled(): + puzzle = """\ +public class T +--- +{ +--- + public int getPrice() +--- + { +--- + if (age < 12) +--- + { +--- + return 5; +--- + } +--- + else +--- + { +--- + return 10; +--- + } +--- + for (int i = 0; i < 3; i++) +--- + { +--- + age++; +--- + } +--- + } +--- +} +""" + blocks = _blocks(restructure_java_puzzle(puzzle)) + assert " if (age < 12)\n {" in blocks + assert " else\n {" in blocks + assert blocks.count(" } // end if") == 1 + assert " } // end else" in blocks + assert " } // end for" in blocks + assert " } // end getPrice" in blocks + + +def test_settled_puzzles_are_unchanged(): + puzzle = ( + "public class A #settled\n---\n{ #settled\n---\n int x;\n---\n} #settled\n" + ) + assert restructure_java_puzzle(puzzle) == puzzle From 8dbed3646a4c8af82dedf45ab9cd9362a02c5c49 Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Mon, 19 Oct 2026 14:40:37 -0400 Subject: [PATCH 2/2] CodeTailor Java puzzles: regroup lines a distractor shares with its block CodeTailor sometimes pairs a block like "}" / header / "{" with a distractor that differs only in the header. Keeping that block whole left the previous method's closing brace stuck to the next header, unlabeled. Lines shared by the block and all its distractors at either end now regroup like any other line; only the differing lines are swapped. Co-Authored-By: Claude Opus 5.5 --- .../java_puzzle_blocks.py | 25 +++++++++++-- .../test_java_puzzle_blocks.py | 35 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/java_puzzle_blocks.py b/bases/rsptx/book_server_api/routers/personalized_parsons/java_puzzle_blocks.py index a158f8eb4..c6ed5dc2f 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/java_puzzle_blocks.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/java_puzzle_blocks.py @@ -123,10 +123,29 @@ def restructure_java_puzzle(puzzle): solution.append(lines) split_units = [] for idx, lines in enumerate(solution): - if idx in distractors: - split_units.append((lines, idx)) - else: + if idx not in distractors: split_units.extend(([ln], None) for ln in lines) + continue + # Lines the block shares with all its distractors at either end (CodeTailor + # often pairs "}" / header / "{" with "}" / wrong header / "{") are not + # what the distractor is about, so they regroup like any other line. + variants = [lines] + distractors[idx] + pre = 0 + while ( + pre < min(map(len, variants)) - 1 + and len({v[pre].strip() for v in variants}) == 1 + ): + pre += 1 + suf = 0 + while ( + suf < min(map(len, variants)) - pre - 1 + and len({v[len(v) - 1 - suf].strip() for v in variants}) == 1 + ): + suf += 1 + distractors[idx] = [d[pre : len(d) - suf] for d in distractors[idx]] + split_units.extend(([ln], None) for ln in lines[:pre]) + split_units.append((lines[pre : len(lines) - suf], idx)) + split_units.extend(([ln], None) for ln in lines[len(lines) - suf :]) flat = [ln for lines, _ in split_units for ln in lines] labels = _brace_labels(flat) diff --git a/test/bases/rsptx/book_server_api/test_java_puzzle_blocks.py b/test/bases/rsptx/book_server_api/test_java_puzzle_blocks.py index cc9798710..6ae0134ca 100644 --- a/test/bases/rsptx/book_server_api/test_java_puzzle_blocks.py +++ b/test/bases/rsptx/book_server_api/test_java_puzzle_blocks.py @@ -105,6 +105,41 @@ def test_distractor_is_swapped_into_a_merged_block(): assert " } // end f" in blocks +def test_lines_shared_with_a_distractor_regroup_normally(): + # CodeTailor paired the constructor's closing brace and the next method's + # header and brace with a distractor that differs only in the header. + puzzle = """\ +public class PizzaOrder +--- +{ +--- + public PizzaOrder(int slices) +--- + { +--- + this.slices = slices; +--- + } + public static void setPrice(double price) + { +--- + } + public void setPrice(double price) + { #paired +--- + pricePerSlice = price; +--- + } +--- +} +""" + blocks = _blocks(restructure_java_puzzle(puzzle)) + assert " } // end constructor" in blocks + i = blocks.index(" public static void setPrice(double price)\n {") + assert blocks[i + 1] == " public void setPrice(double price)\n { #paired" + assert " } // end setPrice" in blocks + + def test_control_structures_are_labeled(): puzzle = """\ public class T