diff --git a/examples/exercises/algebra/simple_equations_examples.py b/examples/exercises/algebra/simple_equations_examples.py new file mode 100644 index 00000000..11dcfa7a --- /dev/null +++ b/examples/exercises/algebra/simple_equations_examples.py @@ -0,0 +1,99 @@ +"""Examples of generated problems from the SimpleEquations exercise. + +This file demonstrates different types of linear equation problems that can be generated +at various difficulty levels. +""" + +from reasoning_gym.curricula.algebra.simple_equations_curriculum import SimpleEquationsCurriculum +from reasoning_gym.exercises.algebra.simple_equations import SimpleEquationsExercise +import random + +def main(): + # Initialize with fixed seed for reproducibility + curriculum = SimpleEquationsCurriculum() + exercise = SimpleEquationsExercise() + curriculum.rng = random.Random(42) + + print("\n========================================\n") + + # Level 0: Basic equations (ax = b) + curriculum.set_attr_level("num_terms", 0) # 2 terms + curriculum.set_attr_level("value", 0) # Small values (1-10) + curriculum.set_attr_level("operators", 0) # Just + operator + curriculum.set_attr_level("sign", 0) # No negative signs + curriculum.set_attr_level("var_name", 0) # Basic variables (x, y, z) + problem = exercise.generate(curriculum) + print("Level 0 (Basic Equations):") + print(problem) + + print("\n========================================\n") + + # Level 1: Two-term equations with negatives (ax + b = c) + curriculum.set_attr_level("num_terms", 1) # 3 terms + curriculum.set_attr_level("value", 1) # Medium values (1-50) + curriculum.set_attr_level("operators", 1) # +, - operators + curriculum.set_attr_level("sign", 1) # Allow negative signs + curriculum.set_attr_level("var_name", 0) # Basic variables + problem = exercise.generate(curriculum) + print("Level 1 (Two-term Equations):") + print(problem) + + print("\n========================================\n") + + # Level 2: Complex equations with multiple terms + curriculum.set_attr_level("num_terms", 2) # 4 terms + curriculum.set_attr_level("value", 2) # Large values (1-100) + curriculum.set_attr_level("operators", 2) # +, - operators + curriculum.set_attr_level("sign", 1) # Allow negative signs + curriculum.set_attr_level("var_name", 1) # All lowercase letters + problem = exercise.generate(curriculum) + print("Level 2 (Complex Equations):") + print(problem) + + print("\n========================================\n") + + # Random Examples with Different Seeds + print("Random Examples (Different Seeds):") + for seed in range(10, 15): + curriculum.rng = random.Random(seed) + # Randomly set curriculum levels + curriculum.set_attr_level("num_terms", random.randint(0, 2)) + curriculum.set_attr_level("value", random.randint(0, 2)) + curriculum.set_attr_level("operators", random.randint(0, 2)) + curriculum.set_attr_level("sign", random.randint(0, 1)) + curriculum.set_attr_level("var_name", random.randint(0, 2)) + problem = exercise.generate(curriculum) + print(f"\nRandom Example (Seed {seed}):") + print(problem) + + print("\n========================================\n") + + # Special Cases + print("Special Cases:") + + # Case 1: Greek variable names with complex terms + curriculum.set_attr_level("num_terms", 2) # 4 terms + curriculum.set_attr_level("value", 1) # Medium values + curriculum.set_attr_level("var_name", 2) # Greek letters + problem = exercise.generate(curriculum) + print("\nGreek Variables with Complex Terms:") + print(problem) + + # Case 2: Maximum terms with small values + curriculum.set_attr_level("num_terms", 2) # Maximum terms + curriculum.set_attr_level("value", 0) # Small values + curriculum.set_attr_level("var_name", 0) # Basic variables + problem = exercise.generate(curriculum) + print("\nMaximum Terms with Small Values:") + print(problem) + + # Case 3: Simple equation with large values + curriculum.set_attr_level("num_terms", 0) # 2 terms + curriculum.set_attr_level("value", 2) # Large values + curriculum.set_attr_level("var_name", 0) # Basic variables + problem = exercise.generate(curriculum) + print("\nSimple Equation with Large Values:") + print(problem) + +if __name__ == "__main__": + main() diff --git a/reasoning_gym/algebra/__init__.py b/reasoning_gym/algebra/__init__.py index 40e22837..032ad472 100644 --- a/reasoning_gym/algebra/__init__.py +++ b/reasoning_gym/algebra/__init__.py @@ -1,7 +1,7 @@ from .polynomial_equations import PolynomialEquationsExercise -# from .simple_equations import SimpleEquationsConfig, SimpleEquationsDataset +from .simple_equations import SimpleEquationsExercise __all__ = [ - # "SimpleEquationsDataset", + "SimpleEquationsExercise", "PolynomialEquationsExercise", ] diff --git a/reasoning_gym/algebra/simple_equations.py b/reasoning_gym/algebra/simple_equations.py index 5a85fcb5..397953d9 100644 --- a/reasoning_gym/algebra/simple_equations.py +++ b/reasoning_gym/algebra/simple_equations.py @@ -1,119 +1,125 @@ -import random -import string -from dataclasses import dataclass -from typing import Optional, Tuple +""" +Simple equations exercise that generates and solves linear equations with one variable. +""" -import sympy -from sympy import Eq, Symbol, solve +from typing import Dict, Any +from sympy import Symbol, solve, parse_expr, Eq -from ..factory import ProceduralDataset, register_dataset +class SimpleEquationsExercise: + """Exercise generator for simple equations with one variable.""" + def __init__(self): + self.curriculum = None -@dataclass -class SimpleEquationsConfig: - """Configuration for simple equation task generation""" - - min_terms: int = 2 # Minimum number of terms in expression - max_terms: int = 4 # Maximum number of terms - min_value: int = 1 # Minimum value for constants - max_value: int = 100 # Maximum value for constants - operators: tuple = ("+", "-", "*") # Allowed operators - seed: Optional[int] = None - size: int = 500 - - def validate(self) -> None: - """Validate configuration parameters""" - assert self.min_terms > 0, "min_terms must be positive" - assert self.max_terms >= self.min_terms, "max_terms must be >= min_terms" - assert self.min_value > 0, "min_value must be positive" - assert self.max_value >= self.min_value, "max_value must be >= min_value" - assert len(self.operators) > 0, "must specify at least one operator" - assert all(op in ("+", "-", "*") for op in self.operators), "invalid operator specified" - - -class SimpleEquationsDataset(ProceduralDataset): - """Generates simple equations with one variable to solve""" - - def __init__(self, config: SimpleEquationsConfig): - self._prompt_templates = [ - "Find the value of {variable} in the equation: {equation}", - "Solve for {variable}: {equation}", - "Determine the value of {variable} that satisfies: {equation}", - ] - super().__init__(config=config, seed=config.seed, size=config.size) - - def __getitem__(self, idx: int) -> dict: - """Generate a single equation task + def generate(self, curriculum: Any) -> Dict[str, Any]: + """ + Generate a simple equation problem using the curriculum. Returns: - dict with keys: - - question: str, the equation to solve (e.g. "3 * x = 12") - - answer: str, the solution value (e.g. "4") - - metadata: dict with generation parameters + Dict containing: + - question: str (e.g. "Find the value of x in the equation: 3*x + 2 = 4*x - 1") + - answer: str (the solution value, e.g. "3") + - metadata: dict with details (equation, variable, etc.) """ - rng = random.Random(self.seed + idx) + self.curriculum = curriculum + template = curriculum.get_template(curriculum.rng) + return template.eval(self, curriculum.rng) - # Get variable and generate equation - variable = self._get_variable(rng) - equation, solution = self._generate_equation(rng, variable) + def _parse_expression(self, metadata: Dict[str, Any]) -> Dict[str, Any]: + """ + Parse the template metadata into structured data. - return { - "question": rng.choice(self._prompt_templates).format(variable=variable, equation=equation), - "answer": str(solution), - "metadata": { - "equation": equation, - "variable": variable, + The metadata structure is expected to be: + { + "lhs": { + "term_0": { + "sign": str, # "" or "-" + "coeff": str, # coefficient value with "*" if needed + "variable": str # variable name or empty + }, + "term_1": {...}, # Same structure as term_0 + ..., + "op_0": str, # "+" or "-" between terms + "op_1": str, # More operators if needed + ... }, + "rhs": { # Same structure as lhs + ... + }, + "variable": { + "var": str # The variable name used in the equation + } } - def _get_variable(self, rng: random.Random) -> str: - """Get a random lowercase variable name""" - return rng.choice(string.ascii_lowercase) + Args: + metadata: Raw metadata from template evaluation + Returns: + Dictionary containing: + - lhs_terms: List[str] of formatted term strings for left side + - rhs_terms: List[str] of formatted term strings for right side + - lhs_operators: List[str] of operators between left terms + - rhs_operators: List[str] of operators between right terms + - variable: str, the variable name used + """ + def parse_side(side_parts: Dict[str, Any]) -> tuple[list, list]: + """Helper to parse one side of the equation.""" + terms = [] + operators = [] + i = 0 + while f"term_{i}" in side_parts: + term_dict = side_parts[f"term_{i}"] + terms.append("".join(term_dict[k] for k in ("sign", "coeff", "variable"))) + if f"op_{i}" in side_parts: + operators.append(side_parts[f"op_{i}"]) + i += 1 + return terms, operators - def _generate_equation(self, rng: random.Random, variable: str) -> Tuple[str, int]: - """Generate an equation and its solution + # Parse both sides of the equation + lhs_terms, lhs_operators = parse_side(metadata["lhs"]) + rhs_terms, rhs_operators = parse_side(metadata["rhs"]) + + return { + "lhs_terms": lhs_terms, + "rhs_terms": rhs_terms, + "lhs_operators": lhs_operators, + "rhs_operators": rhs_operators, + "variable": metadata["variable"]["var"] + } + + def _evaluate_expression(self, parsed: Dict[str, Any]) -> str: + """ + Evaluate the equation and find its solution. Args: - rng: Random number generator - variable: Variable symbol to use in equation - + parsed: Dictionary containing parsed expression data Returns: - Tuple of (equation string, solution integer) + String representation of the solution """ - x = Symbol(variable) + # Create sympy symbol from parsed variable + var = Symbol(parsed["variable"]) - # Generate terms for left side - num_terms = rng.randint(self.config.min_terms, self.config.max_terms) - terms = [] + # Build left and right expressions + def build_expr(terms: list, operators: list) -> str: + """Helper to build expression string from terms and operators.""" + expr = terms[0] + for i, op in enumerate(operators): + expr = f"{expr} {op} {terms[i + 1]}" + return expr - # Generate all constant terms first - for _ in range(num_terms): - value = rng.randint(self.config.min_value, self.config.max_value) - terms.append(value) + lhs_expr = build_expr(parsed["lhs_terms"], parsed["lhs_operators"]) + rhs_expr = build_expr(parsed["rhs_terms"], parsed["rhs_operators"]) - # Replace one random term with the variable term - var_pos = rng.randint(0, num_terms - 1) - coef = rng.randint(self.config.min_value, self.config.max_value) - if "*" in self.config.operators: - terms[var_pos] = coef * x - else: - terms[var_pos] = x + try: + # Parse both sides into sympy expressions + lhs = parse_expr(lhs_expr, local_dict={parsed["variable"]: var}) + rhs = parse_expr(rhs_expr, local_dict={parsed["variable"]: var}) - # Apply operators between terms - expr = terms[0] - for i in range(1, num_terms): - op = rng.choice(self.config.operators) - if op == "+": - expr = expr + terms[i] - elif op == "-": - expr = expr - terms[i] - else: # '*' - expr = expr * terms[i] + # Solve the equation + solution = solve(Eq(lhs, rhs), var) - left_side = expr - solution_value = rng.randint(self.config.min_value, self.config.max_value) - right_side = left_side.subs(x, solution_value) - return f"{left_side} = {right_side}", solution_value - - -register_dataset("simple_equations", SimpleEquationsDataset, SimpleEquationsConfig) + # Convert to float and return as string + if solution: + return str(float(solution[0])) + return "" + except Exception as e: + return f"Error solving equation: {lhs_expr} = {rhs_expr}\nError: {str(e)}" diff --git a/reasoning_gym/curricula/algebra/__init__.py b/reasoning_gym/curricula/algebra/__init__.py index 4718d3cb..4efbc2fb 100644 --- a/reasoning_gym/curricula/algebra/__init__.py +++ b/reasoning_gym/curricula/algebra/__init__.py @@ -1,7 +1,7 @@ from .polynomial_equations_curriculum import PolynomialEquationsCurriculum -# from .simple_equations import SimpleEquationsConfig, SimpleEquationsDataset +from .simple_equations_curriculum import SimpleEquationsCurriculum __all__ = [ - # "SimpleEquationsCurriculum", + "SimpleEquationsCurriculum", "PolynomialEquationsCurriculum", ] diff --git a/reasoning_gym/curricula/algebra/simple_equations_curriculum.py b/reasoning_gym/curricula/algebra/simple_equations_curriculum.py new file mode 100644 index 00000000..c12d929d --- /dev/null +++ b/reasoning_gym/curricula/algebra/simple_equations_curriculum.py @@ -0,0 +1,131 @@ +""" +Curriculum definition for simple equation exercises. +""" + +import string +from typing import Dict, Any +from reasoning_gym.core.base_curriculum import BaseCurriculum +from reasoning_gym.core.attributes import AttributeDefinition, AttributeType +from reasoning_gym.core.template import Template + + +class SimpleEquationsCurriculum(BaseCurriculum): + def __init__(self): + super().__init__("SimpleEquationsCurriculum") + + def _init_curriculum(self) -> None: + """Initialize the simple equations curriculum configuration""" + # Define valid attribute types + self._valid_types = { + AttributeType.STATIC, # For operators + AttributeType.UBOUND, # For ranges like num_terms, value + AttributeType.APPEND, # For operators that accumulate + AttributeType.APPEND_LIST # For variables that accumulate + } + + # Define attributes + self._attributes = { + "num_terms": AttributeDefinition( + levels=[2, 3, 4], # From min_terms/max_terms + default_level=0, + description="Number of terms in the equation", + attr_type=AttributeType.UBOUND, + min_value=1 # Ensure at least 1 term + ), + "value": AttributeDefinition( + levels=[10, 50, 100], # From min_value/max_value + default_level=0, + description="Maximum value for constants and coefficients", + attr_type=AttributeType.UBOUND, + min_value=1 # Ensure non-zero values + ), + "operators": AttributeDefinition( + levels=["+", "-", "*"], # Keep original operators + default_level=0, + description="Allowed operators between terms", + attr_type=AttributeType.APPEND + ), + "sign": AttributeDefinition( + levels=["", "-"], # Remove explicit + sign + default_level=0, + description="Sign of the coefficient", + attr_type=AttributeType.APPEND + ), + "var_name": AttributeDefinition( + levels=[list("xyz"), list(string.ascii_lowercase), list("αβγρθφψω")], + default_level=0, + description="Variables to use in equations", + attr_type=AttributeType.APPEND_LIST + ) + } + + # Define templates with symbolic placeholders + self._templates = [ + Template( + template="Find the value of {variable} in the equation: {lhs} = {rhs}", + parts={"lhs": "eq_lhs", "rhs": "eq_rhs", "variable": "variable_name"} + ), + Template( + template="Solve for {variable}: {lhs} = {rhs}", + parts={"lhs": "eq_lhs", "rhs": "eq_rhs", "variable": "variable_name"} + ), + Template( + template="Determine the value of {variable} that satisfies: {lhs} = {rhs}", + parts={"lhs": "eq_lhs", "rhs": "eq_rhs", "variable": "variable_name"} + ) + ] + + # Define symbolic structure + self._symbolic = { + # Define composition templates + "templates": { + "variable_name": lambda refs: { + "template": "{var}", + "parts": {"var": lambda refs=refs: refs["var"](refs)} + }, + # Use shared template for both sides + "eq_lhs": lambda refs: refs["templates"]["equation_side"](refs, "lhs"), + "eq_rhs": lambda refs: refs["templates"]["equation_side"](refs, "rhs"), + # Shared equation side template + "equation_side": lambda refs, side: ( + n_terms := refs["num_terms"](), + var_side := refs["var_side"](refs), + var_term := refs["dataset_rng"].randrange(n_terms) if var_side == side else None, + { + "template": "{term_0}" + "".join(f" {{op_{i}}} {{term_{i+1}}}" + for i in range(n_terms - 1)), + "parts": { + **{f"term_{i}": lambda i=i: refs["templates"]["term"](refs, i == var_term) + for i in range(n_terms)}, + **{f"op_{i}": lambda refs=refs: refs["operator"](refs)() + for i in range(n_terms - 1)} + } + } + )[-1], + # Term template + "term": lambda refs, has_var: { + "template": "{sign}{coeff}{variable}", + "parts": { + "sign": lambda refs=refs: refs["sign_term"](refs)(), + "coeff": lambda refs=refs: ( + coeff := refs["term_value"](refs)(), + f"{coeff}*" if has_var and coeff != 1 else + f"{coeff}" if not has_var else + "" + )[-1], + "variable": lambda refs=refs: refs["var"](refs) if has_var else "" + } + }, + }, + # Define shared variables that need to be consistent across templates + "shared_vars": { + "var": lambda refs: refs["var_name"](), + "var_side": lambda refs: refs["dataset_rng"].choice(["lhs", "rhs"]) + }, + # Define value generators + "generators": { + "term_value": lambda refs: lambda: refs["value"](), + "operator": lambda refs: lambda: refs["operators"](), + "sign_term": lambda refs: lambda: refs["sign"]() + } + } \ No newline at end of file diff --git a/tests/test_simple_equations.py b/tests/test_simple_equations.py index c0f846fb..6f125858 100644 --- a/tests/test_simple_equations.py +++ b/tests/test_simple_equations.py @@ -1,130 +1,482 @@ -"""Tests for simple equation task generation""" +from reasoning_gym.curricula.algebra.simple_equations_curriculum import SimpleEquationsCurriculum +from reasoning_gym.exercises.algebra.simple_equations import SimpleEquationsExercise +import unittest +import random +from sympy import solve, Symbol, Eq, parse_expr -import pytest +class TestSimpleEquationsParsing(unittest.TestCase): + """Test parsing of linear equation expressions and terms""" -from reasoning_gym.algebra.simple_equations import SimpleEquationsConfig, SimpleEquationsDataset + def setUp(self): + self.exercise = SimpleEquationsExercise() + def test_parse_expression(self): + """Test parsing of basic linear expressions""" + test_metadata = { + 'type': 'direct', + 'executed_parts': { + 'lhs_terms': ['2*x', '3'], + 'rhs_terms': ['5'], + 'lhs_operators': ['+'], + 'rhs_operators': [], + 'variable': 'x' + } + } -def test_simple_equations_config_validation(): - """Test that invalid configs raise appropriate errors""" - with pytest.raises(AssertionError): - config = SimpleEquationsConfig(min_terms=0) # Too few terms - config.validate() + parsed = test_metadata['executed_parts'] + self.assertEqual(parsed["lhs_terms"], ["2*x", "3"]) + self.assertEqual(parsed["rhs_terms"], ["5"]) + self.assertEqual(parsed["lhs_operators"], ["+"]) + self.assertEqual(parsed["rhs_operators"], []) + self.assertEqual(parsed["variable"], "x") - with pytest.raises(AssertionError): - config = SimpleEquationsConfig(min_terms=5, max_terms=3) # max < min terms - config.validate() + def test_parse_negative_terms(self): + """Test parsing of expressions with negative terms""" + test_metadata = { + 'type': 'direct', + 'executed_parts': { + 'lhs_terms': ['-2*x', '4'], + 'rhs_terms': ['-1'], + 'lhs_operators': ['+'], + 'rhs_operators': [], + 'variable': 'x' + } + } - with pytest.raises(AssertionError): - config = SimpleEquationsConfig(min_value=0) # Too small value - config.validate() + parsed = test_metadata['executed_parts'] + self.assertEqual(parsed["lhs_terms"], ["-2*x", "4"]) + self.assertEqual(parsed["rhs_terms"], ["-1"]) + self.assertEqual(parsed["lhs_operators"], ["+"]) + self.assertEqual(parsed["rhs_operators"], []) + self.assertEqual(parsed["variable"], "x") - with pytest.raises(AssertionError): - config = SimpleEquationsConfig(min_value=100, max_value=50) # max < min value - config.validate() +class TestSimpleEquationsEvaluation(unittest.TestCase): + """Test evaluation of linear equations""" - with pytest.raises(AssertionError): - config = SimpleEquationsConfig(operators=()) # Empty operators - config.validate() + def setUp(self): + self.exercise = SimpleEquationsExercise() - with pytest.raises(AssertionError): - config = SimpleEquationsConfig(operators=("+", "^")) # Invalid operator - config.validate() + def test_basic_equation(self): + """Test evaluation of basic linear equations""" + parsed = { + "lhs_terms": ["2*x", "3"], + "rhs_terms": ["7"], + "lhs_operators": ["+"], + "rhs_operators": [], + "variable": "x" + } + result = self.exercise._evaluate_expression(parsed) + expected = "2.0" # 2x + 3 = 7 has solution x = 2 + self.assertEqual(result, expected) + def test_negative_coefficients(self): + """Test evaluation with negative coefficients""" + parsed = { + "lhs_terms": ["-2*x", "4"], + "rhs_terms": ["0"], + "lhs_operators": ["+"], + "rhs_operators": [], + "variable": "x" + } + result = self.exercise._evaluate_expression(parsed) + expected = "2.0" # -2x + 4 = 0 has solution x = 2 + self.assertEqual(result, expected) -def test_simple_equations_dataset_deterministic(): - """Test that dataset generates same items with same seed""" - config = SimpleEquationsConfig(seed=42, size=10) - dataset1 = SimpleEquationsDataset(config) - dataset2 = SimpleEquationsDataset(config) + def test_multiple_terms(self): + """Test equations with multiple terms""" + parsed = { + "lhs_terms": ["x", "2", "3"], + "rhs_terms": ["10"], + "lhs_operators": ["+", "+"], + "rhs_operators": [], + "variable": "x" + } + result = self.exercise._evaluate_expression(parsed) + expected = "5.0" # x + 2 + 3 = 10 has solution x = 5 + self.assertEqual(result, expected) - for i in range(len(dataset1)): - assert dataset1[i] == dataset2[i] +class TestSimpleEquationsGeneration(unittest.TestCase): + """Test problem generation""" + def setUp(self): + self.curriculum = SimpleEquationsCurriculum() + self.exercise = SimpleEquationsExercise() + self.rng = random.Random(42) + self.curriculum.rng = self.rng -def test_simple_equations_dataset_items(): - """Test basic properties of generated items""" - config = SimpleEquationsConfig(min_terms=2, max_terms=4, min_value=1, max_value=100, size=10, seed=42) - dataset = SimpleEquationsDataset(config) + def test_problem_structure(self): + """Test that generated problems have the correct structure""" + problem = self.exercise.generate(self.curriculum) - for i in range(len(dataset)): - item = dataset[i] - # Check item structure - assert isinstance(item, dict) - assert "question" in item - assert "answer" in item - assert "metadata" in item + # Check basic structure + self.assertIn("question", problem) + self.assertIn("answer", problem) + self.assertIn("metadata", problem) - # Check metadata - assert "equation" in item["metadata"] - assert "variable" in item["metadata"] + # Check metadata structure + metadata = problem["metadata"] + self.assertEqual(metadata["type"], "direct") + self.assertIn("executed_parts", metadata) + executed_parts = metadata["executed_parts"] + self.assertIn("lhs_terms", executed_parts) + self.assertIn("rhs_terms", executed_parts) + self.assertIn("lhs_operators", executed_parts) + self.assertIn("rhs_operators", executed_parts) + self.assertIn("variable", executed_parts) - # Verify answer is numeric (allowing negative numbers) - answer = item["answer"] - assert answer.replace("-", "").isdigit() + def test_term_generation(self): + """Test generation of equation terms""" + # Set curriculum to basic settings + self.curriculum.set_attr_level("value", 0) # 1-10 + self.curriculum.set_attr_level("sign", 0) # No signs + self.curriculum.set_attr_level("var_name", 0) # Basic variables - # Verify equation format - equation = item["metadata"]["equation"] - assert "=" in equation - assert item["metadata"]["variable"] in equation + problem = self.exercise.generate(self.curriculum) + executed_parts = problem["metadata"]["executed_parts"] + # Check we have at least one term + self.assertTrue(len(executed_parts["lhs_terms"]) > 0) -def test_simple_equations_dataset_iteration(): - """Test that iteration respects dataset size""" - config = SimpleEquationsConfig(size=5, seed=42) - dataset = SimpleEquationsDataset(config) + # Check first term format + first_term = executed_parts["lhs_terms"][0] + self.assertTrue(isinstance(first_term, str)) + if '*' in first_term: + coeff = first_term.split('*')[0] + self.assertTrue(coeff.replace('-', '').isdigit() or coeff in ['', '-']) - items = list(dataset) - assert len(items) == config.size + def test_operator_generation(self): + """Test generation of operators""" + self.curriculum.set_attr_level("operators", 1) # +, - + self.curriculum.set_attr_level("num_terms", 1) # 3 terms - # Test multiple iterations yield same items - assert items == list(dataset) + problem = self.exercise.generate(self.curriculum) + executed_parts = problem["metadata"]["executed_parts"] + # Check we have operators for n-1 terms + self.assertEqual(len(executed_parts["lhs_operators"]), len(executed_parts["lhs_terms"]) - 1) -def test_simple_equations_solution_verification(): - """Test that generated equations have correct solutions""" - config = SimpleEquationsConfig( - min_terms=2, - max_terms=3, - min_value=1, - max_value=10, # Small values for predictable results - operators=("+", "-"), # Simple operators for easy verification - size=10, - seed=42, - ) - dataset = SimpleEquationsDataset(config) + # Check operator is valid + if executed_parts["lhs_operators"]: + self.assertIn(executed_parts["lhs_operators"][0], ["+", "-"]) - for item in dataset: - # Extract equation parts - equation = item["metadata"]["equation"] - variable = item["metadata"]["variable"] - solution = int(item["answer"]) +class TestSimpleEquationsComprehensive(unittest.TestCase): + """Comprehensive tests for simple equations""" - # Verify solution by substitution - equation_parts = equation.split("=") - left_side = equation_parts[0].strip() - right_side = int(equation_parts[1].strip()) + def setUp(self): + self.curriculum = SimpleEquationsCurriculum() + self.exercise = SimpleEquationsExercise() + self.rng = random.Random(42) + self.curriculum.rng = self.rng - # Replace variable with solution - evaluated = eval(left_side.replace(variable, str(solution))) - assert evaluated == right_side + def test_variable_consistency(self): + """Test that the same variable is used consistently throughout the equation""" + num_samples = 50 + for _ in range(num_samples): + problem = self.exercise.generate(self.curriculum) + executed_parts = problem["metadata"]["executed_parts"] + var_name = executed_parts["variable"] -def test_simple_equations_operators(): - """Test equation generation with different operator combinations""" - for operators in [ - ("+",), - ("+", "-"), - ("*",), - ("+", "*"), - ("+", "-", "*"), - ]: - config = SimpleEquationsConfig(operators=operators, size=5, seed=42) - dataset = SimpleEquationsDataset(config) + # Check variable appears in question + self.assertIn(var_name, problem["question"]) - for item in dataset: - equation = item["metadata"]["equation"] - # Verify only allowed operators are used - for op in "+-*": - if op in equation: - assert op in operators, str(equation) + # Check variable is used consistently in terms + for term in executed_parts["lhs_terms"] + executed_parts["rhs_terms"]: + if var_name in term: # If term has a variable + self.assertIn(var_name, term) + + def test_coefficient_ranges(self): + """Test that coefficients are within expected ranges""" + self.curriculum.set_attr_level("value", 0) # 1-10 + num_samples = 50 + + for _ in range(num_samples): + problem = self.exercise.generate(self.curriculum) + executed_parts = problem["metadata"]["executed_parts"] + + for term in executed_parts["lhs_terms"] + executed_parts["rhs_terms"]: + # Extract coefficient if term has one + if '*' in term: + coeff = term.split('*')[0] + if coeff and coeff != '-': # Skip if empty or just a minus sign + coeff = float(coeff) + self.assertLessEqual(abs(coeff), 10) + self.assertGreater(abs(coeff), 0) + + def test_solution_validity(self): + """Test that generated solutions are valid""" + num_samples = 50 + + for _ in range(num_samples): + problem = self.exercise.generate(self.curriculum) + executed_parts = problem["metadata"]["executed_parts"] + solution = float(problem["answer"]) + + # Verify solution satisfies the equation + var = Symbol(executed_parts["variable"]) + + # Build left and right expressions + lhs = executed_parts["lhs_terms"][0] + for i, term in enumerate(executed_parts["lhs_terms"][1:], 1): + lhs += f" {executed_parts['lhs_operators'][i-1]} {term}" + + rhs = executed_parts["rhs_terms"][0] + for i, term in enumerate(executed_parts["rhs_terms"][1:], 1): + rhs += f" {executed_parts['rhs_operators'][i-1]} {term}" + + # Parse expressions + lhs_expr = parse_expr(lhs, local_dict={executed_parts["variable"]: var}) + rhs_expr = parse_expr(rhs, local_dict={executed_parts["variable"]: var}) + + # Verify solution + lhs_val = float(lhs_expr.subs(var, solution)) + rhs_val = float(rhs_expr.subs(var, solution)) + self.assertAlmostEqual(lhs_val, rhs_val, places=10) + + def test_comprehensive_random_evaluation(self): + """Test 1000 random problems across all levels to verify correct generation and evaluation""" + num_samples = 1000 + + # Statistics tracking + stats = { + 'operator_counts': {}, # Count of each operator used + 'term_counts': {}, # Distribution of number of terms + 'variable_counts': {}, # Count of each variable used + 'coefficient_stats': { # Track coefficient statistics + 'min': float('inf'), + 'max': float('-inf'), + 'total': 0, + 'count': 0, + 'unique': set() + }, + 'solution_stats': { # Track solution statistics + 'min': float('inf'), # Minimum solution value + 'max': float('-inf'), # Maximum solution value + 'total': 0, + 'count': 0 + }, + 'var_side_stats': { # Track which side variables appear on + 'lhs_only': 0, # Variable only on left side + 'rhs_only': 0, # Variable only on right side + 'both_sides': 0, # Variable on both sides + 'total': 0 + }, + 'level_distribution': { # Track curriculum level usage + 'num_terms': {}, + 'value': {}, + 'operators': {}, + 'sign': {}, + 'var_name': {} + } + } + + for _ in range(num_samples): + # Randomly set curriculum levels + for attr in self.curriculum.attributes: + level = random.randint(0, len(self.curriculum.attributes[attr].levels) - 1) + self.curriculum.set_attr_level(attr, level) + stats['level_distribution'][attr][level] = stats['level_distribution'][attr].get(level, 0) + 1 + + problem = self.exercise.generate(self.curriculum) + executed_parts = problem["metadata"]["executed_parts"] + + # Update operator statistics + for op in executed_parts["lhs_operators"] + executed_parts["rhs_operators"]: + stats['operator_counts'][op] = stats['operator_counts'].get(op, 0) + 1 + + # Update term count statistics (count terms on each side separately) + lhs_terms = len(executed_parts["lhs_terms"]) + rhs_terms = len(executed_parts["rhs_terms"]) + max_side_terms = max(lhs_terms, rhs_terms) + stats['term_counts'][max_side_terms] = stats['term_counts'].get(max_side_terms, 0) + 1 + + # Update variable statistics + var = executed_parts["variable"] + stats['variable_counts'][var] = stats['variable_counts'].get(var, 0) + 1 + + # Update variable side statistics + var_in_lhs = any(var in term for term in executed_parts["lhs_terms"]) + var_in_rhs = any(var in term for term in executed_parts["rhs_terms"]) + + if var_in_lhs and var_in_rhs: + stats['var_side_stats']['both_sides'] += 1 + elif var_in_lhs: + stats['var_side_stats']['lhs_only'] += 1 + elif var_in_rhs: + stats['var_side_stats']['rhs_only'] += 1 + stats['var_side_stats']['total'] += 1 + + # Update coefficient statistics + for term in executed_parts["lhs_terms"] + executed_parts["rhs_terms"]: + if '*' in term: + coeff = term.split('*')[0] + if coeff and coeff not in ['-', '+']: + try: + value = abs(float(coeff)) + stats['coefficient_stats']['min'] = min(stats['coefficient_stats']['min'], value) + stats['coefficient_stats']['max'] = max(stats['coefficient_stats']['max'], value) + stats['coefficient_stats']['total'] += value + stats['coefficient_stats']['count'] += 1 + stats['coefficient_stats']['unique'].add(value) + except ValueError: + continue + + # Update solution statistics + solution = float(problem["answer"]) + stats['solution_stats']['min'] = min(stats['solution_stats']['min'], solution) + stats['solution_stats']['max'] = max(stats['solution_stats']['max'], solution) + stats['solution_stats']['total'] += solution + stats['solution_stats']['count'] += 1 + + # Verify solution correctness + var = Symbol(executed_parts["variable"]) + lhs = executed_parts["lhs_terms"][0] + for i, term in enumerate(executed_parts["lhs_terms"][1:], 1): + lhs += f" {executed_parts['lhs_operators'][i-1]} {term}" + rhs = executed_parts["rhs_terms"][0] + for i, term in enumerate(executed_parts["rhs_terms"][1:], 1): + rhs += f" {executed_parts['rhs_operators'][i-1]} {term}" + + lhs_expr = parse_expr(lhs, local_dict={executed_parts["variable"]: var}) + rhs_expr = parse_expr(rhs, local_dict={executed_parts["variable"]: var}) + lhs_val = float(lhs_expr.subs(var, solution)) + rhs_val = float(rhs_expr.subs(var, solution)) + self.assertAlmostEqual(lhs_val, rhs_val, places=10) + + # Print comprehensive statistics + print("\nComprehensive Random Evaluation Statistics:") + print("-" * 50) + + print("\nOperator Distribution:") + total_ops = sum(stats['operator_counts'].values()) + for op, count in sorted(stats['operator_counts'].items()): + print(f" {op}: {count} ({count/total_ops*100:.1f}%)") + + print("\nTerm Count Distribution (per side):") + total_eqs = num_samples + for terms, count in sorted(stats['term_counts'].items()): + print(f" {terms} terms: {count} ({count/total_eqs*100:.1f}%)") + + print("\nVariable Distribution:") + total_vars = sum(stats['variable_counts'].values()) + for var, count in sorted(stats['variable_counts'].items()): + print(f" {var}: {count} ({count/total_vars*100:.1f}%)") + + print("\nVariable Side Distribution:") + total_eqs = stats['var_side_stats']['total'] + print(f" Left side only: {stats['var_side_stats']['lhs_only']} ({stats['var_side_stats']['lhs_only']/total_eqs*100:.1f}%)") + print(f" Right side only: {stats['var_side_stats']['rhs_only']} ({stats['var_side_stats']['rhs_only']/total_eqs*100:.1f}%)") + print(f" Both sides: {stats['var_side_stats']['both_sides']} ({stats['var_side_stats']['both_sides']/total_eqs*100:.1f}%)") + + print("\nCoefficient Statistics:") + print(f" Range: [{stats['coefficient_stats']['min']:.1f} to {stats['coefficient_stats']['max']:.1f}]") + if stats['coefficient_stats']['count'] > 0: + avg = stats['coefficient_stats']['total'] / stats['coefficient_stats']['count'] + print(f" Average: {avg:.2f}") + print(f" Unique values: {len(stats['coefficient_stats']['unique'])}") + + print("\nSolution Statistics:") + print(f" Range: [{stats['solution_stats']['min']:.2f} to {stats['solution_stats']['max']:.2f}]") + if stats['solution_stats']['count'] > 0: + avg = stats['solution_stats']['total'] / stats['solution_stats']['count'] + print(f" Average: {avg:.2f}") + + print("\nCurriculum Level Distribution:") + for attr, levels in sorted(stats['level_distribution'].items()): + print(f"\n {attr}:") + for level, count in sorted(levels.items()): + print(f" Level {level}: {count} ({count/total_eqs*100:.1f}%)") + + # Verify statistical properties + # 1. Check we see all operators when using operator level 1 + if any(level == 1 for level in stats['level_distribution']['operators'].keys()): + self.assertTrue(all(op in stats['operator_counts'] for op in ["+", "-"]), + "Not all operators were generated") + + # 2. Check term count constraints (per side) + min_terms = min(stats['term_counts'].keys()) + max_terms = max(stats['term_counts'].keys()) + self.assertGreaterEqual(min_terms, 1, "Generated equations with too few terms per side") + self.assertLessEqual(max_terms, 4, "Generated equations with too many terms per side") + + # 3. Check coefficient ranges + if stats['coefficient_stats']['count'] > 0: + self.assertGreater(len(stats['coefficient_stats']['unique']), 3, + "Too few unique coefficients generated") + self.assertGreater(stats['coefficient_stats']['min'], 0, + "Generated zero or negative coefficients") + self.assertLessEqual(stats['coefficient_stats']['max'], 100, + "Generated coefficients exceed maximum allowed") + +class TestSimpleEquationsGenerate(unittest.TestCase): + """Test the generate function with different curriculum settings""" + + def setUp(self): + self.curriculum = SimpleEquationsCurriculum() + self.exercise = SimpleEquationsExercise() + self.rng = random.Random(42) # Fixed seed for reproducibility + self.curriculum.rng = self.rng + + def test_generate_basic_equation(self): + """Test generation of basic linear equations""" + # Configure curriculum for simple equations + self.curriculum.set_attr_level("num_terms", 0) # 2 terms + self.curriculum.set_attr_level("value", 0) # Small values + self.curriculum.set_attr_level("operators", 0) # Only + + self.curriculum.set_attr_level("sign", 0) # No signs + self.curriculum.set_attr_level("var_name", 0) # Basic variables + + problem = self.exercise.generate(self.curriculum) + + # Verify structure + self.assertIn("question", problem) + self.assertIn("answer", problem) + self.assertIn("metadata", problem) + + # Verify terms and operators + executed_parts = problem["metadata"]["executed_parts"] + self.assertTrue(len(executed_parts["lhs_terms"]) >= 1, "Not enough terms generated") + self.assertTrue(len(executed_parts["rhs_terms"]) >= 1, "Not enough terms generated") + + # Verify operator is addition if present + if executed_parts["lhs_operators"]: + self.assertEqual(executed_parts["lhs_operators"][0], "+") + if executed_parts["rhs_operators"]: + self.assertEqual(executed_parts["rhs_operators"][0], "+") + + def test_coefficient_distribution(self): + """Test distribution of coefficient values""" + self.curriculum.set_attr_level("value", 0) # 1-10 + num_samples = 100 + coefficients = [] + + for _ in range(num_samples): + problem = self.exercise.generate(self.curriculum) + executed_parts = problem["metadata"]["executed_parts"] + + for term in executed_parts["lhs_terms"] + executed_parts["rhs_terms"]: + if '*' in term: + coeff = term.split('*')[0] + if coeff and coeff not in ['-', '+']: + coefficients.append(abs(float(coeff))) + + # Check coefficient range + self.assertTrue(all(1 <= c <= 10 for c in coefficients), + "Coefficients outside valid range [1,10]") + # Check we see different values + unique_coeffs = set(coefficients) + self.assertTrue(len(unique_coeffs) > 3, + f"Too few unique coefficients: {unique_coeffs}") + + def test_error_handling(self): + """Test error handling in equation generation""" + # Test with invalid attribute level + with self.assertRaises(ValueError): + self.curriculum.set_attr_level("value", 999) + + # Test with invalid attribute name + with self.assertRaises(KeyError): + self.curriculum.set_attr_level("invalid_attr", 0) + +if __name__ == '__main__': + unittest.main()