diff --git a/examples/benchmarks/mathematical.py b/examples/benchmarks/mathematical.py new file mode 100644 index 00000000..41c9163d --- /dev/null +++ b/examples/benchmarks/mathematical.py @@ -0,0 +1,263 @@ +"""Benchmarks based on Vaguery's mathematical programming challenges. + +The scoring functions use :class:`fractions.Fraction` deliberately. These +problems are about exact rational arithmetic and floating-point fitness would +make correct programs appear incorrect for sufficiently large inputs. +""" + +from __future__ import annotations + +from abc import ABC +from dataclasses import dataclass +from fractions import Fraction +from typing import Annotated + +from examples.benchmarks.benchmark import Benchmark +from geneticengine.grammar.grammar import Grammar, extract_grammar +from geneticengine.grammar.metahandlers.ints import IntRange +from geneticengine.problems import Problem, SingleObjectiveProblem + + +class IntegerExpression(ABC): + def evaluate(self, values: tuple[int, ...]) -> int: + raise NotImplementedError + + +@dataclass +class IntegerLiteral(IntegerExpression): + value: Annotated[int, IntRange(-100, 100)] + + def evaluate(self, values: tuple[int, ...]) -> int: + return self.value + + +@dataclass +class InputInteger(IntegerExpression): + index: Annotated[int, IntRange(0, 1)] + + def evaluate(self, values: tuple[int, ...]) -> int: + return values[self.index] + + +@dataclass +class Add(IntegerExpression): + left: IntegerExpression + right: IntegerExpression + + def evaluate(self, values: tuple[int, ...]) -> int: + return self.left.evaluate(values) + self.right.evaluate(values) + + +@dataclass +class Subtract(IntegerExpression): + left: IntegerExpression + right: IntegerExpression + + def evaluate(self, values: tuple[int, ...]) -> int: + return self.left.evaluate(values) - self.right.evaluate(values) + + +@dataclass +class Multiply(IntegerExpression): + left: IntegerExpression + right: IntegerExpression + + def evaluate(self, values: tuple[int, ...]) -> int: + return self.left.evaluate(values) * self.right.evaluate(values) + + +@dataclass +class Quotient(IntegerExpression): + left: IntegerExpression + right: IntegerExpression + + def evaluate(self, values: tuple[int, ...]) -> int: + denominator = self.right.evaluate(values) + return 0 if denominator == 0 else self.left.evaluate(values) // denominator + + +@dataclass +class Remainder(IntegerExpression): + left: IntegerExpression + right: IntegerExpression + + def evaluate(self, values: tuple[int, ...]) -> int: + denominator = self.right.evaluate(values) + return 0 if denominator == 0 else self.left.evaluate(values) % denominator + + +@dataclass +class FiveUnitFractions: + terms: tuple[IntegerExpression, IntegerExpression, IntegerExpression, IntegerExpression, IntegerExpression] + + def evaluate(self, values: tuple[int, ...]) -> tuple[int, ...]: + return tuple(term.evaluate(values) for term in self.terms) + + +@dataclass +class IntegerPair: + numerator: IntegerExpression + denominator: IntegerExpression + + def evaluate(self, values: tuple[int, ...]) -> tuple[int, int]: + return self.numerator.evaluate(values), self.denominator.evaluate(values) + + +def fixed_egyptian_fraction(k: int) -> tuple[int, ...]: + """Return five distinct nonzero denominators summing to ``1/k``.""" + return 2 * k, 3 * k, 6 * k, 7 * k, -7 * k + + +def rational_egyptian_fraction(numerator: int, denominator: int) -> tuple[int, ...]: + """Find five signed unit fractions for a small rational target. + + The finite benchmark domain is intentionally small; exhaustive search is + used for the reference oracle so the benchmark remains independently + checkable and does not encode a particular synthesis strategy. + """ + target = Fraction(numerator, denominator) + forbidden = {numerator, denominator} + candidates = [i for i in range(-200, 201) if i and i not in forbidden] + for a in candidates: + for b in candidates: + if b == a: + continue + remainder = target - Fraction(1, a) - Fraction(1, b) + if remainder.numerator not in (-1, 1): + continue + c = remainder.denominator * remainder.numerator + if c in forbidden or c in {a, b} or c not in candidates: + continue + d = next((x for x in candidates if x not in {a, b, c} and -x in candidates), None) + if d is not None and -d not in {a, b, c, d}: + return a, b, c, d, -d + raise ValueError(f"no five-term representation found for {numerator}/{denominator}") + + +def reptend_fraction(prefix: int, repetend: int) -> tuple[int, int]: + prefix_digits = len(str(prefix)) + repetend_digits = len(str(repetend)) + cycle = 10**repetend_digits - 1 + return prefix * cycle + repetend, 10**prefix_digits * cycle + + +def incremented_reciprocal(n: int) -> tuple[int, int]: + """Increment every digit of the repeating decimal expansion of ``1/n``.""" + if n <= 0: + raise ValueError("n must be positive") + remainder = 1 % n + seen: dict[int, int] = {} + digits: list[int] = [] + while remainder and remainder not in seen: + seen[remainder] = len(digits) + remainder *= 10 + digits.append(remainder // n) + remainder %= n + cycle_start = seen.get(remainder, len(digits)) + nonrepeating = digits[:cycle_start] + repeating = digits[cycle_start:] or [0] + shifted_nonrepeating = [((digit + 1) % 10) for digit in nonrepeating] + shifted_repeating = [((digit + 1) % 10) for digit in repeating] + scale = 10 ** len(shifted_nonrepeating) + prefix = 0 + for digit in shifted_nonrepeating: + prefix = 10 * prefix + digit + cycle_value = 0 + for digit in shifted_repeating: + cycle_value = 10 * cycle_value + digit + denominator = scale * (10 ** len(shifted_repeating) - 1) + value = Fraction(prefix, scale) + Fraction(cycle_value, denominator) + return value.numerator, value.denominator + + +def _unit_fraction_error(candidate: tuple[int, ...], target: Fraction, forbidden: set[int]) -> float: + if len(candidate) != 5 or any(value == 0 for value in candidate): + return 1_000_000.0 + error = abs(sum((Fraction(1, value) for value in candidate), Fraction()) - target) + penalty = 0 if len(set(candidate)) == 5 and not forbidden.intersection(candidate) else 1 + return float(error) + penalty + + +def _pair_error(candidate: tuple[int, int], target: Fraction) -> float: + numerator, denominator = candidate + if denominator == 0: + return 1_000_000.0 + return float(abs(Fraction(numerator, denominator) - target)) + + +class _IntegerExpressionBenchmark(Benchmark): + expression_nodes = [IntegerLiteral, InputInteger, Add, Subtract, Multiply, Quotient, Remainder] + + def get_grammar(self) -> Grammar: + return extract_grammar(self.expression_nodes + [self.root_type], self.root_type) + + +class FixedEgyptianFractionBenchmark(_IntegerExpressionBenchmark): + root_type = FiveUnitFractions + + def __init__(self) -> None: + self.problem = SingleObjectiveProblem( + minimize=True, + target=0, + fitness_function=lambda candidate: _unit_fraction_error( + candidate.evaluate((7, 0)), Fraction(1, 7), {7} + ), + ) + + def get_problem(self) -> Problem: + return self.problem + + +class RationalEgyptianFractionBenchmark(_IntegerExpressionBenchmark): + root_type = FiveUnitFractions + + def __init__(self) -> None: + target = Fraction(5, 12) + self.problem = SingleObjectiveProblem( + minimize=True, + target=0, + fitness_function=lambda candidate: _unit_fraction_error( + candidate.evaluate((5, 12)), target, {5, 12} + ), + ) + + def get_problem(self) -> Problem: + return self.problem + + +class ReptendFractionBenchmark(_IntegerExpressionBenchmark): + root_type = IntegerPair + + def __init__(self) -> None: + target = Fraction(*reptend_fraction(539, 4762)) + self.problem = SingleObjectiveProblem( + minimize=True, + target=0, + fitness_function=lambda candidate: _pair_error(candidate.evaluate((539, 4762)), target), + ) + + def get_problem(self) -> Problem: + return self.problem + + +class IncrementedReciprocalBenchmark(_IntegerExpressionBenchmark): + root_type = IntegerPair + + def __init__(self) -> None: + target = Fraction(*incremented_reciprocal(7)) + self.problem = SingleObjectiveProblem( + minimize=True, + target=0, + fitness_function=lambda candidate: _pair_error(candidate.evaluate((7, 0)), target), + ) + + def get_problem(self) -> Problem: + return self.problem + + +BENCHMARKS = [ + FixedEgyptianFractionBenchmark, + RationalEgyptianFractionBenchmark, + ReptendFractionBenchmark, + IncrementedReciprocalBenchmark, +] diff --git a/examples/mathematical_programming/__init__.py b/examples/mathematical_programming/__init__.py new file mode 100644 index 00000000..b2f2afe6 --- /dev/null +++ b/examples/mathematical_programming/__init__.py @@ -0,0 +1 @@ +"""Examples for the mathematical programming challenges.""" diff --git a/examples/mathematical_programming/fixed_egyptian_fractions.py b/examples/mathematical_programming/fixed_egyptian_fractions.py new file mode 100644 index 00000000..f8f6b923 --- /dev/null +++ b/examples/mathematical_programming/fixed_egyptian_fractions.py @@ -0,0 +1,8 @@ +"""Genetic programming example for fixed-size Egyptian fractions.""" + +from examples.benchmarks.benchmark import example_run +from examples.benchmarks.mathematical import FixedEgyptianFractionBenchmark + + +if __name__ == "__main__": + example_run(FixedEgyptianFractionBenchmark()) diff --git a/examples/mathematical_programming/incremented_reciprocal.py b/examples/mathematical_programming/incremented_reciprocal.py new file mode 100644 index 00000000..9097ed4a --- /dev/null +++ b/examples/mathematical_programming/incremented_reciprocal.py @@ -0,0 +1,8 @@ +"""Genetic programming example for the incremented reciprocal challenge.""" + +from examples.benchmarks.benchmark import example_run +from examples.benchmarks.mathematical import IncrementedReciprocalBenchmark + + +if __name__ == "__main__": + example_run(IncrementedReciprocalBenchmark()) diff --git a/examples/mathematical_programming/rational_egyptian_fractions.py b/examples/mathematical_programming/rational_egyptian_fractions.py new file mode 100644 index 00000000..b17d74e3 --- /dev/null +++ b/examples/mathematical_programming/rational_egyptian_fractions.py @@ -0,0 +1,8 @@ +"""Genetic programming example for rational-target Egyptian fractions.""" + +from examples.benchmarks.benchmark import example_run +from examples.benchmarks.mathematical import RationalEgyptianFractionBenchmark + + +if __name__ == "__main__": + example_run(RationalEgyptianFractionBenchmark()) diff --git a/examples/mathematical_programming/reptend_fraction.py b/examples/mathematical_programming/reptend_fraction.py new file mode 100644 index 00000000..9e5aff41 --- /dev/null +++ b/examples/mathematical_programming/reptend_fraction.py @@ -0,0 +1,8 @@ +"""Genetic programming example for repeating-decimal rational numbers.""" + +from examples.benchmarks.benchmark import example_run +from examples.benchmarks.mathematical import ReptendFractionBenchmark + + +if __name__ == "__main__": + example_run(ReptendFractionBenchmark()) diff --git a/run_examples.sh b/run_examples.sh index 9347c1e8..6bcaf72c 100755 --- a/run_examples.sh +++ b/run_examples.sh @@ -66,6 +66,11 @@ run_example examples/benchmarks/santafe.py run_example examples/benchmarks/string_match.py run_example examples/benchmarks/vectorialgp.py +run_example examples/mathematical_programming/fixed_egyptian_fractions.py +run_example examples/mathematical_programming/rational_egyptian_fractions.py +run_example examples/mathematical_programming/reptend_fraction.py +run_example examples/mathematical_programming/incremented_reciprocal.py + run_example examples/progsys/Number_IO.py run_example examples/progsys/Median.py diff --git a/tests/benchmarks/test_mathematical.py b/tests/benchmarks/test_mathematical.py new file mode 100644 index 00000000..3db5afdd --- /dev/null +++ b/tests/benchmarks/test_mathematical.py @@ -0,0 +1,46 @@ +from fractions import Fraction + +from examples.benchmarks.mathematical import FixedEgyptianFractionBenchmark +from examples.benchmarks.mathematical import IncrementedReciprocalBenchmark +from examples.benchmarks.mathematical import RationalEgyptianFractionBenchmark +from examples.benchmarks.mathematical import ReptendFractionBenchmark +from examples.benchmarks.mathematical import fixed_egyptian_fraction +from examples.benchmarks.mathematical import incremented_reciprocal +from examples.benchmarks.mathematical import reptend_fraction + + +def test_fixed_egyptian_fraction_is_valid(): + terms = fixed_egyptian_fraction(7) + assert len(set(terms)) == 5 + assert Fraction(1, 7) == sum((Fraction(1, term) for term in terms), Fraction()) + + +def test_reptend_example(): + numerator, denominator = reptend_fraction(539, 4762) + assert Fraction(numerator, denominator) == Fraction(539 * 9999 + 4762, 1000 * 9999) + + +def test_incremented_reciprocal_examples(): + assert incremented_reciprocal(1) == (1, 9) + assert incremented_reciprocal(2) == (11, 18) + # The page prints 16/163, but 0.overline{253968} reduces to 16/63. + assert incremented_reciprocal(7) == (16, 63) + + +def test_benchmarks_expose_zero_fitness_reference_targets(): + fixed = FixedEgyptianFractionBenchmark() + candidate = type("C", (), {"evaluate": staticmethod(lambda _: fixed_egyptian_fraction(7))})() + assert fixed.get_problem().evaluate(candidate).fitness_components == [0] + reptend = ReptendFractionBenchmark() + assert reptend.get_problem().target == [0] + assert incremented_reciprocal(7) == (16, 63) + + +def test_all_benchmark_grammars_are_constructible(): + for benchmark in ( + FixedEgyptianFractionBenchmark(), + RationalEgyptianFractionBenchmark(), + ReptendFractionBenchmark(), + IncrementedReciprocalBenchmark(), + ): + assert benchmark.get_grammar() is not None