Modifying Operators with the Interceptor Pattern

The interceptor design pattern does what?

In this framework, an interceptor adds to the behaviour of an operator. This example implements an interceptor that changes any selector to an elitist selector.

To begin, consider a simple example. Declare a class NumberBox with attribute .value and method .increment. For now, calling .increment increments .value by 1.

[12]:
from typing import Self
from typing import override
[13]:
class NumberBox:
    def __init__(self: Self, value: int)-> None:
        self.value: int = value

    def increment(self: Self)-> None:
        self.value = self.value + 1

def check_increment(nb: NumberBox):
    old_value = nb.value
    print(f"Initial value is {old_value}")
    new_value = (nb.increment(), nb.value)[-1]
    print(f"Increased by {new_value - old_value}")

Check that NumberBox behaves as expected:

[14]:
nb = NumberBox(1)
check_increment(nb)
Initial value is 1
Increased by 1

There are many ways to change the number .increment increases .value by. For example, one can define a new class, NumberBoxBy2, which extends NumberBox and overrides .increment.

[15]:
class NumberBoxBy2(NumberBox):
    @override
    def increment(self: Self)-> None:
        self.value = self.value + 2
[16]:
nb_new = NumberBoxBy2(1)
check_increment(nb_new)
Initial value is 1
Increased by 2

This approach is inflexible, however. For each class with .increment that increases its .value, one must extend it separately. [STRIKEOUT:It would be helpful] to have a way to change the behaviour of .increment for any class that has that method.

Suppose there is a function that takes a NumberBox and changes its behaviour, so that its .increment now increases .value by 2. The signature of that function is as follows:

[17]:
def by2(sel: NumberBox):
    pass
[18]:
from typing import Any, Callable
from functools import wraps
from types import MethodType
[19]:
def by2(numbox: NumberBox):
    pass

In this example, by2 creates a wrapper of the .increment method of its argument numbox, then replaces the original .increment with that wrapper.

[20]:
def by2(numbox: NumberBox):
    def wrap_function(original_increment:
                      Callable[[NumberBox], None]) -> Callable:

        @wraps(original_increment)
        def wrapper(self: NumberBox) -> None:
            original_increment(self)
            original_increment(self)
        return wrapper

    setattr(numbox, 'increment',
            MethodType(
                wrap_function(numbox.increment.__func__),  # type:ignore
                numbox))
[21]:
new_nb = NumberBox(1)
check_increment(new_nb)
Initial value is 1
Increased by 1
[22]:
modified_nb = NumberBox(1)
by2(modified_nb)
check_increment(modified_nb)
Initial value is 1
Increased by 2

This use case is common in the framework. For example, all selectors share methods named .select and .select_to_many. It would be good to be able to change the behaviour of an instance of a selector without modifying or extending its class.

[23]:
from evokit.core import Selector, Population, Individual

from typing import Callable, Tuple, TypeVar, Any

D = TypeVar("D", bound=Individual)

def Elitist(sel: Selector[D]) -> Selector:

    def wrap_function(original_select_to_many:
                      Callable[[Selector[D], Population[D]],
                               Tuple[D, ...]]) -> Callable:

        @wraps(original_select_to_many)
        def wrapper(self: Selector[D],
                    population: Population[D],
                    *args: Any, **kwargs: Any) -> Tuple[D, ...]:

            population_best: D = population.best()
            my_best: D

            # Monkey-patch an attribute onto the selector. If the
            # Current name is taken from a randomly generated SSH pubkey.
            #   Nobody else will use a name *this* absurd.
            UBER_SECRET_BEST_INDIVIDUAL_NAME = "___g1AfoA2NMh8ZZCmRJbwFcne4jS1f3Y2TRPIvBmVXQP"
            if not hasattr(self, UBER_SECRET_BEST_INDIVIDUAL_NAME):
                setattr(self, UBER_SECRET_BEST_INDIVIDUAL_NAME, population_best.copy())

            hof_individual: D
            my_best = getattr(self, UBER_SECRET_BEST_INDIVIDUAL_NAME)

            if my_best.fitness > population_best.fitness:
                hof_individual = my_best
                #print("use my best", end="")
            else:
                hof_individual = population_best
                setattr(self, UBER_SECRET_BEST_INDIVIDUAL_NAME, population_best.copy())
                #print("use population best", end="")
            #print(f", {str(hof_individual)}score is m{my_best.fitness} > p{population_best.fitness}")

            # Acquire results of the original selector
            results: Tuple[D, ...] = \
                original_select_to_many(self, population, *args, **kwargs)

            # Append the best individual to results
            return (*results, hof_individual.copy())
        return wrapper

    setattr(sel, 'select_to_many',
            MethodType(
                wrap_function(sel.select_to_many.__func__),  # type:ignore
                sel))
    return sel