diff --git a/examples/exercises/algorithmic/base_conversion_examples.py b/examples/exercises/algorithmic/base_conversion_examples.py new file mode 100644 index 00000000..5cae766a --- /dev/null +++ b/examples/exercises/algorithmic/base_conversion_examples.py @@ -0,0 +1,98 @@ +"""Examples of generated problems from the BaseConversion exercise. + +This file demonstrates different types of base conversion problems that can be generated +at various difficulty levels. +""" + +from reasoning_gym.curricula.algorithmic.base_conversion_curriculum import BaseConversionCurriculum +from reasoning_gym.exercises.algorithmic.base_conversion import BaseConversionExercise +import random + +def main(): + # Initialize with fixed seed for reproducibility + curriculum = BaseConversionCurriculum() + exercise = BaseConversionExercise() + curriculum.rng = random.Random(42) + + print("\n========================================\n") + + # Level 0: Basic binary/decimal conversions + curriculum.set_attr_level("value", 0) # Small values (up to 100) + curriculum.set_attr_level("base_range", 0) # Up to base-16 + curriculum.set_attr_level("base_names", 0) # Basic names (binary, hexadecimal) + curriculum.set_attr_level("hint", 0) # Include hints + problem = exercise.generate(curriculum) + print("Level 0 (Basic Binary/Decimal):") + print(problem) + + print("\n========================================\n") + + # Level 1: Medium difficulty with octal/decimal + curriculum.set_attr_level("value", 1) # Medium values (up to 1000) + curriculum.set_attr_level("base_range", 0) # Up to base-16 + curriculum.set_attr_level("base_names", 1) # Add octal/decimal names + curriculum.set_attr_level("hint", 0) # Include hints + problem = exercise.generate(curriculum) + print("Level 1 (Medium with Octal):") + print(problem) + + print("\n========================================\n") + + # Level 2: Advanced with higher bases + curriculum.set_attr_level("value", 2) # Large values (up to 10000) + curriculum.set_attr_level("base_range", 1) # Up to base-26 + curriculum.set_attr_level("base_names", 1) # All base names + curriculum.set_attr_level("hint", 1) # No hints + problem = exercise.generate(curriculum) + print("Level 2 (Advanced High Bases):") + 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("value", random.randint(0, 2)) + curriculum.set_attr_level("base_range", random.randint(0, 2)) + curriculum.set_attr_level("base_names", random.randint(0, 1)) + curriculum.set_attr_level("hint", random.randint(0, 1)) + problem = exercise.generate(curriculum) + print(f"\nRandom Example (Seed {seed}):") + print(problem) + + print("\n========================================\n") + + # Special Cases + print("Special Cases:") + + # Case 1: Maximum value in binary + curriculum.set_attr_level("value", 2) # Large values + curriculum.set_attr_level("base_range", 0) # Basic bases + curriculum.set_attr_level("base_names", 0) # Basic names + curriculum.set_attr_level("hint", 0) # With hints + problem = exercise.generate(curriculum) + print("\nLarge Binary Conversion:") + print(problem) + + # Case 2: High base with small value + curriculum.set_attr_level("value", 0) # Small values + curriculum.set_attr_level("base_range", 2) # Up to base-36 + curriculum.set_attr_level("base_names", 1) # All names + curriculum.set_attr_level("hint", 0) # With hints + problem = exercise.generate(curriculum) + print("\nHigh Base with Small Value:") + print(problem) + + # Case 3: Medium value with no hints + curriculum.set_attr_level("value", 1) # Medium values + curriculum.set_attr_level("base_range", 1) # Up to base-26 + curriculum.set_attr_level("base_names", 1) # All names + curriculum.set_attr_level("hint", 1) # No hints + problem = exercise.generate(curriculum) + print("\nMedium Value No Hints:") + print(problem) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/reasoning_gym/algorithmic/__init__.py b/reasoning_gym/algorithmic/__init__.py index 1b509970..5034eecd 100644 --- a/reasoning_gym/algorithmic/__init__.py +++ b/reasoning_gym/algorithmic/__init__.py @@ -6,40 +6,29 @@ Algorithmic tasks for training reasoning capabilities: - Pattern matching """ -from .base_conversion import BaseConversionConfig, BaseConversionDataset -from .caesar_cipher import CaesarCipherConfig, CaesarCipherDataset -from .letter_counting import LetterCountingConfig, LetterCountingDataset -from .letter_jumble import LetterJumbleConfig, LetterJumbleDataset -from .number_filtering import NumberFilteringConfig, NumberFilteringDataset -from .number_sorting import NumberSortingConfig, NumberSortingDataset -from .sentence_reordering import SentenceReorderingConfig, SentenceReorderingDataset -from .spell_backward import SpellBackwardConfig, SpellBackwardDataset -from .word_ladder import WordLadderConfig, WordLadderDataset -from .word_sequence_reversal import WordSequenceReversalConfig, WordSequenceReversalDataset -from .word_sorting import TextTransformation, WordSortingConfig, WordSortingDataset +from .base_conversion import BaseConversionExercise +# from .caesar_cipher import CaesarCipherExercise +# from .letter_counting import LetterCountingExercise +# from .letter_jumble import LetterJumbleExercise +# from .number_filtering import NumberFilteringExercise +# from .number_sorting import NumberSortingExercise +# from .sentence_reordering import SentenceReorderingExercise +# from .spell_backward import SpellBackwardExercise +# from .word_ladder import WordLadderExercise +# from .word_sequence_reversal import WordSequenceReversalExercise +# from .word_sorting import TextTransformation, WordSortingExercise __all__ = [ - "SpellBackwardConfig", - "SpellBackwardDataset", - "BaseConversionConfig", - "BaseConversionDataset", - "CaesarCipherConfig", - "CaesarCipherDataset", - "LetterCountingConfig", - "LetterCountingDataset", - "LetterJumbleConfig", - "LetterJumbleDataset", - "NumberFilteringConfig", - "NumberFilteringDataset", - "NumberSortingConfig", - "NumberSortingDataset", - "SentenceReorderingConfig", - "SentenceReorderingDataset", - "WordSequenceReversalConfig", - "WordSequenceReversalDataset", - "WordSortingConfig", - "WordSortingDataset", - "TextTransformation", - "WordLadderConfig", - "WordLadderDataset", + # "SpellBackwardDataset", + "BaseConversionExercise", + # "CaesarCipherDataset", + # "LetterCountingDataset", + # "LetterJumbleDataset", + # "NumberFilteringDataset", + # "NumberSortingDataset", + # "SentenceReorderingDataset", + # "WordSequenceReversalDataset", + # "WordSortingDataset", + # "TextTransformation", + # "WordLadderDataset", ] diff --git a/reasoning_gym/algorithmic/base_conversion.py b/reasoning_gym/algorithmic/base_conversion.py index afa6200a..036860a2 100644 --- a/reasoning_gym/algorithmic/base_conversion.py +++ b/reasoning_gym/algorithmic/base_conversion.py @@ -1,109 +1,104 @@ -"""Base conversion task generator""" +"""Base conversion exercise that converts numbers between different bases.""" -from dataclasses import dataclass -from random import Random -from typing import Optional, Tuple +from typing import Dict, Any -from ..factory import ProceduralDataset, register_dataset +class BaseConversionExercise: + """Exercise generator for base conversion problems.""" + def __init__(self): + self.curriculum = None -@dataclass -class BaseConversionConfig: - """Configuration for base conversion task generation""" + def generate(self, curriculum: Any) -> Dict[str, Any]: + """ + Generate a base conversion problem using the curriculum. - min_base: int = 2 # Minimum base (2=binary) - max_base: int = 16 # Maximum base (16=hex) - min_value: int = 0 # Minimum decimal value to convert - max_value: int = 1000 # Maximum decimal value to convert - seed: Optional[int] = None - size: int = 500 # Virtual dataset size + Returns: + Dict containing: + - question: str (e.g. "Convert the binary number 1010 to hexadecimal") + - answer: str (the converted number in target base) + - metadata: dict with details (value, source_base, target_base, etc.) + """ + self.curriculum = curriculum + template = curriculum.get_template(curriculum.rng) + return template.eval(self, curriculum.rng) - def validate(self) -> None: - """Validate configuration parameters""" - assert 2 <= self.min_base <= 36, "min_base must be between 2 and 36" - assert self.min_base <= self.max_base <= 36, "max_base must be between min_base and 36" - assert self.min_value >= 0, "min_value must be non-negative" - assert self.max_value > self.min_value, "max_value must be > min_value" + def _parse_expression(self, metadata: Dict[str, Any]) -> Dict[str, Any]: + """ + Parse the template metadata into structured data. - -class BaseConversionDataset(ProceduralDataset): - """Generates base conversion tasks""" - - def __init__(self, config: BaseConversionConfig): - super().__init__(config=config, seed=config.seed, size=config.size) - - def _format_base_name(self, base: int) -> str: - """Get human-readable name for common bases""" - if base == 2: - return "binary" - elif base == 16: - return "hexadecimal" - else: - return f"base-{base}" - - def _generate_conversion(self, rng: Random) -> Tuple[int, int, int]: - """Generate random value and source/target bases""" - value = rng.randint(self.config.min_value, self.config.max_value) - - # Choose source and target bases - source_base = rng.randint(self.config.min_base, self.config.max_base) - target_base = rng.randint(self.config.min_base, self.config.max_base) - while target_base == source_base: # Ensure different bases - target_base = rng.randint(self.config.min_base, self.config.max_base) - - return value, source_base, target_base - - def __getitem__(self, idx: int) -> dict: - """Generate a single base conversion task""" - rng = Random(self.seed + idx) - - value, source_base, target_base = self._generate_conversion(rng) - - # Convert decimal to source base representation - if source_base == 16: - source_repr = format(value, "x") - elif source_base == 2: - source_repr = format(value, "b") - else: - # Manual conversion for other bases - n = value - digits = [] - while n: - digits.append(int(n % source_base)) - n //= source_base - source_repr = "".join(str(d) if d < 10 else chr(ord("a") + d - 10) for d in reversed(digits) or [0]) - - # Convert decimal to target base for answer - if target_base == 16: - target_repr = format(value, "x") - elif target_base == 2: - target_repr = format(value, "b") - else: - # Manual conversion for other bases - n = value - digits = [] - while n: - digits.append(int(n % target_base)) - n //= target_base - target_repr = "".join(str(d) if d < 10 else chr(ord("a") + d - 10) for d in reversed(digits) or [0]) - - source_name = self._format_base_name(source_base) - target_name = self._format_base_name(target_base) - - # Add hint for bases > 10 about using lowercase letters - hint = " (use lowercase letters a-z for digits above 9)" if target_base > 10 else "" - - return { - "question": f"Convert the {source_name} number {source_repr} to {target_name}{hint}", - "answer": target_repr, - "metadata": { - "decimal_value": value, - "source_base": source_base, - "target_base": target_base, - "source_repr": source_repr, - "target_repr": target_repr, - }, + The metadata structure from the curriculum: + { + "source_value": {"val": str}, # e.g. "1010" or "a5" + "source_base": {"base": str}, # e.g. "binary" or "base-3" + "target_base": {"base": str, "hint": str}, # e.g. "hexadecimal" or "base-8" with optional hint } + Returns: + Dictionary containing: + - source_value: str (value to convert) + - source_base: int (base to convert from) + - target_base: int (base to convert to) + """ + def parse_base_name(name: str) -> int: + """Convert base name to numeric value.""" + name = name.lower() + if name == "binary": + return 2 + elif name == "octal": + return 8 + elif name == "decimal": + return 10 + elif name == "hexadecimal": + return 16 + elif name.startswith("base-"): + return int(name[5:]) + raise ValueError(f"Unknown base name: {name}") -register_dataset("base_conversion", BaseConversionDataset, BaseConversionConfig) + return { + "source_value": metadata["source_value"]["val"], + "source_base": parse_base_name(metadata["source_base"]["base"]), + "target_base": parse_base_name(metadata["target_base"]["base"]) + } + + def _evaluate_expression(self, parsed: Dict[str, Any]) -> str: + """ + Convert the number between bases. + + Args: + parsed: Dictionary containing: + - source_base: int (base to convert from) + - target_base: int (base to convert to) + - source_value: str (value to convert) + Returns: + String representation of the number in target base + """ + try: + # Convert source value to decimal, handling letter digits + source_value = parsed["source_value"].lower() + decimal_value = 0 + for digit in source_value: + if digit.isdigit(): + digit_val = int(digit) + else: + digit_val = ord(digit) - ord('a') + 10 + if digit_val >= parsed["source_base"]: + raise ValueError(f"Digit {digit} is invalid for base {parsed['source_base']}") + decimal_value = decimal_value * parsed["source_base"] + digit_val + + # Convert decimal to target base + if decimal_value == 0: + return "0" + + # Manual conversion for all bases + digits = [] + n = decimal_value + while n: + digits.append(int(n % parsed["target_base"])) + n //= parsed["target_base"] + # Convert to string with letters for digits > 9 + result = "".join(str(d) if d < 10 else chr(ord("a") + d - 10) + for d in reversed(digits)) + return result + + except ValueError as e: + return f"Error converting number: {str(e)}" diff --git a/reasoning_gym/curricula/algorithmic/__init__.py b/reasoning_gym/curricula/algorithmic/__init__.py new file mode 100644 index 00000000..53d8f82a --- /dev/null +++ b/reasoning_gym/curricula/algorithmic/__init__.py @@ -0,0 +1,5 @@ +from .base_conversion_curriculum import BaseConversionCurriculum + +__all__ = [ + "BaseConversionCurriculum", +] diff --git a/reasoning_gym/curricula/algorithmic/base_conversion_curriculum.py b/reasoning_gym/curricula/algorithmic/base_conversion_curriculum.py new file mode 100644 index 00000000..09910c3d --- /dev/null +++ b/reasoning_gym/curricula/algorithmic/base_conversion_curriculum.py @@ -0,0 +1,135 @@ +""" +Curriculum definition for base conversion exercises. +""" + +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 BaseConversionCurriculum(BaseCurriculum): + def __init__(self): + super().__init__("BaseConversionCurriculum") + + def _init_curriculum(self) -> None: + """Initialize the base conversion curriculum configuration""" + # Define valid attribute types + self._valid_types = { + AttributeType.STATIC, # For base names + AttributeType.UBOUND, # For ranges like value, base + AttributeType.APPEND # For accumulating options + } + + # Define attributes + self._attributes = { + "value": AttributeDefinition( + levels=[100, 1000, 10000], + default_level=0, + description="Maximum decimal value to convert", + attr_type=AttributeType.UBOUND, + min_value=1 + ), + "base_range": AttributeDefinition( + levels=[16, 26, 36], + default_level=0, + description="Maximum base value (2 is minimum)", + attr_type=AttributeType.UBOUND, + min_value=2 # Ensure at least binary + ), + "base_names": AttributeDefinition( + levels=[{"2": "binary", "16": "hexadecimal"}, + {"8": "octal", "10": "decimal"}], + default_level=0, + description="Special names for bases", + attr_type=AttributeType.APPEND + ), + "hint": AttributeDefinition( + levels=[True, False], + default_level=0, + description="Whether to include a hint", + attr_type=AttributeType.STATIC + ) + } + + # Define templates with symbolic placeholders + self._templates = [ + Template( + template="Convert {source_value} from {source_base} to {target_base}", + parts={ + "source_value": "value", + "source_base": "base_src", + "target_base": "base_trg" + } + ), + Template( + template="What is {source_value} ({source_base}) in {target_base}", + parts={ + "source_value": "value", + "source_base": "base_src", + "target_base": "base_trg" + } + ), + Template( + template="Express the {source_base} number {source_value} in {target_base}", + parts={ + "source_value": "value", + "source_base": "base_src", + "target_base": "base_trg" + } + ) + ] + + # Define symbolic structure + self._symbolic = { + # Define shared variables that need to be consistent + "shared_vars": { + "src_base": lambda refs: refs["base_range"]() + }, + "generators": { + "trg_base": lambda refs: ( + base := refs["base_range"](), + base if base != refs["src_base"](refs) else refs["trg_base"](refs) + )[-1], + "format_base": lambda refs: lambda base: ( + names := refs["base_names"](), + names.get(str(base), f"base-{base}") if refs["dataset_rng"].random() < 0.5 else f"base-{base}" + )[-1], + # Convert decimal to any base + "convert_decimal": lambda refs: lambda base: ( + n := refs["value"](), + digits := [], + [digits.append(int(n % base)) or (n := n // base) for _ in range(32) if n > 0], + "".join(str(d) if d < 10 else chr(ord("a") + d - 10) for d in reversed(digits) or [0]) + )[-1], + "format_hint": lambda refs: lambda base: " (use lowercase letters a-z for digits above 9)" if base > 10 and refs["hint"]() else "" + }, + # Define composition templates + "templates": { + "value": lambda refs: { + "template": "{val}", + "parts": { + "val": lambda refs=refs: refs["convert_decimal"](refs)(refs["src_base"](refs)) + } + }, + "base_src": lambda refs: ( + base := refs["src_base"](refs), + { + "template": "{base}", + "parts": { + "base": lambda refs=refs: refs["format_base"](refs)(base) + } + } + )[-1], + "base_trg": lambda refs: ( + base := refs["trg_base"](refs), + { + "template": "{base}{hint}", + "parts": { + "base": lambda refs=refs: refs["format_base"](refs)(base), + "hint": lambda refs=refs: refs["format_hint"](refs)(base) + } + } + )[-1] + } + } \ No newline at end of file diff --git a/tests/test_base_conversion.py b/tests/test_base_conversion.py index 8017d74a..a3dc307a 100644 --- a/tests/test_base_conversion.py +++ b/tests/test_base_conversion.py @@ -1,165 +1,422 @@ -"""Tests for base conversion task generation""" +"""Unit tests for the base conversion exercise.""" -import pytest +from enum import verify +from reasoning_gym.curricula.algorithmic.base_conversion_curriculum import BaseConversionCurriculum +from reasoning_gym.exercises.algorithmic.base_conversion import BaseConversionExercise +import unittest +import random +from collections import defaultdict -from reasoning_gym.algorithmic.base_conversion import BaseConversionConfig, BaseConversionDataset +class TestBaseConversionParsing(unittest.TestCase): + """Test parsing of base conversion metadata""" + def setUp(self): + self.exercise = BaseConversionExercise() -def test_base_conversion_config_validation(): - """Test that invalid configs raise appropriate errors""" - with pytest.raises(AssertionError): - config = BaseConversionConfig(min_base=1) # Too small - config.validate() + def test_parse_expression_basic(self): + """Test parsing of basic base conversion metadata""" + test_metadata = { + "source_value": {"val": "1010"}, + "source_base": {"base": "binary"}, + "target_base": {"base": "hexadecimal", "hint": ""} + } + parsed = self.exercise._parse_expression(test_metadata) + self.assertEqual(parsed["source_value"], "1010") + self.assertEqual(parsed["source_base"], 2) + self.assertEqual(parsed["target_base"], 16) - with pytest.raises(AssertionError): - config = BaseConversionConfig(min_base=37) # Too large - config.validate() + def test_parse_base_names(self): + """Test parsing of different base names""" + test_cases = [ + ({"base": "binary"}, 2), + ({"base": "octal"}, 8), + ({"base": "decimal"}, 10), + ({"base": "hexadecimal"}, 16), + ({"base": "base-3"}, 3), + ({"base": "base-36"}, 36) + ] + for base_dict, expected in test_cases: + metadata = { + "source_value": {"val": "0"}, + "source_base": base_dict, + "target_base": {"base": "decimal", "hint": ""} + } + parsed = self.exercise._parse_expression(metadata) + self.assertEqual(parsed["source_base"], expected) - with pytest.raises(AssertionError): - config = BaseConversionConfig(min_base=10, max_base=5) # max < min - config.validate() + def test_invalid_base_name(self): + """Test handling of invalid base names""" + metadata = { + "source_value": {"val": "0"}, + "source_base": {"base": "invalid"}, + "target_base": {"base": "decimal", "hint": ""} + } + with self.assertRaises(ValueError): + self.exercise._parse_expression(metadata) - with pytest.raises(AssertionError): - config = BaseConversionConfig(min_value=-1) # Negative not allowed - config.validate() + def test_parse_with_hints(self): + """Test parsing with different hint configurations""" + test_cases = [ + ({"hint": ""}, ""), + ({"hint": " (use lowercase letters a-z for digits above 9)"}, " (use lowercase letters a-z for digits above 9)"), + ({"hint": " (hint: convert to decimal first)"}, " (hint: convert to decimal first)") + ] + for hint_dict, expected in test_cases: + metadata = { + "source_value": {"val": "0"}, + "source_base": {"base": "binary"}, + "target_base": {"base": "hexadecimal", "hint": hint_dict["hint"]} + } + parsed = self.exercise._parse_expression(metadata) + self.assertEqual(parsed["source_base"], 2) + self.assertEqual(parsed["target_base"], 16) +class TestBaseConversionEvaluation(unittest.TestCase): + """Test evaluation of base conversion problems""" -def test_base_conversion_dataset_deterministic(): - """Test that dataset generates same items with same seed""" - config = BaseConversionConfig(seed=42, size=10) - dataset1 = BaseConversionDataset(config) - dataset2 = BaseConversionDataset(config) + def setUp(self): + self.exercise = BaseConversionExercise() - for i in range(len(dataset1)): - assert dataset1[i] == dataset2[i] + def test_binary_to_decimal(self): + """Test binary to decimal conversion""" + test_cases = [ + ("1010", "10"), # 10 in decimal + ("1111", "15"), # 15 in decimal + ("10000", "16"), # 16 in decimal + ("0", "0"), # 0 in any base is 0 + ("1", "1") # 1 in any base is 1 + ] + for binary, expected in test_cases: + parsed = { + "source_value": binary, + "source_base": 2, + "target_base": 10 + } + result = self.exercise._evaluate_expression(parsed) + self.assertEqual(result, expected) + def test_decimal_to_hex(self): + """Test decimal to hexadecimal conversion""" + test_cases = [ + ("255", "ff"), # Max 8-bit value + ("16", "10"), # Power of 16 + ("10", "a"), # Single hex digit + ("0", "0"), # Zero + ("4096", "1000") # Power of 16 + ] + for decimal, expected in test_cases: + parsed = { + "source_value": decimal, + "source_base": 10, + "target_base": 16 + } + result = self.exercise._evaluate_expression(parsed) + self.assertEqual(result, expected) -def test_base_conversion_dataset_items(): - """Test basic properties of generated items""" - config = BaseConversionConfig(min_base=2, max_base=16, min_value=0, max_value=1000, size=10, seed=42) - dataset = BaseConversionDataset(config) + def test_hex_to_octal(self): + """Test hexadecimal to octal conversion""" + test_cases = [ + ("ff", "377"), # Max 8-bit value + ("10", "20"), # Simple conversion + ("a5", "245"), # Mixed digits and letters + ("0", "0"), # Zero + ("100", "400") # Power of 16 + ] + for hex_val, expected in test_cases: + parsed = { + "source_value": hex_val, + "source_base": 16, + "target_base": 8 + } + result = self.exercise._evaluate_expression(parsed) + self.assertEqual(result, expected) - 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 + def test_zero_value(self): + """Test conversion of zero in any base""" + bases = [2, 3, 8, 10, 16, 36] # Test more bases + for source_base in bases: + for target_base in bases: + parsed = { + "source_value": "0", + "source_base": source_base, + "target_base": target_base + } + result = self.exercise._evaluate_expression(parsed) + self.assertEqual(result, "0") - # Check metadata - assert "decimal_value" in item["metadata"] - assert "source_base" in item["metadata"] - assert "target_base" in item["metadata"] - assert "source_repr" in item["metadata"] - assert "target_repr" in item["metadata"] + def test_invalid_digits(self): + """Test handling of invalid digits for given base""" + test_cases = [ + ("123", 2), # Invalid binary + ("9", 8), # Invalid octal + ("g", 16), # Invalid hex + ("z", 35) # Invalid for base-35 + ] + for value, base in test_cases: + parsed = { + "source_value": value, + "source_base": base, + "target_base": 10 + } + result = self.exercise._evaluate_expression(parsed) + self.assertTrue(result.startswith("Error")) - # Verify value range - assert config.min_value <= item["metadata"]["decimal_value"] <= config.max_value + def test_edge_cases(self): + """Test edge cases and boundary values""" + test_cases = [ + # Max values for different bases + ("11111111", 2, 16, "ff"), # Max 8-bit binary to hex + ("77777777", 8, 16, "ffffff"), # Large octal to hex + ("ffffff", 16, 2, "111111111111111111111111"), # Large hex to binary + # Single digits + ("1", 2, 36, "1"), + ("z", 36, 2, "100011"), # Corrected: 'z' in base-36 is 35, which is 100011 in binary + # Alternating patterns + ("101010", 2, 8, "52"), + ("aaaaaa", 16, 10, "11184810") + ] + for value, source_base, target_base, expected in test_cases: + parsed = { + "source_value": value, + "source_base": source_base, + "target_base": target_base + } + result = self.exercise._evaluate_expression(parsed) + self.assertEqual(result, expected) - # Verify base range - assert config.min_base <= item["metadata"]["source_base"] <= config.max_base - assert config.min_base <= item["metadata"]["target_base"] <= config.max_base - assert item["metadata"]["source_base"] != item["metadata"]["target_base"] +class TestBaseConversionGeneration(unittest.TestCase): + """Test problem generation""" - # Verify conversion correctness - decimal_value = item["metadata"]["decimal_value"] - target_base = item["metadata"]["target_base"] + def setUp(self): + self.curriculum = BaseConversionCurriculum() + self.exercise = BaseConversionExercise() + self.rng = random.Random(42) + self.curriculum.rng = self.rng - # Use same conversion logic as implementation - if target_base == 16: - expected = format(decimal_value, "x") - elif target_base == 2: - expected = format(decimal_value, "b") - else: - # Manual conversion for other bases - n = decimal_value - digits = [] - while n: - digits.append(int(n % target_base)) - n //= target_base - expected = "".join(str(d) if d < 10 else chr(ord("a") + d - 10) for d in reversed(digits) or [0]) - assert item["answer"] == expected + def test_problem_structure(self): + """Test that generated problems have the correct structure""" + problem = self.exercise.generate(self.curriculum) + # Check basic structure + self.assertIn("question", problem) + self.assertIn("answer", problem) + self.assertIn("metadata", problem) -def test_base_conversion_dataset_iteration(): - """Test that iteration respects dataset size""" - config = BaseConversionConfig(size=5, seed=42) - dataset = BaseConversionDataset(config) + # Check metadata structure + metadata = problem["metadata"] + self.assertEqual(metadata["type"], "direct") + self.assertIn("executed_parts", metadata) + executed_parts = metadata["executed_parts"] + self.assertIn("source_value", executed_parts) + self.assertIn("source_base", executed_parts) + self.assertIn("target_base", executed_parts) - items = list(dataset) - assert len(items) == config.size + def test_value_ranges(self): + """Test that generated values are within expected ranges""" + # Test all value levels + level_max_values = {0: 100, 1: 1000, 2: 10000} + + for level, max_value in level_max_values.items(): + self.curriculum.set_attr_level("value", level) + problem = self.exercise.generate(self.curriculum) + decimal_val = int(problem["metadata"]["executed_parts"]["source_value"], + problem["metadata"]["executed_parts"]["source_base"]) + self.assertLessEqual(decimal_val, max_value) - # Test multiple iterations yield same items - assert items == list(dataset) + def test_base_ranges(self): + """Test that bases are within expected ranges""" + # Test all base range levels + level_max_bases = {0: 16, 1: 26, 2: 36} + + for level, max_base in level_max_bases.items(): + self.curriculum.set_attr_level("base_range", level) + problem = self.exercise.generate(self.curriculum) + source_base = problem["metadata"]["executed_parts"]["source_base"] + target_base = problem["metadata"]["executed_parts"]["target_base"] + self.assertLessEqual(source_base, max_base) + self.assertLessEqual(target_base, max_base) + self.assertGreaterEqual(source_base, 2) + self.assertGreaterEqual(target_base, 2) + def test_template_variation(self): + """Test that different templates are used""" + templates_seen = set() + num_samples = 100 -def test_base_conversion_validity(): - """Test that generated numbers are valid for their bases""" - config = BaseConversionConfig(min_base=2, max_base=36, min_value=0, max_value=1000, size=100, seed=42) - dataset = BaseConversionDataset(config) + for _ in range(num_samples): + problem = self.exercise.generate(self.curriculum) + templates_seen.add(problem["question"].split(":")[0]) # Get the question pattern - def is_valid_for_base(num_str: str, base: int) -> bool: - valid_chars = "0123456789abcdefghijklmnopqrstuvwxyz"[:base] - return all(c in valid_chars for c in num_str.lower()) + self.assertGreater(len(templates_seen), 1, "Not enough template variation") - for i in range(len(dataset)): - item = dataset[i] - assert is_valid_for_base( - item["metadata"]["source_repr"], item["metadata"]["source_base"] - ), f"Invalid source number {item['metadata']['source_repr']} for base {item['metadata']['source_base']}" - assert is_valid_for_base( - item["metadata"]["target_repr"], item["metadata"]["target_base"] - ), f"Invalid target number {item['metadata']['target_repr']} for base {item['metadata']['target_base']}" +class TestBaseConversionComprehensive(unittest.TestCase): + """Comprehensive tests for base conversion""" + def setUp(self): + self.curriculum = BaseConversionCurriculum() + self.exercise = BaseConversionExercise() + self.rng = random.Random(42) + self.curriculum.rng = self.rng -def test_base_conversion_special_bases(): - """Test conversion between special bases (binary, hex)""" - config = BaseConversionConfig( - min_base=2, - max_base=16, - min_value=0, - max_value=255, # Use small range for predictable results - size=100, - seed=42, - ) - dataset = BaseConversionDataset(config) + def _extract_base(self, text): + """Helper method to extract base from problem text.""" + if "binary" in text.lower(): + return 2 + if "octal" in text.lower(): + return 8 + if "decimal" in text.lower(): + return 10 + if "hexadecimal" in text.lower(): + return 16 - binary_found = False - hex_found = False + # Try to find base-N pattern + import re + match = re.search(r'base-(\d+)', text.lower()) + if match: + return int(match.group(1)) + return None - for i in range(len(dataset)): - item = dataset[i] - if item["metadata"]["target_base"] == 2: - binary_found = True - # Verify binary format - assert all(c in "01" for c in item["answer"]) - elif item["metadata"]["target_base"] == 16: - hex_found = True - # Verify hex format - assert all(c in "0123456789abcdef" for c in item["answer"]) + def test_all_base_combinations(self): + """Test conversion between all possible base combinations""" + bases = [2, 8, 10, 16, 36] # Test common bases + test_values = ["10", "ff", "xyz", "777", "42"] # Test values - assert binary_found, "No binary conversion tasks generated" - assert hex_found, "No hexadecimal conversion tasks generated" + for source_base in bases: + for target_base in bases: + for value in test_values: + try: + # Skip if value is invalid for source base + int(value, min(source_base, 36)) + except ValueError: + continue + parsed = { + "source_value": value, + "source_base": source_base, + "target_base": target_base + } + result = self.exercise._evaluate_expression(parsed) -def test_base_conversion_formatting(): - """Test number formatting in different bases""" - config = BaseConversionConfig( - min_base=11, # Force bases that use letters - max_base=36, - min_value=10, # Ensure multi-digit numbers - max_value=1000, - size=10, - seed=42, - ) - dataset = BaseConversionDataset(config) + # Verify result by converting back + try: + decimal = int(result, target_base) + original = int(value, source_base) + self.assertEqual(decimal, original) + except ValueError: + self.fail(f"Invalid conversion: {value} from base {source_base} to base {target_base}") - for i in range(len(dataset)): - item = dataset[i] - # Verify lowercase letters are used - assert item["answer"] == item["answer"].lower() - # Verify no whitespace in answer - assert item["answer"].strip() == item["answer"] - # Verify hint is included for bases > 10 - assert "use lowercase letters" in item["question"] + def test_hint_inclusion(self): + """Test that hints are included appropriately""" + # Test with hints enabled + self.curriculum.set_attr_level("hint", 0) + problem = self.exercise.generate(self.curriculum) + if problem["metadata"]["executed_parts"]["target_base"] > 10: + self.assertIn("use lowercase letters", problem["question"].lower()) + + # Test with hints disabled + self.curriculum.set_attr_level("hint", 1) + problem = self.exercise.generate(self.curriculum) + self.assertNotIn("use lowercase letters", problem["question"].lower()) + + def test_base_names(self): + """Test that base names are used correctly""" + # Test with basic names + self.curriculum.set_attr_level("base_names", 0) + problem = self.exercise.generate(self.curriculum) + question = problem["question"].lower() + self.assertTrue(any(name in question for name in ["binary", "hexadecimal", "base-"])) + + # Test with extended names + self.curriculum.set_attr_level("base_names", 1) + problem = self.exercise.generate(self.curriculum) + question = problem["question"].lower() + self.assertTrue(any(name in question for name in ["octal", "decimal", "base-"])) + + def test_comprehensive_random_evaluation(self): + """Test random evaluation with all base combinations and track statistics.""" + self.rng = random.Random(42) # Fixed seed for reproducibility + self.curriculum.rng = self.rng + + # Track statistics + base_name_usage = defaultdict(int) + source_bases = defaultdict(int) + target_bases = defaultdict(int) + values = [] + hint_count = 0 + total_samples = 1000 + + # Generate test cases + for _ in range(total_samples): + # Set random attribute levels + for attr in ["value", "base_range"]: + self.curriculum.set_attr_level(attr, self.rng.randint(0, 2)) + for attr in ["base_names", "hint"]: + self.curriculum.set_attr_level(attr, self.rng.randint(0, 1)) + + # Generate and evaluate a random problem + problem = self.exercise.generate(self.curriculum) + + # Track statistics + if "binary" in problem["question"].lower(): + base_name_usage["binary"] += 1 + elif "octal" in problem["question"].lower(): + base_name_usage["octal"] += 1 + elif "hexadecimal" in problem["question"].lower(): + base_name_usage["hexadecimal"] += 1 + elif "decimal" in problem["question"].lower(): + base_name_usage["decimal"] += 1 + else: + base_name_usage["other"] += 1 + + # Track source and target bases + metadata = problem["metadata"]["executed_parts"] + source_base = metadata["source_base"] + target_base = metadata["target_base"] + + if source_base: + source_bases[source_base] += 1 + if target_base: + target_bases[target_base] += 1 + + # Track if hints are included + if "(use lowercase letters a-z for digits above 9)" in problem["question"]: + hint_count += 1 + + # Track value statistics + try: + value = int(metadata["source_value"], source_base) + values.append(value) + except ValueError: + pass + + # Print statistics + print("\nBase name usage:") + for name, count in base_name_usage.items(): + print(f" {name}: {count}") + + print("\nSource bases used (35 bases):") + for base in range(2, 37): + if source_bases[base] > 0: + print(f" base-{base}: {source_bases[base]}") + + print("\nTarget bases used (35 bases):") + for base in range(2, 37): + if target_bases[base] > 0: + print(f" base-{base}: {target_bases[base]}") + + print("\nValue statistics:") + if values: + print(f" Min value: {min(values)}") + print(f" Max value: {max(values)}") + print(f" Average value: {sum(values) / len(values):.2f}") + print(f" Total samples with hints: {hint_count} / {total_samples}") + + # verify statistics + self.assertTrue(base_name_usage["hexadecimal"] >= 4, "Hexadecimal base name was not used enough") + self.assertTrue(len(source_bases) >= 10, "Not enough different source bases used") + self.assertTrue(len(target_bases) >= 10, "Not enough different target bases used") + self.assertTrue(hint_count > 0, "No hints were included") + self.assertTrue(hint_count < total_samples, "Too many hints were included") + +if __name__ == '__main__': + unittest.main()