Repository: EC-KitY/EC-KitY (language: python)

Issue:
Typed GP
Closes #31 


Discussion / hints:

Fix patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000..21a4f33e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,34 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: triage
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. iOS]
+ - Version [e.g. 22]
+
+**Python (please complete the following information):**
+ - Version [e.g. 3.10.14]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..bbcbbe7d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/workflows/gendocs.yml b/.github/workflows/gendocs.yml
new file mode 100644
index 00000000..590e327e
--- /dev/null
+++ b/.github/workflows/gendocs.yml
@@ -0,0 +1,41 @@
+name: Update Documentation
+
+on:
+  release:
+    types: [published]
+  workflow_dispatch:
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
+    steps:
+    - uses: actions/checkout@v4.1.5
+
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v5.1.0
+      with:
+        python-version: ${{ matrix.python-version }}
+
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install scikit-learn>=1.1 --use-pep517
+        python -m pip install -r requirements.txt
+        python -m pip install pdoc pytest
+
+    - name: Update documentation
+      run: pdoc -d numpy -o docs ./eckity
+
+    - name: Commit and push changes
+      if: matrix.python-version == '3.11'
+      run: |
+        git config --global user.name "github-actions[bot]"
+        git config --global user.email "github-actions[bot]@users.noreply.github.com"
+        git add .
+        git commit -m "Update documentation"
+        git push
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
index 383e65cd..19ea0660 100644
--- a/.github/workflows/pylint.yml
+++ b/.github/workflows/pylint.yml
@@ -1,17 +1,22 @@
 name: Pylint
 
-on: [push]
+on: 
+  push:
+    branches: [ main, develop ]
+  pull_request:
+    branches: [ main, develop ]
+  workflow_dispatch:
 
 jobs:
   build:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ["3.8", "3.9", "3.10"]
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4.1.5
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v3
+      uses: actions/setup-python@v5.1.0
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
@@ -20,4 +25,9 @@ jobs:
         pip install pylint
     - name: Analysing the code with pylint
       run: |
-        pylint $(git ls-files '*.py')
+        pylint $(git ls-files '*.py') --output=lint.txt || true
+    - name: Upload Artifact
+      uses: actions/upload-artifact@v3
+      with:
+        name: lint.txt
+        path: lint.txt
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 25ff5fb6..3837f49d 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -5,37 +5,38 @@ name: Python package
 
 on:
   push:
-    branches: [ main, develop ]
+    branches: [main, develop]
   pull_request:
-    branches: [ main, develop ]
+    branches: [main, develop]
+  workflow_dispatch:
 
 jobs:
   build:
-
     runs-on: ubuntu-latest
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["3.8", "3.9", "3.10"]
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
 
     steps:
-    - uses: actions/checkout@v3
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v3
-      with:
-        python-version: ${{ matrix.python-version }}
-    - name: Install dependencies
-      run: |
-        python -m pip install --upgrade pip
-        python -m pip install flake8 pytest matplotlib
-        python -m pip install scikit-learn --use-pep517
-        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
-    - name: Lint with flake8
-      run: |
-        # stop the build if there are Python syntax errors or undefined names
-        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
-        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
-        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
-    - name: Test with pytest
-      run: |
-        pytest
+      - uses: actions/checkout@v4.1.5
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v5.1.0
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install flake8 pytest coverage matplotlib
+          python -m pip install scikit-learn>=1.1 --use-pep517
+          if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+      - name: Lint with flake8
+        run: |
+          # stop the build if there are Python syntax errors or undefined names
+          flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+          # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
+          flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+      - name: Test with pytest and coverage
+        run: |
+          coverage run -m pytest
+          coverage report --fail-under=85
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 4f15e8e1..072fd180 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -9,11 +9,9 @@
 name: Upload Python Package
 
 on:
-  workflow_dispatch:
-    inputs:
-        repo_name:
-          description: 'EC-KitY'
-          required: false
+  # workflow_dispatch is disabled on purpose. 
+  # We must decide on the version number before publishing and create a release message.
+  # workflow_dispatch:
   release:
     types: [published]
 
@@ -26,9 +24,9 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4.1.5
     - name: Set up Python
-      uses: actions/setup-python@v3
+      uses: actions/setup-python@v5.1.0
       with:
         python-version: '3.x'
     - name: Install dependencies
diff --git a/.github/workflows/random-detector.yml b/.github/workflows/random-detector.yml
new file mode 100644
index 00000000..7a5c661e
--- /dev/null
+++ b/.github/workflows/random-detector.yml
@@ -0,0 +1,44 @@
+name: Random usage detector
+
+on:
+  pull_request:
+    branches: [main, develop]
+
+jobs:
+  scan-changed-files:
+    runs-on: ubuntu-latest
+    permissions:
+      pull-requests: write
+    name: Scan Changed Files
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Scan for 'random' in changed files
+        id: random-detector
+        run: |
+          git diff HEAD..origin/main | grep -i -n "random" > detections.txt
+          if [ -s detections.txt ]; then
+            cat detections.txt
+            echo "RANDOM_DETECTED=true" >> $GITHUB_OUTPUT
+          fi
+
+      - name: Comment PR
+        if: ${{ steps.random-detector.outputs.RANDOM_DETECTED == 'true' }}
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          # Use GitHub API to create a comment on the PR
+          PR_NUMBER=${{ github.event.pull_request.number }}
+          COMMENT="🚨 This PR contains the word 'random' in its changed files 🚨
+          Before merging, please verify that the algorithm's random generator is being used."
+          GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}
+          COMMENT_URL="https://api.github.com/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments"
+          gh api \
+            --method POST \
+            -H "Accept: application/vnd.github+json" \
+            -H "X-GitHub-Api-Version: 2022-11-28" \
+            /repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \
+             -f "body=${COMMENT}"
diff --git a/.github/workflows/run-examples.yml b/.github/workflows/run-examples.yml
new file mode 100644
index 00000000..262029e5
--- /dev/null
+++ b/.github/workflows/run-examples.yml
@@ -0,0 +1,44 @@
+name: Run Examples
+
+on: 
+  push:
+    branches: [ main, develop ]
+  pull_request:
+    branches: [ main, develop ]
+  workflow_dispatch:
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
+    steps:
+    - uses: actions/checkout@v4.1.5
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v5.1.0
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install scikit-learn>=1.1 --use-pep517
+        python -m pip install matplotlib
+        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+
+    - name: Set PYTHONPATH
+      run: echo "PYTHONPATH=$(pwd)" >> $GITHUB_ENV
+
+      # Shortening runtime significantly
+    - name: Replace generations and population size
+      run: |
+        find examples -type f -name "*.py" -exec sed -E -i 's/max_generation\s*=\s*[0-9]+/max_generation=3/g' {} \;
+        find examples -type f -name "*.py" -exec sed -E -i 's/population_size\s*=\s*[0-9]+/population_size=6/g' {} \;
+
+    - name: Find and run Python files sequentially
+      run: |
+        find examples -type f -name "*.py" | while read -r file; do
+        echo "Running $file"
+        cat "$file"
+        python "$file" || exit 1
+        done
diff --git a/.github/workflows/version-detector.yml b/.github/workflows/version-detector.yml
new file mode 100644
index 00000000..4795691e
--- /dev/null
+++ b/.github/workflows/version-detector.yml
@@ -0,0 +1,29 @@
+name: Check version changes
+
+on:
+  pull_request:
+    branches:
+      - main
+
+jobs:
+  check_version_change:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0 # Fetch all history for all branches and tags
+
+      # check that the version is updated on a PR to main
+      - name: Scan for '__version__' in changed files
+        id: version-detector
+        run: |
+          TARGET_BRANCH="${{ github.event.pull_request.base.ref }}"
+          git diff HEAD..origin/"$TARGET_BRANCH" | grep -i -n "__version__[[:space:]]*=" > detections.txt
+
+          if ! [ -s detections.txt ]; then
+            cat detections.txt
+            echo "ERROR: The __version__ line was not changed in a pull request to main."
+            exit 1
+          fi
diff --git a/.gitignore b/.gitignore
index fead0135..e5a27337 100644
--- a/.gitignore
+++ b/.gitignore
@@ -292,6 +292,7 @@ dmypy.json
 cython_debug/
 
 ### VisualStudioCode ###
+.vscode/
 .vscode/*
 !.vscode/settings.json
 !.vscode/tasks.json
diff --git a/README.md b/README.md
index 3dc4db90..166738f9 100644
--- a/README.md
+++ b/README.md
@@ -14,14 +14,12 @@ Currently we have implemented Genetic Algorithm (GA) and tree-based Genetic Prog
 - Designed to support all popular EC paradigms (GA, GP, ES, coevolution, multi-objective, etc').
 
 ### Dependencies
-For the basic evolution mode, EC-KitY requires:
-- Python (>=3.6)
-- Numpy (>=1.14.6)
-- Pandas (>=0.25.0)
-- Overrides (>= 6.1.0)
+The minimal Python Version for EC-KitY is Python 3.8
+
+The dependencies of our package are described in `requirements.txt` 
 
 For sklearn mode, EC-KitY additionally requires:
-- scikit-learn (>=0.24.2)
+- scikit-learn (>=1.1)
 
 ### User installation
 
@@ -46,11 +44,23 @@ You can run an EA with just 3 lines of code. The problem being solved herein is
 
 Additional information on this problem can be found in the [Symbolic Regression Tutorial](https://github.com/EC-KitY/EC-KitY/wiki/Tutorial:-Symbolic-Regression).
 ```python
-from eckity.algorithms.simple_evolution import SimpleEvolution
 from eckity.subpopulation import Subpopulation
-from examples.treegp.non_sklearn_mode.symbolic_regression.sym_reg_evaluator import SymbolicRegressionEvaluator
-
-algo = SimpleEvolution(Subpopulation(SymbolicRegressionEvaluator()))
+from eckity.algorithms import SimpleEvolution
+from eckity.base.untyped_functions import f_add, f_sub, f_mul, f_div
+from eckity.creators import FullCreator
+from eckity.genetic_operators import SubtreeCrossover, SubtreeMutation
+from examples.treegp.basic_mode.symbolic_regression import SymbolicRegressionEvaluator
+
+algo = SimpleEvolution(
+    Subpopulation(
+        SymbolicRegressionEvaluator(),
+        creator=FullCreator(
+            terminal_set=['x', 'y', 'z'],
+            function_set=[f_add, f_sub, f_mul, f_div]
+        ),
+        operators_sequence=[SubtreeCrossover(), SubtreeMutation()]
+    )
+)
 algo.evolve()
 print(f'algo.execute(x=2,y=3,z=4): {algo.execute(x=2, y=3, z=4)}')
 ```
diff --git a/eckity/__init__.py b/eckity/__init__.py
index e5d8fdd4..8bd2f056 100644
--- a/eckity/__init__.py
+++ b/eckity/__init__.py
@@ -2,4 +2,4 @@
 EC-KitY: Evolutionary Computation Tool Kit in Python
 """
 
-__version__ = '0.3.2.1'
+__version__ = "0.4.0"
diff --git a/eckity/algorithms/__init__.py b/eckity/algorithms/__init__.py
index e69de29b..d68dc6fc 100644
--- a/eckity/algorithms/__init__.py
+++ b/eckity/algorithms/__init__.py
@@ -0,0 +1,2 @@
+from .algorithm import Algorithm
+from .simple_evolution import SimpleEvolution
diff --git a/eckity/algorithms/algorithm.py b/eckity/algorithms/algorithm.py
index 088d9dd3..fdb24fb6 100644
--- a/eckity/algorithms/algorithm.py
+++ b/eckity/algorithms/algorithm.py
@@ -2,380 +2,452 @@
 This module implements the Algorithm class.
 """
 
-from abc import abstractmethod
-
-import random
-from concurrent.futures.thread import ThreadPoolExecutor
+import logging
+import sys
+from abc import ABC, abstractmethod
 from concurrent.futures.process import ProcessPoolExecutor
+from concurrent.futures.thread import ThreadPoolExecutor
 from time import time
+from typing import Any, Callable, Dict, List, Union
 
 from overrides import overrides
 
-from eckity.event_based_operator import Operator
 from eckity.population import Population
-from eckity.statistics.statistics import Statistics
 from eckity.subpopulation import Subpopulation
+from eckity.breeders import Breeder
+from eckity.evaluators import PopulationEvaluator
+from eckity.event_based_operator import Operator
+from eckity.individual import Individual
+from eckity.random import RNG
+from eckity.statistics.statistics import Statistics
+from eckity.termination_checkers import TerminationChecker
 
-SEED_MIN_VALUE = 0
-SEED_MAX_VALUE = 1000000
+logger = logging.getLogger(__name__)
+logging.basicConfig(level=logging.INFO, stream=sys.stdout)
 
 
-class Algorithm(Operator):
-	"""
-	Evolutionary algorithm to be executed.
+class Algorithm(Operator, ABC):
+    """
+    Evolutionary algorithm to be executed.
 
-	Abstract Algorithm that can be extended to concrete algorithms such as SimpleAlgorithm.
+    Abstract Algorithm that can be extended to concrete algorithms,
+    such as SimpleEvolution, Coevolution etc.
 
-	Parameters
-	----------
-	population: Population
-		The population to be evolved. Consists of a list of individuals.
+    Parameters
+    ----------
+    population: Population
+        The population to be evolved.
+        Consists of several sub-populations.
+        ref: https://api.eckity.org/eckity/population.html
 
-	statistics: Statistics or list of Statistics, default=None
-		Provide multiple statistics on the population during the evolutionary run.
+    statistics: Statistics or list of Statistics, default=None
+        Provide statistics on the population during the evolution.
+        ref: https://api.eckity.org/eckity/statistics.html
 
     breeder: Breeder, default=SimpleBreeder()
-        Responsible for applying the selection method and operator sequence on the individuals
+        Responsible for applying selection and operator sequence on individuals
         in each generation. Applies on one sub-population in simple case.
+        ref: https://api.eckity.org/eckity/breeders.html
+
+    population_evaluator: PopulationEvaluator,
+                          default=SimplePopulationEvaluator()
+        Evaluates individual fitness scores concurrently and returns the best
+        individual of each subpopulation (one individual in simple case).
+        ref: https://api.eckity.org/eckity/evaluators.html
+
+    max_generation: int, default=100
+        Maximal number of generations to run the evolutionary process.
+        Note the evolution could end before reaching max_generation,
+        depends on the termination checker.
+        Note there are up to `max_generation + 1` fitness calculations,
+        but only up to `max_generation` selections
+
+    events: dict(str, dict(object, function)), default=None
+        dict of events, each event holds a dict (subscriber, callback).
+
+    event_names: list of strings, default=None
+        Names of events to publish during the evolution.
+
+    termination_checker: TerminationChecker or a list of TerminationCheckers,
+                          default=None
+        Checks if the algorithm should terminate early.
+        ref: https://api.eckity.org/eckity/termination_checkers.html
+
+    max_workers: int, default=None
+        Maximal number of worker nodes for the Executor object
+        that evaluates the fitness of the individuals.
+        ref: https://docs.python.org/3/library/concurrent.futures.html
+
+    random_generator: RNG, default=RNG()
+        Random Number Generator.
+
+    random_seed: int, default=current system time
+        Random seed for deterministic experiment.
+
+    generation_seed: int, default=None
+        Current generation seed.
+        Useful for resuming a previously paused experiment.
+
+    generation_num: int, default=0
+        Current generation number
+
+    Attributes
+    ----------
+    final_generation_: int
+        The generation in which the evolution ended.
+    """
+
+    def __init__(
+        self,
+        population: Union[Population, Subpopulation, List[Subpopulation]],
+        statistics: Union[Statistics, List[Statistics]] = None,
+        breeder: Breeder = None,
+        population_evaluator: PopulationEvaluator = None,
+        termination_checker: Union[
+            TerminationChecker, List[TerminationChecker]
+        ] = None,
+        max_generation: int = 100,
+        events: Dict[str, Dict[object, Callable]] = None,
+        event_names: List[str] = None,
+        random_generator: RNG = RNG(),
+        random_seed: int = None,
+        generation_seed: int = None,
+        executor: str = "process",
+        max_workers: int = None,
+        generation_num: int = 0,
+    ):
+
+        ext_event_names = event_names.copy() if event_names is not None else []
+
+        ext_event_names.extend(
+            ["init", "evolution_finished", "after_generation"]
+        )
+        super().__init__(events=events, event_names=ext_event_names)
+
+        self._validate_population_type(population)
+        self._validate_statistics_type(statistics)
+
+        self.breeder = breeder
+        self.population_evaluator = population_evaluator
+        self.termination_checker = termination_checker
+        self.max_generation = max_generation
+
+        # set random seed to current time if not provided
+        if random_seed is None:
+            t = time()
+            # convert seed to int for np.random compatibility
+            pre_dec_pnt, post_dec_pnt = str(t).split(".")
+            int_seed = int(pre_dec_pnt + post_dec_pnt)
+            random_seed = int_seed % (2**32)
+
+        self.random_generator = random_generator
+        self.random_seed = random_seed
+        self.generation_seed = (
+            generation_seed if generation_seed is not None else random_seed
+        )
+
+        self.best_of_run_ = None
+        self.worst_of_gen = None
+        self.generation_num = generation_num
+
+        self.max_workers = max_workers
+
+        if executor == "thread":
+            self.executor = ThreadPoolExecutor(max_workers=max_workers)
+        elif executor == "process":
+            self.executor = ProcessPoolExecutor(max_workers=max_workers)
+        else:
+            raise ValueError('Executor must be either "thread" or "process"')
+        self._executor_type = executor
+
+        self.final_generation_ = 0
+
+    @overrides
+    def apply_operator(self, payload):
+        """
+        begin the evolutionary run
+        """
+        self.evolve()
 
-    population_evaluator: PopulationEvaluator, default=SimplePopulationEvaluator()
-        Responsible for evaluating each individual's fitness concurrently and returns the best
-         individual of each subpopulation (returns a single individual in simple case).
-
-	max_generation: int, default=1000
-		Maximal number of generations to run the evolutionary process.
-		Note the evolution could end before reaching max_generation,
-		depends on the termination checker.
-		Note that there are max_generation + 1 (at max) fitness calculations,
-		but only max_generation (at max) of selection
-
-	events: dict(str, dict(object, function)), default=None
-		Dictionary of events, where each event holds a dictionary of (subscriber, callback method).
-
-	event_names: list of strings, default=None
-		Names of events to publish during the evolution.
-
-    termination_checker: TerminationChecker or a list of TerminationCheckers, default=ThresholdFromTargetTerminationChecker()
-        Responsible for checking if the algorithm should finish before reaching max_generation.
-
-	max_workers: int, default=None
-		Maximal number of worker nodes for the Executor object
-		that evaluates the fitness of the individuals.
-
-	random_generator: module, default=random
-		Random generator module.
-
-	random_seed: float or int, default=current system time
-		Random seed for deterministic experiment.
-
-	generation_seed: int, default=None
-		Current generation seed. Useful for resuming a previously paused experiment.
-
-	generation_num: int, default=0
-		Current generation number
-
-	Attributes
-	----------
-	final_generation_: int
-		The generation in which the evolution ended.
-	"""
-
-	def __init__(self,
-				 population,
-				 statistics=None,
-				 breeder=None,
-				 population_evaluator=None,
-				 termination_checker=None,
-				 max_generation=None,
-				 events=None,
-				 event_names=None,
-				 random_generator=None,
-				 random_seed=time(),
-				 generation_seed=None,
-				 executor='thread',
-				 max_workers=None,
-				 generation_num=0):
-
-		ext_event_names = event_names.copy() if event_names is not None else []
-
-		ext_event_names.extend(["init", "evolution_finished", "after_generation"])
-		super().__init__(events=events, event_names=ext_event_names)
-
-		# Assert valid population input
-		if population is None:
-			raise ValueError('Population cannot be None')
-
-		if isinstance(population, Population):
-			self.population = population
-		elif isinstance(population, Subpopulation):
-			self.population = Population([population])
-		elif isinstance(population, list):
-			if len(population) == 0:
-				raise ValueError('Population cannot be empty')
-			for sub_pop in population:
-				if not isinstance(sub_pop, Subpopulation):
-					raise ValueError('Detected a non-Subpopulation '
-									 'instance as an element in Population')
-			self.population = Population(population)
-		else:
-			raise ValueError(
-				'Parameter population must be either a Population, '
-				'a Subpopulation or a list of Subpopulations\n '
-				'received population with unexpected type of', type(population)
-			)
-
-		# Assert valid statistics input
-		if isinstance(statistics, Statistics):
-			self.statistics = [statistics]
-		elif isinstance(statistics, list):
-			for stat in statistics:
-				if not isinstance(stat, Statistics):
-					raise ValueError('Expected a Statistics instance as an element'
-									 ' in Statistics list, but received', type(stat))
-			self.statistics = statistics
-		else:
-			raise ValueError(
-				'Parameter statistics must be either a subclass of Statistics'
-				' or a list of subclasses of Statistics.\n'
-				'received statistics with unexpected type of', type(statistics)
-			)
-
-		self.breeder = breeder
-		self.population_evaluator = population_evaluator
-		self.termination_checker = termination_checker
-		self.max_generation = max_generation
-
-		if random_generator is None:
-			random_generator = random
-
-		self.random_generator = random_generator
-		self.random_seed = random_seed
-		self.generation_seed = generation_seed
-
-		self.best_of_run_ = None
-		self.worst_of_gen = None
-		self.generation_num = generation_num
-
-		self.max_workers = max_workers
-
-		if executor == 'thread':
-			self.executor = ThreadPoolExecutor(max_workers=max_workers)
-		elif executor == 'process':
-			self.executor = ProcessPoolExecutor(max_workers=max_workers)
-		else:
-			raise ValueError('Executor must be either "thread" or "process"')
-		self._executor_type = executor
-		
-
-		self.final_generation_ = 0
-
-	@overrides
-	def apply_operator(self, payload):
-		"""
-		begin the evolutionary run
-		"""
-		self.evolve()
-
-	def evolve(self):
-		"""
-		Performs the evolutionary run by initializing the random seed, creating the population,
-		performing the evolutionary loop and finally finishing the evolution process
-		"""
-		self.initialize()
-
-		if self.should_terminate(self.population,
-                                 self.best_of_run_,
-                                 self.generation_num):
-			self.final_generation_ = 0
-			self.publish('after_generation')
-		else:
-			self.evolve_main_loop()
-
-		self.finish()
-		self.publish('evolution_finished')
-
-	@abstractmethod
-	def execute(self, **kwargs):
-		"""
-		Execute the algorithm result after evolution ended.
-
-		Parameters
-		----------
-		kwargs : keyword arguments (relevant in GP representation)
-			Input to program, including every variable in the terminal set as a keyword argument.
-			For example, if `terminal_set=['x', 'y', 'z', 0, 1, -1]`
-			then call `execute(x=..., y=..., z=...)`.
-
-		Returns
-		-------
-		object
-			Result of algorithm execution (for example: the best
-			 individual in GA, or the best individual execution in GP)
-		"""
-		raise ValueError("execute is an abstract method in class Algorithm")
-
-	def initialize(self):
-		"""
-		Initialize the algorithm before beginning the evolution process
+    def evolve(self) -> None:
+        """
+        Performs the evolutionary run by initializing the random seed,
+        creating the population, performing the evolutionary loop
+        and finally finishing the evolution process
+        """
+        self.initialize()
 
+        if self.should_terminate(
+            self.population, self.best_of_run_, self.generation_num
+        ):
+            self.final_generation_ = 0
+            self.publish("after_generation")
+        else:
+            self.evolve_main_loop()
+
+        self.publish("evolution_finished")
+        self.finish()
+
+    @abstractmethod
+    def execute(self, **kwargs) -> object:
+        """
+        Execute the algorithm result after evolution ended.
+
+        Parameters
+        ----------
+        kwargs : keyword arguments (relevant in GP representation)
+                Input to program, including every variable
+                in the terminal set as a keyword argument.
+                For example, if `terminal_set=['x', 'y', 'z', 0, 1, -1]`
+                then call `execute(x=..., y=..., z=...)`.
+
+        Returns
+        -------
+        object
+                Result of algorithm execution (for example: the best
+                 individual in GA, or the best individual execution in GP)
+        """
+        raise ValueError("execute is an abstract method in class Algorithm")
+
+    def initialize(self) -> None:
+        """
         Initialize seed, Executor and relevant operators
         """
-		self.set_random_seed(self.random_seed)
-		print('debug: random seed =', self.random_seed)
-		self.population_evaluator.set_executor(self.executor)
-
-		for field in self.__dict__.values():
-			if isinstance(field, Operator):
-				field.initialize()
-
-		self.create_population()
-		self.best_of_run_ = self.population_evaluator.act(self.population)
-		self.publish('init')
-
-	def evolve_main_loop(self):
-		"""
-		Performs the evolutionary main loop
-		"""
-		# there was already "preprocessing" generation created - gen #0
-		# now create another self.max_generation generations (at maximum), starting for gen #1
-		for gen in range(1, self.max_generation + 1):
-			self.generation_num = gen
-			self.update_gen(self.population, gen)
-
-			self.set_generation_seed(self.next_seed())
-			self.generation_iteration(gen)
-			if self.should_terminate(self.population, self.best_of_run_, gen):
-				self.final_generation_ = gen
-				self.publish('after_generation')
-				break
-			self.publish('after_generation')
-
-		self.executor.shutdown()
-
-	def update_gen(self, population, gen):
-		for subpopulation in population.sub_populations:
-			for ind in subpopulation.individuals:
-				ind.gen = gen
-
-	@abstractmethod
-	def generation_iteration(self, gen):
-		"""
-		Performs an iteration of the evolutionary main loop
-
-		Parameters
-		----------
-		gen: int
-			current generation number
-
-		Returns
-		-------
-		bool
-			True if the main loop should terminate, False otherwise
-		"""
-		raise ValueError("generation_iteration is an abstract method in class Algorithm")
-
-	@abstractmethod
-	def finish(self):
-		"""
-		Finish the evolutionary run
-		"""
-		raise ValueError("finish is an abstract method in class Algorithm")
-
-	def set_generation_seed(self, seed):
-		"""
-		Set the seed for current generation
-
-		Parameters
-		----------
-		seed: int
-			current generation seed
-		"""
-		self.random_generator.seed(seed)
-		self.generation_seed = seed
-
-	def create_population(self):
-		"""
-		Create the population for the evolutionary run
-		"""
-		self.population.create_population_individuals()
-
-	def event_name_to_data(self, event_name):
-		"""
-		Convert a given event name to relevant data of the Algorithm for the event
-
-		Parameters
-		----------
-		event_name: string
-			name of the event that is happening
-
-		Returns
-		----------
-		dict
-			Algorithm data regarding the given event
-		"""
-		if event_name == "init":
-			return {"population": self.population,
-					"statistics": self.statistics,
-					"breeder": self.breeder,
-					"termination_checker": self.termination_checker,
-					"max_generation": self.max_generation,
-					"events": self.events,
-					"max_workers": self.max_workers}
-		return {}
-
-	def set_random_generator(self, rng):
-		"""
-		Set random generator object
-
-		Parameters
-		----------
-		rng: object
-			random number generator
-		"""
-		self.random_generator = rng
-
-	def set_random_seed(self, seed=None):
-		"""
-		Set random seed
-
-		Parameters
-		----------
-		seed: int
-			random seed number
-		"""
-		self.random_generator.seed(seed)
-		self.random_seed = seed
-
-	def next_seed(self):
-		"""
-		Generate a random seed
-
-		Returns
-		----------
-		int
-			random seed number
-		"""
-		return self.random_generator.randint(SEED_MIN_VALUE, SEED_MAX_VALUE)
-
-	def should_terminate(self, population, best_of_run_, generation_num):
-		if isinstance(self.termination_checker, list):
-			return any([t.should_terminate(population, best_of_run_, generation_num) for t in self.termination_checker])
-		else:
-			return self.termination_checker.should_terminate(population, best_of_run_, generation_num)
-
-    # Necessary for valid pickling, since SimpleQueue object cannot be pickled
-	def __getstate__(self):
-		state = self.__dict__.copy()
-		del state['executor']
-		del state['random_generator']
-
-		return state
-
-	# Necessary for valid unpickling, since SimpleQueue object cannot be pickled
-	def __setstate__(self, state):
-		self.__dict__.update(state)
-		if self._executor_type == 'thread':
-			self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
-		else:
-			self.executor = ProcessPoolExecutor(max_workers=self.max_workers)
-		self.random_generator = random
+        self.set_random_seed(self.random_seed)
+        logger.info("random seed = %d", self.random_seed)
+        self.population_evaluator.set_executor(self.executor)
+
+        for field in self.__dict__.values():
+            if isinstance(field, Operator):
+                field.initialize()
+
+        self.create_population()
+        self.best_of_run_ = self.population_evaluator.act(self.population)
+        self.publish("init")
+
+    def _validate_population_type(self, population: Any) -> None:
+        # Assert valid population input
+        if population is None:
+            raise ValueError("Population cannot be None")
+
+        if isinstance(population, Population):
+            self.population = population
+        elif isinstance(population, Subpopulation):
+            self.population = Population([population])
+        elif isinstance(population, list):
+            if len(population) == 0:
+                raise ValueError("Population cannot be empty")
+            for sub_pop in population:
+                if not isinstance(sub_pop, Subpopulation):
+                    raise ValueError(
+                        "Detected a non-Subpopulation "
+                        "instance as an element in Population"
+                    )
+            self.population = Population(population)
+        else:
+            raise ValueError(
+                "Parameter population must be either a Population, "
+                "a Subpopulation or a list of Subpopulations. "
+                "Received population with unexpected type of",
+                type(population),
+            )
+
+    def _validate_statistics_type(self, statistics: Any) -> None:
+        # Assert valid statistics input
+        if isinstance(statistics, Statistics):
+            self.statistics = [statistics]
+        elif isinstance(statistics, list):
+            for stat in statistics:
+                if not isinstance(stat, Statistics):
+                    raise ValueError(
+                        "Expected a Statistics instance as an element"
+                        " in Statistics list, but received",
+                        type(stat),
+                    )
+            self.statistics = statistics
+        else:
+            raise ValueError(
+                "Parameter statistics must be either a subclass of Statistics"
+                " or a list of subclasses of Statistics.\n"
+                "received statistics with unexpected type of",
+                type(statistics),
+            )
+
+    def evolve_main_loop(self) -> None:
+        """
+        Performs the evolutionary main loop
+        """
+        # there was already "preprocessing" generation created - gen #0
+        # now create another self.max_generation generations, starting gen #1
+        for gen in range(1, self.max_generation + 1):
+            self.generation_num = gen
+            self.update_gen(gen)
+
+            self.set_generation_seed(self.next_seed())
+            self.generation_iteration(gen)
+            if self.should_terminate(self.population, self.best_of_run_, gen):
+                self.final_generation_ = gen
+                self.publish("after_generation")
+                break
+            self.publish("after_generation")
+
+    def update_gen(self, gen: int) -> None:
+        """
+        Update `gen` field for all individuals
+
+        Parameters
+        ----------
+        gen : int
+            Current generation number
+        """
+        for subpopulation in self.population.sub_populations:
+            for ind in subpopulation.individuals:
+                ind.gen = gen
+
+    @abstractmethod
+    def generation_iteration(self, gen: int) -> bool:
+        """
+        Performs an iteration of the evolutionary main loop
+
+        Parameters
+        ----------
+        gen: int
+            current generation number
+
+        Returns
+        -------
+        bool
+            True if the main loop should terminate, False otherwise
+        """
+        raise ValueError(
+            "generation_iteration is an abstract method in class Algorithm"
+        )
+
+    def finish(self) -> None:
+        """
+        Finish the evolutionary run
+        """
+        self.executor.shutdown()
+
+    def create_population(self) -> None:
+        """
+        Create the population for the evolutionary run
+        """
+        self.population.create_population_individuals()
+
+    def event_name_to_data(self, event_name) -> Dict[str, object]:
+        """
+        Convert event name to relevant data of the Algorithm for the event
+
+        Parameters
+        ----------
+        event_name: string
+            name of the event that is happening
+
+        Returns
+        ----------
+        Dict[str, object]
+            Algorithm data regarding the given event
+        """
+        if event_name == "init":
+            return {
+                "population": self.population,
+                "statistics": self.statistics,
+                "breeder": self.breeder,
+                "termination_checker": self.termination_checker,
+                "max_generation": self.max_generation,
+                "events": self.events,
+                "max_workers": self.max_workers,
+            }
+        return {}
+
+    def set_random_seed(self, seed: int = None) -> None:
+        """
+        Set the initial seed for the random generator
+        This method is called once at the beginning of the run.
+
+        Parameters
+        ----------
+        seed: int
+                random seed number
+        """
+        self.random_generator.set_seed(seed)
+        self.random_seed = seed
+
+    def set_generation_seed(self, seed: int) -> None:
+        """
+        Set the seed for current generation.
+        This method is called once every generation.
+
+        Parameters
+        ----------
+        seed: int
+                current generation seed
+        """
+        self.random_generator.set_seed(seed)
+        self.generation_seed = seed
+
+    def next_seed(self) -> int:
+        """
+        Increase the random seed for the next generation.
+
+        Returns
+        ----------
+        int
+        random seed number
+        """
+        return (self.generation_seed + 1) % (2**32)
+
+    def should_terminate(
+        self,
+        population: Population,
+        best_of_run_: Individual,
+        generation_num: int,
+    ) -> bool:
+        if self.termination_checker is None:
+            return False
+        elif isinstance(self.termination_checker, list):
+            return any(
+                [
+                    t.should_terminate(
+                        population, best_of_run_, generation_num
+                    )
+                    for t in self.termination_checker
+                ]
+            )
+        else:
+            return self.termination_checker.should_terminate(
+                population, best_of_run_, generation_num
+            )
+
+    def __getstate__(self) -> Dict[str, object]:
+        """
+        Return a dictionary of the Algorithm's fields and values.
+        It is mainly used for serialization.
+        We remove executor field since it cannot be pickled.
+
+        Returns
+        -------
+        Dict[str, object]
+            Dictionary of {field name: field value} for the Algorithm object.
+        """
+        state = self.__dict__.copy()
+        del state["executor"]
+        return state
+
+    def __setstate__(self, state: Dict[str, object]) -> None:
+        """
+        Set the __dict__ of the algorithm upon deserialization.
+        We update executor field according to the _executor_type field,
+        since the executor was removed in the serialization process.
+
+        Parameters
+        ----------
+        state : Dict[str, object]
+            Dictionary of {field name: field value} for the Algorithm object.
+        """
+        self.__dict__.update(state)
+        if self._executor_type == "thread":
+            self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
+        else:
+            self.executor = ProcessPoolExecutor(max_workers=self.max_workers)
diff --git a/eckity/algorithms/simple_evolution.py b/eckity/algorithms/simple_evolution.py
index b54be25c..fd509494 100644
--- a/eckity/algorithms/simple_evolution.py
+++ b/eckity/algorithms/simple_evolution.py
@@ -2,206 +2,253 @@
 This module implements the SimpleEvolution class.
 """
 
-from time import time
 from overrides import overrides
 
 from eckity.algorithms.algorithm import Algorithm
 from eckity.breeders.simple_breeder import SimpleBreeder
-from eckity.evaluators.simple_population_evaluator import SimplePopulationEvaluator
-from eckity.termination_checkers.threshold_from_target_termination_checker \
-	import ThresholdFromTargetTerminationChecker
+from eckity.evaluators import SimplePopulationEvaluator
+from eckity.random import RNG
+from eckity.termination_checkers import ThresholdFromTargetTerminationChecker
 
 
 class SimpleEvolution(Algorithm):
-	"""
-	Simple case evolutionary algorithm.
-
-	Basic evolutionary algorithm that contains one subpopulation.
-	Does not include and is not meant for multi-objective, co-evolution etc.
-
-	Parameters
-	----------
-	population: Population
-		The population to be evolved. Contains only one subpopulation in simple case.
-		Consists of a list of individuals.
-
-	statistics: Statistics or list of Statistics, default=None
-		Provide multiple statistics on the population during the evolutionary run.
-
-	breeder: SimpleBreeder, default=SimpleBreeder instance
-		Responsible for applying the selection method and operator sequence on the individuals
-		in each generation. Applies on one subpopulation in simple case.
-
-	population_evaluator: SimplePopulationEvaluator, default=SimplePopulationEvaluator instance
-		Responsible for evaluating each individual's fitness concurrently and returns the best individual
-		of each subpopulation (returns a single individual in simple case).
-
-	max_generation: int, default=1000
-		Maximal number of generations to run the evolutionary process.
-		Note the evolution could end before reaching max_generation, depending on the termination checker.
-
-	events: dict(str, dict(object, function)), default=None
-		Dictionary of events, where each event holds a dictionary of (subscriber, callback method).
-
-	event_names: list of strings, default=None
-		Names of events to publish during the evolution.
-
-	termination_checker: TerminationChecker, default=ThresholdFromTargetTerminationChecker()
-		Responsible for checking if the algorithm should finish before reaching max_generation.
-
-	max_workers: int, default=None
-		Maximal number of worker nodes for the Executor object that evaluates the fitness of the individuals.
-
-	random_generator: module, default=random
-		Random generator module.
-
-	random_seed: float or int, default=current system time
-		Random seed for deterministic experiment.
-
-	generation_seed: int, default=None
-		Current generation seed. Useful for resuming a previously paused experiment.
-
-	best_of_run_: Individual, default=None
-		The individual that has the best fitness in the entire evolutionary run.
-
-	best_of_gen: Individual, default=None
-		The individual that has the best fitness in the current generation.
-
-	worst_of_gen: Individual, default=None
-		The individual that has the worst fitness in the current generation.
-
-	generation_num: int, default=0
-		Current generation number
-	"""
-
-	def __init__(self,
-				 population,
-				 statistics=None,
-				 breeder=SimpleBreeder(),
-				 population_evaluator=SimplePopulationEvaluator(),
-				 max_generation=500,
-				 events=None,
-				 event_names=None,
-				 termination_checker=ThresholdFromTargetTerminationChecker(threshold=0),
-				 executor='thread',
-				 max_workers=None,
-				 random_generator=None,
-				 random_seed=time(),
-				 generation_seed=None,
-				 best_of_run_=None,
-				 best_of_gen=None,
-				 worst_of_gen=None,
-				 generation_num=0):
-		
-		if event_names is None:
-			_event_names = ['before_eval', 'after_eval', 'before_breeding', 'after_breeding']
-		else:
-			_event_names = event_names
-
-		if statistics is None:
-			statistics = []
-
-		super().__init__(population, statistics=statistics, breeder=breeder, population_evaluator=population_evaluator,
-						 events=events, event_names=_event_names, executor=executor, max_workers=max_workers,
-						 random_generator=random_generator, random_seed=random_seed, generation_seed=generation_seed,
-						 termination_checker=termination_checker, generation_num=generation_num)
-
-		self.termination_checker = termination_checker
-		self.best_of_run_ = best_of_run_
-		self.best_of_gen = best_of_gen
-		self.worst_of_gen = worst_of_gen
-		self.max_generation = max_generation
-
-		self.final_generation_ = None
-
-	def initialize(self):
-		"""
-		Initialize the evolutionary algorithm
-
-		Register statistics to `after_generation` event
-		"""
-		super().initialize()
-		for stat in self.statistics:
-			self.register('after_generation', stat.write_statistics)
-
-	@overrides
-	def generation_iteration(self, gen):
-		"""
-		Performs one iteration of the evolutionary run, for the current generation
-
-		Parameters
-		----------
-		gen:
-			current generation number (for example, generation #100)
-
-		Returns
-		-------
-		None.
-		"""
-
-		# breed population
-		self.breeder.breed(self.population)
-
-		# Evaluate the entire population and get the best individual
-		self.best_of_gen = self.population_evaluator.act(self.population)
-
-		if self.best_of_gen.better_than(self.best_of_run_):
-			self.best_of_run_ = self.best_of_gen
-
-		self.worst_of_gen = self.population.sub_populations[0].get_worst_individual()
-
-	def execute(self, **kwargs):
-		"""
-		Compute output using best evolved individual.
-		Use `execute` in a non-sklearn setting.
-		Input keyword arguments that set variable values.
-		For example if `terminal_set=['x', 'y', 1, -1]` then call `execute(x=..., y=...)`.
-
-		Parameters
-		----------
-		**kwargs : keyword arguments
-			The input to the program (tree).
-
-		Returns
-		-------
-		object
-			Output as computed by the best individual of the evolutionary run.
-
-		"""
-		return self.best_of_run_.execute(**kwargs)
-
-
-	def finish(self):
-		"""
-		Finish the evolutionary run by showing the best individual and printing the best fitness
-		"""
-		# todo should move to finisher
-		self.best_of_run_.show()
-
-	def get_individual_evaluator(self):
-		return self.population.sub_populations[0].evaluator
-
-	def get_average_fitness(self):  # TODO check if it should be here or register statistic to breeder or sub pop
-		return self.population.get_average_fitness()
-
-	def event_name_to_data(self, event_name):
-		if event_name == "init":
-			return {
-				"population": self.population,
-				"statistics": self.statistics,
-				"breeder": self.breeder,
-				"termination_checker": self.termination_checker,
-				"max_generation": self.max_generation,
-				"events": self.events,
-				"max_workers": self.max_workers,
-				"generation_num": self.generation_num,
-			}
-
-		# default case
-		return {
-			"population": self.population,
-			"best_of_run_": self.best_of_run_,
-			"best_of_gen": self.best_of_gen,
-			"generation_num": self.generation_num
-		}
-	
\ No newline at end of file
+    """
+    Simple case evolutionary algorithm.
+
+    Basic evolutionary algorithm that contains one subpopulation.
+    Does not include and is not meant for multi-objective, co-evolution etc.
+    Such algorithms should be implemented in a new Algorithm subclass.
+
+    Parameters
+    ----------
+    population: Population
+        The population to be evolved.
+        Contains only one subpopulation in simple case.
+
+
+    statistics: Union[Statistics, List[Statistics]], default=None
+        Provide multiple statistics on the population during the evolution.
+
+    breeder: SimpleBreeder
+        Responsible for applying selection and operators on individuals
+        in each generation. Applies on one subpopulation in simple case.
+
+    population_evaluator: SimplePopulationEvaluator,
+                          default=SimplePopulationEvaluator instance
+            Responsible for evaluating each individual's fitness concurrently
+            and returns the best individual of each subpopulation
+            (returns a single individual in simple case).
+
+    max_generation: int, default=1000
+            Maximal number of generations to run the evolutionary process.
+            Note the evolution could end before reaching max_generation,
+            depending on the termination checker.
+
+    events: dict(str, dict(object, function)), default=None
+            Dictionary of events, where each event holds
+            a dictionary of (subscriber, callback method).
+
+    event_names: list of strings, default=None
+            Names of events to publish during the evolution.
+
+    termination_checker: TerminationChecker
+            Checks if the evolution should perform early termination.
+
+    max_workers: int, default=None
+            Maximal number of worker nodes for the Executor object that
+            evaluates the fitness of the individuals.
+
+    rng: RNG
+            Random number generator
+
+    random_seed: int, default=current system time
+            Initial random seed for deterministic experiment.
+
+    generation_seed: int, default=None
+            Current generation seed.
+            Useful for resuming a previously paused experiment.
+
+    best_of_run_: Individual, default=None
+            The individual with the best fitness in the entire evolution.
+
+    best_of_gen: Individual, default=None
+            The individual that has the best fitness in the current generation.
+
+    worst_of_gen: Individual, default=None
+            The individual that has the worst fitness in current generation.
+
+    generation_num: int, default=0
+            Current generation number
+    """
+
+    def __init__(
+        self,
+        population,
+        statistics=None,
+        breeder: SimpleBreeder = SimpleBreeder(),
+        population_evaluator: SimplePopulationEvaluator = None,
+        max_generation=500,
+        events=None,
+        event_names=None,
+        termination_checker=None,
+        executor="thread",
+        max_workers=None,
+        random_generator: RNG = RNG(),
+        random_seed=None,
+        generation_seed=None,
+        best_of_run_=None,
+        best_of_gen=None,
+        worst_of_gen=None,
+        generation_num=0,
+    ):
+
+        if event_names is None:
+            _event_names = [
+                "before_eval",
+                "after_eval",
+                "before_breeding",
+                "after_breeding",
+            ]
+        else:
+            _event_names = event_names
+
+        if statistics is None:
+            statistics = []
+
+        if breeder is None:
+            breeder = SimpleBreeder()
+        
+        if population_evaluator is None:
+            population_evaluator = SimplePopulationEvaluator()
+
+        if not isinstance(breeder, SimpleBreeder):
+            raise ValueError(
+                f"Expected SimpleBreeder, got {type(breeder)}."
+            )
+        
+        if not isinstance(population_evaluator, SimplePopulationEvaluator):
+            raise ValueError(
+                "Expected SimplePopulationEvaluator, "
+                f"got {type(population_evaluator)}."
+            )
+
+        super().__init__(
+            population,
+            statistics=statistics,
+            breeder=breeder,
+            population_evaluator=population_evaluator,
+            events=events,
+            event_names=_event_names,
+            executor=executor,
+            max_workers=max_workers,
+            random_generator=random_generator,
+            random_seed=random_seed,
+            generation_seed=generation_seed,
+            termination_checker=termination_checker,
+            generation_num=generation_num,
+        )
+
+        self.termination_checker = termination_checker
+        self.best_of_run_ = best_of_run_
+        self.best_of_gen = best_of_gen
+        self.worst_of_gen = worst_of_gen
+        self.max_generation = max_generation
+
+        self.final_generation_ = None
+
+    def initialize(self):
+        """
+        Initialize the evolutionary algorithm
+
+        Register statistics to `after_generation` event
+        """
+        super().initialize()
+        for stat in self.statistics:
+            self.register("after_generation", stat.write_statistics)
+
+    @overrides
+    def generation_iteration(self, gen: int) -> bool:
+        """
+        Performs one iteration of the evolutionary run,
+        for the current generation
+
+        Parameters
+        ----------
+        gen:
+                current generation number (for example, generation #100)
+
+        Returns
+        -------
+        None.
+        """
+
+        # breed population
+        self.breeder.breed(self.population)
+
+        # Evaluate the entire population and get the best individual
+        self.best_of_gen = self.population_evaluator.act(self.population)
+
+        if self.best_of_gen.better_than(self.best_of_run_):
+            self.best_of_run_ = self.best_of_gen
+
+        self.worst_of_gen = self.population.sub_populations[
+            0
+        ].get_worst_individual()
+
+    def execute(self, **kwargs):
+        """
+        Compute output using best evolved individual.
+        Use `execute` in a non-sklearn setting.
+        Input keyword arguments that set variable values.
+        For example if `terminal_set=['x', 'y', 1, -1]`
+        then call `execute(x=..., y=...)`.
+
+        Parameters
+        ----------
+        **kwargs : keyword arguments
+                The input to the program (tree).
+
+        Returns
+        -------
+        object
+                Output as computed by the best individual of the evolution.
+
+        """
+        return self.best_of_run_.execute(**kwargs)
+
+    def finish(self):
+        """
+        Finish the evolutionary run by showing the best individual
+        and printing the best fitness
+        """
+        super().finish()
+        self.best_of_run_.show()
+
+    def get_individual_evaluator(self):
+        return self.population.sub_populations[0].evaluator
+
+    def get_average_fitness(
+        self,
+    ):  # TODO move this function to statistics
+        return self.population.get_average_fitness()
+
+    def event_name_to_data(self, event_name):
+        if event_name == "init":
+            return {
+                "population": self.population,
+                "statistics": self.statistics,
+                "breeder": self.breeder,
+                "termination_checker": self.termination_checker,
+                "max_generation": self.max_generation,
+                "events": self.events,
+                "max_workers": self.max_workers,
+                "generation_num": self.generation_num,
+            }
+
+        # default case
+        return {
+            "population": self.population,
+            "best_of_run_": self.best_of_run_,
+            "best_of_gen": self.best_of_gen,
+            "generation_num": self.generation_num,
+        }
diff --git a/eckity/base/__init__.py b/eckity/base/__init__.py
index 244b0d3b..e69de29b 100644
--- a/eckity/base/__init__.py
+++ b/eckity/base/__init__.py
@@ -1,1 +0,0 @@
-# turns current folder into module
diff --git a/eckity/base/typed_functions.py b/eckity/base/typed_functions.py
new file mode 100644
index 00000000..3ce0c5d6
--- /dev/null
+++ b/eckity/base/typed_functions.py
@@ -0,0 +1,199 @@
+"""
+This module implements functions used in the function nodes of a GP tree.
+Note: all functions work on numpy arrays.
+"""
+
+from typing import Any
+import numpy as np
+from .types import t_argmax
+
+
+def add2floats(x: float, y: float) -> float:
+    """x+y"""
+    return np.add(x, y)
+
+
+def sub2floats(x: float, y: float) -> float:
+    """x-y"""
+    return np.subtract(x, y)
+
+
+def mul2floats(x: float, y: float) -> float:
+    """x*y"""
+    return np.multiply(x, y)
+
+
+def div2floats(x: float, y: float) -> float:
+    """protected division: if abs(y) > 0.001 return x/y else return 0"""
+    with np.errstate(divide="ignore", invalid="ignore"):
+        return np.where(np.abs(y) > 0.001, np.divide(x, y), 0.0)
+
+
+def sqrt_float(x: float) -> float:
+    """protected square root: sqrt(abs(x))"""
+    return np.sqrt(np.absolute(x))
+
+
+def log_float(x: float) -> float:
+    """protected log: if abs(x) > 0.001 return log(abs(x)) else return 0"""
+    with np.errstate(divide="ignore", invalid="ignore"):
+        return np.where(np.abs(x) > 0.001, np.log(np.abs(x)), 0.0)
+
+
+def abs_float(x: float) -> float:
+    """absolute value of x"""
+    return np.absolute(x)
+
+
+def neg_float(x: float) -> float:
+    """negative of x"""
+    return np.negative(x)
+
+
+def inv_float(x: float) -> float:
+    """protected inverse: if abs(x) > 0.001 return 1/x else return 0"""
+    return div2floats(1, x)
+
+
+def max2floats(x: float, y: float) -> float:
+    """maximum(x,y)"""
+    return np.maximum(x, y)
+
+
+def min2floats(x: float, y: float) -> float:
+    """minimum(x,y)"""
+    return np.minimum(x, y)
+
+
+def sin_float(x: float) -> float:
+    """sin(x)"""
+    return np.sin(x)
+
+
+def cos_float(x: float) -> float:
+    """cos(x)"""
+    return np.cos(x)
+
+
+def tan_float(x: float) -> float:
+    """tan(x)"""
+    return np.tan(x)
+
+
+def iflte0_floats(x: float, y: float, z: float) -> float:
+    """if x <= 0 return y else return z"""
+    return np.where(x <= 0, y, z)
+
+
+def ifgt0_floats(x: float, y: float, z: float) -> float:
+    """if x > 0 return y else return z"""
+    return np.where(x > 0, y, z)
+
+
+def iflte_floats(x: float, y: float, z: float, w: float) -> float:
+    """if x <= y return z else return w"""
+    return np.where(x <= y, z, w)
+
+
+def ifgt_floats(x: float, y: float, z: float, w: float) -> float:
+    """if x > y return z else return w"""
+    return np.where(x > y, z, w)
+
+
+def and2floats(x: float, y: float) -> float:
+    """x and y"""
+    return np.logical_and(x, y)
+
+
+def or2floats(x: float, y: float) -> float:
+    """x or y"""
+    return np.logical_or(x, y)
+
+
+def not2floats(x: float) -> float:
+    """not x"""
+    return np.logical_not(x).astype(float)
+
+
+def if_then_else(test: bool, dit: Any, dif: Any) -> float:
+    """if test return dit else return dif"""
+    return np.where(test, dit, dif)
+
+
+def argmax2floats(x0: float, x1: float) -> t_argmax:
+    return np.argmax([x0, x1], axis=0)
+
+
+def and2ints(x: int, y: int) -> int:
+    """x and y"""
+    return np.logical_and(x, y)
+
+
+def or2ints(x: int, y: int) -> int:
+    """x or y"""
+    return np.logical_or(x, y)
+
+
+def not2ints(x: int) -> int:
+    """not x"""
+    return np.logical_not(x).astype(int)
+
+
+def if_then_else3ints(test: int, dit: int, dif: int) -> int:
+    """if test return dit else return dif"""
+    return np.where(test, dit, dif)
+
+
+def and2bools(x: bool, y: bool) -> bool:
+    """x and y"""
+    return np.bitwise_and(x, y)
+
+
+def or2bools(x: bool, y: bool) -> bool:
+    """x or y"""
+    return np.bitwise_or(x, y)
+
+
+def not2bools(x: bool) -> bool:
+    """not x"""
+    return np.logical_not(x)
+
+
+def if_then_else3bools(test: bool, dit: bool, dif: bool) -> bool:
+    """if test return dit else return dif"""
+    return np.where(test, dit, dif)
+
+
+__all__ = [
+    "add2floats",
+    "sub2floats",
+    "mul2floats",
+    "div2floats",
+    "sqrt_float",
+    "log_float",
+    "abs_float",
+    "neg_float",
+    "inv_float",
+    "max2floats",
+    "min2floats",
+    "sin_float",
+    "cos_float",
+    "tan_float",
+    "iflte0_floats",
+    "ifgt0_floats",
+    "iflte_floats",
+    "ifgt_floats",
+    "and2floats",
+    "or2floats",
+    "not2floats",
+    "if_then_else",
+    "argmax2floats",
+    "and2ints",
+    "or2ints",
+    "not2ints",
+    "if_then_else3ints",
+    "and2bools",
+    "or2bools",
+    "not2bools",
+    "if_then_else3bools",
+]
diff --git a/eckity/base/types.py b/eckity/base/types.py
new file mode 100644
index 00000000..7bcd0168
--- /dev/null
+++ b/eckity/base/types.py
@@ -0,0 +1,1 @@
+t_argmax = type("argmax", (int,), {})
diff --git a/eckity/genetic_encodings/gp/tree/functions.py b/eckity/base/untyped_functions.py
similarity index 79%
rename from eckity/genetic_encodings/gp/tree/functions.py
rename to eckity/base/untyped_functions.py
index 7e5ae441..c94c5d92 100644
--- a/eckity/genetic_encodings/gp/tree/functions.py
+++ b/eckity/base/untyped_functions.py
@@ -23,8 +23,8 @@ def f_mul(x, y):
 
 def f_div(x, y):
     """protected division: if abs(y) > 0.001 return x/y else return 0"""
-    with np.errstate(divide='ignore', invalid='ignore'):
-        return np.where(np.abs(y) > 0.001, np.divide(x, y), 0.)
+    with np.errstate(divide="ignore", invalid="ignore"):
+        return np.where(np.abs(y) > 0.001, np.divide(x, y), 0.0)
 
 
 def f_sqrt(x):
@@ -34,8 +34,8 @@ def f_sqrt(x):
 
 def f_log(x):
     """protected log: if abs(x) > 0.001 return log(abs(x)) else return 0"""
-    with np.errstate(divide='ignore', invalid='ignore'):
-        return np.where(np.abs(x) > 0.001, np.log(np.abs(x)), 0.)
+    with np.errstate(divide="ignore", invalid="ignore"):
+        return np.where(np.abs(x) > 0.001, np.log(np.abs(x)), 0.0)
 
 
 def f_abs(x):
@@ -50,8 +50,6 @@ def f_neg(x):
 
 def f_inv(x):
     """protected inverse: if abs(x) > 0.001 return 1/x else return 0"""
-    # with np.errstate(divide='ignore', invalid='ignore'):
-    #     return np.where(np.abs(x) > 0.001, 1. / x, 0.)
     return f_div(1, x)
 
 
@@ -120,6 +118,27 @@ def f_if_then_else(test, dit, dif):
     return np.where(test, dit, dif)
 
 
-
-full_function_set = [f_add, f_sub, f_mul, f_div, f_sqrt, f_log, f_abs, f_neg, f_inv, f_max, f_min, f_sin, f_cos, f_tan,
-                     f_iflte0, f_ifgt0, f_iflte, f_ifgt, f_add, f_or, f_not, f_if_then_else]
+__all__ = [
+    "f_add",
+    "f_sub",
+    "f_mul",
+    "f_div",
+    "f_sqrt",
+    "f_log",
+    "f_abs",
+    "f_neg",
+    "f_inv",
+    "f_max",
+    "f_min",
+    "f_sin",
+    "f_cos",
+    "f_tan",
+    "f_iflte0",
+    "f_ifgt0",
+    "f_iflte",
+    "f_ifgt",
+    "f_and",
+    "f_or",
+    "f_not",
+    "f_if_then_else",
+]
diff --git a/eckity/base/utils.py b/eckity/base/utils.py
index 2a4facf9..624f52ab 100644
--- a/eckity/base/utils.py
+++ b/eckity/base/utils.py
@@ -3,18 +3,19 @@
 """
 
 from inspect import getfullargspec
+from typing import Callable
 
 
-def arity(func):
-	"""
-	Parameters
-	----------
-	func : function
-		A function.
+def arity(func: Callable) -> int:
+    """
+    Parameters
+    ----------
+    func : function
+            A function.
 
-	Returns
-	-------
-	arity : int
-		The function's arity.
-	"""
-	return len(getfullargspec(func)[0])
+    Returns
+    -------
+    arity : int
+            The function's arity.
+    """
+    return len(getfullargspec(func)[0])
diff --git a/eckity/breeders/__init__.py b/eckity/breeders/__init__.py
index e69de29b..3e801a75 100644
--- a/eckity/breeders/__init__.py
+++ b/eckity/breeders/__init__.py
@@ -0,0 +1,2 @@
+from .breeder import Breeder
+from .simple_breeder import SimpleBreeder
diff --git a/eckity/breeders/breeder.py b/eckity/breeders/breeder.py
index 7e536591..125bac52 100644
--- a/eckity/breeders/breeder.py
+++ b/eckity/breeders/breeder.py
@@ -1,4 +1,4 @@
-from abc import abstractmethod
+from abc import abstractmethod, ABC
 
 from overrides import overrides
 
@@ -6,38 +6,37 @@
 from eckity.population import Population
 
 
-class Breeder(Operator):
-	"""
-	The Breeder is responsible to activate the genetic operators (selection, crossover, mutation)
-	on the existing population
-
-	Parameters
-	----------
-	events: dict(str, dict(object, function))
-		dictionary of event names to dictionary of subscribers to callback methods
-	"""
-
-	def __init__(self,
-				 events=None):
-		super().__init__(events=events)
-
-	def breed(self, population):
-		"""
-		Breed the given population of the experiment.
-		Hence, apply genetic operators on the individuals of the population.
-
-		Parameters
-		----------
-		population: Population
-		The population of individuals existing in the current experiment.
-		"""
-		self.act(population)
-
-	@abstractmethod
-	def apply_breed(self, population):
-		pass
-
-	@overrides
-	def apply_operator(self, payload):
-		population: Population = payload
-		self.apply_breed(population)
+class Breeder(Operator, ABC):
+    """
+    The Breeder is responsible to activate the genetic operators (selection, crossover, mutation)
+    on the existing population
+
+    Parameters
+    ----------
+    events: dict(str, dict(object, function))
+            dictionary of event names to dictionary of subscribers to callback methods
+    """
+
+    def __init__(self, events=None):
+        super().__init__(events=events)
+
+    def breed(self, population):
+        """
+        Breed the given population of the experiment.
+        Hence, apply genetic operators on the individuals of the population.
+
+        Parameters
+        ----------
+        population: Population
+        The population of individuals existing in the current experiment.
+        """
+        self.act(population)
+
+    @abstractmethod
+    def apply_breed(self, population):
+        pass
+
+    @overrides
+    def apply_operator(self, payload):
+        population: Population = payload
+        self.apply_breed(population)
diff --git a/eckity/breeders/simple_breeder.py b/eckity/breeders/simple_breeder.py
index 56b9863c..68366469 100644
--- a/eckity/breeders/simple_breeder.py
+++ b/eckity/breeders/simple_breeder.py
@@ -1,71 +1,106 @@
 from eckity.breeders.breeder import Breeder
-from eckity.genetic_operators.selections.elitism_selection import ElitismSelection
+from eckity.genetic_operators.selections.elitism_selection import (
+    ElitismSelection,
+)
 
 
 class SimpleBreeder(Breeder):
-	def __init__(self,
-				 events=None):
-		super().__init__(events=events)
-		self.selected_individuals = []  # TODO why do we need this field? what about applied_individuals?
-		self.best_of_run = []  # TODO this field isn't used
-
-	def apply_breed(self, population):
-		"""
-		Apply elitism, selection method and the sub-population's operator sequence on each sub-population.
-		In simple case, the operator sequence is applied on the one and only sub-population.
-
-		Parameters
-		----------
-		population:
-			Population of sub-populations of individuals. The operators will be applied on those individuals.
-
-		Returns
-		-------
-		None.
-		"""
-		for subpopulation in population.sub_populations:
-			nextgen_population = []
-
-			num_elites = subpopulation.n_elite
-			if num_elites > 0:
-				elitism_sel = ElitismSelection(num_elites=num_elites, higher_is_better=subpopulation.higher_is_better)
-				elitism_sel.apply_operator((subpopulation.individuals, nextgen_population))
-
-			self.selected_individuals = subpopulation.get_selection_methods()[0][0] \
-				.select(subpopulation.individuals, nextgen_population)
-
-			# then runs all operators on next_gen
-			nextgen_population = self._apply_operators(subpopulation.get_operators_sequence(),
-													   self.selected_individuals)
-			# TODO assert simple operators the has %0 with pop size
-
-			subpopulation.individuals = nextgen_population
-
-	def _apply_operators(self, operator_seq, individuals_to_apply_on):
-		"""
-		Apply a given operator sequence on a given list of individuals.
-		The operators are done sequentially.
-
-		Parameters
-		----------
-		operator_seq: list of operators
-			Operator sequence. The operators will be applied sequentially on the given individuals.
-		individuals_to_apply_on: list of individuals
-			The individuals to apply the operator sequence on.
-
-		Returns
-		-------
-		list of individuals
-			The individuals list after the operators were applied on them.
-		"""
-		for operator in operator_seq:
-			operator_arity = operator.get_operator_arity()
-			for i in range(0, len(individuals_to_apply_on), operator_arity):
-				op_res = operator.apply_operator(individuals_to_apply_on[i:i + operator_arity])
-				individuals_to_apply_on[i:i + operator_arity] = op_res
-		return individuals_to_apply_on
-
-	def event_name_to_data(self, event_name):
-		if event_name == "after_selection":
-			return {"selected_individuals": self.selected_individuals,
-					"best_of_run": self.best_of_run}
+    """
+    A Simple version of Breeder class.
+    All simple classes assume there is only one sub-population in population.
+    """
+
+    def __init__(self, events=None):
+        super().__init__(events=events)
+        self.selected_individuals = (
+            []
+        )  # TODO why do we need this field? what about applied_individuals?
+        self.best_of_run = []  # TODO this field isn't used
+
+    def apply_breed(self, population):
+        """
+        Apply elitism, selection and operator sequence on the sub-populations.
+        In simple case, the operator sequence is applied on one sub-population.
+
+        Parameters
+        ----------
+        population:
+                Population of sub-populations of individuals.
+                The operators will be applied on those individuals.
+
+        Returns
+        -------
+        None.
+        """
+        for subpopulation in population.sub_populations:
+            # Assert that operator arities are compatible with pop size
+            # tuples of (selection, probability)
+            selection_methods = subpopulation.get_selection_methods()
+            selection_methods = [t[0] for t in selection_methods]
+
+            operators_sequence = subpopulation.get_operators_sequence()
+            operators = selection_methods + operators_sequence
+
+            for oper in operators:
+                if subpopulation.population_size % oper.arity != 0:
+                    raise ValueError(
+                        f"Operator {oper} arity must be "
+                        f"dividable by population size"
+                    )
+
+            nextgen_population = []
+
+            num_elites = subpopulation.n_elite
+            if num_elites > 0:
+                elitism_sel = ElitismSelection(
+                    num_elites=num_elites,
+                    higher_is_better=subpopulation.higher_is_better,
+                )
+                elitism_sel.apply_operator(
+                    (subpopulation.individuals, nextgen_population)
+                )
+
+            self.selected_individuals = subpopulation.get_selection_methods()[
+                0
+            ][0].select(subpopulation.individuals, nextgen_population)
+
+            # then runs all operators on next_gen
+            nextgen_population = self._apply_operators(
+                subpopulation.get_operators_sequence(),
+                self.selected_individuals,
+            )
+
+            subpopulation.individuals = nextgen_population
+
+    def _apply_operators(self, operator_seq, individuals_to_apply_on):
+        """
+        Apply a given operator sequence on a given list of individuals.
+        The operators are done sequentially.
+
+        Parameters
+        ----------
+        operator_seq: list of operators
+                Operator sequence. Applied sequentially on the individuals.
+        individuals_to_apply_on: list of individuals
+                The individuals to apply the operator sequence on.
+
+        Returns
+        -------
+        list of individuals
+                The individuals list after the operators were applied on them.
+        """
+        for operator in operator_seq:
+            operator_arity = operator.get_operator_arity()
+            for i in range(0, len(individuals_to_apply_on), operator_arity):
+                op_res = operator.apply_operator(
+                    individuals_to_apply_on[i: i + operator_arity]
+                )
+                individuals_to_apply_on[i: i + operator_arity] = op_res
+        return individuals_to_apply_on
+
+    def event_name_to_data(self, event_name):
+        if event_name == "after_selection":
+            return {
+                "selected_individuals": self.selected_individuals,
+                "best_of_run": self.best_of_run,
+            }
diff --git a/eckity/creators/__init__.py b/eckity/creators/__init__.py
index e69de29b..4ff69fb1 100644
--- a/eckity/creators/__init__.py
+++ b/eckity/creators/__init__.py
@@ -0,0 +1,13 @@
+from .creator import Creator
+
+# GA creators
+from .ga_creators.simple_vector_creator import GAVectorCreator
+from .ga_creators.bit_string_vector_creator import GABitStringVectorCreator
+from .ga_creators.float_vector_creator import GAFloatVectorCreator
+from .ga_creators.int_vector_creator import GAIntVectorCreator
+
+# GP creators
+from .gp_creators.tree_creator import GPTreeCreator
+from .gp_creators.grow import GrowCreator
+from .gp_creators.full import FullCreator
+from .gp_creators.half import HalfCreator
diff --git a/eckity/creators/creator.py b/eckity/creators/creator.py
index 4415c9cb..59460317 100644
--- a/eckity/creators/creator.py
+++ b/eckity/creators/creator.py
@@ -1,24 +1,29 @@
 from abc import abstractmethod
+from typing import Any, Dict, List
 
+from eckity.individual import Individual
 from eckity.event_based_operator import Operator
 from eckity.fitness.simple_fitness import SimpleFitness
 
 
 class Creator(Operator):
-	def __init__(self, events=None, fitness_type=SimpleFitness):
-		super().__init__(events)
-		self.created_individuals = None
-		self.fitness_type = fitness_type
+    def __init__(self, events=None, fitness_type=SimpleFitness):
+        super().__init__(events)
+        self.created_individuals = None
+        self.fitness_type = fitness_type
 
-	@abstractmethod
-	def create_individuals(self, n_individuals, higher_is_better):
-		pass
+    @abstractmethod
+    def create_individuals(
+        self, n_individuals: int, higher_is_better: bool
+    ) -> List[Individual]:
+        pass
 
-	def apply_operator(self, payload):
-		return self.create_individuals(payload)
+    def apply_operator(self, payload: Any) -> Any:
+        return self.create_individuals(payload)
 
-	def event_name_to_data(self, event_name):
-		if event_name == "after_operator":
-			return {"created_individuals": self.created_individuals}
-		else:
-			return {}
+    def event_name_to_data(self, event_name: str) -> Dict[str, Any]:
+        return (
+            {"created_individuals": self.created_individuals}
+            if event_name == "after_operator"
+            else {}
+        )
diff --git a/eckity/creators/ga_creators/bit_string_vector_creator.py b/eckity/creators/ga_creators/bit_string_vector_creator.py
index c33ee453..a8959868 100644
--- a/eckity/creators/ga_creators/bit_string_vector_creator.py
+++ b/eckity/creators/ga_creators/bit_string_vector_creator.py
@@ -3,10 +3,19 @@
 
 
 class GABitStringVectorCreator(GAVectorCreator):
-	def __init__(self,
-				 length=1,
-				 bounds=(0, 1),
-				 gene_creator=None,
-				 events=None):
-		super().__init__(length=length, bounds=bounds, gene_creator=gene_creator, vector_type=BitStringVector,
-						 events=events)
+    def __init__(
+        self,
+        length=1,
+        bounds=(0, 1),
+        gene_creator=None,
+        events=None,
+        update_parents=False,
+    ):
+        super().__init__(
+            length=length,
+            bounds=bounds,
+            gene_creator=gene_creator,
+            vector_type=BitStringVector,
+            events=events,
+            update_parents=update_parents,
+        )
diff --git a/eckity/creators/ga_creators/float_vector_creator.py b/eckity/creators/ga_creators/float_vector_creator.py
index a7882203..cab41906 100644
--- a/eckity/creators/ga_creators/float_vector_creator.py
+++ b/eckity/creators/ga_creators/float_vector_creator.py
@@ -3,13 +3,19 @@
 
 
 class GAFloatVectorCreator(GAVectorCreator):
-	def __init__(self,
-				 length=1,
-				 gene_creator=None,
-				 bounds=(0.0, 1.0),
-				 events=None):
-		super().__init__(length=length,
-						 gene_creator=gene_creator,
-						 bounds=bounds,
-						 vector_type=FloatVector,
-						 events=events)
+    def __init__(
+        self,
+        length=1,
+        gene_creator=None,
+        bounds=(0.0, 1.0),
+        events=None,
+        update_parents=False,
+    ):
+        super().__init__(
+            length=length,
+            gene_creator=gene_creator,
+            bounds=bounds,
+            vector_type=FloatVector,
+            events=events,
+            update_parents=update_parents,
+        )
diff --git a/eckity/creators/ga_creators/int_vector_creator.py b/eckity/creators/ga_creators/int_vector_creator.py
index 6ff38349..296717d1 100644
--- a/eckity/creators/ga_creators/int_vector_creator.py
+++ b/eckity/creators/ga_creators/int_vector_creator.py
@@ -3,13 +3,19 @@
 
 
 class GAIntVectorCreator(GAVectorCreator):
-	def __init__(self,
-				 length=1,
-				 gene_creator=None,
-				 bounds=(0, 1),
-				 events=None):
-		super().__init__(length=length,
-						 gene_creator=gene_creator,
-						 bounds=bounds,
-						 vector_type=IntVector,
-						 events=events)
+    def __init__(
+        self,
+        length=1,
+        gene_creator=None,
+        bounds=(0, 1),
+        events=None,
+        update_parents=False,
+    ):
+        super().__init__(
+            length=length,
+            gene_creator=gene_creator,
+            bounds=bounds,
+            vector_type=IntVector,
+            events=events,
+            update_parents=update_parents,
+        )
diff --git a/eckity/creators/ga_creators/simple_vector_creator.py b/eckity/creators/ga_creators/simple_vector_creator.py
index e1f9987e..4d949e6a 100644
--- a/eckity/creators/ga_creators/simple_vector_creator.py
+++ b/eckity/creators/ga_creators/simple_vector_creator.py
@@ -4,41 +4,50 @@
 
 
 class GAVectorCreator(Creator):
-	def __init__(self,
-				 length=1,
-				 gene_creator=None,
-				 bounds=(0.0, 1.0),
-				 vector_type=BitStringVector,
-				 fitness_type=SimpleFitness,
-				 events=None):
-		if events is None:
-			events = ["after_creation"]
-		super().__init__(events, fitness_type)
-
-		if gene_creator is None:
-			gene_creator = self.default_gene_creator
-		self.gene_creator = gene_creator
-
-		self.type = vector_type
-		self.length = length
-		self.bounds = bounds
-
-	def create_individuals(self, n_individuals, higher_is_better):
-		individuals = [self.type(length=self.length,
-								 bounds=self.bounds,
-								 fitness=self.fitness_type(higher_is_better=higher_is_better))
-					   for _ in range(n_individuals)]
-		for ind in individuals:
-			self.create_vector(ind)
-		self.created_individuals = individuals
-
-		return individuals
-
-	def create_vector(self, individual):
-		# vector = [self.gene_creator(individual.bounds[i % len(individual.bounds)]) for i in individual.size()]
-		vector = [self.gene_creator(individual, i) for i in range(self.length)]
-		individual.set_vector(vector)
-
-	@staticmethod
-	def default_gene_creator(individual, index):
-		return individual.get_random_number_in_bounds(index)
+    def __init__(
+        self,
+        length=1,
+        gene_creator=None,
+        bounds=(0.0, 1.0),
+        vector_type=BitStringVector,
+        fitness_type=SimpleFitness,
+        events=None,
+        update_parents=False,
+    ):
+        if events is None:
+            events = ["after_creation"]
+        super().__init__(events, fitness_type)
+
+        if gene_creator is None:
+            gene_creator = self.default_gene_creator
+        self.gene_creator = gene_creator
+
+        self.type = vector_type
+        self.length = length
+        self.bounds = bounds
+        self.update_parents = update_parents
+
+    def create_individuals(self, n_individuals, higher_is_better):
+        individuals = [
+            self.type(
+                length=self.length,
+                bounds=self.bounds,
+                fitness=self.fitness_type(higher_is_better=higher_is_better),
+                update_parents=self.update_parents,
+            )
+            for _ in range(n_individuals)
+        ]
+        for ind in individuals:
+            self.create_vector(ind)
+        self.created_individuals = individuals
+
+        return individuals
+
+    def create_vector(self, individual):
+        # vector = [self.gene_creator(individual.bounds[i % len(individual.bounds)]) for i in individual.size()]
+        vector = [self.gene_creator(individual, i) for i in range(self.length)]
+        individual.set_vector(vector)
+
+    @staticmethod
+    def default_gene_creator(individual, index):
+        return individual.get_random_number_in_bounds(index)
diff --git a/eckity/creators/gp_creators/full.py b/eckity/creators/gp_creators/full.py
index 8e6b478b..ff600664 100644
--- a/eckity/creators/gp_creators/full.py
+++ b/eckity/creators/gp_creators/full.py
@@ -1,88 +1,102 @@
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+
 from overrides import overrides
 
 from eckity.creators.gp_creators.tree_creator import GPTreeCreator
+from eckity.genetic_encodings.gp import (
+    TerminalNode,
+    FunctionNode,
+)
+from eckity.genetic_encodings.gp.tree.utils import get_func_types
 
 
 class FullCreator(GPTreeCreator):
-	def __init__(self,
-				 init_depth=None,
-				 function_set=None,
-				 terminal_set=None,
-				 erc_range=None,
-				 bloat_weight=0.1,
-				 events=None):
-		"""
-		Tree creator using the full method
-
-		Parameters
-		----------
-		init_depth : (int, int)
-		Min and max depths of initial random trees. The default is None.
-
-		function_set : list
-			List of functions used as internal nodes in the GP tree. The default is None.
-
-		terminal_set : list
-			List of terminals used in the GP-tree leaves. The default is None.
-
-		erc_range : (float, float)
-			Range of values for ephemeral random constant (ERC). The default is None.
-
-		bloat_weight : float
-			Bloat control weight to punish large trees. Bigger values make a bigger punish.
-
-		events : list
-			List of events related to this class
-		"""
-		super().__init__(init_depth=init_depth, function_set=function_set, terminal_set=terminal_set,
-						 erc_range=erc_range, bloat_weight=bloat_weight, events=events)
-
-	@overrides
-	def create_tree(self, tree_ind, max_depth=5):
-		"""
-		Create a random tree using the full method, and assign it to the given individual.
-
-		Parameters
-		----------
-		tree_ind: Tree
-			An individual of GP Tree representation with an initially empty tree
-
-		max_depth: int
-			Maximum depth of tree. The default is 5.
-
-		Returns
-		-------
-		None.
-		"""
-		self._create_tree(tree_ind, max_depth, 0)
-
-	def _create_tree(self, tree_ind, max_depth=5, depth=0):
-		"""
-		Recursively create a random tree using the full method.
-
-		Parameters
-		----------
-		max_depth : int
-			Maximum depth of tree. The default is 5.
-
-		depth : int, optional
-			Current depth in recursive process. The default is 0.
-
-		Returns
-		-------
-		None.
-
-		"""
-		is_func = False
-
-		if depth >= max_depth:
-			node = tree_ind.random_terminal()
-		else:
-			node = tree_ind.random_function()
-			is_func = True
-
-		tree_ind.add_tree(node)
-
-		if is_func:
-			for i in range(tree_ind.arity[node]):
-				self._create_tree(tree_ind, max_depth, depth=depth + 1)
+    def __init__(
+        self,
+        init_depth: Tuple[int, int] = None,
+        function_set: List[Callable] = None,
+        terminal_set: Union[Dict[Any, type], List[Any]] = None,
+        erc_range: Union[Tuple[int, int], Tuple[float, float]] = None,
+        bloat_weight: float = 0.0,
+        events: List[str] = None,
+        root_type: Optional[type] = None,
+        update_parents: bool = False,
+    ):
+        """
+        Tree creator using the full method
+
+        Parameters
+        ----------
+        init_depth : (int, int)
+        Min and max depths of initial random trees. The default is None.
+
+        function_set : list
+                List of functions used as internal nodes in the GP tree. The default is None.
+
+        terminal_set : list
+                List of terminals used in the GP-tree leaves. The default is None.
+
+        bloat_weight : float
+                Bloat control weight to punish large trees. Bigger values make a bigger punish.
+
+        events : list
+                List of events related to this class
+        """
+        super().__init__(
+            init_depth=init_depth,
+            function_set=function_set,
+            terminal_set=terminal_set,
+            bloat_weight=bloat_weight,
+            erc_range=erc_range,
+            events=events,
+            root_type=root_type,
+            update_parents=update_parents,
+        )
+
+    @overrides
+    def create_tree(
+        self,
+        tree,
+        random_function: Callable[[type], Optional[FunctionNode]],
+        random_terminal: Callable[[type], Optional[TerminalNode]],
+        depth: int = 0,
+        node_type: Optional[type] = None,
+    ) -> None:
+        """
+        Recursively create a random tree using the full method
+
+        Parameters
+        ----------
+        depth: int
+                Current depth in recursive process.
+
+        Returns
+        -------
+        None.
+
+        """
+        max_depth = self.init_depth[1]
+
+        if depth >= max_depth:
+            node = random_terminal(node_type)
+            self._assert_node_created(node)
+
+            # add the new node to the tree of the given individual
+            tree.append(node)
+        else:
+            node = random_function(node_type)
+            self._assert_node_created(node)
+
+            # add the new node to the tree of the given individual
+            tree.append(node)
+
+            # recursively add argument nodes to the tree
+            func_types = get_func_types(node.function)[:-1]
+            for t in func_types:
+                self.create_tree(
+                    tree,
+                    random_function,
+                    random_terminal,
+                    depth=depth + 1,
+                    node_type=t,
+                )
diff --git a/eckity/creators/gp_creators/grow.py b/eckity/creators/gp_creators/grow.py
index 65070c43..65b71cbc 100644
--- a/eckity/creators/gp_creators/grow.py
+++ b/eckity/creators/gp_creators/grow.py
@@ -1,97 +1,118 @@
-from random import random
+import random
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union
 
 from overrides import overrides
 
 from eckity.creators.gp_creators.tree_creator import GPTreeCreator
+from eckity.genetic_encodings.gp import (
+    FunctionNode,
+    TerminalNode,
+    TreeNode,
+)
 
 
 class GrowCreator(GPTreeCreator):
-	def __init__(self,
-				 init_depth=None,
-				 function_set=None,
-				 terminal_set=None,
-				 erc_range=None,
-				 bloat_weight=0.1,
-				 events=None):
-		"""
-		Tree creator using the grow method
-
-		Parameters
-		----------
-		init_depth : (int, int)
-		Min and max depths of initial random trees. The default is None.
-
-		function_set : list
-			List of functions used as internal nodes in the GP-tree. The default is None.
-
-		terminal_set : list
-			List of terminals used in the GP-tree leaves. The default is None.
-
-		erc_range : (float, float)
-			Range of values for ephemeral random constant (ERC). The default is None.
-
-		bloat_weight : float
-			Bloat control weight to punish large trees. Bigger values make a bigger punish.
-
-		events : list
-			List of events related to this class
-		"""
-		super().__init__(init_depth=init_depth, function_set=function_set, terminal_set=terminal_set,
-						 erc_range=erc_range, bloat_weight=bloat_weight, events=events)
-
-	@overrides
-	def create_tree(self, tree_ind, max_depth=5):
-		"""
-		Create a random tree using the grow method, and assign it to the given individual.
-
-		Parameters
-		----------
-		tree_ind: Tree
-			An individual of GP Tree representation with an initially empty tree
-
-		max_depth: int
-			Maximum depth of tree. The default is 5.
-
-		Returns
-		-------
-		None.
-		"""
-		self._create_tree(tree_ind, max_depth, 0)
-
-	def _create_tree(self, tree_ind, max_depth=5, depth=0):
-		"""
-		Recursively create a random tree using the grow method
-
-		Parameters
-		----------
-		max_depth : int
-			Maximum depth of tree. The default is 2.
-		depth : int, optional
-			Current depth in recursive process. The default is 0.
-
-		Returns
-		-------
-		None.
-
-		"""
-		is_func = False
-
-		if depth < self.init_depth[0]:
-			node = tree_ind.random_function()
-			is_func = True
-		elif depth >= max_depth:
-			node = tree_ind.random_terminal()
-		else:  # intermediate depth, grow
-			if random() > 0.5:
-				node = tree_ind.random_function()
-				is_func = True
-			else:
-				node = tree_ind.random_terminal()
-
-		# add the new node to the tree of the given individual
-		tree_ind.add_tree(node)
-
-		if is_func:
-			# recursively add arguments to the function node, according to its arity
-			for i in range(tree_ind.arity[node]):
-				self._create_tree(tree_ind, max_depth, depth=depth + 1)
+    def __init__(
+        self,
+        init_depth: Tuple[int, int] = None,
+        function_set: List[Callable] = None,
+        terminal_set: Union[Dict[Any, type], List[Any]] = None,
+        bloat_weight: float = 0.0,
+        erc_range: Union[Tuple[int, int], Tuple[float, float]] = None,
+        p_prune: float = 0.5,
+        events: List[str] = None,
+        root_type: Optional[type] = None,
+        update_parents: bool = False
+    ):
+        """
+        Tree creator using the grow method
+
+        Parameters
+        ----------
+        init_depth : (int, int)
+        Min and max depths of initial random trees. The default is None.
+
+        function_set : list, default=None
+                List of functions used as internal nodes in the GP-tree.
+
+        terminal_set : list, default=None.
+                List of terminals used in the GP-tree leaves.
+
+        bloat_weight : float, default=0.0
+                Bloat control weight to punish large trees.
+
+        p_prune : float, default=0.5
+                Probability of pruning the tree at each level.
+
+        events : list
+                List of events related to this class
+        """
+        super().__init__(
+            init_depth=init_depth,
+            function_set=function_set,
+            terminal_set=terminal_set,
+            bloat_weight=bloat_weight,
+            events=events,
+            root_type=root_type,
+            update_parents=update_parents,
+            erc_range=erc_range,
+        )
+        self.p_prune = p_prune
+
+    @overrides
+    def create_tree(
+        self,
+        tree: List[TreeNode],
+        random_function: Callable[[type], Optional[FunctionNode]],
+        random_terminal: Callable[[type], Optional[TerminalNode]],
+        depth: int = 0,
+        node_type: Optional[type] = None,
+    ) -> None:
+        """
+        Generate a list of TreeNodes in-place.
+
+        Parameters
+        ----------
+        tree : List[TreeNode]
+            List of tree nodes representing a tree of a Tree individual.
+        random_function : Callable[[type], Optional[FunctionNode]]
+            Random FunctionNode generator.
+        random_terminal : Callable[[type], Optional[TerminalNode]]
+            Random TerminalNode generator.
+        depth : int, optional
+            current depth of the tree, by default 0
+        node_type : Optional[type], optional
+            _description_, by default None
+        """
+        if tree is None:
+            tree = []
+
+        min_depth, max_depth = self.init_depth
+
+        is_func = False
+        if depth < min_depth:
+            node = random_function(node_type)
+
+            is_func = True
+        elif depth >= max_depth:
+            node = random_terminal(node_type)
+        else:  # intermediate depth, grow
+            if random.random() < self.p_prune:
+                node = random_terminal(node_type)
+            else:
+                node = random_function(node_type)
+                is_func = True
+
+        # add the new node to the tree of the given individual
+        tree.append(node)
+
+        self._assert_node_created(node)
+
+        if is_func:
+            self._add_children(
+                tree=tree,
+                fn_node=node,
+                random_function=random_function,
+                random_terminal=random_terminal,
+                depth=depth,
+            )
diff --git a/eckity/creators/gp_creators/half.py b/eckity/creators/gp_creators/half.py
new file mode 100644
index 00000000..f24ed9a5
--- /dev/null
+++ b/eckity/creators/gp_creators/half.py
@@ -0,0 +1,141 @@
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+
+from overrides import override
+
+from eckity.creators.gp_creators.full import FullCreator
+from eckity.creators.gp_creators.grow import GrowCreator
+from eckity.creators.gp_creators.tree_creator import GPTreeCreator
+from eckity.genetic_encodings.gp.tree.tree_individual import Tree
+
+
+class HalfCreator(GPTreeCreator):
+    def __init__(
+        self,
+        grow_creator: GrowCreator = None,
+        full_creator: FullCreator = None,
+        init_depth: Tuple[int, int] = None,
+        function_set: List[Callable] = None,
+        terminal_set: Union[Dict[Any, type], List[Any]] = None,
+        bloat_weight: float = 0.0,
+        erc_range: Union[Tuple[int, int], Tuple[float, float]] = None,
+        events: List[str] = None,
+        root_type: Optional[type] = None,
+    ):
+        """
+        Tree creator that creates trees using the Ramped Half and Half method
+
+        Parameters
+        ----------
+        grow_creator: GrowCreator
+                a tree creator that creates trees using the grow method
+
+        full_creator: FullCreator
+                a tree creator that creates trees using the full method
+
+        init_depth : (int, int)
+        Min and max depths of initial random trees. The default is None.
+
+        function_set : list
+                List of functions used as internal nodes in the GP tree. The default is None.
+
+        terminal_set : list or dict
+                List of terminals used in the GP-tree leaves. The default is None.
+
+        bloat_weight : float
+                Bloat control weight to punish large trees. Bigger values make a bigger punish.
+
+        events : list
+                List of events related to this class
+        """
+        super().__init__(
+            init_depth=init_depth,
+            function_set=function_set,
+            terminal_set=terminal_set,
+            bloat_weight=bloat_weight,
+            erc_range=erc_range,
+            events=events,
+            root_type=root_type,
+        )
+
+        # assign default creators
+        if grow_creator is None:
+            grow_creator = GrowCreator(
+                init_depth=self.init_depth,
+                function_set=self.function_set,
+                terminal_set=self.terminal_set,
+                events=self.events,
+                root_type=root_type,
+                erc_range=self.erc_range,
+            )
+        if full_creator is None:
+            full_creator = FullCreator(
+                init_depth=self.init_depth,
+                function_set=self.function_set,
+                terminal_set=self.terminal_set,
+                events=self.events,
+                root_type=root_type,
+                erc_range=self.erc_range,
+            )
+
+        self.grow_creator = grow_creator
+        self.full_creator = full_creator
+
+    @override
+    def create_individuals(
+        self, n_individuals: int, higher_is_better: bool
+    ) -> List[Tree]:
+        """
+        Initialize the subpopulation individuals using ramped half-and-half method.
+
+        Parameters
+        ----------
+        n_individuals: int
+                number of individuals to create
+
+        higher_is_better: bool
+                determines if the fitness of the created individuals should be minimized or maximized
+
+        Returns
+        -------
+
+        """
+        min_depth, max_depth = self.init_depth
+
+        # if pop size is 100 and we want depths 2,3,4,5,6 then group_size is 10:
+        # 10 'grow' with depth 2, 10 'full' with depth 2, 10 'grow' with depth 3, 10 'full' with depth 3, etc.
+        group_size = int(n_individuals / (2 * (max_depth + 1 - min_depth)))
+
+        if group_size == 0:
+            raise ValueError(
+                "Incompatible population size and init_depth. "
+                "Population size must be "
+                f"at least {2 * (max_depth + 1 - min_depth)}."
+            )
+
+        individuals = []
+
+        for depth in range(min_depth, max_depth + 1):
+            # first create `group_size` individuals using grow method
+            self.grow_creator.init_depth = (min_depth, depth)
+            grown_inds = self.grow_creator.create_individuals(
+                group_size, higher_is_better
+            )
+
+            # then create `group_size` individuals using full method
+            self.full_creator.init_depth = (min_depth, depth)
+            full_inds = self.full_creator.create_individuals(
+                group_size, higher_is_better
+            )
+
+            individuals.extend(grown_inds + full_inds)
+
+        # might need to add a few because 'group_size' may have been a float that was truncated
+        n_missing = n_individuals - len(individuals)
+        if n_missing > 0:
+            missing_inds = self.full_creator.create_individuals(
+                n_missing, higher_is_better
+            )
+            individuals.extend(missing_inds)
+
+        self.created_individuals = individuals
+        return individuals
diff --git a/eckity/creators/gp_creators/ramped_hh.py b/eckity/creators/gp_creators/ramped_hh.py
deleted file mode 100644
index 6d27e9f2..00000000
--- a/eckity/creators/gp_creators/ramped_hh.py
+++ /dev/null
@@ -1,120 +0,0 @@
-from eckity.creators.gp_creators.full import FullCreator
-from eckity.creators.gp_creators.grow import GrowCreator
-from eckity.creators.gp_creators.tree_creator import GPTreeCreator
-from eckity.genetic_encodings.gp.tree.tree_individual import Tree
-from eckity.fitness.gp_fitness import GPFitness
-
-
-class RampedHalfAndHalfCreator(GPTreeCreator):
-	def __init__(self,
-				 grow_creator=None,
-				 full_creator=None,
-				 init_depth=None,
-				 function_set=None,
-				 terminal_set=None,
-				 erc_range=None,
-				 bloat_weight=0.1,
-				 events=None):
-		"""
-		Tree creator that creates trees using the Ramped Half and Half method
-
-		Parameters
-		----------
-		grow_creator: GrowCreator
-			a tree creator that creates trees using the grow method
-
-		full_creator: FullCreator
-			a tree creator that creates trees using the full method
-
-		init_depth : (int, int)
-		Min and max depths of initial random trees. The default is None.
-
-		function_set : list
-			List of functions used as internal nodes in the GP tree. The default is None.
-
-		terminal_set : list
-			List of terminals used in the GP-tree leaves. The default is None.
-
-		erc_range : (float, float)
-			Range of values for ephemeral random constant (ERC). The default is None.
-
-		bloat_weight : float
-			Bloat control weight to punish large trees. Bigger values make a bigger punish.
-
-		events : list
-			List of events related to this class
-		"""
-		super().__init__(init_depth=init_depth,
-						 function_set=function_set,
-						 terminal_set=terminal_set,
-						 erc_range=erc_range,
-						 bloat_weight=bloat_weight,
-						 events=events)
-
-		# assign default creators
-		if grow_creator is None:
-			grow_creator = GrowCreator(init_depth=self.init_depth, function_set=self.function_set,
-									   terminal_set=self.terminal_set, erc_range=self.erc_range, events=self.events)
-		if full_creator is None:
-			full_creator = FullCreator(init_depth=self.init_depth, function_set=self.function_set,
-									   terminal_set=self.terminal_set, erc_range=self.erc_range, events=self.events)
-
-		self.grow_creator, self.full_creator = grow_creator, full_creator
-
-		self.init_method = None  # current creator in use (either grow or full)
-
-	def create_individuals(self, n_individuals, higher_is_better):
-		"""
-		Initialize the subpopulation individuals using ramped half-and-half method.
-
-		Parameters
-		----------
-		n_individuals: int
-			number of individuals to create
-
-		higher_is_better: bool
-			determines if the fitness of the created individuals should be minimized or maximized
-
-		Returns
-		-------
-
-		"""
-
-		min_depth, max_depth = self.init_depth[0], self.init_depth[1]
-
-		# if pop size is 100 and we want depths 2,3,4,5,6 then group_size is 10:
-		# 10 'grow' with depth 2, 10 'full' with depth 2, 10 'grow' with depth 3, 10 'full' with depth 3, etc.
-		group_size = int(n_individuals / (max_depth + 1 - min_depth) / 2)
-
-		individuals = []
-
-		for depth in range(min_depth, max_depth + 1):
-			for i in range(group_size):
-				# as explained above, first create (group_size) individuals using grow method
-				self.init_method = self.grow_creator
-				self._create_individuals(individuals, depth, higher_is_better)
-
-				# then create (group_size) individuals using full method
-				self.init_method = self.full_creator
-				self._create_individuals(individuals, depth, higher_is_better)
-
-		# might need to add a few because 'group_size' may have been a float that was truncated
-		self.init_method = self.full_creator
-		for i in range(n_individuals - len(individuals)):
-			self._create_individuals(individuals, max_depth, higher_is_better)
-
-		# TODO we don't need this event since creators have before/after operator events
-		# self.publish("after_creation")
-
-		self.created_individuals = individuals
-		return individuals
-
-	def _create_individuals(self, individuals, max_depth, higher_is_better):
-		t = Tree(init_depth=self.init_depth, function_set=self.function_set,
-				 terminal_set=self.terminal_set, erc_range=self.erc_range,
-				 fitness=GPFitness(bloat_weight=self.bloat_weight, higher_is_better=higher_is_better))
-		self.create_tree(t, max_depth=max_depth)
-		individuals.append(t)
-
-	def create_tree(self, tree_ind, max_depth):
-		self.init_method.create_tree(tree_ind, max_depth)
diff --git a/eckity/creators/gp_creators/tree_creator.py b/eckity/creators/gp_creators/tree_creator.py
index 6b52f61e..c2acdcef 100644
--- a/eckity/creators/gp_creators/tree_creator.py
+++ b/eckity/creators/gp_creators/tree_creator.py
@@ -1,56 +1,173 @@
+from typing import Any, Callable, Dict, List, Tuple, Union, Optional
+
+from overrides import override
+
+from eckity.base.utils import arity
 from eckity.creators.creator import Creator
-from eckity.fitness.simple_fitness import SimpleFitness
-from eckity.genetic_encodings.gp.tree.functions import f_add, f_sub, f_mul, f_div
 from eckity.fitness.gp_fitness import GPFitness
-from eckity.genetic_encodings.gp.tree.tree_individual import Tree
-
-from abc import abstractmethod
+from eckity.fitness.simple_fitness import SimpleFitness
+from eckity.genetic_encodings.gp import (
+    FunctionNode,
+    TerminalNode,
+    Tree,
+    TreeNode,
+)
+from eckity.genetic_encodings.gp.tree.utils import get_func_types
 
 
 class GPTreeCreator(Creator):
-	def __init__(self,
-				 init_depth=None,
-				 function_set=None,
-				 terminal_set=None,
-				 erc_range=None,
-				 fitness_type=SimpleFitness,
-				 bloat_weight=0.1,
-				 events=None):
-		if events is None:
-			events = ["after_creation"]
-		super().__init__(events, fitness_type)
-
-		if init_depth is None:
-			init_depth = (2, 4)
-
-		if function_set is None:
-			function_set = [f_add, f_sub, f_mul, f_div]
-
-		if terminal_set is None:
-			terminal_set = ['x', 'y', 'z', 0, 1, -1]
-
-		self.init_depth = init_depth
-		self.function_set = function_set
-		self.terminal_set = terminal_set
-		self.erc_range = erc_range
-		self.bloat_weight = bloat_weight
-
-	def create_individuals(self, n_individuals, higher_is_better):
-		individuals = [Tree(function_set=self.function_set,
-							terminal_set=self.terminal_set,
-							erc_range=self.erc_range,
-							fitness=GPFitness(bloat_weight=self.bloat_weight, higher_is_better=higher_is_better),
-							init_depth=self.init_depth)
-					   for _ in range(n_individuals)]
-		for ind in individuals:
-			self.create_tree(ind, self.init_depth[1])
-		self.created_individuals = individuals
-
-		# TODO do we need this event? creator is instance of Operator and already has before/after operator
-		# self.publish("after_creation")
-
-		return individuals
-
-	@abstractmethod
-	def create_tree(self, tree_ind, max_depth):
-		pass
+    def __init__(
+        self,
+        init_depth: Tuple[int, int] = None,
+        function_set: List[Callable] = None,
+        terminal_set: Union[Dict[Any, type], List[Any]] = None,
+        erc_range: Union[Tuple[int, int], Tuple[float, float]] = None,
+        fitness_type: type = SimpleFitness,
+        bloat_weight: float = 0.0,
+        events: List[str] = None,
+        root_type: Optional[type] = None,
+        update_parents: bool = False
+    ):
+        if events is None:
+            events = ["after_creation"]
+        super().__init__(events, fitness_type)
+
+        if init_depth is None:
+            init_depth = (2, 4)
+
+        if function_set is None:
+            raise ValueError("function_set must be provided")
+
+        if terminal_set is None:
+            raise ValueError("terminal_set must be provided")
+
+        self.init_depth = init_depth
+        self.function_set = function_set
+        self.terminal_set = terminal_set
+        self.bloat_weight = bloat_weight
+        self.root_type = root_type
+        self.update_parents = update_parents
+        self.erc_range = erc_range
+
+    @override
+    def create_individuals(
+        self, n_individuals: int, higher_is_better: bool
+    ) -> List[Tree]:
+        individuals = [
+            Tree(
+                function_set=self.function_set,
+                terminal_set=self.terminal_set,
+                fitness=GPFitness(
+                    bloat_weight=self.bloat_weight,
+                    higher_is_better=higher_is_better,
+                ),
+                root_type=self.root_type,
+                update_parents=self.update_parents,
+                erc_range=self.erc_range,
+            )
+            for _ in range(n_individuals)
+        ]
+        for ind in individuals:
+            self.create_tree(
+                ind.tree,
+                ind.random_function,
+                ind.random_terminal,
+                node_type=self.root_type,
+            )
+        self.created_individuals = individuals
+        return individuals
+
+    def create_tree(
+        self,
+        tree: List[TreeNode],
+        random_function: Callable[[type], Optional[FunctionNode]],
+        random_terminal: Callable[[type], Optional[TerminalNode]],
+        depth: int = 0,
+        node_type: Optional[type] = None,
+    ) -> None:
+        """
+        Recursively build the tree representation
+        of an existing Tree individual.
+        
+        (This method is not abstract as it is not required in HalfCreator)
+
+        Parameters
+        ----------
+        tree_ind : Tree
+            Individual to create the tree representation for
+        """
+        pass
+
+    def _add_children(
+        self,
+        tree: List[TreeNode],
+        fn_node: FunctionNode,
+        random_function: Callable[[type], Optional[FunctionNode]],
+        random_terminal: Callable[[type], Optional[TerminalNode]],
+        depth: int,
+    ) -> None:
+        """
+        Recursively add children to a function node.
+
+        Parameters
+        ----------
+        tree : List[TreeNode]
+            Total representation of the tree.
+        fn_node : FunctionNode
+            Function node, parent of the children to be created.
+        random_function : Callable[[type], Optional[FunctionNode]]
+            Random FunctionNode generator.
+        random_terminal : Callable[[type], Optional[TerminalNode]]
+            Random TerminalNode generator.
+        depth : int
+            current depth of the tree
+        """
+        func_types = get_func_types(fn_node.function)
+        for i in range(arity(fn_node.function)):
+            self.create_tree(
+                tree,
+                random_function,
+                random_terminal,
+                depth=depth + 1,
+                node_type=func_types[i],
+            )
+
+    def _assert_node_created(
+        self,
+        node: Optional[TreeNode],
+        node_type: Optional[type] = None
+    ) -> None:
+        """
+        Assert that a TreeNode was created successfully.
+
+        Parameters
+        ----------
+        node : Optional[TreeNode]
+            Generated tree node.
+        node_type : Optional[type], optional
+            Generated tree node type, by default None
+
+        Raises
+        ------
+        ValueError
+            Raised if a node was not generated successfully.
+        """
+        if node is None:
+            # optionally add type info for typed case
+            type_info = (
+                f"with type {node_type}" if node_type is not None else ""
+            )
+
+            # optionally add erc_range info if it was defined
+            erc_info = (
+                f"erc range ({self.erc_range}) ,"
+                if self.erc_range is not None
+                else ""
+            )
+
+            raise ValueError(
+                f"Could not generate node {type_info} "
+                f"due to a mismatch in terminals ({self.terminal_set}), "
+                f"functions ({self.function_set}), {erc_info} "
+                f"or init_depth ({self.init_depth}) configuration."
+            )
diff --git a/eckity/evaluators/__init__.py b/eckity/evaluators/__init__.py
index e69de29b..3e366f19 100644
--- a/eckity/evaluators/__init__.py
+++ b/eckity/evaluators/__init__.py
@@ -0,0 +1,5 @@
+from .individual_evaluator import IndividualEvaluator
+from .simple_individual_evaluator import SimpleIndividualEvaluator
+
+from .population_evaluator import PopulationEvaluator
+from .simple_population_evaluator import SimplePopulationEvaluator
diff --git a/eckity/evaluators/simple_individual_evaluator.py b/eckity/evaluators/simple_individual_evaluator.py
index 05f0d4df..bddeb7dc 100644
--- a/eckity/evaluators/simple_individual_evaluator.py
+++ b/eckity/evaluators/simple_individual_evaluator.py
@@ -6,51 +6,54 @@
 
 
 class SimpleIndividualEvaluator(IndividualEvaluator):
-	"""
-	Computes fitness value for the given individuals.
-	In simple case, evaluates each individual separately.
-	You will need to extend this class with your user-defined fitness evaluation methods.
-	"""
-
-	@overrides
-	def evaluate(self, individual, environment_individuals):
-		"""
-		Updates the fitness score of the given individuals, then returns the best individual
-
-		Parameters
-		----------
-		individual: Individual
-			the current individual to evaluate its fitness
-
-		environment_individuals: list of Individuals
-			the individuals in the current individual's environment
-			those individuals will affect the current individual's fitness
-			(not used in simple case)
-
-		Returns
-		-------
-		Individual
-			the individual with the best fitness out of the given individuals
-		"""
-		super().evaluate(individual, environment_individuals)
-		fitness_score = self.evaluate_individual(individual)
-		individual.fitness.set_fitness(fitness_score)
-		return individual
-
-	@abstractmethod
-	def evaluate_individual(self, individual):
-		"""
-		Evaluate the fitness score for the given individual.
-		This function must be implemented by subclasses of this class (user-defined evaluators)
-
-		Parameters
-		----------
-		individual: Individual
-			The individual to compute the fitness for
-
-		Returns
-		-------
-		float
-			The evaluated fitness value for the given individual
-		"""
-		raise ValueError("evaluate_individual is an abstract method in SimpleIndividualEvaluator")
+    """
+    Computes fitness value for the given individuals.
+    All simple classes assume only one sub-population.
+    Evaluates each individual separately.
+    You will need to extend this class with your fitness evaluation methods.
+    """
+
+    @overrides
+    def evaluate(self, individual, environment_individuals):
+        """
+        Updates the fitness score of the given individuals, then returns the best individual
+
+        Parameters
+        ----------
+        individual: Individual
+                the current individual to evaluate its fitness
+
+        environment_individuals: list of Individuals
+                the individuals in the current individual's environment
+                those individuals will affect the current individual's fitness
+                (not used in simple case)
+
+        Returns
+        -------
+        Individual
+                the individual with the best fitness of the given individuals
+        """
+        super().evaluate(individual, environment_individuals)
+        fitness_score = self.evaluate_individual(individual)
+        individual.fitness.set_fitness(fitness_score)
+        return individual
+
+    @abstractmethod
+    def evaluate_individual(self, individual):
+        """
+        Evaluate the fitness score for the given individual.
+        This function must be implemented by subclasses of this class
+
+        Parameters
+        ----------
+        individual: Individual
+                The individual to compute the fitness for
+
+        Returns
+        -------
+        float
+                The evaluated fitness value for the given individual
+        """
+        raise ValueError(
+            "evaluate_individual is an abstract method in SimpleIndividualEvaluator"
+        )
diff --git a/eckity/evaluators/simple_population_evaluator.py b/eckity/evaluators/simple_population_evaluator.py
index 4d771eb8..e0f1b54c 100644
--- a/eckity/evaluators/simple_population_evaluator.py
+++ b/eckity/evaluators/simple_population_evaluator.py
@@ -7,39 +7,67 @@
 
 
 class SimplePopulationEvaluator(PopulationEvaluator):
-	@overrides
-	def _evaluate(self, population):
-		"""
-		Updates the fitness score of the given individuals, then returns the best individual
-
-		Parameters
-		----------
-		population:
-			the population of the evolutionary experiment
-
-		Returns
-		-------
-		individual
-			the individual with the best fitness out of the given individuals
-		"""
-		super()._evaluate(population)
-		for sub_population in population.sub_populations:
-			sub_population = population.sub_populations[0]
-			sp_eval: IndividualEvaluator = sub_population.evaluator
-			eval_results = self.executor.map(sp_eval.evaluate_individual, sub_population.individuals)
-			for ind, fitness_score in zip(sub_population.individuals, eval_results):
-				ind.fitness.set_fitness(fitness_score)
-
-
-		# only one subpopulation in simple case
-		individuals = population.sub_populations[0].individuals
-
-		best_ind: Individual = population.sub_populations[0].individuals[0]
-		best_fitness: Fitness = best_ind.fitness
-
-		for ind in individuals[1:]:
-			if ind.fitness.better_than(ind, best_fitness, best_ind):
-				best_ind = ind
-				best_fitness = ind.fitness
-
-		return best_ind
+    """
+    Computes fitness value for the whole population.
+    All simple classes assume only one sub-population.
+    """
+
+    def __init__(self, executor_method="map"):
+        super().__init__()
+        if executor_method not in ["map", "submit"]:
+            raise ValueError(
+                f'executor_method must be either "map" or "submit", got {executor_method}'
+            )
+        self.executor_method = executor_method
+
+    @overrides
+    def _evaluate(self, population):
+        """
+        Updates the fitness score of the given individuals, then returns the best individual
+
+        Parameters
+        ----------
+        population:
+                the population of the evolutionary experiment
+
+        Returns
+        -------
+        individual
+                the individual with the best fitness of the given individuals
+        """
+        super()._evaluate(population)
+
+        if len(population.sub_populations) != 1:
+            raise ValueError(
+                f"SimpleBreeder can only handle one subpopulation. \
+                    Got: {len(population.sub_populations)}"
+            )
+        sub_population = population.sub_populations[0]
+        individuals = sub_population.individuals
+        sp_eval: IndividualEvaluator = sub_population.evaluator
+
+        if self.executor_method == "submit":
+            eval_futures = [
+                self.executor.submit(
+                    sp_eval.evaluate, ind, sub_population.individuals
+                )
+                for ind in sub_population.individuals
+            ]
+            eval_results = [future.result() for future in eval_futures]
+        elif self.executor_method == "map":
+            eval_results = self.executor.map(
+                sp_eval.evaluate_individual, sub_population.individuals
+            )
+        for ind, fitness_score in zip(
+            sub_population.individuals, eval_results
+        ):
+            ind.fitness.set_fitness(fitness_score)
+        best_ind: Individual = individuals[0]
+        best_fitness: Fitness = best_ind.fitness
+
+        for ind in individuals[1:]:
+            if ind.fitness.better_than(ind, best_fitness, best_ind):
+                best_ind = ind
+                best_fitness = ind.fitness
+
+        return best_ind
diff --git a/eckity/event_based_operator.py b/eckity/event_based_operator.py
index 9461ea25..fed6f5c5 100644
--- a/eckity/event_based_operator.py
+++ b/eckity/event_based_operator.py
@@ -2,12 +2,13 @@
 This module implements the Operator class
 """
 
-from abc import abstractmethod
+from abc import ABC, abstractmethod
+from typing import Any
 
 from eckity.before_after_publisher import BeforeAfterPublisher
 
 
-class Operator(BeforeAfterPublisher):
+class Operator(BeforeAfterPublisher, ABC):
     def __init__(self, arity=1, events=None, event_names=None):
         super().__init__(events=events, event_names=event_names)
         self.applied_individuals = None
@@ -34,7 +35,24 @@ def act(self, payload=None):
         -------
         the return value of the operator implemented in the sub-class
         """
-        return self.act_and_publish_before_after(lambda: self.apply_operator(payload))
+        self._assert_arity(payload)
+        return self.act_and_publish_before_after(
+            lambda: self.apply_operator(payload)
+        )
+
+    def _assert_arity(self, payload: Any) -> None:
+        if not isinstance(payload, list):
+            if self.arity != 1:
+                raise ValueError(
+                    f"Received payload of type {type(payload)}, "
+                    f"expected arity of 1 but got {self.arity}."
+                )
+
+        elif (n_individuals := len(payload)) != self.arity:
+            raise ValueError(
+                f"Expected {self.arity} individuals,"
+                f"but received {n_individuals}."
+            )
 
     def get_operator_arity(self):
         """
diff --git a/eckity/fitness/__init__.py b/eckity/fitness/__init__.py
index e69de29b..29eb3323 100644
--- a/eckity/fitness/__init__.py
+++ b/eckity/fitness/__init__.py
@@ -0,0 +1,3 @@
+from .fitness import Fitness
+from .simple_fitness import SimpleFitness
+from .gp_fitness import GPFitness
diff --git a/eckity/fitness/fitness.py b/eckity/fitness/fitness.py
index f7bfd5c4..878c9689 100644
--- a/eckity/fitness/fitness.py
+++ b/eckity/fitness/fitness.py
@@ -2,47 +2,42 @@
 This module implements the class `Fitness`
 """
 
-from abc import abstractmethod
+from abc import abstractmethod, ABC
 
 
-class Fitness:
+class Fitness(ABC):
     """
     This class is responsible for handling the fitness score of some Individual
     (checking if fitness is evaluated, comparing fitness scores with other individuals etc.)
 
-    context: list of Individuals
-        individuals involved in calculating the fitness (co-evolution)
-
-    trials: list of floats
-        fitness results for previous trials done to calculate fitness (co-evolution)
-
-    _is_evaluated: bool
+    is_evaluated: bool
         declares if fitness score is evaluated and updated in the current generation
 
-    is_relative_fitness: bool
-        declares whether the fitness score is absolute or relative
-
-    should_cache_between_gens: bool
-        declares whether the fitness score should reset at the end of each generation
-
     higher_is_better: bool
         declares the fitness direction.
         i.e., if it should be minimized or maximized
+
+    cache: bool
+        declares whether the fitness score should reset at the end of each generation
+
+    is_relative_fitness: bool
+        declares whether the fitness score is absolute or relative
     """
-    def __init__(self,
-                 context=None,
-                 trials=None,
-                 is_evaluated=False,
-                 is_relative_fitness=False,
-                 should_cache_between_gens=True,
-                 higher_is_better=False):
-        self.context = context
-        self.trials = trials
+
+    def __init__(
+        self,
+        is_evaluated: bool = False,
+        higher_is_better: bool = None,
+        is_relative_fitness: bool = False,
+        cache: bool = False,
+    ):
         self._is_evaluated = is_evaluated
         self.is_relative_fitness = is_relative_fitness
-        self.should_cache_between_gens = False if is_relative_fitness else should_cache_between_gens
+        self.cache = False if is_relative_fitness else cache
+
+        if higher_is_better is None:
+            raise ValueError("higher_is_better must be set to True/False")
         self.higher_is_better = higher_is_better
-        self.optimal_fitness = 1 if higher_is_better else 0
 
     @abstractmethod
     def get_pure_fitness(self):
diff --git a/eckity/fitness/gp_fitness.py b/eckity/fitness/gp_fitness.py
index 9e9ded10..cfc50e9a 100644
--- a/eckity/fitness/gp_fitness.py
+++ b/eckity/fitness/gp_fitness.py
@@ -22,14 +22,30 @@ class GPFitness(SimpleFitness):
         declares the fitness direction.
         i.e., if it should be minimized or maximized
 
+    cache: bool
+        declares whether the fitness score should reset at the end of each generation
+
+    is_relative_fitness: bool
+        declares whether the fitness score is absolute or relative
+
     bloat_weight: float
         the weight of the bloat control fitness reduction
     """
-    def __init__(self,
-                 fitness: float = None,
-                 higher_is_better=False,
-                 bloat_weight=0.1):
-        super().__init__(fitness=fitness, higher_is_better=higher_is_better)
+
+    def __init__(
+        self,
+        fitness: float = None,
+        higher_is_better: bool = False,
+        cache: bool = False,
+        is_relative_fitness: bool = False,
+        bloat_weight: float = 0.1,
+    ):
+        super().__init__(
+            fitness=fitness,
+            higher_is_better=higher_is_better,
+            cache=cache,
+            is_relative_fitness=is_relative_fitness,
+        )
         self.bloat_weight = bloat_weight
 
     @overrides
@@ -47,11 +63,16 @@ def get_augmented_fitness(self, individual):
         float
             augmented fitness score after applying bloat control
         """
-        if not self.is_fitness_evaluated():
-            raise ValueError('Fitness not evaluated yet')
+        score = self.get_pure_fitness()
+
+        if self.bloat_weight == 0:
+            # no bloat
+            return score
 
         # subtract bloat value from the fitness score if it should be maximized,
         # otherwise add bloat value to fitness score
-        return self.fitness - self.bloat_weight * individual.size() \
-            if self.higher_is_better \
-            else self.fitness + self.bloat_weight * individual.size()
+        return (
+            score - self.bloat_weight * individual.size()
+            if self.higher_is_better
+            else score + self.bloat_weight * individual.size()
+        )
diff --git a/eckity/fitness/simple_fitness.py b/eckity/fitness/simple_fitness.py
index decdbe57..adb86b30 100644
--- a/eckity/fitness/simple_fitness.py
+++ b/eckity/fitness/simple_fitness.py
@@ -1,7 +1,4 @@
-from overrides import overrides
-"""
-This module implements the class `SimpleFitness`
-"""
+from overrides import override
 
 from eckity.fitness.fitness import Fitness
 
@@ -11,6 +8,7 @@ class SimpleFitness(Fitness):
     This class is responsible for handling the fitness score of some Individual
     (checking if fitness is evaluated, comparing fitness scores with other individuals etc.)
 
+    All simple classes assume only one sub-population.
     In the simple case, each individual holds a float fitness score
 
     fitness: float
@@ -19,12 +17,28 @@ class SimpleFitness(Fitness):
     higher_is_better: bool
         declares the fitness direction.
         i.e., if it should be minimized or maximized
+
+    cache: bool
+        declares whether the fitness score should reset at the end of each generation
+
+    is_relative_fitness: bool
+        declares whether the fitness score is absolute or relative
     """
-    def __init__(self,
-                 fitness: float = None,
-                 higher_is_better=False):
+
+    def __init__(
+        self,
+        fitness: float = None,
+        higher_is_better: bool = False,
+        cache: bool = False,
+        is_relative_fitness: bool = False,
+    ):
         is_evaluated = fitness is not None
-        super().__init__(higher_is_better=higher_is_better, is_evaluated=is_evaluated)
+        super().__init__(
+            higher_is_better=higher_is_better,
+            is_evaluated=is_evaluated,
+            cache=cache,
+            is_relative_fitness=is_relative_fitness,
+        )
         self.fitness: float = fitness
 
     def set_fitness(self, fitness):
@@ -39,7 +53,7 @@ def set_fitness(self, fitness):
         self.fitness = fitness
         self._is_evaluated = True
 
-    @overrides
+    @override
     def get_pure_fitness(self):
         """
         Returns the pure fitness score of the individual (before applying balancing methods like bloat control)
@@ -50,10 +64,10 @@ def get_pure_fitness(self):
             fitness score of the individual
         """
         if not self._is_evaluated:
-            raise ValueError('Fitness not evaluated yet')
+            raise ValueError("Fitness not evaluated yet")
         return self.fitness
 
-    @overrides
+    @override
     def set_not_evaluated(self):
         """
         Set this fitness score status to be not evaluated
@@ -71,11 +85,13 @@ def check_comparable_fitness_scores(self, other_fitness):
             True if fitness scores are comparable, False otherwise
         """
         if not isinstance(other_fitness, SimpleFitness):
-            raise TypeError('Expected SimpleFitness object in better_than, got', type(other_fitness))
+            raise TypeError(
+                "Expected SimpleFitness object in better_than, got", type(other_fitness)
+            )
         if not self.is_fitness_evaluated() or not other_fitness.is_fitness_evaluated():
-            raise ValueError('Fitness scores must be evaluated before comparison')
+            raise ValueError("Fitness scores must be evaluated before comparison")
 
-    @overrides
+    @override
     def better_than(self, ind, other_fitness, other_ind):
         """
         Compares between the current fitness of the individual `ind` to the fitness score `other_fitness` of `other_ind`
@@ -98,11 +114,15 @@ def better_than(self, ind, other_fitness, other_ind):
             True if this fitness score is better than the `other` fitness score, False otherwise
         """
         self.check_comparable_fitness_scores(other_fitness)
-        return self.get_augmented_fitness(ind) > other_fitness.get_augmented_fitness(other_ind) \
-            if self.higher_is_better \
-            else self.get_augmented_fitness(ind) < other_fitness.get_augmented_fitness(other_ind)
-
-    @overrides
+        return (
+            self.get_augmented_fitness(ind)
+            > other_fitness.get_augmented_fitness(other_ind)
+            if self.higher_is_better
+            else self.get_augmented_fitness(ind)
+            < other_fitness.get_augmented_fitness(other_ind)
+        )
+
+    @override
     def equal_to(self, ind, other_fitness, other_ind):
         """
         Compares between the current fitness of the individual `ind` to the fitness score `other_fitness` of `other_ind`
@@ -125,11 +145,13 @@ def equal_to(self, ind, other_fitness, other_ind):
             True if this fitness score is equal to the `other` fitness score, False otherwise
         """
         self.check_comparable_fitness_scores(other_fitness)
-        return self.get_augmented_fitness(ind) == other_fitness.get_augmented_fitness(other_ind)
+        return self.get_augmented_fitness(ind) == other_fitness.get_augmented_fitness(
+            other_ind
+        )
 
     def __getstate__(self):
         state = self.__dict__.copy()
-        if not self.should_cache_between_gens:
-            state['_is_evaluated'] = False
-            state['fitness'] = None
+        if not self.cache:
+            state["_is_evaluated"] = False
+            state["fitness"] = None
         return state
diff --git a/eckity/genetic_encodings/ga/__init__.py b/eckity/genetic_encodings/ga/__init__.py
index e69de29b..917380a3 100644
--- a/eckity/genetic_encodings/ga/__init__.py
+++ b/eckity/genetic_encodings/ga/__init__.py
@@ -0,0 +1,4 @@
+from .bit_string_vector import BitStringVector
+from .float_vector import FloatVector
+from .int_vector import IntVector
+from .vector_individual import Vector
diff --git a/eckity/genetic_encodings/ga/bit_string_vector.py b/eckity/genetic_encodings/ga/bit_string_vector.py
index 623c1ad3..d18bbc25 100644
--- a/eckity/genetic_encodings/ga/bit_string_vector.py
+++ b/eckity/genetic_encodings/ga/bit_string_vector.py
@@ -30,11 +30,13 @@ def __init__(self,
                  fitness,
                  length,
                  bounds=(0, 1),
-                 vector=None):
+                 vector=None,
+                 update_parents=False):
         super().__init__(fitness=fitness,
                          length=length,
                          bounds=bounds,
-                         vector=vector)
+                         vector=vector,
+                         update_parents=update_parents)
 
     def get_random_number_in_bounds(self, index):
         """
diff --git a/eckity/genetic_encodings/ga/float_vector.py b/eckity/genetic_encodings/ga/float_vector.py
index 64d6d6ee..d9186f62 100644
--- a/eckity/genetic_encodings/ga/float_vector.py
+++ b/eckity/genetic_encodings/ga/float_vector.py
@@ -23,12 +23,22 @@ class FloatVector(Vector):
     bounds : tuple or list of tuples
         Min/Max values for each vector cell (if of length n), or the minimum and maximum (if of length 1).
     """
-    def __init__(self,
-                 fitness,
-                 length,
-                 bounds=(0.0, 1.0),
-                 vector=None):
-        super().__init__(fitness=fitness, length=length, bounds=bounds, vector=vector)
+
+    def __init__(
+        self,
+        fitness,
+        length,
+        bounds=(0.0, 1.0),
+        vector=None,
+        update_parents=False,
+    ):
+        super().__init__(
+            fitness=fitness,
+            length=length,
+            bounds=bounds,
+            vector=vector,
+            update_parents=update_parents,
+        )
 
     def get_random_number_in_bounds(self, index):
         """
@@ -68,4 +78,5 @@ def get_random_number_with_gauss(self, index, mu, sigma):
         """
         return self.cell_value(index) + gauss(mu, sigma)
 
+
 # end class float vector
diff --git a/eckity/genetic_encodings/ga/int_vector.py b/eckity/genetic_encodings/ga/int_vector.py
index 39dcd39e..aa99d3fd 100644
--- a/eckity/genetic_encodings/ga/int_vector.py
+++ b/eckity/genetic_encodings/ga/int_vector.py
@@ -6,8 +6,8 @@
 
 from eckity.genetic_encodings.ga.vector_individual import Vector
 
-MIN_BOUND = 2 ** 31 - 1
-MAX_BOUND = -2 ** 31
+MIN_BOUND = 2**31 - 1
+MAX_BOUND = -(2**31)
 
 
 class IntVector(Vector):
@@ -26,12 +26,22 @@ class IntVector(Vector):
     bounds : tuple or list of tuples
         Min/Max values for each vector cell (if of length n), or the minimum and maximum (if of length 1).
     """
-    def __init__(self,
-                 fitness,
-                 length,
-                 bounds=(MIN_BOUND, MAX_BOUND),
-                 vector=None):
-        super().__init__(fitness, length=length, bounds=bounds, vector=vector)
+
+    def __init__(
+        self,
+        fitness,
+        length,
+        bounds=(MIN_BOUND, MAX_BOUND),
+        vector=None,
+        update_parents=False,
+    ):
+        super().__init__(
+            fitness,
+            length=length,
+            bounds=bounds,
+            vector=vector,
+            update_parents=update_parents,
+        )
 
     def get_random_number_in_bounds(self, index):
         """
@@ -51,4 +61,5 @@ def get_random_number_in_bounds(self, index):
             return randint(self.bounds[0], self.bounds[1])
         return randint(self.bounds[index][0], self.bounds[index][1])
 
+
 # end class int vector
diff --git a/eckity/genetic_encodings/ga/vector_individual.py b/eckity/genetic_encodings/ga/vector_individual.py
index 00080396..8e6939e9 100644
--- a/eckity/genetic_encodings/ga/vector_individual.py
+++ b/eckity/genetic_encodings/ga/vector_individual.py
@@ -1,11 +1,15 @@
 """
 This module implements the vector class.
 """
+
 from abc import abstractmethod
 from random import randint
+import logging
 
 from eckity.individual import Individual
 
+logger = logging.getLogger(__name__)
+
 
 class Vector(Individual):
     """
@@ -24,28 +28,33 @@ class Vector(Individual):
         Min/Max values for each vector cell (if of length n), or the minimum and maximum (if of length 1).
     """
 
-    def __init__(self,
-                 fitness,
-                 bounds,
-                 length,
-                 vector=None):
-        super().__init__(fitness)
+    def __init__(
+        self, fitness, bounds, length, vector=None, update_parents=False
+    ):
+        super().__init__(fitness, update_parents=update_parents)
 
-        if (type(bounds) == tuple and len(bounds) != 2) \
-                or (type(bounds) == list and len(bounds) != length):
-            raise ValueError(f'Bounds must be either a tuple of size 2 or a list of {length} tuples')
+        if (type(bounds) == tuple and len(bounds) != 2) or (
+            type(bounds) == list and len(bounds) != length
+        ):
+            raise ValueError(
+                f"Bounds must be either a tuple of size 2 or a list of {length} tuples"
+            )
 
         self.bounds = bounds
         self.length = length
-        
+
         if vector is None:
             self.vector = []
-        
+
         else:
             if not isinstance(vector, list):
-                raise ValueError(f'Expected vector argument in Vector constructor to be a list, got {type(vector)}')
+                raise ValueError(
+                    f"Expected vector argument in Vector constructor to be a list, got {type(vector)}"
+                )
             if len(vector) != length:
-                raise ValueError(f'Expected vector argument in Vector constructor to be of length {length}, got {len(vector)}')
+                raise ValueError(
+                    f"Expected vector argument in Vector constructor to be of length {length}, got {len(vector)}"
+                )
             self.vector = vector
 
     def size(self):
@@ -81,10 +90,14 @@ def check_if_in_bounds(self):
         """
         for i in range(self.size()):
             if len(self.bounds) == 2:
-                if (self.vector[i] < self.bounds[0]) or (self.vector[i] > self.bounds[1]):
+                if (self.vector[i] < self.bounds[0]) or (
+                    self.vector[i] > self.bounds[1]
+                ):
                     return False
             else:
-                if (self.vector[i] < self.bounds[i][0]) or (self.vector[i] > self.bounds[i][1]):
+                if (self.vector[i] < self.bounds[i][0]) or (
+                    self.vector[i] > self.bounds[i][1]
+                ):
                     return False
         return True
 
@@ -149,7 +162,7 @@ def random_vector_part(self):
         # todo add tests to make sure this logic works
         rnd_i = randint(0, self.size() - 1)
         end_i = randint(rnd_i, self.size() - 1)
-        return self.vector[rnd_i:end_i + 1]
+        return self.vector[rnd_i : end_i + 1]
 
     def replace_vector_part_random(self, inserted_part):
         """
@@ -165,7 +178,9 @@ def replace_vector_part_random(self, inserted_part):
         list
             previous vector part of this vector genome
         """
-        index = randint(0, self.size() - len(inserted_part))  # select a random index
+        index = randint(
+            0, self.size() - len(inserted_part)
+        )  # select a random index
         end_i = index + len(inserted_part)
         replaced_part = self.vector[index:end_i]
         self.vector = self.vector[:index] + inserted_part + self.vector[end_i:]
@@ -190,7 +205,9 @@ def replace_vector_part(self, inserted_part, start_index):
         """
         end_i = start_index + len(inserted_part)
         replaced_part = self.vector[start_index:end_i]
-        self.vector = self.vector[:start_index] + inserted_part + self.vector[end_i:]
+        self.vector = (
+            self.vector[:start_index] + inserted_part + self.vector[end_i:]
+        )
         return replaced_part
 
     def get_vector_part(self, index, end_i):
@@ -261,7 +278,9 @@ def get_random_number_in_bounds(self, index):
         object
             vector cell value
         """
-        raise NotImplementedError("get_random_number is an abstract method in vector individual")
+        raise NotImplementedError(
+            "get_random_number is an abstract method in vector individual"
+        )
 
     def execute(self, *args, **kwargs):
         """
@@ -291,6 +310,7 @@ def show(self):
         -------
         None.
         """
-        print(self.vector)
+        logger.debug(self.vector)
+
 
 # end class Vector
diff --git a/eckity/genetic_encodings/gp/__init__.py b/eckity/genetic_encodings/gp/__init__.py
index e69de29b..8c7c485b 100644
--- a/eckity/genetic_encodings/gp/__init__.py
+++ b/eckity/genetic_encodings/gp/__init__.py
@@ -0,0 +1,2 @@
+from .tree.tree_individual import Tree
+from .tree.tree_node import TreeNode, FunctionNode, TerminalNode
diff --git a/eckity/genetic_encodings/gp/tree/tree_individual.py b/eckity/genetic_encodings/gp/tree/tree_individual.py
index 55eb6628..58fa8d44 100644
--- a/eckity/genetic_encodings/gp/tree/tree_individual.py
+++ b/eckity/genetic_encodings/gp/tree/tree_individual.py
@@ -2,15 +2,24 @@
 This module implements the tree class.
 """
 
-import numpy as np
+import logging
+import random
 from numbers import Number
-from random import randint, uniform, random
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union
 
-from eckity.base.utils import arity
-from eckity.genetic_encodings.gp.tree.utils import _generate_args
+import numpy as np
 
+from eckity.fitness import Fitness, GPFitness
+from eckity.genetic_encodings.gp.tree.tree_node import (
+    FunctionNode,
+    TerminalNode,
+    TreeNode,
+)
 from eckity.individual import Individual
-from eckity.genetic_encodings.gp.tree.functions import f_add, f_sub, f_mul, f_div
+
+from .utils import generate_args, get_func_types, get_return_type
+
+logger = logging.getLogger(__name__)
 
 
 class Tree(Individual):
@@ -19,46 +28,75 @@ class Tree(Individual):
     It is represented by a list of nodes in depth-first order.
     There are two types of nodes: functions and terminals.
 
-    (tree is not meant as a stand-alone -- parameters are supplied through the call from the Tree Creators)
-
-    Parameters
-    ----------
-    init_depth : (int, int)
-        Min and max depths of initial random trees. The default is None.
-        
-    function_set : list
-        List of functions used as internal nodes in the GP tree. The default is None.
-        
-    terminal_set : list
-        List of terminals used in the GP-tree leaves. The default is None.
-        
-    erc_range : (float, float)
-        Range of values for ephemeral random constant (ERC). The default is None.
+    (tree is not meant as a stand-alone,
+    parameters are supplied through the call from the Tree Creators)
     """
-    def __init__(self,
-                 fitness,
-                 function_set=None,
-                 terminal_set=None,
-                 erc_range=None,
-                 init_depth=(1, 2)):
-        super().__init__(fitness)
-        if function_set is None:
-            function_set = [f_add, f_sub, f_mul, f_div]
 
-        if terminal_set is None:
-            terminal_set = ['x', 'y', 'z', 0, 1, -1]
+    def __init__(
+        self,
+        fitness: Fitness = GPFitness(),
+        function_set: List[Callable] = None,
+        terminal_set: Union[Dict[Any, type], List[Any]] = None,
+        tree: List[TreeNode] = None,
+        erc_range: Optional[
+            Union[Tuple[float, float], Tuple[int, int]]
+        ] = None,
+        root_type: Optional[type] = None,
+        update_parents: bool = False
+    ):
+        """
+        GP Tree Individual.
+
+        Parameters
+        ----------
+        fitness : Fitness
+            Manages fitness state, by default GPFitness
+        function_set: List[Callable], default=None
+            List of functions used as internal nodes in the GP tree.
+        terminal_set : Union[Dict[Any, type], List[Any]], optional
+            Mapping of terminal nodes and their types.
+            In the untyped case, all types are None.
+            Lists are treated as untyped, and will be assigned None.
+        tree : List[TreeNode], optional
+            Actual tree representation, by default None
+        erc_range : tuple of float or int, optional
+            Range of Ephemeral random constant values, by default None
+        root_type: type, optional
+            Root node type, by default None
+
+        Raises
+        ------
+        ValueError
+            If typed function is used with untyped terminals.
+        """
+        super().__init__(fitness, update_parents=update_parents)
+
+        self.erc_range = erc_range
+
+        function_set, terminal_set = self._handle_input_types(
+            function_set, terminal_set, root_type
+        )
 
         self.function_set = function_set
         self.terminal_set = terminal_set
-        self.n_terminals = len(terminal_set)
-        self.arity = dict([(func, arity(func)) for func in self.function_set])
-        self.vars = [t for t in terminal_set if not isinstance(t, Number)]
-        self.erc_range = erc_range
-        self.n_functions = len(self.function_set)
-        self.init_depth = init_depth
-        self.tree = []
 
-    def size(self):
+        # actual tree representation
+        if tree is None:
+            tree = []
+        self.tree = tree
+
+        # this is the type of the execution result of the program (tree)
+        self.root_type = root_type
+
+    @property
+    def root(self) -> TreeNode:
+        return self.tree[0]
+
+    @property
+    def erc_type(self) -> Optional[Union[int, float]]:
+        return type(self.erc_range[0]) if self.erc_range else None
+
+    def size(self) -> int:
         """
         Compute size of tree.
 
@@ -69,28 +107,43 @@ def size(self):
         """
         return len(self.tree)
 
-    def add_tree(self, node):
-        self.tree.append(node)
-
-    def empty_tree(self):
-        self.tree = []
+    def add_tree(self, node: TreeNode) -> None:
+        """Add a node to the tree following the defined type constrains"""
+        if (
+            self.size() == 0 and node.node_type == self.root_type
+        ) or self._should_add([0], node):
+            self.tree.append(node)
+        else:
+            raise ValueError(f"Could not add node {node} to tree {self.tree}")
 
-    def _depth(self, pos, depth):
-        """Recursively compute depth 
-           (pos is a size-1 list so as to pass "by reference" on successive recursive calls)."""
+    def _should_add(self, pos: int, new_node: TreeNode) -> bool:
+        """
+        Recursively find the parent function of the new node and check it
+        matches by type to the expected parameter. (pos is a size-1 list
+        so as to pass "by reference" on successive recursive calls).
+        """
+        if (
+            pos[0] == self.size()
+        ):  # reached the place the new node should be placed
+            return True
 
         node = self.tree[pos[0]]
-
-        depths = []
-        if node in self.function_set:
-            for i in range(self.arity[node]):
+        res = None
+        if isinstance(node, FunctionNode):
+            func_types = get_func_types(node.function)
+            for i in range(node.n_args):
                 pos[0] += 1
-                depths.append(1 + self._depth(pos, depth + 1))
-            return max(depths)
-        else:
-            return 0
+                res = self._should_add(pos, node)
+                if res:
+                    return func_types[i] == new_node.node_type
+                elif res is not None:
+                    return res
+        return res
 
-    def depth(self):
+    def empty_tree(self) -> None:
+        self.tree = []
+
+    def depth(self) -> int:
         """
         Compute depth of tree (maximal path length to a leaf).
 
@@ -99,56 +152,92 @@ def depth(self):
         int
             tree depth.
         """
+        return self._depth([0], d=0)
 
-        return self._depth([0], 0)
-
-    def random_function(self):
-        """select a random function"""
-        return self.function_set[randint(0, self.n_functions - 1)]
-
-    def random_terminal(self):
-        """Select a random terminal or create an ERC terminal"""
-        if self.erc_range is None:
-            node = self.terminal_set[randint(0, self.n_terminals - 1)]
-        else:
-            if random() > 0.5:
-                node = self.terminal_set[randint(0, self.n_terminals - 1)]
-            else:
-                node = round(uniform(*self.erc_range), 4)
-        return node
-
-    def _execute(self, pos, **kwargs):
-        """Recursively execute the tree by traversing it in a depth-first order 
-           (pos is a size-1 list so as to pass "by reference" on successive recursive calls)."""
+    def _depth(self, pos, d) -> int:
+        """Recursively compute depth
+        (pos is a size-1 list so as to pass "by reference"
+        on successive recursive calls).
+        """
 
         node = self.tree[pos[0]]
 
-        if node in self.function_set:
-            arglist = []
-            for i in range(self.arity[node]):
+        depths = []
+        if isinstance(node, FunctionNode):
+            for _ in range(node.n_args):
                 pos[0] += 1
-                res = self._execute(pos, **kwargs)
-                arglist.append(res)
-            return node(*arglist)
-        else:  # terminal
-            if isinstance(node, Number):  # terminal is a constant
-                return node
-            else:  # terminal is a variable, return its value
-                return kwargs[node]
+                depths.append(1 + self._depth(pos, d + 1))
+            return max(depths)
+        else:
+            return 0
 
-    def execute(self, *args, **kwargs):
+    def random_function(
+        self,
+        node_type: Optional[type] = None
+    ) -> Optional[FunctionNode]:
+        functions_types = {
+            func: get_return_type(func) for func in self.function_set
+        }
+        relevant_functions = [
+            func
+            for func in self.function_set
+            if functions_types[func] == node_type
+        ]
+
+        # Return None in case there are no functions of the given type
+        if not relevant_functions:
+            return None
+
+        func = random.choice(relevant_functions)
+        return FunctionNode(func)
+
+    def random_terminal(
+        self,
+        node_type: Optional[type] = None
+    ) -> Optional[TerminalNode]:
+        """Select a random terminal, including constants from ERC range"""
+        relevant_terminals = [
+            term
+            for term, term_type in self.terminal_set.items()
+            if term_type == node_type
+        ]
+
+        if self.erc_range is not None and (
+            node_type is None or issubclass(node_type, Number)
+        ):
+            relevant_terminals.append(
+                random.uniform(*self.erc_range)
+                if type(self.erc_range[0]) is float
+                else random.randint(*self.erc_range)
+            )
+
+        # Return None in case there are no terminals of the given type
+        if not relevant_terminals:
+            return None
+
+        terminal = random.choice(relevant_terminals)
+
+        # erc terminal will be typeless in untyped case,
+        # and int/float in typed case
+        default_type = None if node_type is None else type(terminal)
+        node_type = self.terminal_set.get(terminal, default_type)
+
+        return TerminalNode(terminal, node_type=node_type)
+
+    def execute(self, *args, **kwargs) -> object:
         """
-        Execute the program (tree). 
+        Execute the program (tree).
         Input is a numpy array or keyword arguments (but not both).
 
         Parameters
         ----------
         args : arguments
             A numpy array.
-        
+
         kwargs : keyword arguments
-            Input to program, including every variable in the terminal set as a keyword argument.
-            For example, if `terminal_set=['x', 'y', 'z', 0, 1, -1]` 
+            Input to program, including every variable
+            in the terminal set as a keyword argument.
+            For example, if `terminal_set=['x', 'y', 'z']`
             then call `execute(x=..., y=..., z=...)`.
 
         Returns
@@ -157,100 +246,295 @@ def execute(self, *args, **kwargs):
             Result of tree execution.
         """
 
+        if not self.tree:
+            raise ValueError("Tree is empty, cannot execute.")
+
         reshape = False
-        if args != ():  # numpy array -- convert to kwargs
+        if args:  # numpy array -- convert to kwargs
             try:
                 X = args[0]
-                kwargs = _generate_args(X)
+                kwargs = generate_args(X)
                 reshape = True
             except Exception:
-                raise ValueError(f'Bad argument to tree.execute, must be numpy array or kwargs: {args}')
+                raise ValueError(
+                    f"Bad argument to tree.execute, "
+                    f"must be np array or kwargs: {args}"
+                )
 
         kw = list(kwargs.keys())
 
-        bad_vars = [item for item in kw if item not in self.vars]
+        bad_vars = [item for item in kw if item not in self.terminal_set]
         if len(bad_vars) > 0:
-            raise ValueError(f'tree.execute received variable arguments not in terminal set: {bad_vars}')
+            raise ValueError(
+                f"received variable arguments not in terminal set: {bad_vars}"
+            )
 
-        missing_vars = [item for item in self.vars if item not in kw]
+        missing_vars = [item for item in self.terminal_set if item not in kw]
         if len(missing_vars) > 0:
             raise ValueError(
-                f'Some variable terminals were not passed to tree.execute as keyword arguments: {missing_vars}')
+                f"Missing variable terminals as execute kwargs: {missing_vars}"
+            )
 
         res = self._execute([0], **kwargs)
+
         if reshape and (isinstance(res, Number) or res.shape == np.shape(0)):
             # sometimes a tree degenrates to a scalar value
             res = np.full_like(X[:, 0], res)
         return res
 
-    def random_subtree(self):
-        # select a random node index from this individual's tree
-        rnd_i = randint(0, self.size() - 1)
-        # find the subtree's end
-        end_i = self._find_subtree_end([rnd_i])
-        # now we have a random subtree from this individual
-        return self.tree[rnd_i:end_i + 1]
-
-    def _find_subtree_end(self, pos):
-        """find index of final node of subtree that starts at `pos` 
-          (pos is a size-1 list so as to pass "by reference" on successive recursive calls)."""
+    def _execute(self, pos, **kwargs):
+        """
+        Recursively execute the tree by traversing it in a depth-first order
+        (pos is a size-1 list so as to pass "by reference"
+        on successive recursive calls).
+        """
 
         node = self.tree[pos[0]]
 
-        if node in self.function_set:
-            for i in range(self.arity[node]):
+        if isinstance(node, FunctionNode):
+            arglist = []
+            for _ in range(node.n_args):
                 pos[0] += 1
-                self._find_subtree_end(pos)
+                res = self._execute(pos, **kwargs)
+                arglist.append(res)
+            return node.function(*arglist)
+        else:  # terminal
+            if node.value in kwargs:
+                # terminal is a variable, return its value
+                return kwargs[node.value]
+            else:  # terminal is a constant
+                return node.value
 
-        return pos[0]
+    def filter_tree(self, filter_func: Callable) -> None:
+        return [node for node in self.tree if filter_func(node)]
 
-    def replace_subtree(self, subtree):
+    def get_random_numeric_node(self) -> Optional[TerminalNode]:
         """
-        Replace the subtree starting at `index` with `subtree`
+        Get a random leaf node of the tree.
 
         Parameters
         ----------
-        subtree - new subtree to replace the some existing subtree in this individual's tree
+        node_type : type, default=None
+            Type of node to return.
 
         Returns
         -------
-        None
+        TreeNode
+            Random leaf node.
         """
-
-        index = randint(0, self.size() - 1)  # select a random node (index)
-        end_i = self._find_subtree_end([index])
-        if isinstance(self.tree[end_i], list):
-            print(self.tree[end_i], list)
-        left_part = self.tree[:index]
-        right_part = self.tree[(end_i + 1):]
-        self.tree = left_part + subtree + right_part
-
-    def _node_label(self, node):
+        erc_nodes = self.filter_tree(
+            lambda node: isinstance(node, TerminalNode)
+            and isinstance(node.value, Number)
+        )
+        return random.choice(erc_nodes) if erc_nodes else None
+
+    def random_subtree(
+        self,
+        node_type: Optional[type] = None
+    ) -> Optional[List[TreeNode]]:
+        relevant_nodes = self.filter_tree(
+            lambda node: node.node_type is None  # untyped case
+            or (
+                node_type is None and node != self.root
+            )  # typed case with first invocation
+            or (
+                node.node_type == node_type and node != self.root
+            )  # typed case with subsequent invocations
+        )
+        if not relevant_nodes:
+            return None
+        subtree_root = random.choice(relevant_nodes)
+
+        return self._get_subtree_by_root(subtree_root)
+
+    def _get_subtree_by_root(self, subtree_root: TreeNode) -> List[TreeNode]:
+        start_i = self.tree.index(subtree_root)
+
+        if start_i == 0:
+            return self.tree
+
+        end_i = self._find_subtree_end([start_i])
+        return self.tree[start_i: end_i + 1]
+
+    def replace_subtree(
+        self, old_subtree: List[TreeNode], new_subtree: List[TreeNode]
+    ) -> None:
         """
-        return a string label for the node
+        Replace the subtree starting at `index` with `subtree`
 
         Parameters
         ----------
-        node - some node in the tree's function/terminal set
+        subtree - new subtree to replace the existing subtree in the tree
 
         Returns
         -------
-        node name - either a terminal (x0, x1,...) or a function (f_add, f_or, ...)
+        None
         """
-        return node.__name__ if node in self.function_set else str(node)
+        start_i = self.tree.index(old_subtree[0])
+        end_i = start_i + len(old_subtree)
+        left_part = self.tree[:start_i]
+        right_part = self.tree[end_i:]
+        self.tree = left_part + new_subtree + right_part
 
-    def _show(self, prefix, pos):
-        """Recursively produce a simple textual printout of the tree 
-        (pos is a size-1 list so as to pass "by reference" on successive recursive calls)."""
+    def _find_subtree_end(self, pos):
+        """find index of final node of subtree that starts at `pos`
+        (pos is a size-1 list so as to pass "by reference" on successive recursive calls).
+        """
+
+        node = self.tree[pos[0]]
+
+        if (
+            isinstance(node, FunctionNode)
+            and node.function in self.function_set
+        ):
+            for _ in range(node.n_args):
+                pos[0] += 1
+                self._find_subtree_end(pos)
+
+        return pos[0]
+
+    def _handle_input_types(
+        self,
+        function_set: List[Callable],
+        terminal_set: Union[Dict[Any, type], List[str]],
+        root_type: type
+    ):
+        if function_set is None:
+            raise ValueError("Function set must be provided.")
+
+        if terminal_set is None:
+            raise ValueError("Terminal set must be provided.")
+
+        for t in function_set:
+            if not isinstance(t, Callable):
+                raise ValueError(
+                    f"Functions must be Callble, but {t} is of type {type(t)}"
+                )
+
+        if not isinstance(terminal_set, (list, dict)):
+            raise ValueError(
+                "Terminal set must be a list or a dictionary, "
+                f"got {type(terminal_set)}."
+            )
+
+        # untyped case - convert to dictionary of Nones.
+        if isinstance(terminal_set, list):
+            # check if any function has type hints
+            if any(f.__annotations__ for f in function_set):
+                raise ValueError(
+                    "Detected typed function with untyped terminal set. \
+                    Please provide a dictionary with types for terminals."
+                )
+
+            terminal_set = {t: None for t in terminal_set}
+            return function_set, terminal_set
+
+        # typed case - check every value is a type
+        for v in terminal_set.values():
+            if not isinstance(v, type):
+                raise ValueError(
+                    "Values in terminal set dictionary must be types, "
+                    f"but {v} is of type {type(v)}."
+                )
+
+        function_return_types = {
+            get_return_type(f) for f in function_set
+        }
+        if root_type not in function_return_types:
+            raise ValueError(
+                f"Detected a mismatch between root_type ({root_type}) "
+                f"and function set ({function_set}).\n"
+                f"Root type must be the return type of at least one function."
+            )
+        if self.erc_type is not None and self.erc_type not in function_return_types:
+            raise ValueError(
+                f"Detected a mismatch between ERC type ({self.erc_type}) "
+                f"and function set ({function_set}).\n"
+                f"ERC range should not be defined if there are no numeric functions."
+            )
+
+        # check terminals and functions type intersection
+        function_arg_types = {
+            t for func in function_set for t in get_func_types(func)[:-1]
+        }
+        terminal_types = set(terminal_set.values())
+
+        if self.erc_type:
+            terminal_types.add(self.erc_type)
+
+        if function_arg_types != terminal_types:
+            raise ValueError(
+                f"Function args type hints ({function_arg_types}) "
+                f"must match terminal types ({terminal_types})."
+            )
+
+        return function_set, terminal_set
+
+    def __str__(self) -> str:
+        """
+        Return a simple textual representation of the tree.
+
+        Returns
+        -------
+        str
+            String representation of the tree.
+
+        Examples
+        --------
+        untyped case:
+        >>> ut = Tree(terminal_set=[x, y, z], function_set=[f_add])
+        >>> ut.tree = [FunctionNode(f_add), TerminalNode(1), TerminalNode(2)]
+        >>> tree.show()
+        def func_0(x, y, z):
+            return f_add(x, y)
+
+        typed case:
+        >>> tt = Tree(terminal_set={x: float, y: float},
+        ...           function_set=[typed_add])
+        >>> tt.tree = [FunctionNode(typed_add),
+        ...            TerminalNode(1.0, float),
+        ...            TerminalNode(2.0, float)]
+        >>> str(tree)
+        def func_0(x: float, y: float) -> float:
+            return typed_add(1.0, 2.0)
+        """
+        args = (
+            list(self.terminal_set.keys())
+            if None in self.terminal_set.values()
+            else [f"{k}: {v.__name__}" for k, v in self.terminal_set.items()]
+        )
+        ret_type_str = (
+            ""
+            if None in self.terminal_set.values()
+            else f" -> {self.root.node_type.__name__}"
+        )
+
+        result = [
+            f"def func_{str(self.id)}({', '.join(args)}){ret_type_str}:\n  return "
+        ]
+        self._str_rec("", [0], result)
+        return "".join(result)
+
+    def _str_rec(self, prefix: str, pos: List[int], result: str) -> str:
+        """Recursively produce a simple textual printout of the tree
+        (pos is a size-1 list so as to pass "by reference" on successive
+         recursive calls).
+        """
 
         node = self.tree[pos[0]]
-        if node in self.function_set:
-            print(f'{prefix}{self._node_label(node)}')
-            for i in range(self.arity[node]):
+        if isinstance(node, FunctionNode):
+            result.append(f"{prefix}{str(node)}(\n")
+            prefix = prefix + "  " if pos[0] == 0 else prefix
+            for i in range(node.n_args):
                 pos[0] += 1
-                self._show(prefix + "   ", pos)
+                self._str_rec(prefix + "  ", pos, result)
+                if i < node.n_args - 1:
+                    result.append(",\n")
+                elif 0 < i < node.n_args:
+                    result.append("\n")
+            result.append(prefix + ")")
         else:  # terminal
-            print(f'{prefix}{self._node_label(node)}')
+            result.append(f"{prefix}{str(node)}")
 
     def show(self):
         """
@@ -260,6 +544,7 @@ def show(self):
         -------
         None.
         """
-        self._show("", [0])
+        logger.info("\n" + str(self))
 
-# end class tree
+    def __repr__(self):
+        return str(self)
diff --git a/eckity/genetic_encodings/gp/tree/tree_node.py b/eckity/genetic_encodings/gp/tree/tree_node.py
new file mode 100644
index 00000000..6cc07f18
--- /dev/null
+++ b/eckity/genetic_encodings/gp/tree/tree_node.py
@@ -0,0 +1,102 @@
+from abc import ABC, abstractmethod
+from typing import Any, Callable, Optional
+
+from overrides import override
+
+from eckity.base.utils import arity
+
+from .utils import get_func_types
+
+
+class TreeNode(ABC):
+    """
+    GP Node
+
+    Attributes
+    ----------
+    node_type : type
+        node type
+    """
+
+    def __init__(self, node_type: Optional[type] = None) -> None:
+        self.node_type = node_type
+
+    @abstractmethod
+    def __str__(self) -> str:
+        pass
+
+    def __eq__(self, other):
+        return (
+            isinstance(other, TreeNode) and self.node_type is other.node_type
+        )
+
+    def __repr__(self):
+        return str(self)
+
+
+class FunctionNode(TreeNode):
+    def __init__(
+        self,
+        function: Callable,
+    ) -> None:
+        # infer the return type of the function
+        func_types = get_func_types(function)
+        return_type = func_types[-1] if func_types else None
+        self.n_args = arity(function)
+
+        if 0 < len(func_types) < self.n_args + 1:
+            raise ValueError(
+                f"Function {function.__name__} has missing type hints."
+                f"Please provide type hints for all arguments and return type."
+            )
+
+        super().__init__(return_type)
+
+        self.function = function
+
+    @override
+    def __eq__(self, other: object) -> bool:
+        """
+        Compare two FunctionNodes for equality.
+        Function nodes are equal if they have the same function.
+
+        Parameters
+        ----------
+        other : object
+            The object to compare to
+
+        Returns
+        -------
+        bool
+            True if the two FunctionNodes are equal, False otherwise
+        """
+        return (
+            super().__eq__(other)
+            and isinstance(other, FunctionNode)
+            and self.function == other.function
+        )
+
+    @override
+    def __str__(self) -> str:
+        return self.function.__name__
+
+
+class TerminalNode(TreeNode):
+    def __init__(
+        self,
+        value: Any,
+        node_type: Optional[type] = None
+    ) -> None:
+        super().__init__(node_type)
+        self.value = value
+
+    @override
+    def __eq__(self, other):
+        return (
+            super().__eq__(other)
+            and isinstance(other, TerminalNode)
+            and self.value is other.value
+        )
+
+    def __str__(self):
+        return str(self.value)
diff --git a/eckity/genetic_encodings/gp/tree/utils.py b/eckity/genetic_encodings/gp/tree/utils.py
index 165b8950..8e979650 100644
--- a/eckity/genetic_encodings/gp/tree/utils.py
+++ b/eckity/genetic_encodings/gp/tree/utils.py
@@ -1,11 +1,19 @@
 """
-This module implements some utility functions. 
+This module implements some utility functions.
 """
 
+from typing import Callable, Dict, List, Union, get_type_hints
 
-def create_terminal_set(X):
+import numpy as np
+
+from eckity.base.utils import arity
+
+
+def create_terminal_set(
+    X: np.ndarray, typed=False
+) -> Union[List[str], Dict[str, type]]:
     """
-    Create a terminal set from a 2D-shaped numpy array. 
+    Create a terminal set from a 2D-shaped numpy array.
 
     Example: \n
         X = array([[  4,   7,  -7, -10],  \n
@@ -25,15 +33,19 @@ def create_terminal_set(X):
     Terminal set (a list).
 
     """
-    
-    return [f'x{i}' for i in range(X.shape[1])]
 
+    features = [f"x{i}" for i in range(X.shape[1])]
+    if not typed:
+        return features
+    # convert numpy dtypes to python types
+    return {x: type(X[0, i].item()) for i, x in enumerate(features)}
 
-def _generate_args(X):
+
+def generate_args(X: np.ndarray) -> Dict[str, np.ndarray]:
     """
     Generate keyword arguments from a 2d array for passing to GPTree.execute.
-    
-    Example: \n 
+
+    Example: \n
         X = array([[  4,   7,  -7, -10],  \n
                    [  7,  -3,   3,  -8],  \n
                    [  8,  -5,  -3,  -1]])
@@ -42,16 +54,54 @@ def _generate_args(X):
         {'x0': array([4, 7, 8]), \n
          'x1': array([ 7, -3, -5]), \n
          'x2': array([-7,  3, -3]), \n
-         'x3': array([-10,  -8,  -1])}     
+         'x3': array([-10,  -8,  -1])}
 
     Returns
     -------
     kwargs : dict
-        A keyword dictionary that includes a value for every variable x_i in the terminal set. 
+        A keyword dictionary that includes a value for every variable x_i in the terminal set.
 
     """
-    
-    kwargs = dict()
-    for i in range(X.shape[1]):
-        kwargs[f'x{i}'] = X[:, i]
-    return kwargs
+
+    return {f"x{i}": X[:, i] for i in range(X.shape[1])}
+
+
+def get_func_types(f: Callable) -> List[type]:
+    """
+    Return list of function types in the following format:
+    [type_arg_1, type_arg_2, ..., type_arg_n, return_type]
+
+    Parameters
+    ----------
+    f : Callable
+        function (builtin or user-defined)
+
+    Returns
+    -------
+    List[type]
+        List of function types, sorted by argument order
+        with the return type as the last element.
+        For untyped functions, None is used.
+
+    Examples
+    --------
+    >>> def f(x: int, y: float) -> float:
+    ...     return x + y
+    >>> get_func_types(f)
+    [int, float, float]
+
+    >>> def f(x, y):
+    ...     return x + y
+    >>> get_func_types(f)
+    [None, None, None]
+    """
+    params_types: Dict = get_type_hints(f)
+    type_list = list(params_types.values())
+    if not type_list:
+        # If we don't have type hints, assign None
+        type_list = [None] * (arity(f) + 1)
+    return type_list
+
+
+def get_return_type(func: Callable) -> type:
+    return get_type_hints(func).get("return", None)
diff --git a/eckity/genetic_operators/__init__.py b/eckity/genetic_operators/__init__.py
index e69de29b..f4059d4a 100644
--- a/eckity/genetic_operators/__init__.py
+++ b/eckity/genetic_operators/__init__.py
@@ -0,0 +1,29 @@
+# general operators
+from .genetic_operator import GeneticOperator
+from .failable_operator import FailableOperator
+
+# selections
+from .selections.selection_method import SelectionMethod
+from .selections.tournament_selection import TournamentSelection
+from .selections.fp_selection import FitnessProportionateSelection
+from .selections.elitism_selection import ElitismSelection
+
+# crossovers
+from .crossovers.subtree_crossover import SubtreeCrossover
+from .crossovers.vector_k_point_crossover import VectorKPointsCrossover
+
+# mutations
+from .mutations.erc_mutation import ERCMutation
+from .mutations.identity_transformation import IdentityTransformation
+from .mutations.subtree_mutation import SubtreeMutation
+from .mutations.vector_n_point_mutation import VectorNPointMutation
+from .mutations.vector_random_mutation import (
+    BitStringVectorFlipMutation,
+    BitStringVectorNFlipMutation,
+    FloatVectorGaussNPointMutation,
+    FloatVectorGaussOnePointMutation,
+    FloatVectorUniformNPointMutation,
+    FloatVectorUniformOnePointMutation,
+    IntVectorNPointMutation,
+    IntVectorOnePointMutation
+)
diff --git a/eckity/genetic_operators/crossovers/subtree_crossover.py b/eckity/genetic_operators/crossovers/subtree_crossover.py
index 15fc99c5..8af06599 100644
--- a/eckity/genetic_operators/crossovers/subtree_crossover.py
+++ b/eckity/genetic_operators/crossovers/subtree_crossover.py
@@ -1,47 +1,105 @@
-from eckity.genetic_operators.genetic_operator import GeneticOperator
 
+from typing import Any, List, Tuple, Optional
 
-class SubtreeCrossover(GeneticOperator):
-    def __init__(self, probability=1, arity=2, events=None):
+from overrides import override
+
+from eckity.genetic_encodings.gp import Tree, TreeNode
+from eckity.genetic_operators.failable_operator import FailableOperator
+
+
+class SubtreeCrossover(FailableOperator):
+    def __init__(self, probability=1.0, arity=2, events=None, attempts=1):
+        super().__init__(
+            probability=probability,
+            arity=arity,
+            events=events,
+            attempts=attempts,
+        )
         self.individuals = None
         self.applied_individuals = None
-        super().__init__(probability=probability, arity=arity, events=events)
 
-    def apply(self, individuals):
+    @override
+    def attempt_operator(
+        self, payload: Any, attempt_num: int
+    ) -> Tuple[bool, Any]:
         """
-        Perform subtree crossover between this tree and `other` tree:
-            1. Select random node from `other` tree
-            2. Get subtree rooted at selected node
-            1. Select a random node in this tree
-            2. Place `other` selected subtree at this node, replacing current subtree
+        Perform subtree crossover between a list of trees in a cyclic manner.
+        Meaning, the second individual will have a subtree from the first,
+        and the first individual will have a subtree from the last individual.
 
         Parameters
         ----------
-        individuals
-        select_func: callable
-        Selection method used to receive additional individuals to perform crossover on
+        payload: List[Individual]
+            List of Trees to perform crossover on
 
         individual: Tree
         tree individual to perform crossover on
 
         Returns
         -------
-        a new, modified individual
+        List
+            List of individuals after crossover (modified in-place)
         """
+        individuals = payload
 
-        assert len(individuals) == self.arity, f'Expected individuals list of size {self.arity}, got {len(individuals)}'
+        if len(individuals) != self.arity:
+            raise ValueError(
+                f"Expected individuals of size {self.arity}, "
+                f"got {len(individuals)}."
+            )
 
         self.individuals = individuals
 
-        # select a random subtree from each individual's tree
-        subtrees = [ind.random_subtree() for ind in individuals]
-
-        # assign the next individual's subtree to the current individual's tree
-        for i in range(len(individuals) - 1):
-            individuals[i].replace_subtree(subtrees[i+1])
+        subtrees: Optional[List[List[TreeNode]]] = self._pick_subtrees(
+            individuals
+        )
 
-        # to complete the crossover circle, assign the first subtree to the last individual
-        individuals[-1].replace_subtree(subtrees[0])
+        if subtrees is None:
+            return False, individuals
 
+        self._swap_subtrees(individuals, subtrees)
         self.applied_individuals = individuals
-        return individuals
+
+        return True, individuals
+
+    @staticmethod
+    def _pick_subtrees(
+        individuals: List[Tree],
+    ) -> Optional[List[List[TreeNode]]]:
+        # select a random subtree from first individual tree
+        first_subtree: List[TreeNode] = individuals[0].random_subtree()
+
+        if first_subtree is None:
+            # failed attempt
+            return None
+
+        m_type: type = first_subtree[0].node_type
+
+        # now select a random subtree from the rest of the individuals
+        # with regards to the type of the first subtree
+        rest_subtrees = [ind.random_subtree(m_type) for ind in individuals[1:]]
+
+        # fails if any subtree doesn't contain a node with of type `m_type`
+        if None in rest_subtrees:
+            return None
+
+        subtrees = [first_subtree] + rest_subtrees
+        return subtrees
+
+    @staticmethod
+    def _swap_subtrees(
+        individuals: List[TreeNode], subtrees: List[List[TreeNode]]
+    ) -> None:
+        """
+        Replace subtrees for all individuals in a cyclic manner
+        For n subtrees (st_1, st_2, ..., st_n):
+        st_n receives the subtree of st_n-1
+        st_n-1 receives the subtree of st_n-2
+        ...
+        st_2 receives the subtree of st_1
+        st_1 receives the subtree of st_n
+        """
+        for i in range(len(individuals) - 1, -1, -1):
+            individuals[i].replace_subtree(
+                old_subtree=subtrees[i], new_subtree=subtrees[i - 1]
+            )
diff --git a/eckity/genetic_operators/crossovers/vector_k_point_crossover.py b/eckity/genetic_operators/crossovers/vector_k_point_crossover.py
index 77254077..d58fb19a 100644
--- a/eckity/genetic_operators/crossovers/vector_k_point_crossover.py
+++ b/eckity/genetic_operators/crossovers/vector_k_point_crossover.py
@@ -1,65 +1,88 @@
 from random import sample
 
 from eckity.genetic_operators.genetic_operator import GeneticOperator
-from eckity.genetic_encodings.ga.vector_individual import Vector
+from eckity.genetic_encodings.ga import Vector
+
+from typing import List, Tuple
 
 
 class VectorKPointsCrossover(GeneticOperator):
     def __init__(self, probability=1, arity=2, k=1, events=None):
         """
-            Vector N Point Mutation.
+        Vector K Point Crossover.
 
-            Randomly chooses N vector cells and performs a small change in their values.
+        Randomly chooses K points in the vector and swaps the parts
+        of two vectors at these points.
 
-            Parameters
-            ----------
-            probability : float
-                The probability of the mutation operator to be applied
+        Parameters
+        ----------
+        probability : float
+            The probability of the mutation operator to be applied
 
-            arity : int
-                The number of individuals this mutation is applied on
+        arity : int
+            The number of individuals this mutation is applied on
 
-            k : int
-                Number of points to cut the vector for the crossover.
+        k : int
+            Number of points to cut the vector for the crossover.
 
-            events: list of strings
-                Events to publish before/after the mutation operator
+        events: list of strings
+            Custom events to be published by the mutation, by default None
         """
         self.individuals = None
         self.applied_individuals = None
         self.k = k
-        self.points = None
         super().__init__(probability=probability, arity=arity, events=events)
 
-    def apply(self, individuals):
+    def apply(self, individuals: List[Vector]) -> List[Vector]:
         """
         Attempt to perform the mutation operator
 
         Parameters
         ----------
-        individuals : list of individuals
+        individuals : List[Vector]
             individuals to perform crossover on
 
         Returns
         ----------
-        list of individuals
+        List[Vector]
             individuals after the crossover
         """
         self.individuals = individuals
-        self.points = sorted(sample(range(1, individuals[0].size()), self.k))
-        
-        start_index = 0
-        for i in range(0, len(self.points), 2):
-            end_point = self.points[i]
-            replaced_part = individuals[0].get_vector_part(start_index, end_point)
-            replaced_part = individuals[1].replace_vector_part(replaced_part, start_index)
-            individuals[0].replace_vector_part(replaced_part, start_index)
-            start_index = end_point
-
-        # replace the last part (from last point to end)
-        replaced_part = individuals[0].get_vector_part(self.points[-1], individuals[0].size())
-        replaced_part = individuals[1].replace_vector_part(replaced_part, self.points[-1])
-        individuals[0].replace_vector_part(replaced_part, self.points[-1])
+        xo_points = sorted(sample(range(1, individuals[0].size()), self.k))
+        self._swap_vector_parts(
+            individuals[0].vector, individuals[1].vector, xo_points
+        )
 
         self.applied_individuals = individuals
         return individuals
+
+    def _swap_vector_parts(
+        self, vector1: List[int], vector2: List[int], xo_points: List[int]
+    ) -> Tuple[List[int]]:
+        """
+        Swap parts of two vectors at the given crossover points.
+
+        Parameters
+        ----------
+        vector1 : List[int]
+            first vector encoding
+        vector2 : List[int]
+            second vector encoding
+        xo_points : List[int]
+            crossover points
+
+        Returns
+        -------
+        Tuple[List[int]]
+            _description_
+        """
+        if len(xo_points) == self.k:
+            xo_points.append(len(vector1))
+
+        start_idx = 0
+        for i in range(0, len(xo_points), 2):
+            end_idx = xo_points[i]
+            replaced_part = vector1[start_idx:end_idx]
+            vector1[start_idx:end_idx] = vector2[start_idx:end_idx]
+            vector2[start_idx:end_idx] = replaced_part
+            start_idx = xo_points[i + 1] if end_idx < len(xo_points) else -1
diff --git a/eckity/genetic_operators/failable_operator.py b/eckity/genetic_operators/failable_operator.py
index 683d25e2..cb9d5f0e 100644
--- a/eckity/genetic_operators/failable_operator.py
+++ b/eckity/genetic_operators/failable_operator.py
@@ -1,12 +1,15 @@
-from eckity.genetic_operators.genetic_operator import GeneticOperator
 from abc import abstractmethod
+from typing import Any, List, Tuple
+
+from eckity.genetic_operators.genetic_operator import GeneticOperator
 
 
 class FailableOperator(GeneticOperator):
     """
     Genetic operator that has a chance of failing.
 
-    For example, adding a gaussian noise to a FloatVector cell might exceed the legal bounds of the vector.
+    For example, adding a gaussian noise to a FloatVector cell
+    might exceed the legal bounds of the vector.
     In that case, the Gauss Mutation fails.
 
     Parameters
@@ -23,24 +26,37 @@ class FailableOperator(GeneticOperator):
     attempts: int
         number of attempts to be made during the operator execution
     """
-    def __init__(self, probability=0.05, arity=0, events=None, attempts=1):
+
+    def __init__(
+        self,
+        probability: float = 0.05,
+        arity: int = 0,
+        events: List[str] = None,
+        attempts: int = 1,
+    ):
         super().__init__(probability, arity, events)
-        if attempts <= 1:
+        if attempts < 1:
             raise ValueError("Number of attempts must be at least 1")
         self.attempts = attempts
 
     # TODO add event of on fail or on fail all retries
-    def apply(self, payload):
+    def apply(self, payload: object) -> object:
         """
         Apply the operator, with a chance of failing.
 
-        Attempt to apply the operator `attempts` times, finish by succeeding in one of the attempts or by failing
+        Attempt to apply the operator `attempts` times,
+        finish by succeeding in one of the attempts or by failing
         all attempts and executing `on_fail` method.
 
         Parameters
         -------
         payload: object
-            relevant data for the applied operator (usually a list of individuals)
+            relevant data for the operator (usually a list of individuals)
+
+        Returns
+        -------
+        object
+            result value
         """
         for i in range(self.attempts):
             # attempt to execute the operator
@@ -53,14 +69,16 @@ def apply(self, payload):
         return self.on_fail(payload)
 
     @abstractmethod
-    def attempt_operator(self, payload, attempt_num):
+    def attempt_operator(
+        self, payload: Any, attempt_num: int
+    ) -> Tuple[bool, Any]:
         """
         A single attempt of the operator
 
         Parameters
         -------
         payload: object
-            relevant data for the applied operator (usually a list of individuals)
+            relevant data for the operator (usually a list of individuals)
 
         attempt_num: int
             current attempt number
@@ -72,21 +90,21 @@ def attempt_operator(self, payload, attempt_num):
         """
         pass
 
-    @abstractmethod
-    def on_fail(self, payload):
+    def on_fail(self, payload: Any) -> Any:
         """
         What to do when all operator attempts failed
-
         This method is called once all operator attempts have failed
+        By default, this method does nothing.
 
         Parameters
         -------
         payload: object
-            relevant data for the failure handling mechanism (usually a list of individuals)
+            relevant data for the failure handling mechanism
+            (usually a list of individuals)
 
         Returns
         -------
-        object
-            result value
+        Any
+            failure handling mechanism, does nothing by default
         """
-        pass
+        return payload
diff --git a/eckity/genetic_operators/genetic_operator.py b/eckity/genetic_operators/genetic_operator.py
index d0375597..95220c17 100644
--- a/eckity/genetic_operators/genetic_operator.py
+++ b/eckity/genetic_operators/genetic_operator.py
@@ -1,24 +1,72 @@
-from abc import abstractmethod
-from random import uniform
+import random
+from abc import ABC, abstractmethod
 
 from eckity.event_based_operator import Operator
 
 
-class GeneticOperator(Operator):
-    def __init__(self, probability=0.05, arity=0, events=None):
+class GeneticOperator(Operator, ABC):
+    """
+    Abstract class for genetic operators.
+    Each operator has a probability of being applied each generation.
+
+    Genetic operators are applied in-place.
+
+
+    Parameters
+    ----------
+    probability : float, optional
+        probability of being applied each generation, by default 1.0
+    arity : int, optional
+        number of individuals required for the operator, by default 0
+    events : List[str], optional
+        custom events that the operator publishes, by default None
+    """
+
+    def __init__(self, probability=1.0, arity=0, events=None):
         super().__init__(events=events, arity=arity)
         self.probability = probability
 
     def apply_operator(self, individuals):
-        if uniform(0, 1) <= self.probability:
+        """
+        Apply the genetic operator with a certain probability.
+        The individuals are modified in-place, so it is not mandatory
+        to return them.
+
+        Parameters
+        ----------
+        individuals : List[Individual]
+            Individuals to apply the operator to.
+
+        Returns
+        -------
+        List[Individual]
+            The individuals after applying the operator.
+        """
+        if random.random() <= self.probability:
+            # Fitness is irrelevant once the operator is applied
             for individual in individuals:
                 individual.set_fitness_not_evaluated()
             op_res = self.apply(individuals)
+
+            # Add the operator to the applied operators list
             for ind in op_res:
                 ind.applied_operators.append(type(self).__name__)
+                
+                if ind.update_parents:
+                    parents = [p.id for p in individuals]
+                    ind.parents.extend(parents)
             return op_res
         return individuals
 
     @abstractmethod
     def apply(self, individuals):
+        """
+        Apply the genetic operator to the individuals.
+        This method should be implemented by the subclasses.
+
+        Parameters
+        ----------
+        individuals : List[Individual]
+            Individuals to apply the operator to.
+        """
         pass
diff --git a/eckity/genetic_operators/mutations/erc_mutation.py b/eckity/genetic_operators/mutations/erc_mutation.py
index addd3616..083bdd17 100644
--- a/eckity/genetic_operators/mutations/erc_mutation.py
+++ b/eckity/genetic_operators/mutations/erc_mutation.py
@@ -1,14 +1,29 @@
-from random import choice, gauss
+from typing import Any, List, Tuple
 
-from eckity.event_based_operator import Operator
-from eckity.genetic_operators.genetic_operator import GeneticOperator
+from overrides import override
+import random
 
+from eckity.genetic_encodings.gp import Tree
+from eckity.genetic_operators import FailableOperator
 
-class ERCMutation(GeneticOperator):
-    def __init__(self, probability=1, arity=1, events=None):
+
+class ERCMutation(FailableOperator):
+    def __init__(
+        self,
+        probability=1.0,
+        arity=1,
+        mu=0,
+        sigma=1,
+        events=None,
+    ):
         super().__init__(probability=probability, arity=arity, events=events)
+        self.mu = mu
+        self.sigma = sigma
 
-    def apply(self, individuals):
+    @override
+    def attempt_operator(
+        self, payload: Any, attempt_num: int
+    ) -> Tuple[bool, Any]:
         """
         Perform ephemeral random constant (ERC) mutation: select an ERC node at random
         and add Gaussian noise to it.
@@ -17,11 +32,16 @@ def apply(self, individuals):
         -------
         None.
         """
-        for j in range(len(individuals)):
-            erc_indexes = [i for i, node in enumerate(individuals[j].tree) if
-                           isinstance(node, (int, float)) and node not in individuals[j].terminal_set]
-            if len(erc_indexes) > 0:
-                m_point = choice(erc_indexes)
-                individuals[j].tree[m_point] = round(individuals[j].tree[m_point] + gauss(mu=0, sigma=1), 4)
+        individuals: List[Tree] = payload
+
+        leaves = [ind.get_random_numeric_node() for ind in individuals]
+
+        if None in leaves:
+            return False, individuals
+
+        mu, sigma = self.mu, self.sigma
+        for terminal in leaves:
+            terminal.value = terminal.value + random.gauss(mu, sigma)
+
         self.applied_individuals = individuals
-        return individuals
+        return True, individuals
diff --git a/eckity/genetic_operators/mutations/identity_transformation.py b/eckity/genetic_operators/mutations/identity_transformation.py
index d827a18e..d33f2739 100644
--- a/eckity/genetic_operators/mutations/identity_transformation.py
+++ b/eckity/genetic_operators/mutations/identity_transformation.py
@@ -2,7 +2,17 @@
 
 
 class IdentityTransformation(GeneticOperator):
-    def __init__(self,probability=1, events=None):
+    def __init__(self, probability=1.0, events=None):
+        """
+        Basic mutation operator that does not change the individuals.
+
+        Parameters
+        ----------
+        probability : float, optional
+            Probability to apply each generation, by default 1.0
+        events : List[str], optional
+            custom events that the operator publishes, by default None
+        """
         super().__init__(probability=probability, arity=1, events=events)
 
     def apply(self, individuals):
diff --git a/eckity/genetic_operators/mutations/subtree_mutation.py b/eckity/genetic_operators/mutations/subtree_mutation.py
index d6147b10..2811ad67 100644
--- a/eckity/genetic_operators/mutations/subtree_mutation.py
+++ b/eckity/genetic_operators/mutations/subtree_mutation.py
@@ -1,34 +1,80 @@
-from random import randint
+from typing import Any, List, Optional, Tuple
+
+from overrides import override
 
 from eckity.creators.gp_creators.grow import GrowCreator
-from eckity.genetic_operators.genetic_operator import GeneticOperator
+from eckity.genetic_encodings.gp import Tree, TreeNode
+from eckity.genetic_operators import FailableOperator
 
 
-class SubtreeMutation(GeneticOperator):
-    def __init__(self, probability=1, arity=1, init_depth=None, events=None):
-        super().__init__(probability=probability, arity=arity, events=events)
+class SubtreeMutation(FailableOperator):
+    def __init__(
+        self,
+        arity=1,
+        probability: float = 1.0,
+        init_depth: Tuple[int, int] = (2, 4),
+        events=None,
+        attempts=1,
+    ):
+        super().__init__(
+            probability=probability, arity=1, events=events, attempts=attempts
+        )
         self.init_depth = init_depth
+        self.tree_creator = None
 
-    def apply(self, individuals):
+    @override
+    def attempt_operator(
+        self, payload: Any, attempt_num: int
+    ) -> Tuple[bool, Any]:
         """
-        Perform subtree mutation: select a subtree at random to be replaced by a new, randomly generated subtree.
+        Perform subtree mutation: select a subtree at random
+        to be replaced by a new, randomly generated subtree.
 
         Returns
         -------
-        None.
+        Tuple[bool, Any]
+            A tuple containing a boolean indicating whether the operator was
+            successful and a list of the individuals.
         """
+        individuals: List[Tree] = payload
+
+        old_subtrees: List[Optional[List[TreeNode]]] = [
+            ind.random_subtree() for ind in individuals
+        ]
 
-        for ind in individuals:
-            init_depth = (ind.init_depth[0], randint(ind.init_depth[0], ind.init_depth[1])) \
-                if self.init_depth is None \
-                else self.init_depth
-            tree_creator = GrowCreator(init_depth=init_depth,
-                                       function_set=ind.function_set, terminal_set=ind.terminal_set,
-                                       erc_range=ind.erc_range)
+        if None in old_subtrees:
+            return False, individuals
 
-            # TODO refactor dummy individual creation, only the tree should be generated
-            subtree_individual = tree_creator.create_individuals(1, None)[0]
-            ind.replace_subtree(subtree_individual.tree)
+        self._swap_subtrees(individuals, old_subtrees)
 
         self.applied_individuals = individuals
-        return individuals
+        return True, individuals
+
+    def _swap_subtrees(
+        self, individuals: List[Tree], old_subtrees: List[List[TreeNode]]
+    ) -> None:
+
+        if self.tree_creator is None:
+            self.tree_creator = GrowCreator(
+                init_depth=self.init_depth,
+                function_set=individuals[0].function_set,
+                terminal_set=individuals[0].terminal_set,
+            )
+
+        for ind, old_subtree in zip(individuals, old_subtrees):
+            # generate a random tree with the same root type
+            # of the old subtree to not cause type errors
+
+            new_subtree = []
+            self.tree_creator.create_tree(
+                new_subtree,
+                ind.random_function,
+                ind.random_terminal,
+                depth=0,
+                node_type=old_subtree[0].node_type,
+            )
+
+            # replace the old subtree with the newly generated one
+            ind.replace_subtree(
+                old_subtree=old_subtree, new_subtree=new_subtree
+            )
diff --git a/eckity/genetic_operators/mutations/vector_n_point_mutation.py b/eckity/genetic_operators/mutations/vector_n_point_mutation.py
index 7513985f..253c6237 100644
--- a/eckity/genetic_operators/mutations/vector_n_point_mutation.py
+++ b/eckity/genetic_operators/mutations/vector_n_point_mutation.py
@@ -35,16 +35,24 @@ class VectorNPointMutation(FailableOperator):
     events: list of strings
         Events to publish before/after the mutation operator
     """
-    def __init__(self,
-                 n=1,
-                 probability=1.0,
-                 arity=1,
-                 cell_selector=None,
-                 mut_val_getter=None,
-                 success_checker=None,
-                 events=None,
-                 attempts=5):
-        super().__init__(probability=probability, arity=arity, events=events, attempts=attempts)
+
+    def __init__(
+        self,
+        n=1,
+        probability=1.0,
+        arity=1,
+        cell_selector=None,
+        mut_val_getter=None,
+        success_checker=None,
+        events=None,
+        attempts=5,
+    ):
+        super().__init__(
+            probability=probability,
+            arity=arity,
+            events=events,
+            attempts=attempts,
+        )
         self.n = n
 
         if cell_selector is None:
@@ -66,12 +74,14 @@ def default_mut_val_getter(vec: Vector, idx: int) -> Union[int, float]:
     @staticmethod
     def default_success_checker(old_vec: Vector, new_vec: Vector) -> bool:
         return new_vec.check_if_in_bounds()
-    
+
     def default_cell_selector(self, vec: Vector) -> List[int]:
         vector_indices = range(vec.size())
         return random.sample(vector_indices, k=self.n)
 
-    def attempt_operator(self, individuals: List[Vector], attempt_num) -> Tuple[bool, List[Vector]]:
+    def attempt_operator(
+        self, individuals: List[Vector], attempt_num
+    ) -> Tuple[bool, List[Vector]]:
         """
         Attempt to perform the mutation operator
 
@@ -85,18 +95,22 @@ def attempt_operator(self, individuals: List[Vector], attempt_num) -> Tuple[bool
 
         Returns
         ----------
-        tuple of (bool, list of vectors)
+        Tuple[bool, List[Vector]]
             first return value determines if the the attempt succeeded
             second return value is the operator result
         """
         succeeded = True
         for individual in individuals:
+            # clone the individual to revert in case of failure
             old_individual = individual.clone()
 
             # randomly select n points of the vector (without repetitions)
             m_points = self.cell_selector(individual)
             # obtain the mutated values
-            mut_vals = [self.mut_val_getter(individual, m_point) for m_point in m_points]
+            mut_vals = [
+                self.mut_val_getter(individual, m_point)
+                for m_point in m_points
+            ]
 
             # update the mutated value in-place
             for m_point, mut_val in zip(m_points, mut_vals):
diff --git a/eckity/genetic_operators/mutations/vector_random_mutation.py b/eckity/genetic_operators/mutations/vector_random_mutation.py
index 2c9f9300..1f3cfffc 100644
--- a/eckity/genetic_operators/mutations/vector_random_mutation.py
+++ b/eckity/genetic_operators/mutations/vector_random_mutation.py
@@ -1,118 +1,318 @@
 from random import random
 
-from eckity.genetic_operators.mutations.vector_n_point_mutation import VectorNPointMutation
-from eckity.genetic_encodings.ga.vector_individual import Vector
+from eckity.genetic_operators.mutations.vector_n_point_mutation import (
+    VectorNPointMutation,
+)
+
+"""
+This module contains the implementation of various vector mutations
+The key difference between these mutations is the way they generate the new
+value for the mutated cell (mut_val_getter).
+
+Additional parameters are used to control the mutation process, such as
+the way of selecting cells to mutate (cell_selector),
+the way of checking if the new value is legal (success_checker).
+For additional information, see the VectorNPointMutation class.
+
+Standard parameters for the mutation are number of mutation points (n),
+the probability of the operator itself to occur (probability),
+the probability of each point to be mutated (probability_for_each),
+the number of attempts to be made, etc.
+"""
 
 
 class FloatVectorUniformOnePointMutation(VectorNPointMutation):
     """
-    Uniform One Point Float Mutation
+    Uniform One Point Float Mutation.
+    Mutates a single cell of a float vector.
+    Mutated value is drawn from a uniform distribution,
+    with respect to the bounds of the vector.
+
+    Parameters
+    ----------
+    probability : float, optional
+        probability of the operator to occur, by default 1.0
+    arity : int, optional
+        individuals required for the mutation, by default 1
+    events : List[str], optional
+        custom events to be published by the mutation, by default None
     """
+
     def __init__(self, probability=1.0, arity=1, events=None):
-        super().__init__(n=1,
-                         probability=probability,
-                         arity=arity,
-                         mut_val_getter=lambda vec, index: vec.get_random_number_in_bounds(index),
-                         events=events)
+        super().__init__(
+            n=1,
+            probability=probability,
+            arity=arity,
+            mut_val_getter=lambda vec, index: vec.get_random_number_in_bounds(
+                index
+            ),
+            events=events,
+        )
 
 
 class FloatVectorUniformNPointMutation(VectorNPointMutation):
     """
-    Uniform N Point Float Mutation
+    Uniform N Point Float Mutation.
+    Mutates exactly n cells of a float vector.
+    Mutated values are drawn from a uniform distribution,
+    with respect to the bounds of the vector.
+
+    Parameters
+    ----------
+    probability : float, optional
+        probability of the operator to occur, by default 1.0
+    arity : int, optional
+        individuals required for the mutation, by default 1
+    events : List[str], optional
+        custom events to be published by the mutation, by default None
     """
+
     def __init__(self, n=1, probability=1.0, arity=1, events=None):
-        super().__init__(n=n,
-                         probability=probability,
-                         arity=arity,
-                         mut_val_getter=lambda vec, index: vec.get_random_number_in_bounds(index),
-                         events=events)
+        super().__init__(
+            n=n,
+            probability=probability,
+            arity=arity,
+            mut_val_getter=lambda vec, index: vec.get_random_number_in_bounds(
+                index
+            ),
+            events=events,
+        )
 
 
 class FloatVectorGaussOnePointMutation(VectorNPointMutation):
     """
-    Gaussian One Point Float Mutation
+    Gaussian One Point Float Mutation.
+    Mutates a single cell of a float vector.
+    Mutated value is drawn from a Gaussian
+    distribution with mean mu and standard deviation sigma.
+
+    The mutation is repeated until the new value is legal
+    (within the bounds of the vector), or the number of attempts
+    is exceeded.
+
+    Parameters
+    ----------
+    probability : float, optional
+        probability of the operator to occur, by default 1.0
+    arity : int, optional
+        individuals required for the mutation, by default 1
+    mu : float, optional
+        Gaussian distribution mean value, by default 0.0
+    sigma : float, optional
+        Gaussian distribution std value, by default 1.0
+    events : List[str], optional
+        custom events to be published by the mutation, by default None
+    attempts : int, optional
+        number of attempts till failure, by default 5
     """
-    def __init__(self, probability=1.0, arity=1, mu=0.0, sigma=1.0, events=None, attempts=5):
-        super().__init__(n=1,
-                         probability=probability,
-                         arity=arity,
-                         mut_val_getter=lambda vec, index: vec.get_random_number_with_gauss(index, mu, sigma),
-                         events=events,
-                         attempts=attempts)
+
+    def __init__(
+            self,
+            probability=1.0,
+            arity=1,
+            mu=0.0,
+            sigma=1.0,
+            events=None,
+            attempts=5,
+    ):
+        super().__init__(
+            n=1,
+            probability=probability,
+            arity=arity,
+            mut_val_getter=lambda vec, index: vec.get_random_number_with_gauss(
+                index, mu, sigma
+            ),
+            events=events,
+            attempts=attempts,
+        )
 
     def on_fail(self, payload):
         """
-        Handle gauss mutation failure by returning a callable uniform mutation
+        Handle gauss mutation failure by performing uniform mutation.
         """
-        mut = FloatVectorUniformOnePointMutation(self.probability, self.arity, self.events)
+        mut = FloatVectorUniformOnePointMutation(
+            self.probability, self.arity, self.events
+        )
         return mut.apply_operator(payload)
 
 
 class FloatVectorGaussNPointMutation(VectorNPointMutation):
     """
-    Gaussian N Point Float Mutation
+    Gaussian N Point Float Mutation.
+    Mutates exactly n cells of a float vector.
+    Mutated values are drawn from a Gaussian
+    distribution with mean mu and standard deviation sigma.
+
+    The mutation is repeated until the new value is legal
+    (within the bounds of the vector), or the number of attempts
+    is exceeded.
+
+    Parameters
+    ----------
+    probability : float, optional
+        probability of the operator to occur, by default 1.0
+    arity : int, optional
+        individuals required for the mutation, by default 1
+    mu : float, optional
+        Gaussian distribution mean value, by default 0.0
+    sigma : float, optional
+        Gaussian distribution std value, by default 1.0
+    events : List[str], optional
+        custom events to be published by the mutation, by default None
+    attempts : int, optional
+        number of attempts till failure, by default 5
     """
-    def __init__(self, n=1, probability=1.0, arity=1, mu=0.0, sigma=1.0, events=None, attempts=5):
-        super().__init__(n=n,
-                         probability=probability,
-                         arity=arity,
-                         mut_val_getter=lambda vec, index: vec.get_random_number_with_gauss(index, mu, sigma),
-                         events=events,
-                         attempts=attempts)
+
+    def __init__(
+            self,
+            n=1,
+            probability=1.0,
+            arity=1,
+            mu=0.0,
+            sigma=1.0,
+            events=None,
+            attempts=5,
+    ):
+        super().__init__(
+            n=n,
+            probability=probability,
+            arity=arity,
+            mut_val_getter=lambda vec, index: vec.get_random_number_with_gauss(
+                index, mu, sigma
+            ),
+            events=events,
+            attempts=attempts,
+        )
 
     def on_fail(self, payload):
         """
-        Handle gauss mutation failure by returning a callable uniform mutation
+        Handle gauss mutation failure by performing uniform mutation.
         """
-        mut = FloatVectorUniformNPointMutation(self.n, self.probability, self.arity, self.events)
+        mut = FloatVectorUniformNPointMutation(
+            self.n, self.probability, self.arity, self.events
+        )
         return mut.apply_operator(payload)
 
 
 class IntVectorOnePointMutation(VectorNPointMutation):
     """
-    Uniform One Point Integer Mutation
+    Uniform one point Int mutation.
+    Mutates a single cell of a int vector.
+    Mutated value is drawn from a uniform distribution,
+    with respect to the bounds of the vector.
+
+    Parameters
+    ----------
+    probability : float, optional
+        probability of the operator to occur, by default 1.0
+    arity : int, optional
+        individuals required for the mutation, by default 1
+    events : List[str], optional
+        custom events to be published by the mutation, by default None
     """
-    def __init__(self, probability=1.0, arity=1, events=None):
+
+    def __init__(self, probability=0.5, arity=1, events=None, probability_for_each=0.1):
+        self.probability_for_each = probability_for_each
         super().__init__(probability=probability,
                          arity=arity,
-                         mut_val_getter=lambda individual, index: individual.get_random_number_in_bounds(index),
-                         events=events,
-                         n=1)
+                         mut_val_getter=lambda individual, index: individual.get_random_number_in_bounds(
+                             index) if random() <= self.probability_for_each else individual.cell_value(index),
+                         events=events, cell_selector=lambda vec: list(range(vec.size())))
 
 
 class IntVectorNPointMutation(VectorNPointMutation):
     """
-    Uniform N Point Integer Mutation
+    Uniform N point Int mutation.
+    Mutates exactly n cells of a int vector.
+    Mutated value is drawn from a uniform distribution,
+    with respect to the bounds of the vector.
+
+    Parameters
+    ----------
+    probability : float, optional
+        probability of the operator to occur, by default 1.0
+    arity : int, optional
+        individuals required for the mutation, by default 1
+    events : List[str], optional
+        custom events to be published by the mutation, by default None
     """
+
     def __init__(self, probability=1.0, arity=1, events=None, n=1):
-        super().__init__(probability=probability,
-                         arity=arity,
-                         mut_val_getter=lambda individual, index: individual.get_random_number_in_bounds(index),
-                         events=events,
-                         n=n)
+        super().__init__(
+            probability=probability,
+            arity=arity,
+            mut_val_getter=lambda individual, index: individual.get_random_number_in_bounds(
+                index
+            ),
+            events=events,
+            n=n,
+        )
 
 
 class BitStringVectorFlipMutation(VectorNPointMutation):
     """
     One Point Bit-Flip Mutation
+    Flips a single bit of a bit vector.
+
+    Parameters
+    ----------
+    probability : float, optional
+        probability of the operator to occur, by default 1.0
+    arity : int, optional
+        individuals required for the mutation, by default 1
+    events : List[str], optional
+        custom events to be published by the mutation, by default None
     """
+
     def __init__(self, probability=1.0, arity=1, events=None):
-        super().__init__(probability=probability,
-                         arity=arity,
-                         mut_val_getter=lambda individual, index: individual.bit_flip(index),
-                         n=1,
-                         events=events)
+        super().__init__(
+            probability=probability,
+            arity=arity,
+            mut_val_getter=lambda individual, index: individual.bit_flip(
+                index
+            ),
+            n=1,
+            events=events,
+        )
 
 
 class BitStringVectorNFlipMutation(VectorNPointMutation):
     """
-    N Point Bit-Flip Mutation
+    Multiple Bit-Flip Mutation
+    Traverses the bit vector and flips each bit with a certain probability
+    (probability_for_each).
+    Note that this mutation is not guaranteed to flip an exact number of bits,
+    as the flipping is done with a certain probability.
+
+    Parameters
+    ----------
+    probability : float, optional
+        probability of the operator to occur, by default 1.0
+    arity : int, optional
+        individuals required for the mutation, by default 1
+    events : List[str], optional
+        custom events to be published by the mutation, by default None
+    probability_for_each=0.2 : float, optional
+        probability of flipping each bit, by default 0.2
     """
-    def __init__(self, probability=1.0, arity=1, events=None, probability_for_each=0.2, n=1):
+
+    def __init__(
+            self,
+            probability=1.0,
+            arity=1,
+            events=None,
+            probability_for_each=0.2,
+            n=1,
+    ):
         self.probability_for_each = probability_for_each
-        super().__init__(probability=probability,
-                         arity=arity,
-                         mut_val_getter=lambda individual, index: individual.bit_flip(
-                             index) if random() <= self.probability_for_each else individual.cell_value(index),
-                         events=events,
-                         n=n)
+        super().__init__(
+            probability=probability,
+            arity=arity,
+            mut_val_getter=lambda individual, index: (
+                individual.bit_flip(index)
+                if random() <= self.probability_for_each
+                else individual.cell_value(index)
+            ),
+            events=events,
+            n=n
+        )
diff --git a/eckity/genetic_operators/selections/elitism_selection.py b/eckity/genetic_operators/selections/elitism_selection.py
index 8d24fc3f..a5d7f263 100644
--- a/eckity/genetic_operators/selections/elitism_selection.py
+++ b/eckity/genetic_operators/selections/elitism_selection.py
@@ -1,4 +1,4 @@
-from eckity.genetic_operators.selections.selection_method import SelectionMethod
+from eckity.genetic_operators import SelectionMethod
 
 
 class ElitismSelection(SelectionMethod):
@@ -8,14 +8,14 @@ def __init__(self, num_elites, higher_is_better=False, events=None):
         self.higher_is_better = higher_is_better
 
     def select(self, source_inds, dest_inds):
-        elites = sorted(source_inds,
-                        key=lambda ind: ind.get_augmented_fitness(),
-                        reverse=self.higher_is_better)[:self.num_elites]
+        elites = sorted(
+            source_inds,
+            key=lambda ind: ind.get_augmented_fitness(),
+            reverse=self.higher_is_better,
+        )[: self.num_elites]
         for elite in elites:
             cloned = elite.clone()
             cloned.selected_by.append(type(self).__name__)
             dest_inds.append(cloned)
         self.selected_individuals = dest_inds
-        # TODO shouldn't it be after_operator? why is this needed?
-        # self.publish("after_selection")
         return dest_inds
diff --git a/eckity/genetic_operators/selections/fp_selection.py b/eckity/genetic_operators/selections/fp_selection.py
new file mode 100644
index 00000000..356ae42d
--- /dev/null
+++ b/eckity/genetic_operators/selections/fp_selection.py
@@ -0,0 +1,73 @@
+import numpy as np
+
+from overrides import override
+from scipy.special import softmax
+
+from eckity.genetic_operators import SelectionMethod
+
+
+class FitnessProportionateSelection(SelectionMethod):
+    def __init__(
+        self,
+        higher_is_better=False,
+        events=None,
+    ):
+        """
+        Fitness Proportionate Selection, AKA Roulette Wheel Selection.
+        In this method, the probability of selecting an individual from the
+        population is proportional to its fitness score.
+        Individuals with higher fitness have a higher chance of being
+        selected as parents. This selection method simulates a roulette wheel,
+        where the size of the slice for each individual on the wheel is
+        determined by its fitness score
+
+        Parameters
+        ----------
+        higher_is_better : bool, optional
+            is higher fitness better or worse, by default False
+        events : List[str], optional
+            selection events, by default None
+        """
+        super().__init__(events=events, higher_is_better=higher_is_better)
+
+    @override
+    def select(self, source_inds, dest_inds):
+        n_selected = len(source_inds) - len(dest_inds)
+
+        fitness_scores = np.array(
+            [ind.get_augmented_fitness() for ind in source_inds]
+        )
+
+        min_val = np.min(fitness_scores)
+
+        if min_val < 0:
+            raise ValueError(
+                "Fitness scores must be non-negative for FP Selection"
+            )
+
+        # convert higher fitness scores to be better
+        if not self.higher_is_better:
+            # add smoothing (if necessary) to avoid division by zero
+            smoothing = 1 if min_val == 0 else 0
+            fitness_scores = 1 / (fitness_scores + smoothing)
+
+        # generate a distribution of fitness scores
+        fit_p = (
+            softmax(fitness_scores)
+            if np.sum(fitness_scores) != 1
+            else fitness_scores
+        )
+
+        # select individuals proportionate to fitness
+        selected_inds = np.random.choice(
+            source_inds, size=n_selected, replace=True, p=fit_p
+        )
+
+        for selected_ind in selected_inds:
+            clone = selected_ind.clone()
+            clone.selected_by.append(type(self).__name__)
+            dest_inds.append(clone)
+
+        self.selected_individuals = dest_inds
+
+        return dest_inds
diff --git a/eckity/genetic_operators/selections/tournament_selection.py b/eckity/genetic_operators/selections/tournament_selection.py
index 73668441..0aedd771 100644
--- a/eckity/genetic_operators/selections/tournament_selection.py
+++ b/eckity/genetic_operators/selections/tournament_selection.py
@@ -1,29 +1,79 @@
+import random
+from overrides import override
 
-from random import choices
-
-from eckity.genetic_operators.selections.selection_method import SelectionMethod
+from eckity.genetic_operators.selections.selection_method import (
+    SelectionMethod,
+)
 
 
 class TournamentSelection(SelectionMethod):
-    def __init__(self, tournament_size, higher_is_better=False, events=None):
+    def __init__(
+        self,
+        tournament_size,
+        higher_is_better=False,
+        replace=True,
+        events=None,
+    ):
+        """
+        Tournament Selection method.
+        In this method, small groups (known as tournaments) are created from
+        randomly chosen individuals. The individual(s) with the best fitness
+        scores are selected to reproduce the next generation.
+
+        Parameters
+        ----------
+        tournament_size : int
+            Size of each tournament.
+            larger tournaments focus on exploitation,
+            while small tournaments focus on exploration.
+        higher_is_better : bool, optional
+            is higher fitness better or worse, by default False
+        replace : bool, optional
+            whether tournaments can contain multiple copies of the same
+            individual, by default False
+        events : List[str], optional
+            selection events, by default None
+        """
         super().__init__(events=events, higher_is_better=higher_is_better)
         self.tournament_size = tournament_size
+        self.replace = replace
 
+    @override
     def select(self, source_inds, dest_inds):
-        # the selection should add len(source_inds) individuals to dest_inds, so the required number of tournaments
-        # is the size of source individuals divided by the number of winners per tournament
-        # n_tournaments = len(source_inds) // self.operator_arity
+        if not self.replace and len(source_inds) < self.tournament_size:
+            raise ValueError(
+                f"""Tournament size must be greater or equal to
+                            the number of individuals to be selected from.
+                             Tournament size={self.tournament_size},
+                             number of individuals: {len(source_inds)}"""
+            )
+
+        """
+        The selection should add len(source_inds) individuals to dest_inds,
+        so the required number of tournaments is the size of source
+        individuals divided by the number of winners per tournament.
+        `n_tournaments = len(source_inds) // self.operator_arity`
+        """
         n_tournaments = (len(source_inds) - len(dest_inds)) // self.arity
 
+        """
+        Select the appropriate tournament creation function.
+        `random.choices` selects k elements with replacements,
+        `random.sample` selects k unique elements.
+        """
+        sel_func = random.choices if self.replace else random.sample
+
         # create all tournaments beforehand
-        tournaments = [choices(source_inds, k=self.tournament_size) for _ in range(n_tournaments)]
+        tournaments = [
+            sel_func(source_inds, k=self.tournament_size)
+            for _ in range(n_tournaments)
+        ]
 
         # pick the winner of each tournament and add all winners to dest_inds
         winners = [self._pick_tournament_winner(tour) for tour in tournaments]
         dest_inds.extend(winners)
 
         self.selected_individuals = dest_inds
-        # self.publish("after_selection")   # TODO this already publishes 'after_operator' event
 
         return dest_inds
 
diff --git a/eckity/individual.py b/eckity/individual.py
index ed7c8ec5..322f279d 100644
--- a/eckity/individual.py
+++ b/eckity/individual.py
@@ -31,9 +31,10 @@ class Individual:
         in the last generation.
         *** Note that failed operators are still included in this list. ***
     """
+
     id = 1
 
-    def __init__(self, fitness: Fitness):
+    def __init__(self, fitness: Fitness, update_parents: bool = False):
         self.update_id()
         self.gen = 0
         self.fitness = fitness
@@ -43,6 +44,9 @@ def __init__(self, fitness: Fitness):
         self.selected_by = []  # last gen
         self.applied_operators = []  # last gen
 
+        self.update_parents = update_parents
+        if update_parents:
+            self.parents = []  # last gen
 
     def update_id(self):
         self.id = Individual.id
@@ -54,6 +58,8 @@ def set_fitness_not_evaluated(self):
     def clone(self):
         result = deepcopy(self)
         result.cloned_from.append(self.id)
+        if result.update_parents:
+            result.parents = []
         result.update_id()
         return result
 
diff --git a/eckity/multi_objective_evolution/__init__.py b/eckity/multi_objective_evolution/__init__.py
index e69de29b..74aa2dbe 100644
--- a/eckity/multi_objective_evolution/__init__.py
+++ b/eckity/multi_objective_evolution/__init__.py
@@ -0,0 +1,6 @@
+from .crowding_termination_checker import CrowdingTerminationChecker
+from .nsga2_breeder import NSGA2Breeder
+from .nsga2_evolution import NSGA2Evolution
+from .nsga2_front_sorting import NSGA2FrontSorting
+from .nsga2_plot import NSGA2Plot
+from .moe_best_worst_statistics import MOEBestWorstStatistics
diff --git a/eckity/multi_objective_evolution/crowding_termination_checker.py b/eckity/multi_objective_evolution/crowding_termination_checker.py
index 4ec8b684..dce14b34 100644
--- a/eckity/multi_objective_evolution/crowding_termination_checker.py
+++ b/eckity/multi_objective_evolution/crowding_termination_checker.py
@@ -17,7 +17,6 @@ def __init__(self, threshold=0.1):
 
 	def should_terminate(self, population=None, best_individual=None, gen_number=None):
 		"""
-		allawys return false so the program wont terminate until it finishes its iteration
 		Parameters
 		----------
 		population: Population
@@ -34,12 +33,15 @@ def should_terminate(self, population=None, best_individual=None, gen_number=Non
 		bool
 			True if the algorithm should terminate early, False otherwise.
 		"""
-		max_crwding_in_pop = self._find_max_crowding(population)
-		return max_crwding_in_pop < self.threshold and max_crwding_in_pop != 0
+		max_crowding_in_pop = self._find_max_crowding(population)
+		return max_crowding_in_pop < self.threshold and max_crowding_in_pop != 0
 
 	def _find_max_crowding(self, population):
 		crowdings = []
 		for sup_pop in population.sub_populations:
-			crowdings.append(
-				max([ind.fitness.crowding for ind in sup_pop.individuals if ind.fitness.crowding != float("inf")]))
+			values = [ind.fitness.crowding for ind in sup_pop.individuals if ind.fitness.crowding != float("inf")]
+			if values:
+				crowdings.append(max(values))
+			else:
+				crowdings.append(float("inf"))
 		return max(crowdings)
diff --git a/eckity/multi_objective_evolution/moe_best_worst_statistics.py b/eckity/multi_objective_evolution/moe_best_worst_statistics.py
new file mode 100644
index 00000000..398c382f
--- /dev/null
+++ b/eckity/multi_objective_evolution/moe_best_worst_statistics.py
@@ -0,0 +1,79 @@
+from sys import stdout
+
+from eckity.statistics.statistics import Statistics
+
+
+class MOEBestWorstStatistics(Statistics):
+    """
+    Concrete Statistics class.
+    Intended for Multi Objective Evolution.
+    Provides statistics about the best and worst fitness fronts of every sub-population in
+    some generation.
+
+    Parameters
+    ----------
+    format_string: str
+        String format of the data to output.
+        Value depends on the information the statistics provides.
+        For more information, check out the concrete classes who extend this class.
+
+    output_stream: Optional[SupportsWrite[str]], default=stdout
+        Output file for the statistics.
+        By default, the statistics will be written to stdout.
+    """
+
+    def __init__(self, format_string=None, output_stream=stdout):
+        if format_string is None:
+            format_string = "first front has {} individuals ({:.1f}%)\nfirst front's corners: {}\nlast front's corners: {}\n"
+        super().__init__(format_string)
+        self.output_stream = output_stream
+
+    def write_statistics(self, sender, data_dict):
+        print(
+            f'generation #{data_dict["generation_num"]}',
+            file=self.output_stream,
+        )
+        for index, sub_pop in enumerate(
+            data_dict["population"].sub_populations
+        ):
+            first_front_corners = self.get_corners(sub_pop, 1)
+            last_rank = sorted(
+                list(
+                    set(
+                        [
+                            indiv.fitness.front_rank
+                            for indiv in sub_pop.individuals
+                        ]
+                    )
+                )
+            )[-1]
+            last_front_corners = self.get_corners(sub_pop, last_rank)
+            first_front = [
+                indiv.get_pure_fitness()
+                for indiv in sub_pop.individuals
+                if indiv.fitness.front_rank == 1
+            ]
+            print(
+                f"subpopulation #{index} has {last_rank} fronts",
+                file=self.output_stream,
+            )
+            print(
+                self.format_string.format(
+                    len(first_front),
+                    len(first_front) / len(sub_pop.individuals) * 100,
+                    first_front_corners,
+                    last_front_corners,
+                ),
+                file=self.output_stream,
+            )
+
+    def get_corners(self, sub_pop, rank):
+        result = [
+            indiv.get_pure_fitness()
+            for indiv in sub_pop.individuals
+            if indiv.fitness.front_rank == rank
+            and indiv.fitness.crowding == float("inf")
+        ]
+        result = [list(x) for x in set(tuple(x) for x in result)]  # unique
+        assert len(result) in [1, 2]
+        return result
diff --git a/eckity/multi_objective_evolution/nsga2_evolution.py b/eckity/multi_objective_evolution/nsga2_evolution.py
index 444c6e3f..fd26beaf 100644
--- a/eckity/multi_objective_evolution/nsga2_evolution.py
+++ b/eckity/multi_objective_evolution/nsga2_evolution.py
@@ -1,148 +1,178 @@
+import logging
 from time import time
+
 from overrides import overrides
 
 from eckity.algorithms.algorithm import Algorithm
 from eckity.breeders.simple_breeder import SimpleBreeder
-from eckity.evaluators.simple_population_evaluator import SimplePopulationEvaluator
-from eckity.multi_objective_evolution.nsga2_front_sorting import NSGA2FrontSorting
+from eckity.evaluators import SimplePopulationEvaluator
+from eckity.multi_objective_evolution.nsga2_front_sorting import (
+    NSGA2FrontSorting,
+)
+from eckity.random import RNG
+from eckity.termination_checkers import ThresholdFromTargetTerminationChecker
 
-from eckity.termination_checkers.threshold_from_target_termination_checker \
-	import ThresholdFromTargetTerminationChecker
+logger = logging.getLogger(__name__)
 
 
 class NSGA2Evolution(Algorithm):
-	def __init__(self,
-				 population,
-				 statistics=None,
-				 breeder=SimpleBreeder(),
-				 population_evaluator=SimplePopulationEvaluator(),
-				 NSGA2FrontSorting=NSGA2FrontSorting(),
-				 max_generation=500,
-				 events=None,
-				 event_names=None,
-				 termination_checker=ThresholdFromTargetTerminationChecker(threshold=0),
-				 max_workers=None,
-				 random_generator=None,
-				 random_seed=time(),
-				 generation_seed=None,
-				 best_of_run_=None,
-				 best_of_run_evaluator=None,
-				 best_of_gen=None,
-				 worst_of_gen=None,
-				 generation_num=0):
-
-		if event_names is None:
-			_event_names = ['before_eval', 'after_eval', 'before_breeding', 'after_breeding']
-		else:
-			_event_names = event_names
-
-		if statistics is None:
-			statistics = []
-
-		super().__init__(population, statistics=statistics, breeder=breeder, population_evaluator=population_evaluator,
-						 events=events, event_names=_event_names, max_workers=max_workers,
-						 random_generator=random_generator, random_seed=random_seed, generation_seed=generation_seed,
-						 termination_checker=termination_checker, generation_num=generation_num)
-
-		self.termination_checker = termination_checker
-		self.best_of_run_ = best_of_run_
-		self.best_of_run_evaluator = best_of_run_evaluator
-		self.best_of_gen = best_of_gen
-		self.worst_of_gen = worst_of_gen
-		self.max_generation = max_generation
-
-		self.final_generation_ = None
-		self.NSGA2FrontSorting = NSGA2FrontSorting
-
-	#
-	@overrides
-	def generation_iteration(self, gen):
-		"""
-		Performs one iteration of the evolutionary run, for the current generation
-
-		Parameters
-		----------
-		gen:
-			current generation number (for example, generation #100)
-
-		Returns
-		-------
-		None.
-		"""
-
-		# breed population
-		self.breeder.breed(self.population)
-		# Evaluate the entire population and get the best individual
-
-		self.population_evaluator.act(self.population)
-
-		self.NSGA2FrontSorting.select_for_population(self.population)
-		self.best_of_gen = self._get_pareto_fronts()
-
-	def _get_pareto_fronts(self):
-		'''
-		Returns: the pareto front for each sub_population
-		-------
-		'''
-		pareto_fronts = []
-		for sub_pop in self.population.sub_populations:
-			pareto_fronts.append([ind for ind in sub_pop.individuals if ind.fitness.front_rank == 1])
-		return pareto_fronts
-
-	def initialize(self):
-		"""
-		Initialize the evolutionary algorithm
-
-		Register statistics to `after_generation` event
-		"""
-		super().initialize()
-		for stat in self.statistics:
-			self.register('after_generation', stat.write_statistics)
-
-	def execute(self, **kwargs):
-		"""
-		Compute output using best evolved individual.
-		Use `execute` in a non-sklearn setting.
-		Input keyword arguments that set variable values.
-		For example if `terminal_set=['x', 'y', 1, -1]` then call `execute(x=..., y=...)`.
-
-		Parameters
-		----------
-		**kwargs : keyword arguments
-			The input to the program (tree).
-
-		Returns
-		-------
-		object
-			Output as computed by the best individual of the evolutionary run.
-
-		"""
-		return self.best_of_run_.execute(**kwargs)
-
-	@overrides
-	def finish(self):
-		"""
-		Finish the evolutionary run by showing the best individual and printing the best fitness
-		"""
-		self.best_of_run_.show()
-		print(self.best_of_run_.get_pure_fitness())
-
-	def event_name_to_data(self, event_name):
-		if event_name == "init":
-			return {
-				"population": self.population,
-				"statistics": self.statistics,
-				"breeder": self.breeder,
-				"termination_checker": self.termination_checker,
-				"max_generation": self.max_generation,
-				"events": self.events,
-				"max_workers": self.max_workers
-			}
-
-		# default case
-		return {
-			"population": self.population,
-			"best_of_run_": self.best_of_run_,
-			"best_of_gen": self.best_of_gen,
-			"generation_num": self.generation_num
-		}
+    def __init__(
+        self,
+        population,
+        statistics=None,
+        breeder=SimpleBreeder(),
+        population_evaluator=SimplePopulationEvaluator(),
+        NSGA2FrontSorting=NSGA2FrontSorting(),
+        max_generation=500,
+        events=None,
+        event_names=None,
+        termination_checker=ThresholdFromTargetTerminationChecker(threshold=0),
+        executor="thread",
+        max_workers=None,
+        random_generator=RNG(),
+        random_seed=None,
+        generation_seed=None,
+        best_of_run_=None,
+        best_of_run_evaluator=None,
+        best_of_gen=None,
+        worst_of_gen=None,
+        generation_num=0,
+    ):
+
+        if event_names is None:
+            _event_names = [
+                "before_eval",
+                "after_eval",
+                "before_breeding",
+                "after_breeding",
+            ]
+        else:
+            _event_names = event_names
+
+        if statistics is None:
+            statistics = []
+
+        super().__init__(
+            population,
+            statistics=statistics,
+            breeder=breeder,
+            population_evaluator=population_evaluator,
+            events=events,
+            event_names=_event_names,
+            executor=executor,
+            max_workers=max_workers,
+            random_generator=random_generator,
+            random_seed=random_seed,
+            generation_seed=generation_seed,
+            termination_checker=termination_checker,
+            generation_num=generation_num,
+        )
+
+        self.termination_checker = termination_checker
+        self.best_of_run_ = best_of_run_
+        self.best_of_run_evaluator = best_of_run_evaluator
+        self.best_of_gen = best_of_gen
+        self.worst_of_gen = worst_of_gen
+        self.max_generation = max_generation
+
+        self.final_generation_ = None
+        self.NSGA2FrontSorting = NSGA2FrontSorting
+
+    #
+    @overrides
+    def generation_iteration(self, gen: int) -> bool:
+        """
+        Performs one iteration of the evolutionary run, for the current generation
+
+        Parameters
+        ----------
+        gen:
+                current generation number (for example, generation #100)
+
+        Returns
+        -------
+        None.
+        """
+
+        # breed population
+        self.breeder.breed(self.population)
+        # Evaluate the entire population and get the best individual
+
+        self.population_evaluator.act(self.population)
+
+        self.NSGA2FrontSorting.select_for_population(self.population)
+        self.best_of_gen = self._get_pareto_fronts()
+
+    def _get_pareto_fronts(self):
+        """
+        Returns: the pareto front for each sub_population
+        -------
+        """
+        pareto_fronts = []
+        for sub_pop in self.population.sub_populations:
+            pareto_fronts.append(
+                [
+                    ind
+                    for ind in sub_pop.individuals
+                    if ind.fitness.front_rank == 1
+                ]
+            )
+        return pareto_fronts
+
+    def initialize(self):
+        """
+        Initialize the evolutionary algorithm
+
+        Register statistics to `after_generation` event
+        """
+        super().initialize()
+        for stat in self.statistics:
+            self.register("after_generation", stat.write_statistics)
+
+    def execute(self, **kwargs):
+        """
+        Compute output using best evolved individual.
+        Use `execute` in a non-sklearn setting.
+        Input keyword arguments that set variable values.
+        For example if `terminal_set=['x', 'y', 1, -1]` then call `execute(x=..., y=...)`.
+
+        Parameters
+        ----------
+        **kwargs : keyword arguments
+                The input to the program (tree).
+
+        Returns
+        -------
+        object
+                Output as computed by the best individual of the evolutionary run.
+
+        """
+        return self.best_of_run_.execute(**kwargs)
+
+    @overrides
+    def finish(self):
+        """
+        Finish the evolutionary run by showing the best individual and printing the best fitness
+        """
+        self.best_of_run_.show()
+        logger.info(self.best_of_run_.get_pure_fitness())
+
+    def event_name_to_data(self, event_name):
+        if event_name == "init":
+            return {
+                "population": self.population,
+                "statistics": self.statistics,
+                "breeder": self.breeder,
+                "termination_checker": self.termination_checker,
+                "max_generation": self.max_generation,
+                "events": self.events,
+                "max_workers": self.max_workers,
+            }
+
+        # default case
+        return {
+            "population": self.population,
+            "best_of_run_": self.best_of_run_,
+            "best_of_gen": self.best_of_gen,
+            "generation_num": self.generation_num,
+        }
diff --git a/eckity/multi_objective_evolution/nsga2_fitness.py b/eckity/multi_objective_evolution/nsga2_fitness.py
index 5ae36e8f..3bc7714b 100644
--- a/eckity/multi_objective_evolution/nsga2_fitness.py
+++ b/eckity/multi_objective_evolution/nsga2_fitness.py
@@ -8,127 +8,149 @@
 
 
 class NSGA2Fitness(Fitness):
-	def __init__(self,
-				 fitness: List[float] = None,
-				 higher_is_better=False):
-		is_evaluated = fitness is not None
-		super().__init__(higher_is_better=higher_is_better, is_evaluated=is_evaluated)
-		self.fitness: List[float] = fitness  # list of objectivs
-		self.crowding = 0
-		self.front_rank = float("inf")
-		if self.fitness!=None and type(self.higher_is_better) is bool:
-			self.higher_is_better = [self.higher_is_better] * len(fitness)
-
-
-
-	def set_fitness(self, fitness):
-		if self._is_evaluated:
-			raise AttributeError('fitness already evaluated and set to', self.fitness)
-		self.fitness = fitness
-		self._is_evaluated = True
-		if type(self.higher_is_better) is bool:
-			self.higher_is_better = [self.higher_is_better] * len(fitness)
-
-	@overrides
-	def get_pure_fitness(self):
-		if not self._is_evaluated:
-			raise ValueError('Fitness not evaluated yet')
-		return self.fitness
-
-	@overrides
-	def set_not_evaluated(self):
-		self._is_evaluated = False
-		self.fitness = None
-		self.crowding = 0
-		self.front_rank = float("inf")
-
-	def check_comparable_fitnesses(self, other_fitness: Fitness, ind: Individual, other_ind: Individual):
-		if not isinstance(other_fitness, NSGA2Fitness):
-			raise TypeError('Expected NSGA2Fitness object in better_than, got', type(other_fitness))
-		if not self.is_fitness_evaluated() or not other_fitness.is_fitness_evaluated():
-			raise ValueError('Fitnesses must be evaluated before comparison')
-		if len(other_fitness.get_augmented_fitness(other_ind)) != len(self.get_augmented_fitness(ind)):
-			raise ValueError('Fitnesses must be of the same lngth')
-
-	def better_than(self, ind, other_fitness, other_ind):
-		'''
-
-		Parameters
-		----------
-		ind:Individual
-		other_fitness:NSGA2Fitness
-		other_ind:Individual
-
-		Returns : True ind has lower rank or equal rank and bigger crwoding value
-		-------
-
-		'''
-		if self.front_rank == float("inf"):  # first iteration
-			return bool(rd.getrandbits(1))  # random true false
-		else:
-			self.check_comparable_fitnesses(other_fitness, ind, other_ind)
-			if self.front_rank == other_ind.fitness.front_rank:
-				return self.crowding > other_ind.fitness.crowding
-			return self.front_rank > other_ind.fitness.front_rank
-
-	def equal_to(self, ind, other_fitness, other_ind):
-		return self.front_rank == other_ind.fitness.front_rank and \
-			   self.crowding == other_ind.fitness.crowding
-
-	def dominate(self, ind, other_fitness, other_ind):
-		self.check_comparable_fitnesses(other_fitness, ind, other_ind)
-		self_fit = self.get_augmented_fitness(ind)
-		other_fit = other_fitness.get_augmented_fitness(other_ind)
-		return all([self._o1_at_least_o2(o1, o2, h_is_b) for h_is_b, o1, o2 in
-					zip(self.higher_is_better, self_fit, other_fit)]) and \
-			   any([self._o1_better_then_o2(o1, o2, h_is_b) for h_is_b, o1, o2 in
-					zip(self.higher_is_better, self_fit, other_fit)])
-
-	def _o1_at_least_o2(self, o1, o2, higher_is_better):
-		'''
-
-		Parameters
-		----------
-		o1: objective i of individual 1
-		o2: objective i of individual 2
-		higher_is_better :boolean - is higher better for objective i of he fitness ?
-
-		Returns :x1 >= x2 or x1 <= x2 according to higher is better
-		-------
-
-		'''
-		if higher_is_better:
-			return o1 >= o2
-		else:
-			return o1 <= o2
-
-	def _o1_better_then_o2(self, o1, o2, higher_is_better):
-		'''
-
-		Parameters
-		----------
-		o1: objective i of individual 1
-		o2: objective i of individual 2
-		higher_is_better :boolean - is higher better for objective i of he fitness ?
-
-		Returns :x1 > x2 or x1 < x2 according to higher is better
-		-------
-
-		'''
-		if higher_is_better:
-			return o1 > o2
-		else:
-			return o1 < o2
-
-	# def equal_to(self, ind, other_fitness, other_ind):
-	#     self.check_comparable_fitnesses(other_fitness,ind,other_ind)
-	#     self_fit = self.get_augmented_fitness(ind)
-	#     other_fit = other_fitness.get_augmented_fitness(other_ind)
-	#     all([x1 == x2 for x1, x2 in zip(self_fit, other_fit)])
-
-	def __getstate__(self):
-		state = self.__dict__.copy()
-		if not self.should_cache_between_gens:
-			state['_is_evaluated'] = False
-			state['fitness'] = None
-		return state
+    def __init__(
+        self,
+        fitness: List[float] = None,
+        higher_is_better=False,
+        cache: bool = False,
+        is_relative_fitness: bool = False,
+    ):
+        is_evaluated = fitness is not None
+        super().__init__(
+            higher_is_better=higher_is_better,
+            is_evaluated=is_evaluated,
+            cache=cache,
+            is_relative_fitness=is_relative_fitness,
+        )
+        self.fitness: List[float] = fitness  # list of objectives
+        self.crowding = 0
+        self.front_rank = float("inf")
+        if self.fitness is not None and type(self.higher_is_better) is bool:
+            self.higher_is_better = [self.higher_is_better] * len(fitness)
+
+    def set_fitness(self, fitness):
+        if self._is_evaluated:
+            raise AttributeError("fitness already evaluated and set to", self.fitness)
+        self.fitness = fitness
+        self._is_evaluated = True
+        if type(self.higher_is_better) is bool:
+            self.higher_is_better = [self.higher_is_better] * len(fitness)
+
+    @overrides
+    def get_pure_fitness(self):
+        if not self._is_evaluated:
+            raise ValueError("Fitness not evaluated yet")
+        return self.fitness
+
+    @overrides
+    def set_not_evaluated(self):
+        self._is_evaluated = False
+        self.fitness = None
+        self.crowding = 0
+        self.front_rank = float("inf")
+
+    def check_comparable_fitnesses(
+        self, other_fitness: Fitness, ind: Individual, other_ind: Individual
+    ):
+        if not isinstance(other_fitness, NSGA2Fitness):
+            raise TypeError(
+                "Expected NSGA2Fitness object in better_than, got", type(other_fitness)
+            )
+        if not self.is_fitness_evaluated() or not other_fitness.is_fitness_evaluated():
+            raise ValueError("Fitnesses must be evaluated before comparison")
+        if len(other_fitness.get_augmented_fitness(other_ind)) != len(
+            self.get_augmented_fitness(ind)
+        ):
+            raise ValueError("Fitnesses must be of the same lngth")
+
+    def better_than(self, ind, other_fitness, other_ind):
+        """
+
+        Parameters
+        ----------
+        ind:Individual
+        other_fitness:NSGA2Fitness
+        other_ind:Individual
+
+        Returns : True ind has lower rank or equal rank and bigger crwoding value
+        -------
+
+        """
+        if self.front_rank == float("inf"):  # first iteration
+            return bool(rd.getrandbits(1))  # random true false
+        else:
+            self.check_comparable_fitnesses(other_fitness, ind, other_ind)
+            if self.front_rank == other_ind.fitness.front_rank:
+                return self.crowding > other_ind.fitness.crowding
+            return self.front_rank > other_ind.fitness.front_rank
+
+    def equal_to(self, ind, other_fitness, other_ind):
+        return (
+            self.front_rank == other_ind.fitness.front_rank
+            and self.crowding == other_ind.fitness.crowding
+        )
+
+    def dominate(self, ind, other_fitness, other_ind):
+        self.check_comparable_fitnesses(other_fitness, ind, other_ind)
+        self_fit = self.get_augmented_fitness(ind)
+        other_fit = other_fitness.get_augmented_fitness(other_ind)
+        return all(
+            [
+                self._o1_at_least_o2(o1, o2, h_is_b)
+                for h_is_b, o1, o2 in zip(self.higher_is_better, self_fit, other_fit)
+            ]
+        ) and any(
+            [
+                self._o1_better_then_o2(o1, o2, h_is_b)
+                for h_is_b, o1, o2 in zip(self.higher_is_better, self_fit, other_fit)
+            ]
+        )
+
+    def _o1_at_least_o2(self, o1, o2, higher_is_better):
+        """
+
+        Parameters
+        ----------
+        o1: objective i of individual 1
+        o2: objective i of individual 2
+        higher_is_better :boolean - is higher better for objective i of he fitness ?
+
+        Returns :x1 >= x2 or x1 <= x2 according to higher is better
+        -------
+
+        """
+        if higher_is_better:
+            return o1 >= o2
+        else:
+            return o1 <= o2
+
+    def _o1_better_then_o2(self, o1, o2, higher_is_better):
+        """
+
+        Parameters
+        ----------
+        o1: objective i of individual 1
+        o2: objective i of individual 2
+        higher_is_better :boolean - is higher better for objective i of he fitness ?
+
+        Returns :x1 > x2 or x1 < x2 according to higher is better
+        -------
+
+        """
+        if higher_is_better:
+            return o1 > o2
+        else:
+            return o1 < o2
+
+    # def equal_to(self, ind, other_fitness, other_ind):
+    #     self.check_comparable_fitnesses(other_fitness,ind,other_ind)
+    #     self_fit = self.get_augmented_fitness(ind)
+    #     other_fit = other_fitness.get_augmented_fitness(other_ind)
+    #     all([x1 == x2 for x1, x2 in zip(self_fit, other_fit)])
+
+    def __getstate__(self):
+        state = self.__dict__.copy()
+        if not self.cache:
+            state["_is_evaluated"] = False
+            state["fitness"] = None
+        return state
diff --git a/eckity/multi_objective_evolution/nsga2_front_sorting.py b/eckity/multi_objective_evolution/nsga2_front_sorting.py
index a1946f38..3e34ef2d 100644
--- a/eckity/multi_objective_evolution/nsga2_front_sorting.py
+++ b/eckity/multi_objective_evolution/nsga2_front_sorting.py
@@ -76,9 +76,10 @@ def _calc_fronts_crowding(self, front: List[Individual]):
 			for i in range(1, len(front) - 1):
 				curr_crowding = front[i + 1].get_pure_fitness()[objective_index] - front[i - 1].get_pure_fitness()[
 					objective_index]
-				curr_crowding /= (front[-1].get_pure_fitness()[objective_index] - front[0].get_pure_fitness()[
-					objective_index])
-				front[i].fitness.crowding += curr_crowding
+				d = front[-1].get_pure_fitness()[objective_index] - front[0].get_pure_fitness()[objective_index]
+				if d:
+					curr_crowding /= d
+					front[i].fitness.crowding += curr_crowding
 
 	def _init_domination_dict(self, source_inds: List[Individual]):
 		self.domination_dict = defaultdict(lambda: DominationCounter())
diff --git a/eckity/multi_objective_evolution/nsga2_plot.py b/eckity/multi_objective_evolution/nsga2_plot.py
index e1fff3ca..57457705 100644
--- a/eckity/multi_objective_evolution/nsga2_plot.py
+++ b/eckity/multi_objective_evolution/nsga2_plot.py
@@ -2,6 +2,11 @@
 
 
 class NSGA2Plot:
+	def __init__(self, objective1='Objective function 1', objective2='Objective function 2', savefile=None):
+		self.savefile = savefile
+		self.objective1 = objective1
+		self.objective2 = objective2
+
 	def print_plots(self, sender, data_dict):
 		population = data_dict["population"]
 		ind = population.sub_populations[0].individuals[0]
@@ -16,6 +21,8 @@ def _print_plots_2d(self, population):
 
 	def _print_plot(self, ls1, ls2):
 		plt.scatter(ls1, ls2)
-		plt.xlabel('Objective function 1')
-		plt.ylabel('Objective function 2')
+		plt.xlabel(self.objective1)
+		plt.ylabel(self.objective2)
+		if self.savefile is not None:
+			plt.savefig(self.savefile)
 		plt.show()
diff --git a/eckity/population.py b/eckity/population.py
index 2f07fe1f..222ea529 100644
--- a/eckity/population.py
+++ b/eckity/population.py
@@ -1,5 +1,3 @@
-
-
 class Population:
     """
     Population of individuals to be evolved in the evolutionary run.
@@ -10,6 +8,7 @@ class Population:
         subpopulations contained in the population.
         For more information, see eckity.subpopulation.
     """
+
     def __init__(self, sub_populations):
         self.sub_populations = sub_populations
 
@@ -21,14 +20,22 @@ def find_individual_subpopulation(self, individual):
         for sub_pop in self.sub_populations:
             if sub_pop.contains_individual(individual):
                 return sub_pop
-        raise ValueError('The given individual was not found in any sub-population.'
-                         'It probably belongs to a previous generation population.')
+        raise ValueError(
+            "The given individual was not found in any sub-population."
+            "It probably belongs to a previous generation population."
+        )
 
     def get_best_individuals(self):
-        return [sub_pop.get_best_individual() for sub_pop in self.sub_populations]
+        return [
+            sub_pop.get_best_individual() for sub_pop in self.sub_populations
+        ]
 
     def get_worst_individuals(self):
-        return [sub_pop.get_worst_individual() for sub_pop in self.sub_populations]
+        return [
+            sub_pop.get_worst_individual() for sub_pop in self.sub_populations
+        ]
 
     def get_average_fitness(self):
-        return [sub_pop.get_average_fitness() for sub_pop in self.sub_populations]
+        return [
+            sub_pop.get_average_fitness() for sub_pop in self.sub_populations
+        ]
diff --git a/eckity/random/__init__.py b/eckity/random/__init__.py
new file mode 100644
index 00000000..17485182
--- /dev/null
+++ b/eckity/random/__init__.py
@@ -0,0 +1,1 @@
+from .rng import RNG
diff --git a/eckity/random/rng.py b/eckity/random/rng.py
new file mode 100644
index 00000000..9efa51c4
--- /dev/null
+++ b/eckity/random/rng.py
@@ -0,0 +1,34 @@
+import random
+import numpy as np
+
+
+class RNG:
+    """
+    Random number generator.
+    Necessary for experiment reproducibility.
+    Currently supports `random` and `numpy` modules.
+    For additional modules, extend this class and override `set_seed`.
+
+    Example:
+    class TorchRNG(RNG):
+        @override
+        def set_seed(self, seed: int]) -> None:
+            super().set_seed(seed)
+            torch.manual_seed(seed)
+    """
+
+    def __init__(self) -> None:
+        self._seed = None
+
+    def set_seed(self, seed: int) -> None:
+        """
+        Set seed for random number generator.
+
+        Parameters
+        ----------
+        seed : int
+            Seed for random number generator
+        """
+        self._seed = seed
+        random.seed(seed)
+        np.random.seed(seed)
diff --git a/eckity/sklearn_compatible/__init__.py b/eckity/sklearn_compatible/__init__.py
index e69de29b..847bcd78 100644
--- a/eckity/sklearn_compatible/__init__.py
+++ b/eckity/sklearn_compatible/__init__.py
@@ -0,0 +1,6 @@
+from .classification_evaluator import ClassificationEvaluator
+from .regression_evaluator import RegressionEvaluator
+
+from .sklearn_wrapper import SklearnWrapper
+from .sk_classifier import SKClassifier
+from .sk_regressor import SKRegressor
diff --git a/eckity/sklearn_compatible/classification_evaluator.py b/eckity/sklearn_compatible/classification_evaluator.py
index f3600b7f..3135911a 100644
--- a/eckity/sklearn_compatible/classification_evaluator.py
+++ b/eckity/sklearn_compatible/classification_evaluator.py
@@ -2,24 +2,43 @@
 This module implements the fitness evaluation class, which delivers the fitness function.
 You will need to implement such a class to work with your own problem and fitness function.
 """
+
 import numpy as np
 from sklearn.metrics import accuracy_score
 
-from eckity.evaluators.simple_individual_evaluator import SimpleIndividualEvaluator
+from eckity.evaluators.simple_individual_evaluator import (
+    SimpleIndividualEvaluator,
+)
+from scipy.special import expit as sigmoid
 
-CLASSIFICATION_THRESHOLD = 0
+CLF_METHODS = ("sigmoid", "argmax", "softmax")
 
 
 class ClassificationEvaluator(SimpleIndividualEvaluator):
     """
     Class to compute the fitness of an individual in classification problems.
+    All simple classes assume only one sub-population.
     """
 
-    def __init__(self, X=None, y=None, metric=accuracy_score):
+    def __init__(
+        self,
+        X=None,
+        y=None,
+        metric=accuracy_score,
+        n_classes=2,
+        clf_method=CLF_METHODS[0],
+    ):
         super().__init__()
         self.X = X
         self.y = y
         self.metric = metric
+        self.n_classes = n_classes
+
+        if clf_method not in CLF_METHODS:
+            raise ValueError(
+                "classification method must be one of", CLF_METHODS
+            )
+        self.clf_method = clf_method
 
     def set_context(self, context):
         """
@@ -59,4 +78,31 @@ def evaluate_individual(self, individual):
         return self.metric(y_true=self.y, y_pred=y_pred)
 
     def classify_individual(self, individual):
-        return np.where(individual.execute(self.X) > CLASSIFICATION_THRESHOLD, 1, 0)
+        clf_method_to_function = {
+            "sigmoid": self._clf_sigmoid,
+            "argmax": self._clf_argmax,
+            "softmax": self._clf_softmax,
+        }
+        selected_func = clf_method_to_function[self.clf_method]
+        return selected_func(individual)
+
+    def _clf_sigmoid(self, individual):
+        # normalize execute results between 0 and 1
+        probs = sigmoid(individual.execute(self.X))
+        # Create thresholds: 1/N, 2/N, ..., (N-1)/N
+        thresholds = np.linspace(0, 1, self.n_classes + 1)[1:-1]
+        return np.digitize(probs, thresholds)
+
+    def _clf_argmax(self, individual):
+        return self._clf_root_func(individual, "argmax")
+
+    def _clf_softmax(self, individual):
+        return self._clf_root_func(individual, "softmax")
+
+    def _clf_root_func(self, individual, method):
+        # assumes individual is a GP tree with argmax function in depth 1
+        if method not in individual.root.function.__name__:
+            raise ValueError(
+                f"Individual must have {method} function in depth 0 to classify."
+            )
+        return individual.execute(self.X)
diff --git a/eckity/sklearn_compatible/regression_evaluator.py b/eckity/sklearn_compatible/regression_evaluator.py
index 6cf99f0e..67f586aa 100644
--- a/eckity/sklearn_compatible/regression_evaluator.py
+++ b/eckity/sklearn_compatible/regression_evaluator.py
@@ -1,16 +1,18 @@
 """
-This module implements the fitness evaluation class, which delivers the fitness function.
-You will need to implement such a class to work with your own problem and fitness function.
+This module implements the fitness evaluation class, which delivers the
+fitness function. You will need to implement such a class to work with your
+own problem and fitness function.
 """
 
 from sklearn.metrics import mean_absolute_error
 
-from eckity.evaluators.simple_individual_evaluator import SimpleIndividualEvaluator
+from eckity.evaluators import SimpleIndividualEvaluator
 
 
 class RegressionEvaluator(SimpleIndividualEvaluator):
     """
     Computes the fitness of an individual in regression problems.
+    All simple classes assume only one sub-population.
 
     Parameters
     ----------
@@ -24,7 +26,7 @@ class RegressionEvaluator(SimpleIndividualEvaluator):
     A function which receives two array-like of shapes (n_samples,) or (n_samples, 1) and returns a float or
     ndarray of floats
     """
-        
+
     def __init__(self, X=None, y=None, metric=mean_absolute_error):
         super().__init__()
         self.X = X
diff --git a/eckity/sklearn_compatible/sk_classifier.py b/eckity/sklearn_compatible/sk_classifier.py
index 14330f7b..b65f3eda 100644
--- a/eckity/sklearn_compatible/sk_classifier.py
+++ b/eckity/sklearn_compatible/sk_classifier.py
@@ -2,7 +2,9 @@
 from sklearn.utils.validation import check_is_fitted
 
 from eckity.sklearn_compatible.sklearn_wrapper import SklearnWrapper
-from eckity.sklearn_compatible.classification_evaluator import ClassificationEvaluator
+from eckity.sklearn_compatible.classification_evaluator import (
+    ClassificationEvaluator,
+)
 
 
 class SKClassifier(SklearnWrapper, ClassifierMixin):
@@ -25,15 +27,17 @@ def predict(self, X):
         # Check is fit had been called
         check_is_fitted(self)
 
-        clf_eval: ClassificationEvaluator = self.algorithm.get_individual_evaluator()
+        clf_eval: ClassificationEvaluator = (
+            self.algorithm.get_individual_evaluator()
+        )
 
-        # y doesn't matter since we only need execute result and evolution has already finished
+        # ignore y since we only need execute result and evolution is finished
         clf_eval.set_context((X, None))
 
         return clf_eval.classify_individual(self.algorithm.best_of_run_)
 
     def predict_proba(self, X):
-        raise NotImplementedError('not implemented yet')
+        raise NotImplementedError("not implemented yet")
 
     def predict_log_proba(self, X):
-        raise NotImplementedError('not implemented yet')
+        raise NotImplementedError("not implemented yet")
diff --git a/eckity/statistics/__init__.py b/eckity/statistics/__init__.py
index e69de29b..6cdb6a62 100644
--- a/eckity/statistics/__init__.py
+++ b/eckity/statistics/__init__.py
@@ -0,0 +1,5 @@
+from .statistics import Statistics
+from .best_average_worst_statistics import BestAverageWorstStatistics
+from .minimal_print_statistics import MinimalPrintStatistics
+from .best_avg_worst_size_tree_statistics import \
+    BestAverageWorstSizeTreeStatistics
diff --git a/eckity/statistics/best_average_worst_statistics.py b/eckity/statistics/best_average_worst_statistics.py
index 9ba69a71..217b04fb 100644
--- a/eckity/statistics/best_average_worst_statistics.py
+++ b/eckity/statistics/best_average_worst_statistics.py
@@ -1,7 +1,10 @@
 from sys import stdout
+import logging
 
 from eckity.statistics.statistics import Statistics
 
+logger = logging.getLogger(__name__)
+
 
 class BestAverageWorstStatistics(Statistics):
     """
@@ -16,34 +19,26 @@ class BestAverageWorstStatistics(Statistics):
         Value depends on the information the statistics provides.
         For more information, check out the concrete classes who extend this class.
 
-    output_stream: Optional[SupportsWrite[str]], default=stdout
-        Output file for the statistics.
-        By default, the statistics will be written to stdout.
     """
-    def __init__(self, format_string=None, output_stream=stdout):
+
+    def __init__(self, format_string=None):
         if format_string is None:
-            format_string = 'best fitness {}\nworst fitness {}\naverage fitness {}\n'
-        super().__init__(format_string, output_stream)
+            format_string = (
+                "best fitness {}\nworst fitness {}\naverage fitness {}\n"
+            )
+        super().__init__(format_string)
 
     def write_statistics(self, sender, data_dict):
-        print(f'generation #{data_dict["generation_num"]}', file=self.output_stream)
-        for index, sub_pop in enumerate(data_dict["population"].sub_populations):
-            print(f'subpopulation #{index}', file=self.output_stream)
+        logger.info(f'generation #{data_dict["generation_num"]}')
+        for index, sub_pop in enumerate(
+            data_dict["population"].sub_populations
+        ):
+            logger.info(f"subpopulation #{index}")
             best_individual = sub_pop.get_best_individual()
-            print(self.format_string.format(best_individual.get_pure_fitness(),
-                                            sub_pop.get_worst_individual().get_pure_fitness(),
-                                            sub_pop.get_average_fitness()), file=self.output_stream)
-            # best_individual.show()
-
-    # TODO tostring to indiv
-
-    # Necessary for valid pickling, since modules cannot be pickled
-    def __getstate__(self):
-        state = self.__dict__.copy()
-        del state['output_stream']
-        return state
-
-    # Necessary for valid unpickling, since modules cannot be pickled
-    def __setstate__(self, state):
-        self.__dict__.update(state)
-        self.output_stream = stdout
+            logger.info(
+                self.format_string.format(
+                    best_individual.get_pure_fitness(),
+                    sub_pop.get_worst_individual().get_pure_fitness(),
+                    sub_pop.get_average_fitness(),
+                )
+            )
diff --git a/eckity/statistics/best_avg_worst_size_tree_statistics.py b/eckity/statistics/best_avg_worst_size_tree_statistics.py
index 4990efd7..cc2f2b42 100644
--- a/eckity/statistics/best_avg_worst_size_tree_statistics.py
+++ b/eckity/statistics/best_avg_worst_size_tree_statistics.py
@@ -1,27 +1,30 @@
-import sys
+import logging
 
 import numpy as np
 
 from eckity.statistics.statistics import Statistics
 
+logger = logging.getLogger(__name__)
+
 
 class BestAverageWorstSizeTreeStatistics(Statistics):
-    def __init__(self, format_string=None, output_stream=sys.stdout):
+    def __init__(self, format_string=None):
         if format_string is None:
-            format_string = 'best fitness {}\nworst fitness {}\naverage fitness {}\naverage size {}\n'
-        super().__init__(format_string, output_stream)
+            format_string = "best fitness {}\nworst fitness {}\naverage fitness {}\naverage size {}\n"
+        super().__init__(format_string)
 
     def write_statistics(self, sender, data_dict):
-        print(f'generation #{data_dict["generation_num"]}', file=self.output_stream)
-        for index, sub_pop in enumerate(data_dict['population'].sub_populations):
-            print(f'subpopulation #{index}', file=self.output_stream)
+        logger.info(f'generation #{data_dict["generation_num"]}')
+        for index, sub_pop in enumerate(
+            data_dict["population"].sub_populations
+        ):
+            logger.info(f"subpopulation #{index}")
             best_individual = sub_pop.get_best_individual()
-            print(
-                self.format_string.format(best_individual.get_pure_fitness(),
-                                          sub_pop.get_worst_individual().get_pure_fitness(),
-                                          sub_pop.get_average_fitness(),
-                                          np.average([ind.size() for ind in sub_pop.individuals])),
-                file=self.output_stream
+            logger.info(
+                self.format_string.format(
+                    best_individual.get_pure_fitness(),
+                    sub_pop.get_worst_individual().get_pure_fitness(),
+                    sub_pop.get_average_fitness(),
+                    np.average([ind.size() for ind in sub_pop.individuals]),
+                ),
             )
-
-    # TODO tostring to indiv
diff --git a/eckity/statistics/minimal_print_statistics.py b/eckity/statistics/minimal_print_statistics.py
index 380f4c99..f794b631 100644
--- a/eckity/statistics/minimal_print_statistics.py
+++ b/eckity/statistics/minimal_print_statistics.py
@@ -1,7 +1,9 @@
-from sys import stdout
+import logging
 
 from eckity.statistics.statistics import Statistics
 
+logger = logging.getLogger(__name__)
+
 
 class MinimalPrintStatistics(Statistics):
     """
@@ -15,28 +17,25 @@ class MinimalPrintStatistics(Statistics):
         String format of the data to output.
         Value depends on the information the statistics provides.
         For more information, check out the concrete classes who extend this class.
-
-    output_stream: Optional[SupportsWrite[str]], default=stdout
-        Output file for the statistics.
-        By default, the statistics will be written to stdout.
     """
-    def __init__(self, format_string=None, output_stream=stdout):
+
+    def __init__(self, format_string=None):
         if format_string is None:
-            format_string = 'best fitness {}\nworst fitness {}\naverage fitness {}\n'
-        super().__init__(format_string, output_stream)
+            format_string = (
+                "best fitness {}\nworst fitness {}\naverage fitness {}\n"
+            )
+        super().__init__(format_string)
 
     def write_statistics(self, sender, data_dict):
-        print(f'generation #{data_dict["generation_num"]}', file=self.output_stream)
+        logger.info(f'generation #{data_dict["generation_num"]}')
 
     # TODO tostring to indiv
 
     # Necessary for valid pickling, since modules cannot be pickled
     def __getstate__(self):
         state = self.__dict__.copy()
-        del state['output_stream']
         return state
 
     # Necessary for valid unpickling, since modules cannot be pickled
     def __setstate__(self, state):
         self.__dict__.update(state)
-        self.output_stream = stdout
diff --git a/eckity/statistics/statistics.py b/eckity/statistics/statistics.py
index 2e36617d..35b04772 100644
--- a/eckity/statistics/statistics.py
+++ b/eckity/statistics/statistics.py
@@ -1,8 +1,7 @@
-from sys import stdout
-from abc import abstractmethod
+from abc import abstractmethod, ABC
 
 
-class Statistics:
+class Statistics(ABC):
     """
     Abstract Statistics class.
     Provides statistics about the current evolution state.
@@ -14,18 +13,15 @@ class Statistics:
         Value depends on the information the statistics provides.
         For more information, check out the concrete classes who extend this class.
 
-    output_stream: Optional[SupportsWrite[str]], default=stdout
-        Output file for the statistics.
-        By default, the statistics will be written to stdout.
     """
-    def __init__(self, format_string, output_stream=stdout):
-        self.output_stream = output_stream
+
+    def __init__(self, format_string):
         self.format_string = format_string
 
     @abstractmethod
     def write_statistics(self, sender, data_dict):
         """
-        Write the statistics information using the format string field to the output stream field.
+        Write the statistics information using the format string field.
 
         Parameters
         ----------
@@ -44,4 +40,3 @@ def write_statistics(self, sender, data_dict):
         None.
         """
         pass
-
diff --git a/eckity/subpopulation.py b/eckity/subpopulation.py
index f3e48aa8..a7d2d1ef 100644
--- a/eckity/subpopulation.py
+++ b/eckity/subpopulation.py
@@ -1,20 +1,20 @@
+import logging
 import random
+
 import numpy as np
 
 from eckity.creators.creator import Creator
-from eckity.creators.gp_creators.full import FullCreator
+from eckity.genetic_operators import TournamentSelection
 
-from eckity.genetic_operators.crossovers.subtree_crossover import SubtreeCrossover
-from eckity.genetic_operators.mutations.erc_mutation import ERCMutation
-from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
-from eckity.genetic_operators.selections.tournament_selection import TournamentSelection
+logger = logging.getLogger(__name__)
 
 
 class Subpopulation:
     """
     Subgroup of the experiment population.
 
-    Contains a specific encoding, fitness evaluation method, creator list, operator sequence and selection methods.
+    Contains a specific encoding, fitness evaluation method, creator list,
+    operator sequence and selection methods.
 
     Parameters
     ----------
@@ -30,16 +30,17 @@ class Subpopulation:
         Length must match the length of creators parameter.
 
     operators_sequence: list of Crossovers and Mutations, default=None
-        Possible crossover and mutation actions that can change the individuals' representations.
-        The operators will be done sequentially in each generation, by their order in the list.
-        See eckity.genetic_operators for more details on crossover and mutation operators
+        Crossovers and mutations that changes the individuals' representations.
+        The operators are done by their order in the list in each generation.
+        See eckity.genetic_operators for more information.
 
     selection_methods: list of SelectionMethods
         Methods for selecting individuals in each generation.
         See eckity.genetic_operators for more details on selection methods
 
     elitism_rate: float, default=0.0
-        What percentage of the sub-population's individuals should be kept as elites for the next generation
+        What percentage of the sub-population individuals
+        should be kept as-is for the next generation
 
     population_size: int, default=200
         The number of individuals in this sub-population.
@@ -48,61 +49,75 @@ class Subpopulation:
         The individuals list of this sub-population.
 
     higher_is_better: bool, default=False
-        Determines if the fitness value of this sub-population's individuals should be maximized or minimized.
+        Determines if the fitness values of this sub-population's
+        individuals should be maximized or minimized.
 
     Attributes
     ----------
     n_elite: int
         Number of the sub-population's elite individuals.
-        In every generation, there will be n_elites slots for the elite individuals
-        that will be copied as-is to the next generation.
+        In every generation, there will be n_elites slots
+        for the elite individuals that will be copied as
+        they are to the next generation.
     """
 
-    def __init__(self,
-                 evaluator,
-                 creators=None,
-                 pcr=None,
-                 operators_sequence=None,
-                 selection_methods=None,
-                 elitism_rate=0.0,
-                 population_size=200,
-                 individuals=None,
-                 higher_is_better=False):
+    def __init__(
+        self,
+        evaluator,
+        creators=None,
+        pcr=None,
+        operators_sequence=None,
+        selection_methods=None,
+        elitism_rate=0.0,
+        population_size=200,
+        individuals=None,
+        higher_is_better=False,
+    ):
 
         # verify valid creators and creation probability inputs
         if creators is None:
-            full_cr = FullCreator()
-            creators = [full_cr]
+            raise ValueError("Must specify at least one creator")
         elif isinstance(creators, Creator):
             creators = [creators]
         elif isinstance(creators, list):
             if len(creators) == 0:
-                raise ValueError('Creators list cannot be empty')
+                raise ValueError("Creators cannot be empty list")
             for creator in creators:
                 if not isinstance(creator, Creator):
-                    raise ValueError('Detected a non-creator instance as an element in creators list')
+                    raise ValueError(
+                        "Detected non-creator instance in creators list"
+                    )
         else:
             raise ValueError(
-                'Parameter creators must be either a Creator or a list of Creators\n '
-                'received creators with unexpected type of', type(creators)
+                "Creators must be either a Creator or a list of Creators. "
+                f"Got unexpected type of {type(creators)}",
             )
 
         if pcr is None:
             pcr = [1 / len(creators) for _ in creators]
 
         if len(creators) != len(pcr):
-            raise ValueError(f'Number of creators ({len(creators)}) \
-                                    must match number of creation probabilities {(len(pcr))}!')
+            raise ValueError(
+                f"Number of creators ({len(creators)}) \
+                must match number of creation probabilities {(len(pcr))}!"
+            )
         if sum(pcr) != 1:
-            raise ValueError(f'Sum of creation probabilities ({pcr}) must be 1!')
+            raise ValueError(
+                f"Sum of creation probabilities ({pcr}) must be 1!"
+            )
 
         # set default args
         if operators_sequence is None:
-            operators_sequence = [SubtreeCrossover(arity=2, probability=0.9),
-                                  SubtreeMutation(arity=1, probability=0.7),
-                                  ERCMutation(arity=1, probability=0.1)]
+            raise ValueError("Must specify at least one operator")
         if selection_methods is None:
-            selection_methods = [(TournamentSelection(tournament_size=10, higher_is_better=higher_is_better), 1)]
+            selection_methods = [
+                (
+                    TournamentSelection(
+                        tournament_size=10, higher_is_better=higher_is_better
+                    ),
+                    1,
+                )
+            ]
 
         self.creators = creators
         self._pcr = pcr
@@ -113,14 +128,27 @@ def __init__(self,
         self.evaluator = evaluator
 
         self.n_elite = round(elitism_rate * self.population_size)
+
+        if self.n_elite == 0 and elitism_rate > 0.0:
+            logger.warning(
+                "Detected elitism_rate > 0 but 0 elites. "
+                "Try increasing elitism_rate."
+            )
+
         self.individuals = individuals
 
     def create_subpopulation_individuals(self):
         if self.individuals is None:
-            # Select one creator to generate individuals, with respect to the creators' probabilities
-            selected_creator = random.choices(self.creators, weights=self._pcr)[
-                0]  # random.choices returns [selected_creator]
-            self.individuals = selected_creator.create_individuals(self.population_size, self.higher_is_better)
+            # Select one creator to generate individuals,
+            # with respect to the creators' probabilities
+
+            # choices returns [selected_creator]
+            selected_creator = random.choices(
+                self.creators, weights=self._pcr
+            )[0]
+            self.individuals = selected_creator.create_individuals(
+                self.population_size, self.higher_is_better
+            )
 
     def get_operators_sequence(self):
         return self._operators_sequence
@@ -129,17 +157,25 @@ def get_selection_methods(self):
         return self._selection_methods
 
     def get_best_individual(self):
-        sorted_inds = sorted(self.individuals, key=lambda ind: ind.get_augmented_fitness(),
-                             reverse=self.higher_is_better)
+        sorted_inds = sorted(
+            self.individuals,
+            key=lambda ind: ind.get_augmented_fitness(),
+            reverse=self.higher_is_better,
+        )
         return sorted_inds[0]
 
     def get_worst_individual(self):
-        sorted_inds = sorted(self.individuals, key=lambda ind: ind.get_augmented_fitness(),
-                             reverse=not self.higher_is_better)
+        sorted_inds = sorted(
+            self.individuals,
+            key=lambda ind: ind.get_augmented_fitness(),
+            reverse=not self.higher_is_better,
+        )
         return sorted_inds[0]
 
     def get_average_fitness(self):
-        return np.mean([indiv.get_pure_fitness() for indiv in self.individuals])
+        return np.mean(
+            [indiv.get_pure_fitness() for indiv in self.individuals]
+        )
 
     def contains_individual(self, individual):
         return individual in self.individuals
diff --git a/eckity/termination_checkers/__init__.py b/eckity/termination_checkers/__init__.py
index e69de29b..b959a903 100644
--- a/eckity/termination_checkers/__init__.py
+++ b/eckity/termination_checkers/__init__.py
@@ -0,0 +1,4 @@
+from .termination_checker import TerminationChecker
+from .threshold_from_target_termination_checker import (
+    ThresholdFromTargetTerminationChecker
+)
\ No newline at end of file
diff --git a/eckity/termination_checkers/best_fitness_stagnation_termination_checker.py b/eckity/termination_checkers/best_fitness_stagnation_termination_checker.py
new file mode 100644
index 00000000..c575e26e
--- /dev/null
+++ b/eckity/termination_checkers/best_fitness_stagnation_termination_checker.py
@@ -0,0 +1,50 @@
+import math
+
+from eckity.termination_checkers.termination_checker import TerminationChecker
+
+
+class BestFitnessStagnationTerminationChecker(TerminationChecker):
+    """
+    Concrete Termination Checker that checks that best firnes.
+
+    Parameters
+    ----------
+    stagnation_generations: int, default=100.
+        This termination checker checks if the best fitness hasn't changed for stagnation_generations generations.
+    """
+
+    def __init__(self, stagnation_generations_to_terminate=100):
+        super().__init__()
+        self.stagnation_generations_to_terminate = stagnation_generations_to_terminate
+        self.best_fitnesses = None
+        self.stagnation_generations = 0
+
+    def should_terminate(self, population, best_individual, gen_number):
+        """
+        Determines if the best fitness hasn't changed for stagnation_generations generations.
+        If so, recommends the algorithm to terminate early.
+
+        Parameters
+        ----------
+        population: Population
+            The evolutionary experiment population of individuals.
+
+        best_individual: Individual
+            The individual that has the best fitness of the algorithm.
+
+        gen_number: int
+            Current generation number.
+
+        Returns
+        -------
+        bool
+            True if the algorithm should terminate early, False otherwise.
+        """
+        best_fitnesses = [ind.get_pure_fitness() for ind in population.get_best_individuals()]
+        if self.best_fitnesses and all([math.isclose(a, b) for a, b in zip(best_fitnesses, self.best_fitnesses)]):
+            self.stagnation_generations += 1
+            return self.stagnation_generations >= self.stagnation_generations_to_terminate
+        else:
+            self.stagnation_generations = 0
+            self.best_fitnesses = best_fitnesses
+            return False
diff --git a/eckity/termination_checkers/termination_checker.py b/eckity/termination_checkers/termination_checker.py
index 4e2fc4c5..338804ad 100644
--- a/eckity/termination_checkers/termination_checker.py
+++ b/eckity/termination_checkers/termination_checker.py
@@ -20,7 +20,7 @@ def should_terminate(self, population, best_individual, gen_number):
             The population of the experiment.
 
         best_individual: Individual
-            The best individual in the current generation of the algorithm.
+            The individual that has the best fitness of the algorithm.
 
         gen_number: int
             Current generation number.
diff --git a/eckity/termination_checkers/threshold_from_target_termination_checker.py b/eckity/termination_checkers/threshold_from_target_termination_checker.py
index 32dda423..202ccb56 100644
--- a/eckity/termination_checkers/threshold_from_target_termination_checker.py
+++ b/eckity/termination_checkers/threshold_from_target_termination_checker.py
@@ -34,7 +34,7 @@ def should_terminate(self, population, best_individual, gen_number):
             The evolutionary experiment population of individuals.
 
         best_individual: Individual
-            The individual that has the best fitness of the current generation.
+            The individual that has the best fitness of the algorithm.
 
         gen_number: int
             Current generation number.
diff --git a/examples/treegp/sklearn_mode/symbolic_regression/__init__.py b/examples/multi_objective/moe_base_example/__init__.py
similarity index 100%
rename from examples/treegp/sklearn_mode/symbolic_regression/__init__.py
rename to examples/multi_objective/moe_base_example/__init__.py
diff --git a/examples/multi_objective/moe_base_example/nsga2_basic_example.py b/examples/multi_objective/moe_base_example/nsga2_basic_example.py
new file mode 100644
index 00000000..08735cda
--- /dev/null
+++ b/examples/multi_objective/moe_base_example/nsga2_basic_example.py
@@ -0,0 +1,122 @@
+import math
+import time
+
+from eckity.creators.ga_creators.simple_vector_creator import GAVectorCreator
+from eckity.multi_objective_evolution.nsga2_fitness import NSGA2Fitness
+from eckity.multi_objective_evolution.nsga2_plot import NSGA2Plot
+from eckity.multi_objective_evolution.nsga2_evolution import NSGA2Evolution
+from eckity.multi_objective_evolution.nsga2_breeder import NSGA2Breeder
+from eckity.evaluators.simple_individual_evaluator import (
+    SimpleIndividualEvaluator,
+)
+from eckity.genetic_operators.crossovers.vector_k_point_crossover import (
+    VectorKPointsCrossover,
+)
+from eckity.genetic_operators.mutations.vector_random_mutation import (
+    FloatVectorUniformNPointMutation,
+)
+from eckity.genetic_operators.selections.tournament_selection import (
+    TournamentSelection,
+)
+
+from eckity.population import Population
+from eckity.statistics.minimal_print_statistics import MinimalPrintStatistics
+from eckity.multi_objective_evolution.moe_best_worst_statistics import (
+    MOEBestWorstStatistics,
+)
+from eckity.subpopulation import Subpopulation
+from eckity.genetic_encodings.ga.float_vector import FloatVector
+from eckity.multi_objective_evolution.crowding_termination_checker import (
+    CrowdingTerminationChecker,
+)
+
+
+import random
+
+random.seed(0)
+
+
+class NSGA2BasicExampleEvaluator(SimpleIndividualEvaluator):
+    def evaluate_individual(self, individual):
+        """
+        Compute the fitness value of a given individual.
+
+        Parameters
+        ----------
+        individual: Vector
+            The individual to compute the fitness value for.
+
+        Returns
+        -------
+        list
+            The evaluated fitness value for each of the objectives of the given individual.
+        """
+        n = len(individual.vector)
+        fit1 = 1 - math.exp(
+            -sum([(x - 1 / math.sqrt(n)) ** 2 for x in individual.vector])
+        )
+        fit2 = 1 - math.exp(
+            -sum([(x + 1 / math.sqrt(n)) ** 2 for x in individual.vector])
+        )
+        return [fit1, fit2]
+
+
+def main():
+    # Initialize the evolutionary algorithm
+    algo = NSGA2Evolution(
+        Population(
+            [
+                Subpopulation(
+                    creators=GAVectorCreator(
+                        length=3,
+                        bounds=(-4, 4),
+                        fitness_type=NSGA2Fitness,
+                        vector_type=FloatVector,
+                    ),
+                    population_size=150,
+                    # user-defined fitness evaluation method
+                    evaluator=NSGA2BasicExampleEvaluator(),
+                    # maximization problem (fitness is sum of values), so higher fitness is better
+                    higher_is_better=False,
+                    elitism_rate=1 / 300,
+                    # genetic operators sequence to be applied in each generation
+                    operators_sequence=[
+                        VectorKPointsCrossover(probability=0.7, k=1),
+                        FloatVectorUniformNPointMutation(probability=0.3, n=3),
+                    ],
+                    selection_methods=[
+                        # (selection method, selection probability) tuple
+                        (
+                            TournamentSelection(
+                                tournament_size=3, higher_is_better=True
+                            ),
+                            1,
+                        )
+                    ],
+                )
+            ]
+        ),
+        breeder=NSGA2Breeder(),
+        max_workers=4,
+        executor="process",
+        max_generation=150,
+        termination_checker=CrowdingTerminationChecker(0.01),
+        statistics=[
+            MOEBestWorstStatistics(),
+            MinimalPrintStatistics(),
+        ],
+    )
+    ploter = NSGA2Plot()
+    algo.register("evolution_finished", ploter.print_plots)
+    # evolve the generated initial population
+    start_time = time.time()
+    algo.evolve()
+    total_time = time.time() - start_time
+    print(f"the total time is :{total_time}")
+
+    # Execute (show) the best solution
+    print(algo.execute())
+
+
+if __name__ == "__main__":
+    main()
diff --git a/examples/treegp/non_sklearn_mode/__init__.py b/examples/treegp/basic_mode/__init__.py
similarity index 100%
rename from examples/treegp/non_sklearn_mode/__init__.py
rename to examples/treegp/basic_mode/__init__.py
diff --git a/examples/treegp/basic_mode/multiplexer.py b/examples/treegp/basic_mode/multiplexer.py
new file mode 100644
index 00000000..b55c1216
--- /dev/null
+++ b/examples/treegp/basic_mode/multiplexer.py
@@ -0,0 +1,397 @@
+from time import time
+
+
+from eckity.algorithms.simple_evolution import SimpleEvolution
+from eckity.creators.gp_creators.full import FullCreator
+from eckity.base.untyped_functions import (
+    f_and,
+    f_or,
+    f_not,
+    f_if_then_else,
+)
+from eckity.genetic_operators.crossovers.subtree_crossover import (
+    SubtreeCrossover,
+)
+from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
+from eckity.genetic_operators.selections.tournament_selection import (
+    TournamentSelection,
+)
+from eckity.statistics.best_average_worst_statistics import (
+    BestAverageWorstStatistics,
+)
+from eckity.subpopulation import Subpopulation
+from eckity.termination_checkers.threshold_from_target_termination_checker import (
+    ThresholdFromTargetTerminationChecker,
+)
+
+from itertools import product
+from numbers import Number
+
+import pandas as pd
+import numpy as np
+
+from eckity.evaluators.simple_individual_evaluator import (
+    SimpleIndividualEvaluator,
+)
+
+NUM_SELECT_ENTRIES = 3
+NUM_INPUT_ENTRIES = 2 ** NUM_SELECT_ENTRIES
+NUM_COLUMNS = NUM_SELECT_ENTRIES + NUM_INPUT_ENTRIES
+NUM_ROWS = 2 ** NUM_COLUMNS
+
+
+def _target_func(s0, s1, s2, d0, d1, d2, d3, d4, d5, d6, d7):
+    """
+    Truth table of a 8-3 mux gate
+
+    Returns the value of d_i, where i is the decimal value of the binary
+    number gained from joining s0, s1 and s2 digits (see examples below).
+
+    Parameters
+    ----------
+    s0-s2: int
+        select values for the mux gate
+
+    d0-d7: int
+        input values for the mux gate
+
+    Returns
+    -------
+    int
+        0 (False) or 1 (True), depends on the values of the given parameters
+
+    Examples
+    -------
+    _target_func(s0=0, s1=0, s2=0, d0=1, ...) = 1 (value of input d0)
+    _target_func(s0=0, s1=0, s2=1, d0=1, d1=0, ...) = 0 (value of input d1)
+    """
+    return (
+            ((not s0) and (not s1) and (not s2) and d0)
+            or ((not s0) and (not s1) and s2 and d1)
+            or ((not s0) and s1 and (not s2) and d2)
+            or ((not s0) and s1 and s2 and d3)
+            or (s0 and (not s1) and (not s2) and d4)
+            or (s0 and (not s1) and s2 and d5)
+            or (s0 and s1 and (not s2) and d6)
+            or (s0 and s1 and s2 and d7)
+    )
+
+
+class MuxEvaluator(SimpleIndividualEvaluator):
+    """
+    Evaluator class for the Multiplexer problem, responsible of defining
+    a fitness evaluation method and evaluating it.
+
+    Attributes
+    -------
+    inputs: pd.DataFrame
+        Input columns representing all possible combinations
+        of all select and input values.
+
+    output: pandas Series
+        All possible output values. Values depend on the matching rows
+        from the inputs data frame.
+    """
+
+    def __init__(self):
+        super().__init__()
+
+        # construct a truth table of all combinations of ones and zeros
+        values = [
+            list(x) + [_target_func(*x)]
+            for x in product([0, 1], repeat=_target_func.__code__.co_argcount)
+        ]
+        truth_tbl = pd.DataFrame(
+            values,
+            columns=(list(_target_func.__code__.co_varnames) + ["output"]),
+        )
+
+        # split dataframe to input columns and an output column
+        self.inputs = truth_tbl.iloc[:, :-1]
+        self.output = truth_tbl["output"]
+
+    def evaluate_individual(self, individual):
+        """
+        Compute the fitness value of a given individual.
+
+        Fitness evaluation is done calculating the accuracy between the tree
+        execution result and the optimal result (multiplexer truth table).
+
+        Parameters
+        ----------
+        individual: Tree
+            The individual to compute the fitness value for.
+
+        Returns
+        -------
+        float
+            The evaluated fitness value of the given individual.
+            The value ranges from 0 (worst case) to 1 (best case).
+        """
+
+        # select entries columns
+        select_entries = {
+            f"s{i}": self.inputs[f"s{i}"] for i in range(NUM_SELECT_ENTRIES)
+        }
+        # input entries columns
+        input_entries = {
+            f"d{i}": self.inputs[f"d{i}"] for i in range(NUM_INPUT_ENTRIES)
+        }
+
+        exec_res = individual.execute(**select_entries, **input_entries)
+
+        # sometimes execute will return a single scalar (in constant trees)
+        if isinstance(exec_res, Number) or exec_res.shape == ():
+            exec_res = np.full((NUM_ROWS,), exec_res)
+
+        # Return normalized number of correct results
+        return np.mean(exec_res == self.output)
+
+
+def main():
+    """
+    The goal in the Multiplexer (Mux) problem to create a GP tree that approximates a Multiplexer logical gate.
+
+    Expected run time: less than a minute (on 2 cores, 2.5 GHz CPU)
+    Example output is provided at the end of the file.
+
+    References
+    ----------
+    DEAP Multiplexer Example: https://deap.readthedocs.io/en/master/examples/gp_multiplexer.html
+    """
+
+    start_time = time()
+
+    # The terminal set of the tree will contain the mux inputs (d0-d7 in a 8-3 mux gate),
+    # 3 select lines (s0-s2 in a 8-3 mux gate) and the constants 0 and 1
+    untyped_select_terminals = [f"s{i}" for i in range(NUM_SELECT_ENTRIES)]
+    untyped_input_terminals = [f"d{i}" for i in range(NUM_INPUT_ENTRIES)]
+    terminal_set = untyped_select_terminals + untyped_input_terminals
+
+    # Logical functions: and, or, not and if-then-else
+    function_set = [f_and, f_or, f_not, f_if_then_else]
+    # Initialize SimpleEvolution instance
+    algo = SimpleEvolution(
+        Subpopulation(
+            creators=FullCreator(
+                init_depth=(2, 4),
+                terminal_set=terminal_set,
+                function_set=function_set,
+                bloat_weight=0.00001,
+                erc_range=(0, 1)
+            ),
+            population_size=40,
+            # user-defined fitness evaluation method
+            evaluator=MuxEvaluator(),
+            # this is a maximization problem (fitness is accuracy), so higher fitness is better
+            higher_is_better=True,
+            elitism_rate=0.0,
+            # genetic operators sequence to be applied in each generation
+            operators_sequence=[
+                SubtreeCrossover(probability=0.8, arity=2),
+                SubtreeMutation(probability=0.1, arity=1),
+            ],
+            selection_methods=[
+                # (selection method, selection probability) tuple
+                (
+                    TournamentSelection(
+                        tournament_size=7, higher_is_better=True
+                    ),
+                    1,
+                )
+            ],
+        ),
+        max_workers=1,
+        max_generation=40,
+        termination_checker=ThresholdFromTargetTerminationChecker(
+            optimal=1, threshold=0.01
+        ),
+        statistics=BestAverageWorstStatistics(),
+        random_seed=10,
+    )
+
+    # evolve the generated initial population
+    algo.evolve()
+
+    # execute the best individual after the evolution process ends
+    exec1 = algo.execute(
+        s0=0, s1=0, s2=1, d0=0, d1=0, d2=1, d3=1, d4=1, d5=0, d6=0, d7=1
+    )
+    exec3 = algo.execute(
+        s0=0, s1=1, s2=1, d0=0, d1=0, d2=1, d3=1, d4=1, d5=0, d6=0, d7=1
+    )
+    exec7 = algo.execute(
+        s0=1, s1=1, s2=1, d0=0, d1=0, d2=1, d3=1, d4=1, d5=0, d6=0, d7=1
+    )
+    print("execute(s0=0, s1=1, s2=1, d1=0): expected = 0, actual =", exec1)
+    print("execute(s0=1, s1=1, s2=1, d3=1): expected = 1, actual =", exec3)
+    print("execute(s0=1, s1=1, s2=1, d7=1): expected = 1, actual =", exec7)
+
+    print("best pure fitness:", algo.best_of_run_.get_pure_fitness())
+
+    print("total time:", time() - start_time)
+
+
+if __name__ == "__main__":
+    main()
+
+"""
+Example output (s - select inputs, d - data inputs):
+f_if_then_else
+    f_and
+        f_not
+            f_if_then_else
+            f_or
+                f_and
+                    f_not
+                        s1
+                    f_if_then_else
+                        f_not
+                        f_or
+                            s0
+                            f_or
+                                f_and
+                                    s0
+                                    d3
+                                f_and
+                                    d0
+                                    s1
+                        f_and
+                        d1
+                        s2
+                        f_and
+                        f_or
+                            d4
+                            f_and
+                                f_not
+                                    s1
+                                f_if_then_else
+                                    f_not
+                                    f_or
+                                        s0
+                                        d5
+                                    f_and
+                                    d5
+                                    f_if_then_else
+                                        f_not
+                                            d2
+                                        f_not
+                                            d4
+                                        f_if_then_else
+                                            d5
+                                            d7
+                                            d6
+                                    f_and
+                                    d1
+                                    f_if_then_else
+                                        d1
+                                        s2
+                                        d0
+                        f_and
+                            d5
+                            d5
+                f_and
+                    f_if_then_else
+                        f_if_then_else
+                        s2
+                        s2
+                        s2
+                        d7
+                        d2
+                    s1
+            d7
+            f_if_then_else
+                f_and
+                    f_if_then_else
+                        d2
+                        f_if_then_else
+                        d3
+                        d7
+                        d7
+                        f_if_then_else
+                        d0
+                        s1
+                        d7
+                    s1
+                s1
+                d7
+        d7
+    s1
+    f_or
+        f_and
+            f_not
+            s1
+            f_if_then_else
+            f_not
+                f_or
+                    s0
+                    f_or
+                        f_and
+                        d4
+                        d0
+                        f_and
+                        f_if_then_else
+                            d2
+                            f_if_then_else
+                                d3
+                                f_and
+                                    f_or
+                                    f_and
+                                        d2
+                                        f_and
+                                            1
+                                            d7
+                                    d1
+                                    f_if_then_else
+                                    d5
+                                    f_or
+                                        d0
+                                        f_and
+                                            d0
+                                            d4
+                                    f_if_then_else
+                                        f_if_then_else
+                                            d0
+                                            d0
+                                            d7
+                                        f_or
+                                            d2
+                                            d7
+                                        s0
+                                d7
+                            f_if_then_else
+                                d0
+                                s1
+                                d7
+                        s1
+            f_and
+                d1
+                f_if_then_else
+                    s2
+                    s2
+                    d0
+            f_and
+                f_or
+                    d4
+                    f_and
+                        f_not
+                        s1
+                        f_if_then_else
+                        s2
+                        s2
+                        s2
+                f_and
+                    d5
+                    d5
+        f_and
+            f_if_then_else
+            f_if_then_else
+                f_and
+                    d5
+                    d5
+                s2
+                s2
+            d7
+            d2
+            s1
+"""
diff --git a/examples/treegp/basic_mode/symbolic_regression.py b/examples/treegp/basic_mode/symbolic_regression.py
new file mode 100644
index 00000000..2531f033
--- /dev/null
+++ b/examples/treegp/basic_mode/symbolic_regression.py
@@ -0,0 +1,171 @@
+"""
+A simple example optimizing a three-variable function.
+This is a non-sklearn setting so we use `evolve` and `execute`.
+"""
+
+import numpy as np
+import pandas as pd
+
+from eckity.algorithms.simple_evolution import SimpleEvolution
+from eckity.base.untyped_functions import *
+from eckity.creators.gp_creators.half import HalfCreator
+from eckity.evaluators.simple_individual_evaluator import (
+    SimpleIndividualEvaluator,
+)
+from eckity.genetic_operators.crossovers.subtree_crossover import (
+    SubtreeCrossover,
+)
+from eckity.genetic_operators.mutations.erc_mutation import ERCMutation
+from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
+from eckity.genetic_operators.selections.tournament_selection import (
+    TournamentSelection,
+)
+from eckity.statistics.best_average_worst_statistics import (
+    BestAverageWorstStatistics,
+)
+from eckity.subpopulation import Subpopulation
+from eckity.termination_checkers.threshold_from_target_termination_checker import (
+    ThresholdFromTargetTerminationChecker,
+)
+
+
+class SymbolicRegressionEvaluator(SimpleIndividualEvaluator):
+    """
+    This module implements the Evaluator class, which delivers the fitness function.
+    You will need to implement such a class to work with your own problem and fitness function.
+    """
+
+    def __init__(self):
+        super().__init__()
+
+        data = np.random.uniform(-100, 100, size=(200, 3))
+        self.df = pd.DataFrame(data, columns=["x", "y", "z"])
+        self.df["target"] = self.target_func(
+            self.df["x"], self.df["y"], self.df["z"]
+        )
+
+    @staticmethod
+    def target_func(x, y, z):
+        """
+        True regression function, the individuals
+        Parameters
+        ----------
+        x, y, z: float
+            Values to the parameters of the function.
+
+        Returns
+        -------
+        float
+            The result of target function activation.
+        """
+        return x + 2 * y + 3 * z
+
+    def evaluate_individual(self, individual):
+        """
+        Parameters
+        ----------
+        individual : Tree
+            An individual program tree in the gp population, whose fitness needs to be computed.
+            Makes use of GPTree.execute, which runs the program.
+            Calling `gptree.execute` must use keyword arguments that match the terminal-set variables.
+            For example, if the terminal set includes `x` and `y` then the call is `gptree.execute(x=..., y=...)`.
+
+        Returns
+        -------
+        float
+            fitness value
+        """
+        x, y, z = self.df["x"], self.df["y"], self.df["z"]
+        return np.mean(
+            np.abs(individual.execute(x=x, y=y, z=z) - self.df["target"])
+        )
+
+
+def main():
+    """
+    Solve the Symbolic Regression problem by approximating the regression target function.
+
+    Expected runtime: less than a minute (on 2 cores, 2.5 GHz CPU)
+    Example of an optimal evolved tree:
+    f_add
+       f_add
+          f_add
+             f_add
+                y
+                z
+             x
+          z
+       f_add
+          y
+          z
+    """
+
+    # each node of the GP tree is either a terminal or a function
+    # function nodes, each has two children (which are its operands)
+    function_set = [
+        f_add,
+        f_mul,
+        f_sub,
+        f_div,
+        f_sqrt,
+        f_log,
+        f_abs,
+        f_max,
+        f_min,
+        f_inv,
+        f_neg,
+    ]
+
+    # terminal set, consisted of variables
+    terminal_set = ["x", "y", "z"]
+
+    # Initialize the evolutionary algorithm
+    algo = SimpleEvolution(
+        Subpopulation(
+            creators=HalfCreator(
+                init_depth=(2, 4),
+                terminal_set=terminal_set,
+                function_set=function_set,
+                erc_range=(-1.0, 1.0),
+                bloat_weight=0.0001,
+            ),
+            population_size=200,
+            # user-defined fitness evaluation method
+            evaluator=SymbolicRegressionEvaluator(),
+            # minimization problem (fitness is MAE), so higher fitness is worse
+            higher_is_better=False,
+            elitism_rate=0.05,
+            # genetic operators sequence to be applied in each generation
+            operators_sequence=[
+                SubtreeCrossover(probability=0.9),
+                SubtreeMutation(probability=0.2),
+                ERCMutation(probability=0.05),
+            ],
+            selection_methods=[
+                # (selection method, selection probability) tuple
+                (
+                    TournamentSelection(
+                        tournament_size=4
+                    ),
+                    1,
+                )
+            ],
+        ),
+        max_workers=4,
+        max_generation=40,
+        termination_checker=ThresholdFromTargetTerminationChecker(
+            optimal=0, threshold=0.001
+        ),
+        statistics=BestAverageWorstStatistics(),
+    )
+
+    # evolve the generated initial population
+    algo.evolve()
+
+    # execute the best individual after the evolution process ends, by assigning numeric values to the variable
+    # terminals in the tree
+    print(f"algo.execute(x=2,y=3,z=4): {algo.execute(x=2, y=3, z=4)}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/examples/treegp/basic_mode/typed_multiplexer.py b/examples/treegp/basic_mode/typed_multiplexer.py
new file mode 100644
index 00000000..3d82dd7a
--- /dev/null
+++ b/examples/treegp/basic_mode/typed_multiplexer.py
@@ -0,0 +1,393 @@
+from time import time
+from eckity.algorithms.simple_evolution import SimpleEvolution
+from eckity.base.typed_functions import and2bools, or2bools, not2bools, if_then_else3bools
+from eckity.creators.gp_creators.full import FullCreator
+from eckity.genetic_operators.crossovers.subtree_crossover import (
+    SubtreeCrossover,
+)
+from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
+from eckity.genetic_operators.selections.tournament_selection import (
+    TournamentSelection,
+)
+from eckity.statistics.best_average_worst_statistics import (
+    BestAverageWorstStatistics,
+)
+from eckity.subpopulation import Subpopulation
+from eckity.termination_checkers.threshold_from_target_termination_checker import (
+    ThresholdFromTargetTerminationChecker,
+)
+
+from itertools import product
+from numbers import Number
+
+import pandas as pd
+import numpy as np
+
+from eckity.evaluators.simple_individual_evaluator import (
+    SimpleIndividualEvaluator,
+)
+
+NUM_SELECT_ENTRIES = 3
+NUM_INPUT_ENTRIES = 2 ** NUM_SELECT_ENTRIES
+NUM_COLUMNS = NUM_SELECT_ENTRIES + NUM_INPUT_ENTRIES
+NUM_ROWS = 2 ** NUM_COLUMNS
+
+
+def _target_func(s0, s1, s2, d0, d1, d2, d3, d4, d5, d6, d7):
+    """
+    Truth table of a 8-3 mux gate
+
+    Returns the value of d_i, where i is the decimal value of the binary
+    number gained from joining s0, s1 and s2 digits (see examples below).
+
+    Parameters
+    ----------
+    s0-s2: bool
+        select values for the mux gate
+
+    d0-d7: bool
+        input values for the mux gate
+
+    Returns
+    -------
+    bool
+        False or True, depends on the values of the given parameters
+
+    Examples
+    -------
+    _target_func(s0=False, s1=False, s2=False, d0=True, ...) = True (value of input d0)
+    _target_func(s0=False, s1=False, s2=True, d0=True, d1=False, ...) = False (value of input d1)
+    """
+    return (
+            ((not s0) and (not s1) and (not s2) and d0)
+            or ((not s0) and (not s1) and s2 and d1)
+            or ((not s0) and s1 and (not s2) and d2)
+            or ((not s0) and s1 and s2 and d3)
+            or (s0 and (not s1) and (not s2) and d4)
+            or (s0 and (not s1) and s2 and d5)
+            or (s0 and s1 and (not s2) and d6)
+            or (s0 and s1 and s2 and d7)
+    )
+
+
+class MuxEvaluator(SimpleIndividualEvaluator):
+    """
+    Evaluator class for the Multiplexer problem, responsible of defining
+    a fitness evaluation method and evaluating it.
+
+    Attributes
+    -------
+    inputs: pd.DataFrame
+        Input columns representing all possible combinations
+        of all select and input values.
+
+    output: pandas Series
+        All possible output values. Values depend on the matching rows
+        from the inputs data frame.
+    """
+
+    def __init__(self):
+        super().__init__()
+
+        # construct a truth table of all combinations of ones and zeros
+        values = [
+            list(map(bool, x)) + [_target_func(*map(bool, x))]
+            for x in product([False, True], repeat=_target_func.__code__.co_argcount)
+        ]
+        truth_tbl = pd.DataFrame(
+            values,
+            columns=(list(_target_func.__code__.co_varnames) + ["output"]),
+        )
+
+        # split dataframe to input columns and an output column
+        self.inputs = truth_tbl.iloc[:, :-1]
+        self.output = truth_tbl["output"]
+
+    def evaluate_individual(self, individual):
+        """
+        Compute the fitness value of a given individual.
+
+        Fitness evaluation is done calculating the accuracy between the tree
+        execution result and the optimal result (multiplexer truth table).
+
+        Parameters
+        ----------
+        individual: Tree
+            The individual to compute the fitness value for.
+
+        Returns
+        -------
+        float
+            The evaluated fitness value of the given individual.
+            The value ranges from 0 (worst case) to 1 (best case).
+        """
+
+        # select entries columns
+        select_entries = {
+            f"s{i}": self.inputs[f"s{i}"] for i in range(NUM_SELECT_ENTRIES)
+        }
+        # input entries columns
+        input_entries = {
+            f"d{i}": self.inputs[f"d{i}"] for i in range(NUM_INPUT_ENTRIES)
+        }
+
+        exec_res = individual.execute(**select_entries, **input_entries)
+
+        if isinstance(exec_res, Number) or exec_res.shape == ():
+            exec_res = np.full((NUM_ROWS,), bool(exec_res))
+
+        # Normalize number of correct results
+        return np.mean(exec_res == self.output)
+
+
+def main():
+    """
+    The goal in the Multiplexer (Mux) problem to create a GP tree that approximates a Multiplexer logical gate.
+
+    Expected run time: less than a minute (on 2 cores, 2.5 GHz CPU)
+    Example output is provided at the end of the file.
+
+    References
+    ----------
+    DEAP Multiplexer Example: https://deap.readthedocs.io/en/master/examples/gp_multiplexer.html
+    """
+
+    start_time = time()
+
+    # The terminal set of the tree will contain the mux inputs (d0-d7 in a 8-3 mux gate),
+    # 3 select lines (s0-s2 in a 8-3 mux gate) and the constants 0 and 1
+    # Terminal set for boolean inputs
+    typed_select_terminals = {f"s{i}": bool for i in range(NUM_SELECT_ENTRIES)}
+    typed_input_terminals = {f"d{i}": bool for i in range(NUM_INPUT_ENTRIES)}
+    terminal_set = {**typed_select_terminals, **typed_input_terminals}
+
+    # Logical functions: and, or, not and if-then-else
+    function_set = [and2bools, or2bools, not2bools, if_then_else3bools]
+
+    # Initialize SimpleEvolution instance
+    algo = SimpleEvolution(
+        Subpopulation(
+            creators=FullCreator(
+                init_depth=(2, 4),
+                terminal_set=terminal_set,
+                function_set=function_set,
+                bloat_weight=0.00001,
+                root_type=bool,
+            ),
+            population_size=40,
+            # user-defined fitness evaluation method
+            evaluator=MuxEvaluator(),
+            # this is a maximization problem (fitness is accuracy), so higher fitness is better
+            higher_is_better=True,
+            # genetic operators sequence to be applied in each generation
+            operators_sequence=[
+                SubtreeCrossover(probability=0.8, arity=2),
+                SubtreeMutation(probability=0.1, arity=1),
+            ],
+            selection_methods=[
+                # (selection method, selection probability) tuple
+                (
+                    TournamentSelection(
+                        tournament_size=7, higher_is_better=True
+                    ),
+                    1,
+                )
+            ],
+        ),
+        max_workers=1,
+        max_generation=40,
+        termination_checker=ThresholdFromTargetTerminationChecker(
+            optimal=1, threshold=0.01
+        ),
+        statistics=BestAverageWorstStatistics(),
+        random_seed=10,
+    )
+
+    # evolve the generated initial population
+    algo.evolve()
+
+    # execute the best individual after the evolution process ends
+    exec1 = algo.execute(
+        s0=False, s1=False, s2=True, d0=False, d1=False, d2=True, d3=True,
+        d4=True, d5=False, d6=False, d7=True
+    )
+    exec3 = algo.execute(
+        s0=False, s1=True, s2=True, d0=False, d1=False, d2=True, d3=True,
+        d4=True, d5=False, d6=False, d7=True
+    )
+    exec7 = algo.execute(
+        s0=True, s1=True, s2=True, d0=False, d1=False, d2=True, d3=True,
+        d4=True, d5=False, d6=False, d7=True
+    )
+    print("execute(s0=False, s1=True, s2=True, d1=False): expected = False, actual =", exec1)
+    print("execute(s0=True, s1=True, s2=True, d3=True): expected = True, actual =", exec3)
+    print("execute(s0=True, s1=True, s2=True, d7=True): expected = True, actual =", exec7)
+
+    print("best pure fitness:", algo.best_of_run_.get_pure_fitness())
+
+    print("total time:", time() - start_time)
+
+
+if __name__ == "__main__":
+    main()
+
+"""
+Example output (s - select inputs, d - data inputs):
+f_if_then_else
+    f_and
+        f_not
+            f_if_then_else
+            f_or
+                f_and
+                    f_not
+                        s1
+                    f_if_then_else
+                        f_not
+                        f_or
+                            s0
+                            f_or
+                                f_and
+                                    s0
+                                    d3
+                                f_and
+                                    d0
+                                    s1
+                        f_and
+                        d1
+                        s2
+                        f_and
+                        f_or
+                            d4
+                            f_and
+                                f_not
+                                    s1
+                                f_if_then_else
+                                    f_not
+                                    f_or
+                                        s0
+                                        d5
+                                    f_and
+                                    d5
+                                    f_if_then_else
+                                        f_not
+                                            d2
+                                        f_not
+                                            d4
+                                        f_if_then_else
+                                            d5
+                                            d7
+                                            d6
+                                    f_and
+                                    d1
+                                    f_if_then_else
+                                        d1
+                                        s2
+                                        d0
+                        f_and
+                            d5
+                            d5
+                f_and
+                    f_if_then_else
+                        f_if_then_else
+                        s2
+                        s2
+                        s2
+                        d7
+                        d2
+                    s1
+            d7
+            f_if_then_else
+                f_and
+                    f_if_then_else
+                        d2
+                        f_if_then_else
+                        d3
+                        d7
+                        d7
+                        f_if_then_else
+                        d0
+                        s1
+                        d7
+                    s1
+                s1
+                d7
+        d7
+    s1
+    f_or
+        f_and
+            f_not
+            s1
+            f_if_then_else
+            f_not
+                f_or
+                    s0
+                    f_or
+                        f_and
+                        d4
+                        d0
+                        f_and
+                        f_if_then_else
+                            d2
+                            f_if_then_else
+                                d3
+                                f_and
+                                    f_or
+                                    f_and
+                                        d2
+                                        f_and
+                                            1
+                                            d7
+                                    d1
+                                    f_if_then_else
+                                    d5
+                                    f_or
+                                        d0
+                                        f_and
+                                            d0
+                                            d4
+                                    f_if_then_else
+                                        f_if_then_else
+                                            d0
+                                            d0
+                                            d7
+                                        f_or
+                                            d2
+                                            d7
+                                        s0
+                                d7
+                            f_if_then_else
+                                d0
+                                s1
+                                d7
+                        s1
+            f_and
+                d1
+                f_if_then_else
+                    s2
+                    s2
+                    d0
+            f_and
+                f_or
+                    d4
+                    f_and
+                        f_not
+                        s1
+                        f_if_then_else
+                        s2
+                        s2
+                        s2
+                f_and
+                    d5
+                    d5
+        f_and
+            f_if_then_else
+            f_if_then_else
+                f_and
+                    d5
+                    d5
+                s2
+                s2
+            d7
+            d2
+            s1
+"""
diff --git a/examples/treegp/basic_mode/typed_symbolic_regression.py b/examples/treegp/basic_mode/typed_symbolic_regression.py
new file mode 100644
index 00000000..2f031f7b
--- /dev/null
+++ b/examples/treegp/basic_mode/typed_symbolic_regression.py
@@ -0,0 +1,172 @@
+"""
+A simple example optimizing a three-variable function.
+This is a non-sklearn setting so we use `evolve` and `execute`.
+"""
+
+import numpy as np
+import pandas as pd
+
+from eckity.algorithms.simple_evolution import SimpleEvolution
+from eckity.base.typed_functions import *
+from eckity.creators.gp_creators.half import HalfCreator
+from eckity.evaluators.simple_individual_evaluator import (
+    SimpleIndividualEvaluator,
+)
+from eckity.genetic_operators.crossovers.subtree_crossover import (
+    SubtreeCrossover,
+)
+from eckity.genetic_operators.mutations.erc_mutation import ERCMutation
+from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
+from eckity.genetic_operators.selections.tournament_selection import (
+    TournamentSelection,
+)
+from eckity.statistics.best_average_worst_statistics import (
+    BestAverageWorstStatistics,
+)
+from eckity.subpopulation import Subpopulation
+from eckity.termination_checkers.threshold_from_target_termination_checker import (
+    ThresholdFromTargetTerminationChecker,
+)
+
+
+class SymbolicRegressionEvaluator(SimpleIndividualEvaluator):
+    """
+    This module implements the Evaluator class, which delivers the fitness function.
+    You will need to implement such a class to work with your own problem and fitness function.
+    """
+
+    def __init__(self):
+        super().__init__()
+
+        data = np.random.uniform(-100, 100, size=(200, 3))
+        self.df = pd.DataFrame(data, columns=["x", "y", "z"])
+        self.df["target"] = self.target_func(
+            self.df["x"], self.df["y"], self.df["z"]
+        )
+
+    @staticmethod
+    def target_func(x, y, z):
+        """
+        True regression function, the individuals
+        Parameters
+        ----------
+        x, y, z: float
+            Values to the parameters of the function.
+
+        Returns
+        -------
+        float
+            The result of target function activation.
+        """
+        return x + 2 * y + 3 * z
+
+    def evaluate_individual(self, individual):
+        """
+        Parameters
+        ----------
+        individual : Tree
+            An individual program tree in the gp population, whose fitness needs to be computed.
+            Makes use of GPTree.execute, which runs the program.
+            Calling `gptree.execute` must use keyword arguments that match the terminal-set variables.
+            For example, if the terminal set includes `x` and `y` then the call is `gptree.execute(x=..., y=...)`.
+
+        Returns
+        -------
+        float
+            fitness value
+        """
+        x, y, z = self.df["x"], self.df["y"], self.df["z"]
+        return np.mean(
+            np.abs(individual.execute(x=x, y=y, z=z) - self.df["target"])
+        )
+
+
+def main():
+    """
+    Solve the Symbolic Regression problem by approximating the regression target function.
+
+    Expected runtime: less than a minute (on 2 cores, 2.5 GHz CPU)
+    Example of an optimal evolved tree:
+    f_add
+       f_add
+          f_add
+             f_add
+                y
+                z
+             x
+          z
+       f_add
+          y
+          z
+    """
+
+    # each node of the GP tree is either a terminal or a function
+    # function nodes, each has two children (which are its operands)
+    function_set = [
+        add2floats,
+        mul2floats,
+        sub2floats,
+        div2floats,
+        sqrt_float,
+        log_float,
+        abs_float,
+        max2floats,
+        min2floats,
+        inv_float,
+        neg_float,
+    ]
+
+    # terminal set, consisted of variables
+    terminal_set = {"x": float, "y": float, "z": float}
+
+    # Initialize the evolutionary algorithm
+    algo = SimpleEvolution(
+        Subpopulation(
+            creators=HalfCreator(
+                init_depth=(2, 4),
+                terminal_set=terminal_set,
+                function_set=function_set,
+                erc_range=(-1.0, 1.0),
+                bloat_weight=0.0001,
+                root_type=float,
+            ),
+            population_size=200,
+            # user-defined fitness evaluation method
+            evaluator=SymbolicRegressionEvaluator(),
+            # minimization problem (fitness is MAE), so higher fitness is worse
+            higher_is_better=False,
+            elitism_rate=0.05,
+            # genetic operators sequence to be applied in each generation
+            operators_sequence=[
+                SubtreeCrossover(probability=0.9),
+                SubtreeMutation(probability=0.2),
+                ERCMutation(probability=0.05),
+            ],
+            selection_methods=[
+                # (selection method, selection probability) tuple
+                (
+                    TournamentSelection(
+                        tournament_size=4
+                    ),
+                    1,
+                )
+            ],
+        ),
+        max_workers=4,
+        max_generation=40,
+        termination_checker=ThresholdFromTargetTerminationChecker(
+            optimal=0, threshold=0.001
+        ),
+        statistics=BestAverageWorstStatistics(),
+    )
+
+    # evolve the generated initial population
+    algo.evolve()
+
+    # execute the best individual after the evolution process ends, by assigning numeric values to the variable
+    # terminals in the tree
+    print(f"algo.execute(x=2,y=3,z=4): {algo.execute(x=2, y=3, z=4)}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/examples/treegp/non_sklearn_mode/multiplexer/mux_evaluator.py b/examples/treegp/non_sklearn_mode/multiplexer/mux_evaluator.py
deleted file mode 100644
index 89c9dc25..00000000
--- a/examples/treegp/non_sklearn_mode/multiplexer/mux_evaluator.py
+++ /dev/null
@@ -1,111 +0,0 @@
-from itertools import product
-from numbers import Number
-
-import pandas as pd
-import numpy as np
-
-from eckity.evaluators.simple_individual_evaluator import SimpleIndividualEvaluator
-
-NUM_SELECT_ENTRIES = 3
-NUM_INPUT_ENTRIES = 2 ** NUM_SELECT_ENTRIES
-NUM_COLUMNS = NUM_SELECT_ENTRIES + NUM_INPUT_ENTRIES
-NUM_ROWS = 2 ** NUM_COLUMNS
-
-
-def _target_func(s0, s1, s2, d0, d1, d2, d3, d4, d5, d6, d7):
-    """
-    Truth table of a 8-3 mux gate
-
-    Returns the value of d_i, where i is the decimal value of the binary number gained from joining s0, s1 and s2 digits
-    (see examples below)
-
-    Parameters
-    ----------
-    s0-s2: int
-        select values for the mux gate
-
-    d0-d7: int
-        input values for the mux gate
-
-    Returns
-    -------
-    int
-        0 (False) or 1 (True), depends on the values of the given parameters
-
-    Examples
-    -------
-    _target_func(s0=0, s1=0, s2=0, d0=1, ...) = 1 (the value of input entry d0)
-    _target_func(s0=0, s1=0, s2=1, d0=1, d1=0, ...) = 0 (the value of input entry d1)
-    """
-    return ((not s0) and (not s1) and (not s2) and d0) \
-           or ((not s0) and (not s1) and s2 and d1) \
-           or ((not s0) and s1 and (not s2) and d2) \
-           or ((not s0) and s1 and s2 and d3) \
-           or (s0 and (not s1) and (not s2) and d4) \
-           or (s0 and (not s1) and s2 and d5) \
-           or (s0 and s1 and (not s2) and d6) \
-           or (s0 and s1 and s2 and d7)
-
-
-class MuxEvaluator(SimpleIndividualEvaluator):
-    """
-    Evaluator class for the Multiplexer problem, responsible of defining a fitness evaluation method and evaluating it
-
-    Attributes
-    -------
-    inputs: pandas DataFrame
-        Input columns representing all possible combinations of all select values and input values.
-
-    output: pandas Series
-        All possible output values. Values depend on the matching rows from the inputs DataFrame.
-    """
-
-    def __init__(self):
-        super().__init__()
-
-        # construct a truth table of all combinations of ones and zeros
-        values = [list(x) + [_target_func(*x)] for x in product([0, 1], repeat=_target_func.__code__.co_argcount)]
-        truth_tbl = pd.DataFrame(values, columns=(list(_target_func.__code__.co_varnames) + ['output']))
-
-        # split dataframe to input columns and an output column
-        self.inputs = truth_tbl.iloc[:, :NUM_COLUMNS]
-        self.output = truth_tbl['output']
-
-    def evaluate_individual(self, individual):
-        """
-        Compute the fitness value of a given individual.
-
-        Fitness evaluation is done calculating the accuracy between the tree execution result and the optimal result
-        (multiplexer truth table).
-
-        Parameters
-        ----------
-        individual: Tree
-            The individual to compute the fitness value for.
-
-        Returns
-        -------
-        float
-            The evaluated fitness value of the given individual.
-            The value ranges from 0 (worst case) to 1 (best case).
-        """
-
-        # select entries columns
-        s0, s1, s2 = self.inputs['s0'], self.inputs['s1'], self.inputs['s2']
-        # input entries columns
-        d0, d1, d2, d3, d4, d5, d6, d7 = self.inputs['d0'], self.inputs['d1'], self.inputs['d2'], self.inputs['d0'], \
-                                         self.inputs['d4'], self.inputs['d5'], self.inputs['d6'], self.inputs['d7']
-
-        exec_res = individual.execute(s0=s0, s1=s1, s2=s2, d0=d0, d1=d1, d2=d2, d3=d3, d4=d4, d5=d5, d6=d6, d7=d7)
-
-        # sometimes execute will return a single scalar (in cases of constant trees)
-        if isinstance(exec_res, Number) or exec_res.shape == np.shape(0):
-            exec_res = np.full((NUM_ROWS,), exec_res)
-
-        # The more "matches" the individual's execute result has with expected output,
-        # the better the individual's fitness is.
-        # Worst Case: the individual returned only wrong (binary) results, and should have a fitness of 0.
-        # The bitwise_xor operator will return a vector of ones, which sums to NUM_ROWS, resulting in a fitness of 0.
-        # Best case: the individual returned only right (binary) results, and should have a fitness of 1.
-        # The bitwise_xor operator will return a vector of zeros, which sums to 0, resulting in a fitness of 1.
-        return (NUM_ROWS - np.sum(np.bitwise_xor(exec_res, self.output))) / NUM_ROWS
diff --git a/examples/treegp/non_sklearn_mode/multiplexer/mux_main.py b/examples/treegp/non_sklearn_mode/multiplexer/mux_main.py
deleted file mode 100644
index d2657245..00000000
--- a/examples/treegp/non_sklearn_mode/multiplexer/mux_main.py
+++ /dev/null
@@ -1,241 +0,0 @@
-from time import time
-
-from eckity.algorithms.simple_evolution import SimpleEvolution
-from eckity.breeders.simple_breeder import SimpleBreeder
-from eckity.creators.gp_creators.full import FullCreator
-from eckity.genetic_encodings.gp.tree.functions import f_and, f_or, f_not, f_if_then_else
-from eckity.genetic_operators.crossovers.subtree_crossover import SubtreeCrossover
-from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
-from eckity.genetic_operators.selections.tournament_selection import TournamentSelection
-from eckity.statistics.best_average_worst_statistics import BestAverageWorstStatistics
-from eckity.subpopulation import Subpopulation
-from eckity.termination_checkers.threshold_from_target_termination_checker import ThresholdFromTargetTerminationChecker
-from examples.treegp.non_sklearn_mode.multiplexer.mux_evaluator \
-    import MuxEvaluator, NUM_SELECT_ENTRIES, NUM_INPUT_ENTRIES
-
-
-def main():
-    """
-    The goal in the Multiplexer (Mux) problem to create a GP tree that approximates a Multiplexer logical gate.
-
-    Expected run time: less than a minute (on 2 cores, 2.5 GHz CPU)
-    Example output (s - select inputs, d - data inputs):
-    f_if_then_else
-       f_and
-          f_not
-             f_if_then_else
-                f_or
-                   f_and
-                      f_not
-                         s1
-                      f_if_then_else
-                         f_not
-                            f_or
-                               s0
-                               f_or
-                                  f_and
-                                     s0
-                                     d3
-                                  f_and
-                                     d0
-                                     s1
-                         f_and
-                            d1
-                            s2
-                         f_and
-                            f_or
-                               d4
-                               f_and
-                                  f_not
-                                     s1
-                                  f_if_then_else
-                                     f_not
-                                        f_or
-                                           s0
-                                           d5
-                                     f_and
-                                        d5
-                                        f_if_then_else
-                                           f_not
-                                              d2
-                                           f_not
-                                              d4
-                                           f_if_then_else
-                                              d5
-                                              d7
-                                              d6
-                                     f_and
-                                        d1
-                                        f_if_then_else
-                                           d1
-                                           s2
-                                           d0
-                            f_and
-                               d5
-                               d5
-                   f_and
-                      f_if_then_else
-                         f_if_then_else
-                            s2
-                            s2
-                            s2
-                         d7
-                         d2
-                      s1
-                d7
-                f_if_then_else
-                   f_and
-                      f_if_then_else
-                         d2
-                         f_if_then_else
-                            d3
-                            d7
-                            d7
-                         f_if_then_else
-                            d0
-                            s1
-                            d7
-                      s1
-                   s1
-                   d7
-          d7
-       s1
-       f_or
-          f_and
-             f_not
-                s1
-             f_if_then_else
-                f_not
-                   f_or
-                      s0
-                      f_or
-                         f_and
-                            d4
-                            d0
-                         f_and
-                            f_if_then_else
-                               d2
-                               f_if_then_else
-                                  d3
-                                  f_and
-                                     f_or
-                                        f_and
-                                           d2
-                                           f_and
-                                              1
-                                              d7
-                                        d1
-                                     f_if_then_else
-                                        d5
-                                        f_or
-                                           d0
-                                           f_and
-                                              d0
-                                              d4
-                                        f_if_then_else
-                                           f_if_then_else
-                                              d0
-                                              d0
-                                              d7
-                                           f_or
-                                              d2
-                                              d7
-                                           s0
-                                  d7
-                               f_if_then_else
-                                  d0
-                                  s1
-                                  d7
-                            s1
-                f_and
-                   d1
-                   f_if_then_else
-                      s2
-                      s2
-                      d0
-                f_and
-                   f_or
-                      d4
-                      f_and
-                         f_not
-                            s1
-                         f_if_then_else
-                            s2
-                            s2
-                            s2
-                   f_and
-                      d5
-                      d5
-          f_and
-             f_if_then_else
-                f_if_then_else
-                   f_and
-                      d5
-                      d5
-                   s2
-                   s2
-                d7
-                d2
-             s1
-
-    References
-    ----------
-    DEAP Multiplexer Example: https://deap.readthedocs.io/en/master/examples/gp_multiplexer.html
-    """
-    start_time = time()
-
-    # The terminal set of the tree will contain the mux inputs (d0-d7 in a 8-3 mux gate),
-    # 3 select lines (s0-s2 in a 8-3 mux gate) and the constants 0 and 1
-    select_terminals = [f's{i}' for i in range(NUM_SELECT_ENTRIES)]
-    input_terminals = [f'd{i}' for i in range(NUM_INPUT_ENTRIES)]
-    terminal_set = select_terminals + input_terminals + [0, 1]
-
-    # Logical functions: and, or, not and if-then-else
-    function_set = [f_and, f_or, f_not, f_if_then_else]
-
-    # Initialize SimpleEvolution instance
-    algo = SimpleEvolution(
-        Subpopulation(creators=FullCreator(init_depth=(2, 4),
-                                           terminal_set=terminal_set,
-                                           function_set=function_set,
-                                           bloat_weight=0.00001),
-                      population_size=40,
-                      # user-defined fitness evaluation method
-                      evaluator=MuxEvaluator(),
-                      # this is a maximization problem (fitness is accuracy), so higher fitness is better
-                      higher_is_better=True,
-                      elitism_rate=0.0,
-                      # genetic operators sequence to be applied in each generation
-                      operators_sequence=[
-                          SubtreeCrossover(probability=0.8, arity=2),
-                          SubtreeMutation(probability=0.1, arity=1)
-                      ],
-                      selection_methods=[
-                          # (selection method, selection probability) tuple
-                          (TournamentSelection(tournament_size=7, higher_is_better=True), 1)
-                      ]
-                      ),
-        breeder=SimpleBreeder(),
-        max_workers=1,
-        max_generation=40,
-        termination_checker=ThresholdFromTargetTerminationChecker(optimal=1, threshold=0.01),
-        statistics=BestAverageWorstStatistics(),
-        random_seed=10
-    )
-
-    # evolve the generated initial population
-    algo.evolve()
-
-    # execute the best individual after the evolution process ends
-    exec1 = algo.execute(s0=0, s1=0, s2=1, d0=0, d1=0, d2=1, d3=1, d4=1, d5=0, d6=0, d7=1)
-    exec3 = algo.execute(s0=0, s1=1, s2=1, d0=0, d1=0, d2=1, d3=1, d4=1, d5=0, d6=0, d7=1)
-    exec7 = algo.execute(s0=1, s1=1, s2=1, d0=0, d1=0, d2=1, d3=1, d4=1, d5=0, d6=0, d7=1)
-    print('execute(s0=0, s1=1, s2=1, d1=0): expected value = 0, actual value =', exec1)
-    print('execute(s0=0, s1=0, s2=1, d3=1): expected value = 1, actual value =', exec3)
-    print('execute(s0=1, s1=1, s2=1, d7=1): expected value = 1, actual value =', exec7)
-
-    print('total time:', time() - start_time)
-
-
-if __name__ == '__main__':
-    main()
diff --git a/examples/treegp/non_sklearn_mode/symbolic_regression/sym_reg_evaluator.py b/examples/treegp/non_sklearn_mode/symbolic_regression/sym_reg_evaluator.py
deleted file mode 100644
index cde0a1d0..00000000
--- a/examples/treegp/non_sklearn_mode/symbolic_regression/sym_reg_evaluator.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""
-This module implements the fitness class, which delivers the fitness function.
-You will need to implement such a class to work with your own problem and fitness function.
-"""
-
-import numpy as np
-import pandas as pd
-
-from eckity.evaluators.simple_individual_evaluator import SimpleIndividualEvaluator
-
-
-def _target_func(x, y, z):
-    """
-    True regression function, the individuals
-    Parameters
-    ----------
-    x, y, z: float
-        Values to the parameters of the function.
-
-    Returns
-    -------
-    float
-        The result of target function activation.
-    """
-    return x + 2 * y + 3 * z
-
-
-class SymbolicRegressionEvaluator(SimpleIndividualEvaluator):
-    """
-    Compute the fitness of an individual.
-    """
-
-    def __init__(self):
-        super().__init__()
-
-        # np.random.seed(0)
-
-        data = np.random.uniform(-100, 100, size=(200, 3))
-        self.df = pd.DataFrame(data, columns=['x', 'y', 'z'])
-        self.df['target'] = _target_func(self.df['x'], self.df['y'], self.df['z'])
-
-    def evaluate_individual(self, individual):
-        """
-        Parameters
-        ----------
-        individual : Tree
-            An individual program tree in the gp population, whose fitness needs to be computed.
-            Makes use of GPTree.execute, which runs the program.
-            Calling `gptree.execute` must use keyword arguments that match the terminal-set variables.
-            For example, if the terminal set includes `x` and `y` then the call is `gptree.execute(x=..., y=...)`.
-
-        Returns
-        -------
-        float
-            fitness value
-        """
-        x, y, z = self.df['x'], self.df['y'], self.df['z']
-        return np.mean(np.abs(individual.execute(x=x, y=y, z=z) - self.df['target']))
diff --git a/examples/treegp/non_sklearn_mode/symbolic_regression/sym_reg_main.py b/examples/treegp/non_sklearn_mode/symbolic_regression/sym_reg_main.py
deleted file mode 100644
index 9d536eca..00000000
--- a/examples/treegp/non_sklearn_mode/symbolic_regression/sym_reg_main.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""
-A simple example optimizing a three-variable function.
-This is a non-sklearn setting so we use `evolve` and `execute`.
-"""
-from eckity.algorithms.simple_evolution import SimpleEvolution
-from eckity.breeders.simple_breeder import SimpleBreeder
-from eckity.creators.gp_creators.ramped_hh import RampedHalfAndHalfCreator
-from eckity.genetic_encodings.gp.tree.functions import f_add, f_mul, f_sub, f_div, \
-    f_sqrt, f_log, f_abs, f_max, f_min, f_inv, f_neg
-from eckity.genetic_operators.crossovers.subtree_crossover import SubtreeCrossover
-from eckity.genetic_operators.mutations.erc_mutation import ERCMutation
-from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
-from eckity.genetic_operators.selections.tournament_selection import TournamentSelection
-from eckity.statistics.best_average_worst_statistics import BestAverageWorstStatistics
-from eckity.subpopulation import Subpopulation
-from eckity.termination_checkers.threshold_from_target_termination_checker import ThresholdFromTargetTerminationChecker
-from examples.treegp.non_sklearn_mode.symbolic_regression.sym_reg_evaluator import SymbolicRegressionEvaluator
-
-
-def main():
-    """
-    Solve the Symbolic Regression problem by approximating the regression target function.
-
-    Expected runtime: less than a minute (on 2 cores, 2.5 GHz CPU)
-    Example of an optimal evolved tree:
-    f_add
-       f_add
-          f_add
-             f_add
-                y
-                z
-             x
-          z
-       f_add
-          y
-          z
-    """
-
-    # each node of the GP tree is either a terminal or a function
-    # function nodes, each has two children (which are its operands)
-    function_set = [f_add, f_mul, f_sub, f_div, f_sqrt, f_log, f_abs, f_max, f_min, f_inv, f_neg]
-
-    # terminal set, consisted of variables and constants
-    terminal_set = ['x', 'y', 'z', 0, 1, -1]
-
-    # Initialize the evolutionary algorithm
-    algo = SimpleEvolution(
-        Subpopulation(creators=RampedHalfAndHalfCreator(init_depth=(2, 4),
-                                                        terminal_set=terminal_set,
-                                                        function_set=function_set,
-                                                        bloat_weight=0.0001),
-                      population_size=200,
-                      # user-defined fitness evaluation method
-                      evaluator=SymbolicRegressionEvaluator(),
-                      # minimization problem (fitness is MAE), so higher fitness is worse
-                      higher_is_better=False,
-                      elitism_rate=0.05,
-                      # genetic operators sequence to be applied in each generation
-                      operators_sequence=[
-                          SubtreeCrossover(probability=0.9, arity=2),
-                          SubtreeMutation(probability=0.2, arity=1),
-                          ERCMutation(probability=0.05, arity=1)
-                      ],
-                      selection_methods=[
-                          # (selection method, selection probability) tuple
-                          (TournamentSelection(tournament_size=4, higher_is_better=False), 1)
-                      ]
-                      ),
-        breeder=SimpleBreeder(),
-        max_workers=4,
-        max_generation=500,
-        # random_seed=0,
-        termination_checker=ThresholdFromTargetTerminationChecker(optimal=0, threshold=0.001),
-        statistics=BestAverageWorstStatistics()
-    )
-
-    # evolve the generated initial population
-    algo.evolve()
-
-    # execute the best individual after the evolution process ends, by assigning numeric values to the variable
-    # terminals in the tree
-    print(f'algo.execute(x=2,y=3,z=4): {algo.execute(x=2, y=3, z=4)}')
-
-
-if __name__ == '__main__':
-    main()
diff --git a/examples/treegp/sklearn_mode/breast_cancer.py b/examples/treegp/sklearn_mode/breast_cancer.py
new file mode 100644
index 00000000..3e5b23a8
--- /dev/null
+++ b/examples/treegp/sklearn_mode/breast_cancer.py
@@ -0,0 +1,115 @@
+"""
+Solving a sklearn_mode problem created through scikit-learn's `load breast cancer`.
+This is an sklearn setting so we use `fit` and `predict`.
+"""
+
+from time import time
+
+from sklearn.datasets import load_breast_cancer
+from sklearn.metrics import accuracy_score
+from sklearn.model_selection import train_test_split
+
+from eckity.subpopulation import Subpopulation
+from eckity.algorithms import SimpleEvolution
+from eckity.base.untyped_functions import (
+    f_add,
+    f_div,
+    f_mul,
+    f_sub,
+)
+from eckity.genetic_encodings.gp.tree.utils import create_terminal_set
+from eckity.genetic_operators import (
+    SubtreeCrossover,
+    SubtreeMutation,
+    TournamentSelection,
+)
+from eckity.sklearn_compatible import ClassificationEvaluator, SKClassifier
+from eckity.statistics import BestAverageWorstSizeTreeStatistics
+from eckity.termination_checkers import ThresholdFromTargetTerminationChecker
+from eckity.creators import HalfCreator
+
+
+def main():
+    """
+    Evolve a GP Tree that classifies breast cancer,
+    using a dataset from sklearn
+    """
+    start_time = time()
+
+    # load the brest cancer dataset from sklearn
+    X, y = load_breast_cancer(return_X_y=True)
+
+    # Automatically generate a terminal set.
+    # Since there are 30 features, set terminal_set to: ['x0', 'x1', ..., 'x29']
+    terminal_set = create_terminal_set(X, typed=False)
+
+    # Define function set
+    function_set = [
+        f_add,
+        f_sub,
+        f_mul,
+        f_div,
+    ]
+
+    # Initialize SimpleEvolution instance
+    algo = SimpleEvolution(
+        Subpopulation(
+            creators=HalfCreator(
+                init_depth=(2, 4),
+                terminal_set=terminal_set,
+                function_set=function_set,
+                bloat_weight=0.0001,
+            ),
+            population_size=100,
+            evaluator=ClassificationEvaluator(metric=accuracy_score),
+            # maximization problem (fitness is accuracy), so higher fitness is better
+            higher_is_better=True,
+            elitism_rate=0.05,
+            # genetic operators sequence to be applied in each generation
+            operators_sequence=[
+                SubtreeCrossover(probability=0.9, arity=2),
+                SubtreeMutation(probability=0.2, arity=1),
+            ],
+            selection_methods=[
+                # (selection method, selection probability) tuple
+                (
+                    TournamentSelection(
+                        tournament_size=4, higher_is_better=True
+                    ),
+                    1,
+                )
+            ],
+        ),
+        max_workers=1,
+        max_generation=100,
+        # optimal fitness is 1, evolution ("training") process will be finished when best fitness <= threshold
+        termination_checker=ThresholdFromTargetTerminationChecker(
+            optimal=1, threshold=0.03
+        ),
+        statistics=BestAverageWorstSizeTreeStatistics(),
+    )
+    # wrap the basic evolutionary algorithm with a sklearn-compatible classifier
+    classifier = SKClassifier(algo)
+
+    # split brest cancer dataset to train and test set
+    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
+
+    # fit the model (perform evolution process)
+    classifier.fit(X_train, y_train)
+
+    print("best individual:\n", algo.best_of_run_.root)
+
+    # check training set results
+    print(
+        f"\nbest pure fitness over training set: {algo.best_of_run_.get_pure_fitness()}"
+    )
+
+    # check test set results by computing the accuracy score between the prediction result and the test set result
+    test_score = accuracy_score(y_test, classifier.predict(X_test))
+    print(f"test score: {test_score}")
+
+    print(f"Total runtime: {time() - start_time} seconds.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/examples/treegp/sklearn_mode/breast_cancer/breast_cancer_main.py b/examples/treegp/sklearn_mode/breast_cancer/breast_cancer_main.py
deleted file mode 100644
index 48b5d86f..00000000
--- a/examples/treegp/sklearn_mode/breast_cancer/breast_cancer_main.py
+++ /dev/null
@@ -1,143 +0,0 @@
-"""
-Solving a sklearn_mode problem created through scikit-learn's `load breast cancer`.
-This is an sklearn setting so we use `fit` and `predict`.
-"""
-
-from time import time
-from sklearn.datasets import load_breast_cancer
-from sklearn.metrics import accuracy_score
-from sklearn.model_selection import train_test_split
-
-from eckity.algorithms.simple_evolution import SimpleEvolution
-from eckity.sklearn_compatible.sk_classifier import SKClassifier
-from eckity.breeders.simple_breeder import SimpleBreeder
-from eckity.creators.gp_creators.ramped_hh import RampedHalfAndHalfCreator
-from eckity.genetic_encodings.gp.tree.functions import f_add, f_mul, f_sub, f_div, f_neg, f_sqrt, f_log, f_abs, f_inv, f_max, \
-    f_min
-from eckity.genetic_encodings.gp.tree.utils import create_terminal_set
-from eckity.genetic_operators.crossovers.subtree_crossover import SubtreeCrossover
-from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
-from eckity.genetic_operators.selections.tournament_selection import TournamentSelection
-from eckity.statistics.best_avg_worst_size_tree_statistics import BestAverageWorstSizeTreeStatistics
-from eckity.subpopulation import Subpopulation
-from eckity.termination_checkers.threshold_from_target_termination_checker import ThresholdFromTargetTerminationChecker
-
-# Adding your own functions
-from eckity.sklearn_compatible.classification_evaluator import ClassificationEvaluator
-
-
-def main():
-    """
-    Evolve a GP Tree that classifies breast cancer, using a dataset from sklearn
-
-    Expected running time: ~6 minutes (on 2 cores, 2.5 GHz CPU)
-    Example output (with over 92% accuracy on test set):
-    f_div
-       f_log
-          x10
-       f_min
-          f_div
-             f_log
-                f_sqrt
-                   f_sqrt
-                      x7
-             f_max
-                x19
-                x23
-          f_div
-             f_log
-                f_mul
-                   f_add
-                      f_abs
-                         f_neg
-                            f_sqrt
-                               x19
-                      f_sub
-                         f_inv
-                            x23
-                         x26
-                   f_log
-                      x4
-             f_max
-                f_add
-                   f_abs
-                      f_max
-                         f_sub
-                            f_inv
-                               x23
-                            x26
-                         x23
-                   f_add
-                      x12
-                      f_sqrt
-                         f_sqrt
-                            f_log
-                               f_sqrt
-                                  f_sqrt
-                                     x7
-                f_inv
-                   f_mul
-                      x19
-                      x25
-    """
-    start_time = time()
-
-    # load the brest cancer dataset from sklearn
-    X, y = load_breast_cancer(return_X_y=True)
-
-    # Automatically generate a terminal set.
-    # Since there are 5 features, set terminal_set to: ['x0', 'x1', 'x2', ..., 'x9']
-    terminal_set = create_terminal_set(X)
-
-    # Define function set
-    function_set = [f_add, f_mul, f_sub, f_div, f_sqrt, f_log, f_abs, f_neg, f_inv, f_max, f_min]
-
-    # Initialize SimpleEvolution instance
-    algo = SimpleEvolution(
-        Subpopulation(creators=RampedHalfAndHalfCreator(init_depth=(2, 4),
-                                                        terminal_set=terminal_set,
-                                                        function_set=function_set,
-                                                        bloat_weight=0.0001),
-                      population_size=1000,
-                      evaluator=ClassificationEvaluator(),
-                      # maximization problem (fitness is accuracy), so higher fitness is better
-                      higher_is_better=True,
-                      elitism_rate=0.05,
-                      # genetic operators sequence to be applied in each generation
-                      operators_sequence=[
-                          SubtreeCrossover(probability=0.9, arity=2),
-                          SubtreeMutation(probability=0.2, arity=1)
-                      ],
-                      selection_methods=[
-                          # (selection method, selection probability) tuple
-                          (TournamentSelection(tournament_size=4, higher_is_better=True), 1)
-                      ]
-                      ),
-        breeder=SimpleBreeder(),
-        max_workers=1,
-        max_generation=1000,
-        # optimal fitness is 1, evolution ("training") process will be finished when best fitness <= threshold
-        termination_checker=ThresholdFromTargetTerminationChecker(optimal=1, threshold=0.03),
-        statistics=BestAverageWorstSizeTreeStatistics()
-    )
-    # wrap the basic evolutionary algorithm with a sklearn-compatible classifier
-    classifier = SKClassifier(algo)
-
-    # split brest cancer dataset to train and test set
-    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
-
-    # fit the model (perform evolution process)
-    classifier.fit(X_train, y_train)
-
-    # check training set results
-    print(f'\nbest pure fitness over training set: {algo.best_of_run_.get_pure_fitness()}')
-
-    # check test set results by computing the accuracy score between the prediction result and the test set result
-    test_score = accuracy_score(y_test, classifier.predict(X_test))
-    print(f'test score: {test_score}')
-
-    print(f'Total runtime: {time() - start_time} seconds.')
-
-
-if __name__ == "__main__":
-    main()
diff --git a/examples/treegp/sklearn_mode/breast_cancer/classification_gridsearch.py b/examples/treegp/sklearn_mode/breast_cancer/classification_gridsearch.py
deleted file mode 100644
index 7fe4a799..00000000
--- a/examples/treegp/sklearn_mode/breast_cancer/classification_gridsearch.py
+++ /dev/null
@@ -1,77 +0,0 @@
-from sklearn.datasets import load_breast_cancer
-from sklearn.model_selection import GridSearchCV
-
-from eckity.algorithms.simple_evolution import SimpleEvolution
-from eckity.sklearn_compatible.sk_classifier import SKClassifier
-from eckity.breeders.simple_breeder import SimpleBreeder
-from eckity.creators.gp_creators.ramped_hh import RampedHalfAndHalfCreator
-from eckity.genetic_encodings.gp.tree.functions import f_add, f_mul, f_sub, f_div, f_neg, f_sqrt, f_log, f_abs, f_inv, f_max, \
-    f_min
-from eckity.genetic_encodings.gp.tree.utils import create_terminal_set
-from eckity.genetic_operators.crossovers.subtree_crossover import SubtreeCrossover
-from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
-from eckity.genetic_operators.selections.tournament_selection import TournamentSelection
-from eckity.statistics.best_average_worst_statistics import BestAverageWorstStatistics
-from eckity.subpopulation import Subpopulation
-from eckity.termination_checkers.threshold_from_target_termination_checker import ThresholdFromTargetTerminationChecker
-from eckity.sklearn_compatible.classification_evaluator import ClassificationEvaluator
-
-
-def main():
-    """
-    Demonstrate sklearn compatibility through use of grid search, solving the breast cancer problem.
-    """
-
-    # load the brest cancer dataset from sklearn
-    X, y = load_breast_cancer(return_X_y=True)
-
-    # Automatically generate a terminal set.
-    # Since there are 5 features, set terminal_set to: ['x0', 'x1', 'x2', ..., 'x9']
-    terminal_set = create_terminal_set(X)
-
-    # Define function set
-    function_set = [f_add, f_mul, f_sub, f_div, f_sqrt, f_log, f_abs, f_neg, f_inv, f_max, f_min]
-
-    # Initialize SimpleEvolution instance
-    algo = SimpleEvolution(
-        Subpopulation(creators=RampedHalfAndHalfCreator(init_depth=(2, 4),
-                                                        terminal_set=terminal_set,
-                                                        function_set=function_set,
-                                                        bloat_weight=0.0001),
-                      population_size=1000,
-                      evaluator=ClassificationEvaluator(),
-                      higher_is_better=True,
-                      elitism_rate=0.05,
-                      operators_sequence=[
-                          SubtreeCrossover(probability=0.9, arity=2),
-                          SubtreeMutation(probability=0.2, arity=1)
-                      ],
-                      selection_methods=[
-                          (TournamentSelection(tournament_size=4, higher_is_better=True), 1)
-                      ]
-                      ),
-        breeder=SimpleBreeder(),
-        max_workers=4,
-        max_generation=1000,
-        termination_checker=ThresholdFromTargetTerminationChecker(optimal=1, threshold=0.03),
-        statistics=BestAverageWorstStatistics()
-    )
-    classifier = SKClassifier(algo)
-
-    print('Showcasing GridSearchCV...')
-
-    # Grid search parameters.
-    # The Grid Search model will fit the classifier with every pair combination of the parameter lists
-    # For example: (max_workers=1, max_generation=10), (max_workers=1, max_generation=20) and so on.
-    parameters = {'max_workers': [1, 2, 4], 'max_generation': [10, 20, 30]}
-
-    # create the grid search model and fit it several times, each time with a different combination of the parameters
-    model = GridSearchCV(classifier, parameters)
-    model.fit(X, y)
-
-    print(f'best params: {model.best_params_}')
-    print(f'best score:  {model.best_score_}')
-
-
-if __name__ == "__main__":
-    main()
diff --git a/examples/treegp/sklearn_mode/classification_gridsearch.py b/examples/treegp/sklearn_mode/classification_gridsearch.py
new file mode 100644
index 00000000..ad04950f
--- /dev/null
+++ b/examples/treegp/sklearn_mode/classification_gridsearch.py
@@ -0,0 +1,128 @@
+import numpy as np
+from sklearn.datasets import make_classification
+from sklearn.model_selection import GridSearchCV
+
+from eckity.algorithms.simple_evolution import SimpleEvolution
+from eckity.base.untyped_functions import (
+    f_abs,
+    f_add,
+    f_div,
+    f_inv,
+    f_log,
+    f_max,
+    f_min,
+    f_mul,
+    f_neg,
+    f_sqrt,
+    f_sub,
+)
+from eckity.breeders.simple_breeder import SimpleBreeder
+from eckity.creators.gp_creators.half import HalfCreator
+from eckity.genetic_encodings.gp.tree.utils import create_terminal_set
+from eckity.genetic_operators.crossovers.subtree_crossover import (
+    SubtreeCrossover,
+)
+from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
+from eckity.genetic_operators.selections.tournament_selection import (
+    TournamentSelection,
+)
+from eckity.sklearn_compatible.classification_evaluator import (
+    ClassificationEvaluator,
+)
+from eckity.sklearn_compatible.sk_classifier import SKClassifier
+from eckity.statistics.best_average_worst_statistics import (
+    BestAverageWorstStatistics,
+)
+from eckity.subpopulation import Subpopulation
+from eckity.termination_checkers.threshold_from_target_termination_checker import (
+    ThresholdFromTargetTerminationChecker,
+)
+
+# Adding your own types and functions
+
+t_argmax = type("argmax", (int,), {})
+
+
+def argmax3(x0: float, x1: float, x2: float) -> t_argmax:
+    return np.argmax([x0, x1, x2])
+
+
+def main():
+    """
+    Demonstrate sklearn compatibility through use of grid search, solving a classification problem.
+    """
+
+    # load the brest cancer dataset from sklearn
+    X, y = make_classification(
+        n_samples=100, n_features=10, n_classes=3, n_informative=5
+    )
+
+    # Automatically generate a terminal set.
+    # Since there are 10 features, set terminal_set to: ['x0', 'x1', ..., 'x9']
+    terminal_set = create_terminal_set(X, typed=False)
+
+    # Define function set
+    function_set = [
+        f_abs,
+        f_add,
+        f_div,
+        f_inv,
+        f_log,
+        f_max,
+        f_min,
+        f_mul,
+        f_neg,
+        f_sqrt,
+        f_sub,
+    ]
+
+    # Initialize SimpleEvolution instance
+    algo = SimpleEvolution(
+        Subpopulation(
+            creators=HalfCreator(
+                init_depth=(2, 4),
+                terminal_set=terminal_set,
+                function_set=function_set,
+                bloat_weight=0.0001,
+            ),
+            population_size=10,
+            evaluator=ClassificationEvaluator(),
+            higher_is_better=True,
+            elitism_rate=0.05,
+            operators_sequence=[
+                SubtreeCrossover(probability=0.9, arity=2),
+                SubtreeMutation(probability=0.2, arity=1),
+            ],
+            selection_methods=[
+                (
+                    TournamentSelection(
+                        tournament_size=4, higher_is_better=True
+                    ),
+                    1,
+                )
+            ],
+        ),
+        max_workers=4,
+        max_generation=10,
+        termination_checker=ThresholdFromTargetTerminationChecker(
+            optimal=1, threshold=0.03
+        ),
+        statistics=BestAverageWorstStatistics(),
+    )
+    classifier = SKClassifier(algo)
+
+    print("Showcasing GridSearchCV...")
+
+    # Grid search parameters.
+    # The Grid Search model will fit the classifier with each parameter value
+    parameters = {"max_generation": [10, 20, 30]}
+
+    model = GridSearchCV(classifier, parameters)
+    model.fit(X, y)
+
+    print(f"best params: {model.best_params_}")
+    print(f"best score:  {model.best_score_}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/examples/treegp/sklearn_mode/regression_gridsearch.py b/examples/treegp/sklearn_mode/regression_gridsearch.py
new file mode 100644
index 00000000..8c34d17d
--- /dev/null
+++ b/examples/treegp/sklearn_mode/regression_gridsearch.py
@@ -0,0 +1,111 @@
+from time import time
+
+
+from sklearn.datasets import make_regression
+from sklearn.model_selection import GridSearchCV
+
+from eckity.algorithms.simple_evolution import SimpleEvolution
+from eckity.sklearn_compatible.sk_regressor import SKRegressor
+from eckity.creators.gp_creators.half import HalfCreator
+from eckity.base.untyped_functions import f_add, f_mul, f_sub
+from eckity.genetic_encodings.gp.tree.utils import create_terminal_set
+from eckity.genetic_operators.crossovers.subtree_crossover import (
+    SubtreeCrossover,
+)
+from eckity.genetic_operators.mutations.erc_mutation import ERCMutation
+from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
+from eckity.genetic_operators.selections.tournament_selection import (
+    TournamentSelection,
+)
+from eckity.statistics.best_average_worst_statistics import (
+    BestAverageWorstStatistics,
+)
+from eckity.subpopulation import Subpopulation
+from eckity.termination_checkers.threshold_from_target_termination_checker import (
+    ThresholdFromTargetTerminationChecker,
+)
+
+from eckity.sklearn_compatible.regression_evaluator import RegressionEvaluator
+
+
+def main():
+    """
+    Demonstrate sklearn compatibility through use of grid search
+
+    Perform an exhaustive search over a given set of parameters to find the best parameters.
+    In this example, we will use sklearn GridSearchCV to solve Symbolic Regression GP problem.
+
+    Expected output:    best params: {'max_generation': 30}
+    Expected runtime: ~16 minutes (on 2 cores, 2.5 GHz CPU)
+    """
+    start_time = time()
+
+    # generate a random regression problem.
+    X, y = make_regression(n_samples=500, n_features=5)
+
+    # Automatically generate a terminal set.
+    # Since there are 5 features, set terminal_set to: ['x0', 'x1', 'x2', 'x3', 'x4']
+    terminal_set = create_terminal_set(X, typed=False)
+
+    # Set function set to binary addition, binary multiplication and binary subtraction
+    function_set = [f_add, f_mul, f_sub]
+
+    # Initialize Simple Evolutionary Algorithm instance
+    algo = SimpleEvolution(
+        Subpopulation(
+            creators=HalfCreator(
+                init_depth=(2, 4),
+                terminal_set=terminal_set,
+                function_set=function_set,
+                bloat_weight=0.0001,
+                erc_range=(-100.0, 100.0),
+            ),
+            population_size=1000,
+            # user-defined fitness evaluation method
+            evaluator=RegressionEvaluator(),
+            # minimization problem (fitness is MAE), so higher fitness is worse
+            higher_is_better=False,
+            elitism_rate=0.05,
+            # genetic operators sequence to be applied in each generation
+            operators_sequence=[
+                SubtreeCrossover(probability=0.9, arity=2),
+                SubtreeMutation(probability=0.2, arity=1),
+                ERCMutation(probability=0.05, arity=1),
+            ],
+            selection_methods=[
+                # (selection method, selection probability) tuple
+                (
+                    TournamentSelection(
+                        tournament_size=4, higher_is_better=False
+                    ),
+                    1,
+                )
+            ],
+        ),
+        max_workers=1,
+        max_generation=1000,
+        # optimal fitness is 0, evolution ("training") process will be finished when best fitness <= threshold
+        termination_checker=ThresholdFromTargetTerminationChecker(
+            threshold=0.01
+        ),
+        statistics=BestAverageWorstStatistics(),
+    )
+    # wrap the simple evolutionary algorithm with sklearn compatible regressor
+    regressor = SKRegressor(algo)
+
+    print("Showcasing GridSearchCV...")
+
+    # Grid search parameters.
+    # The Grid Search model will fit the classifier with each parameter value
+    parameters = {"max_generation": [10, 20, 30]}
+
+    # create the grid search model and fit it several times, each time with a different combination of the parameters
+    model = GridSearchCV(regressor, parameters)
+    model.fit(X, y)
+
+    print(f"best params: {model.best_params_}")
+    print(f"Total runtime: {time() - start_time} seconds.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/examples/treegp/sklearn_mode/symbolic_regression/regression_pipeline.py b/examples/treegp/sklearn_mode/regression_pipeline.py
similarity index 54%
rename from examples/treegp/sklearn_mode/symbolic_regression/regression_pipeline.py
rename to examples/treegp/sklearn_mode/regression_pipeline.py
index a0a2ee89..82b2e1a3 100644
--- a/examples/treegp/sklearn_mode/symbolic_regression/regression_pipeline.py
+++ b/examples/treegp/sklearn_mode/regression_pipeline.py
@@ -1,5 +1,6 @@
 from time import time
 
+
 from sklearn.decomposition import PCA
 from sklearn.datasets import make_regression
 from sklearn.model_selection import train_test_split
@@ -9,17 +10,24 @@
 
 from eckity.algorithms.simple_evolution import SimpleEvolution
 from eckity.sklearn_compatible.sk_regressor import SKRegressor
-from eckity.breeders.simple_breeder import SimpleBreeder
-from eckity.creators.gp_creators.ramped_hh import RampedHalfAndHalfCreator
-from eckity.genetic_encodings.gp.tree.functions import f_add, f_mul, f_sub
+from eckity.creators.gp_creators.half import HalfCreator
+from eckity.base.untyped_functions import f_add, f_mul, f_sub
 from eckity.genetic_encodings.gp.tree.utils import create_terminal_set
-from eckity.genetic_operators.crossovers.subtree_crossover import SubtreeCrossover
+from eckity.genetic_operators.crossovers.subtree_crossover import (
+    SubtreeCrossover,
+)
 from eckity.genetic_operators.mutations.erc_mutation import ERCMutation
 from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
-from eckity.genetic_operators.selections.tournament_selection import TournamentSelection
-from eckity.statistics.best_average_worst_statistics import BestAverageWorstStatistics
+from eckity.genetic_operators.selections.tournament_selection import (
+    TournamentSelection,
+)
+from eckity.statistics.best_average_worst_statistics import (
+    BestAverageWorstStatistics,
+)
 from eckity.subpopulation import Subpopulation
-from eckity.termination_checkers.threshold_from_target_termination_checker import ThresholdFromTargetTerminationChecker
+from eckity.termination_checkers.threshold_from_target_termination_checker import (
+    ThresholdFromTargetTerminationChecker,
+)
 from eckity.sklearn_compatible.regression_evaluator import RegressionEvaluator
 
 
@@ -40,41 +48,50 @@ def main():
 
     # Automatically generate a terminal set.
     # Since there are 5 features, set terminal_set to: ['x0', 'x1', 'x2', 'x3', 'x4']
-    terminal_set = create_terminal_set(X)
+    terminal_set = create_terminal_set(X, typed=False)
 
     # Set function set to binary addition, binary multiplication and binary subtraction
     function_set = [f_add, f_mul, f_sub]
 
     # Initialize Simple Evolutionary Algorithm instance
     algo = SimpleEvolution(
-        Subpopulation(creators=RampedHalfAndHalfCreator(init_depth=(2, 4),
-                                                        terminal_set=terminal_set,
-                                                        function_set=function_set,
-                                                        erc_range=(-100, 100),
-                                                        bloat_weight=0.0001),
-                      population_size=1000,
-                      # user-defined fitness evaluation method
-                      evaluator=RegressionEvaluator(),
-                      # minimization problem (fitness is MAE), so higher fitness is worse
-                      higher_is_better=False,
-                      elitism_rate=0.05,
-                      # genetic operators sequence to be applied in each generation
-                      operators_sequence=[
-                          SubtreeCrossover(probability=0.9, arity=2),
-                          SubtreeMutation(probability=0.2, arity=1),
-                          ERCMutation(probability=0.05, arity=1)
-                      ],
-                      selection_methods=[
-                          # (selection method, selection probability) tuple
-                          (TournamentSelection(tournament_size=4, higher_is_better=False), 1)
-                      ]
-                      ),
-        breeder=SimpleBreeder(),
+        Subpopulation(
+            creators=HalfCreator(
+                init_depth=(2, 4),
+                terminal_set=terminal_set,
+                function_set=function_set,
+                bloat_weight=0.0001,
+                erc_range=(-100.0, 100.0),
+            ),
+            population_size=1000,
+            # user-defined fitness evaluation method
+            evaluator=RegressionEvaluator(),
+            # minimization problem (fitness is MAE), so higher fitness is worse
+            higher_is_better=False,
+            elitism_rate=0.05,
+            # genetic operators sequence to be applied in each generation
+            operators_sequence=[
+                SubtreeCrossover(probability=0.9, arity=2),
+                SubtreeMutation(probability=0.2, arity=1),
+                ERCMutation(probability=0.05, arity=1),
+            ],
+            selection_methods=[
+                # (selection method, selection probability) tuple
+                (
+                    TournamentSelection(
+                        tournament_size=4, higher_is_better=False
+                    ),
+                    1,
+                )
+            ],
+        ),
         max_workers=1,
         max_generation=1000,
         # optimal fitness is 0, evolution ("training") process will be finished when best fitness <= threshold
-        termination_checker=ThresholdFromTargetTerminationChecker(optimal=0, threshold=0.01),
-        statistics=BestAverageWorstStatistics()
+        termination_checker=ThresholdFromTargetTerminationChecker(
+            threshold=0.01
+        ),
+        statistics=BestAverageWorstStatistics(),
     )
     # wrap the simple evolutionary algorithm with sklearn compatible regressor
     regressor = SKRegressor(algo)
@@ -83,16 +100,18 @@ def main():
     X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
 
     # create a pipeline: first scale the dataset, then run PCA and finally perform the evolution stage
-    print('Showcasing pipeline...')
-    pipeline = make_pipeline(StandardScaler(), PCA(n_components=len(terminal_set)), regressor)
+    print("Showcasing pipeline...")
+    pipeline = make_pipeline(
+        StandardScaler(), PCA(n_components=len(terminal_set)), regressor
+    )
     pipeline.fit(X_train, y_train)
 
     # compute test score on test set
     test_score = mean_absolute_error(y_test, pipeline.predict(X_test))
-    print(f'pipeline test score: {test_score}')
+    print(f"pipeline test score: {test_score}")
 
-    print(f'Total runtime: {time() - start_time} seconds.')
+    print(f"Total runtime: {time() - start_time} seconds.")
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     main()
diff --git a/examples/treegp/sklearn_mode/root_function_creator.py b/examples/treegp/sklearn_mode/root_function_creator.py
new file mode 100644
index 00000000..e9f43d9e
--- /dev/null
+++ b/examples/treegp/sklearn_mode/root_function_creator.py
@@ -0,0 +1,50 @@
+from typing import Callable, List, Optional
+
+from overrides import override
+
+from eckity.creators import FullCreator
+from eckity.genetic_encodings.gp import FunctionNode, TerminalNode, TreeNode
+
+
+class RootFunctionCreator(FullCreator):
+    """
+    Creator for trees with an immutable root function
+    (e.g. argmax/softmax for classification)
+    """
+
+    def __init__(
+        self,
+        root_function: Callable = None,
+        init_depth=None,
+        function_set=None,
+        terminal_set=None,
+        bloat_weight=0.1,
+        events=None,
+    ) -> None:
+        super().__init__(
+            init_depth=init_depth,
+            function_set=function_set,
+            terminal_set=terminal_set,
+            bloat_weight=bloat_weight,
+            events=events,
+        )
+        self.root_function = root_function
+
+    @override
+    def create_tree(
+        self,
+        tree: List[TreeNode],
+        random_function: Callable[[type], Optional[FunctionNode]],
+        random_terminal: Callable[[type], Optional[TerminalNode]],
+        depth: int = 0,
+        node_type: Optional[type] = None,
+    ) -> None:
+        root = FunctionNode(self.root_function)
+
+        self._add_children(
+            tree=tree,
+            fn_node=root,
+            random_function=random_function,
+            random_terminal=random_terminal,
+            depth=0,
+        )
diff --git a/examples/treegp/sklearn_mode/symbolic_regression.py b/examples/treegp/sklearn_mode/symbolic_regression.py
new file mode 100644
index 00000000..408d1d52
--- /dev/null
+++ b/examples/treegp/sklearn_mode/symbolic_regression.py
@@ -0,0 +1,124 @@
+"""
+Solving a sklearn_mode problem created through scikit-learn's `make_regression`.
+This is an sklearn setting, so we use `fit` and `predict`.
+"""
+
+import numpy as np
+from time import time
+
+from sklearn.datasets import make_regression
+from sklearn.model_selection import train_test_split
+from sklearn.metrics import mean_absolute_error
+
+from eckity.algorithms.simple_evolution import SimpleEvolution
+from eckity.creators.gp_creators.half import HalfCreator
+from eckity.base.untyped_functions import f_add, f_mul, f_sub
+from eckity.genetic_encodings.gp.tree.utils import create_terminal_set
+from eckity.genetic_operators.crossovers.subtree_crossover import (
+    SubtreeCrossover,
+)
+from eckity.genetic_operators.mutations.erc_mutation import ERCMutation
+from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
+from eckity.genetic_operators.selections.tournament_selection import (
+    TournamentSelection,
+)
+from eckity.sklearn_compatible.sk_regressor import SKRegressor
+from eckity.statistics.best_average_worst_statistics import (
+    BestAverageWorstStatistics,
+)
+from eckity.subpopulation import Subpopulation
+from eckity.termination_checkers.threshold_from_target_termination_checker import (
+    ThresholdFromTargetTerminationChecker,
+)
+from eckity.sklearn_compatible.regression_evaluator import RegressionEvaluator
+
+
+# Adding your own functions
+def f_add3(x1, x2, x3):
+    return np.add(np.add(x1, x2), x3)
+
+
+def f_mul3(x1, x2, x3):
+    return np.multiply(np.multiply(x1, x2), x3)
+
+
+def main():
+    """
+    Solve a regression problem imported from sklearn `make_regression` function, using GP Trees.
+    Expected run time: ~25 minutes (on 2 cores, 2.5 GHz CPU)
+    """
+    start_time = time()
+
+    # generate a random regression problem
+    X, y = make_regression(n_samples=500, n_features=5)
+
+    # Automatically generate a terminal set.
+    # Since there are 5 features, set terminal_set to: ['x0', 'x1', 'x2', 'x3', 'x4']
+    terminal_set = create_terminal_set(X, typed=False)
+
+    # Set function set to binary addition, binary multiplication and binary subtraction
+    function_set = [f_add, f_mul, f_sub]
+
+    # Initialize Simple Evolutionary Algorithm instance
+    algo = SimpleEvolution(
+        Subpopulation(
+            creators=HalfCreator(
+                init_depth=(2, 4),
+                terminal_set=terminal_set,
+                function_set=function_set,
+                bloat_weight=0.0001,
+                erc_range=(-100.0, 100.0),
+            ),
+            population_size=1000,
+            # user-defined fitness evaluation method
+            evaluator=RegressionEvaluator(),
+            # minimization problem (fitness is MAE), so higher fitness is worse
+            higher_is_better=False,
+            elitism_rate=0.05,
+            # genetic operators sequence to be applied in each generation
+            operators_sequence=[
+                SubtreeCrossover(probability=0.9, arity=2),
+                SubtreeMutation(probability=0.2, arity=1),
+                ERCMutation(probability=0.05, arity=1),
+            ],
+            selection_methods=[
+                # (selection method, selection probability) tuple
+                (
+                    TournamentSelection(
+                        tournament_size=4, higher_is_better=False
+                    ),
+                    1,
+                )
+            ],
+        ),
+        max_workers=1,
+        max_generation=1000,
+        # optimal fitness is 0, evolution ("training") process will be finished when best fitness <= threshold
+        termination_checker=ThresholdFromTargetTerminationChecker(
+            optimal=0, threshold=0.01
+        ),
+        statistics=BestAverageWorstStatistics(),
+    )
+    # wrap the simple evolutionary algorithm with sklearn-compatible regressor
+    regressor = SKRegressor(algo)
+
+    # split regression dataset to train and test set
+    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
+
+    # fit the model (perform evolution process)
+    regressor.fit(X_train, y_train)
+
+    # check training set results
+    print(
+        f"\nBest pure fitness over training set: {algo.best_of_run_.get_pure_fitness()}"
+    )
+
+    # check test set results by computing the MAE between the prediction result and the test set result
+    test_score = mean_absolute_error(y_test, regressor.predict(X_test))
+    print(f"Test score: {test_score}")
+
+    print(f"Total runtime: {time() - start_time} seconds.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/examples/treegp/sklearn_mode/symbolic_regression/regression_gridsearch.py b/examples/treegp/sklearn_mode/symbolic_regression/regression_gridsearch.py
deleted file mode 100644
index cb3b30d4..00000000
--- a/examples/treegp/sklearn_mode/symbolic_regression/regression_gridsearch.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from time import time
-
-from sklearn.datasets import make_regression
-from sklearn.model_selection import GridSearchCV
-
-from eckity.algorithms.simple_evolution import SimpleEvolution
-from eckity.sklearn_compatible.sk_regressor import SKRegressor
-from eckity.breeders.simple_breeder import SimpleBreeder
-from eckity.creators.gp_creators.ramped_hh import RampedHalfAndHalfCreator
-from eckity.genetic_encodings.gp.tree.functions import f_add, f_mul, f_sub
-from eckity.genetic_encodings.gp.tree.utils import create_terminal_set
-from eckity.genetic_operators.crossovers.subtree_crossover import SubtreeCrossover
-from eckity.genetic_operators.mutations.erc_mutation import ERCMutation
-from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
-from eckity.genetic_operators.selections.tournament_selection import TournamentSelection
-from eckity.statistics.best_average_worst_statistics import BestAverageWorstStatistics
-from eckity.subpopulation import Subpopulation
-from eckity.termination_checkers.threshold_from_target_termination_checker import ThresholdFromTargetTerminationChecker
-
-from eckity.sklearn_compatible.regression_evaluator import RegressionEvaluator
-
-
-def main():
-    """
-    Demonstrate sklearn compatibility through use of grid search
-
-    Perform an exhaustive search over a given set of parameters to find the best parameters.
-    In this example, we will use sklearn GridSearchCV to solve Symbolic Regression GP problem.
-
-    Expected output:    best params: {'max_generation': 30, 'max_workers': 1}
-    Expected runtime: ~16 minutes (on 2 cores, 2.5 GHz CPU)
-    """
-    start_time = time()
-
-    # generate a random regression problem.
-    X, y = make_regression(n_samples=500, n_features=5)
-
-    # Automatically generate a terminal set.
-    # Since there are 5 features, set terminal_set to: ['x0', 'x1', 'x2', 'x3', 'x4']
-    terminal_set = create_terminal_set(X)
-
-    # Set function set to binary addition, binary multiplication and binary subtraction
-    function_set = [f_add, f_mul, f_sub]
-
-    # Initialize Simple Evolutionary Algorithm instance
-    algo = SimpleEvolution(
-        Subpopulation(creators=RampedHalfAndHalfCreator(init_depth=(2, 4),
-                                                        terminal_set=terminal_set,
-                                                        function_set=function_set,
-                                                        erc_range=(-100, 100),
-                                                        bloat_weight=0.0001),
-                      population_size=1000,
-                      # user-defined fitness evaluation method
-                      evaluator=RegressionEvaluator(),
-                      # minimization problem (fitness is MAE), so higher fitness is worse
-                      higher_is_better=False,
-                      elitism_rate=0.05,
-                      # genetic operators sequence to be applied in each generation
-                      operators_sequence=[
-                          SubtreeCrossover(probability=0.9, arity=2),
-                          SubtreeMutation(probability=0.2, arity=1),
-                          ERCMutation(probability=0.05, arity=1)
-                      ],
-                      selection_methods=[
-                          # (selection method, selection probability) tuple
-                          (TournamentSelection(tournament_size=4, higher_is_better=False), 1)
-                      ]
-                      ),
-        breeder=SimpleBreeder(),
-        max_workers=1,
-        max_generation=1000,
-        # optimal fitness is 0, evolution ("training") process will be finished when best fitness <= threshold
-        termination_checker=ThresholdFromTargetTerminationChecker(optimal=0, threshold=0.01),
-        statistics=BestAverageWorstStatistics()
-    )
-    # wrap the simple evolutionary algorithm with sklearn compatible regressor
-    regressor = SKRegressor(algo)
-
-    print('Showcasing GridSearchCV...')
-
-    # Grid search parameters.
-    # The Grid Search model will fit the classifier with every pair combination of the parameter lists
-    # For example: (max_workers=1, max_generation=50), (max_workers=1, max_generation=100) and so on.
-    parameters = {'max_workers': [1, 2, 4], 'max_generation': [10, 20, 30]}
-
-    # create the grid search model and fit it several times, each time with a different combination of the parameters
-    model = GridSearchCV(regressor, parameters)
-    model.fit(X, y)
-
-    print(f'best params: {model.best_params_}')
-    print(f'Total runtime: {time() - start_time} seconds.')
-
-
-if __name__ == "__main__":
-    main()
diff --git a/examples/treegp/sklearn_mode/symbolic_regression/sklearn_sym_reg_main.py b/examples/treegp/sklearn_mode/symbolic_regression/sklearn_sym_reg_main.py
deleted file mode 100644
index d2e115aa..00000000
--- a/examples/treegp/sklearn_mode/symbolic_regression/sklearn_sym_reg_main.py
+++ /dev/null
@@ -1,168 +0,0 @@
-"""
-Solving a sklearn_mode problem created through scikit-learn's `make_regression`.
-This is an sklearn setting, so we use `fit` and `predict`.
-"""
-
-import numpy as np
-from time import time
-
-from sklearn.datasets import make_regression
-from sklearn.model_selection import train_test_split
-from sklearn.metrics import mean_absolute_error
-
-from eckity.algorithms.simple_evolution import SimpleEvolution
-from eckity.breeders.simple_breeder import SimpleBreeder
-from eckity.creators.gp_creators.ramped_hh import RampedHalfAndHalfCreator
-from eckity.genetic_encodings.gp.tree.functions import f_add, f_mul, f_sub
-from eckity.genetic_encodings.gp.tree.utils import create_terminal_set
-from eckity.genetic_operators.crossovers.subtree_crossover import SubtreeCrossover
-from eckity.genetic_operators.mutations.erc_mutation import ERCMutation
-from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
-from eckity.genetic_operators.selections.tournament_selection import TournamentSelection
-from eckity.sklearn_compatible.sk_regressor import SKRegressor
-from eckity.statistics.best_average_worst_statistics import BestAverageWorstStatistics
-from eckity.subpopulation import Subpopulation
-from eckity.termination_checkers.threshold_from_target_termination_checker import ThresholdFromTargetTerminationChecker
-from eckity.sklearn_compatible.regression_evaluator import RegressionEvaluator
-
-
-# Adding your own functions
-def f_add3(x1, x2, x3):
-    return np.add(np.add(x1, x2), x3)
-
-
-def f_mul3(x1, x2, x3):
-    return np.multiply(np.multiply(x1, x2), x3)
-
-
-def main():
-    """
-    Solve a regression problem imported from sklearn `make_regression` function, using GP Trees.
-    Expected run time: ~25 minutes (on 2 cores, 2.5 GHz CPU)
-    Example output (with an error of 0.09 on test set):
-    f_sub
-       f_mul
-          x3
-          57.9788
-       f_sub
-          f_mul
-             f_add
-                x1
-                x0
-             -69.8759
-          f_add
-             f_sub
-                f_add
-                   f_add
-                      f_add
-                         f_add
-                            f_add
-                               f_add
-                                  x1
-                                  x2
-                               x2
-                            f_add
-                               f_add
-                                  x2
-                                  x2
-                               f_sub
-                                  x1
-                                  x0
-                         x1
-                      f_add
-                         f_add
-                            f_add
-                               f_sub
-                                  f_sub
-                                     x1
-                                     f_mul
-                                        x1
-                                        -1.0477
-                                  x1
-                               x2
-                            x4
-                         x1
-                   x1
-                f_mul
-                   -33.4406
-                   f_add
-                      x4
-                      x4
-             f_sub
-                f_mul
-                   x1
-                   f_sub
-                      x2
-                      x2
-                f_mul
-                   f_add
-                      x2
-                      x0
-                   f_sub
-                      -19.0099
-                      -1.0477
-    """
-    start_time = time()
-
-    # generate a random regression problem
-    X, y = make_regression(n_samples=500, n_features=5)
-
-    # Automatically generate a terminal set.
-    # Since there are 5 features, set terminal_set to: ['x0', 'x1', 'x2', 'x3', 'x4']
-    terminal_set = create_terminal_set(X)
-
-    # Set function set to binary addition, binary multiplication and binary subtraction
-    function_set = [f_add, f_mul, f_sub]
-
-    # Initialize Simple Evolutionary Algorithm instance
-    algo = SimpleEvolution(
-        Subpopulation(creators=RampedHalfAndHalfCreator(init_depth=(2, 4),
-                                                        terminal_set=terminal_set,
-                                                        function_set=function_set,
-                                                        erc_range=(-100, 100),
-                                                        bloat_weight=0.0001),
-                      population_size=1000,
-                      # user-defined fitness evaluation method
-                      evaluator=RegressionEvaluator(),
-                      # minimization problem (fitness is MAE), so higher fitness is worse
-                      higher_is_better=False,
-                      elitism_rate=0.05,
-                      # genetic operators sequence to be applied in each generation
-                      operators_sequence=[
-                          SubtreeCrossover(probability=0.9, arity=2),
-                          SubtreeMutation(probability=0.2, arity=1),
-                          ERCMutation(probability=0.05, arity=1)
-                      ],
-                      selection_methods=[
-                          # (selection method, selection probability) tuple
-                          (TournamentSelection(tournament_size=4, higher_is_better=False), 1)
-                      ]
-                      ),
-        breeder=SimpleBreeder(),
-        max_workers=1,
-        max_generation=1000,
-        # optimal fitness is 0, evolution ("training") process will be finished when best fitness <= threshold
-        termination_checker=ThresholdFromTargetTerminationChecker(optimal=0, threshold=0.01),
-        statistics=BestAverageWorstStatistics()
-    )
-    # wrap the simple evolutionary algorithm with sklearn-compatible regressor
-    regressor = SKRegressor(algo)
-
-    # split regression dataset to train and test set
-    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
-
-    # fit the model (perform evolution process)
-    regressor.fit(X_train, y_train)
-
-    # check training set results
-    print(f'\nBest pure fitness over training set: {algo.best_of_run_.get_pure_fitness()}')
-
-    # check test set results by computing the MAE between the prediction result and the test set result
-    test_score = mean_absolute_error(y_test, regressor.predict(X_test))
-    print(f'Test score: {test_score}')
-
-    print(f'Total runtime: {time() - start_time} seconds.')
-
-
-if __name__ == "__main__":
-    main()
diff --git a/examples/treegp/sklearn_mode/typed_classification_gridsearch.py b/examples/treegp/sklearn_mode/typed_classification_gridsearch.py
new file mode 100644
index 00000000..5d1972b2
--- /dev/null
+++ b/examples/treegp/sklearn_mode/typed_classification_gridsearch.py
@@ -0,0 +1,128 @@
+import numpy as np
+from sklearn.datasets import make_classification
+from sklearn.model_selection import GridSearchCV
+
+from eckity.algorithms.simple_evolution import SimpleEvolution
+from eckity.base.typed_functions import (
+    abs_float,
+    add2floats,
+    div2floats,
+    inv_float,
+    log_float,
+    max2floats,
+    min2floats,
+    mul2floats,
+    neg_float,
+    sqrt_float,
+    sub2floats,
+)
+from eckity.creators.gp_creators.half import HalfCreator
+from eckity.genetic_encodings.gp.tree.utils import create_terminal_set
+from eckity.genetic_operators.crossovers.subtree_crossover import (
+    SubtreeCrossover,
+)
+from eckity.genetic_operators.mutations.subtree_mutation import SubtreeMutation
+from eckity.genetic_operators.selections.tournament_selection import (
+    TournamentSelection,
+)
+from eckity.sklearn_compatible.classification_evaluator import (
+    ClassificationEvaluator,
+)
+from eckity.sklearn_compatible.sk_classifier import SKClassifier
+from eckity.statistics.best_average_worst_statistics import (
+    BestAverageWorstStatistics,
+)
+from eckity.subpopulation import Subpopulation
+from eckity.termination_checkers.threshold_from_target_termination_checker import (
+    ThresholdFromTargetTerminationChecker,
+)
+
+# Adding your own types and functions
+
+t_argmax = type("argmax", (int,), {})
+
+
+def argmax3(x0: float, x1: float, x2: float) -> t_argmax:
+    return np.argmax([x0, x1, x2])
+
+
+def main():
+    """
+    Demonstrate sklearn compatibility through use of grid search, solving a classification problem.
+    """
+
+    # load the brest cancer dataset from sklearn
+    X, y = make_classification(
+        n_samples=100, n_features=10, n_classes=3, n_informative=5
+    )
+
+    # Automatically generate a terminal set.
+    # Since there are 10 features, set terminal_set to: ['x0', 'x1', ..., 'x9']
+    terminal_set = create_terminal_set(X, typed=True)
+
+    # Define function set
+    function_set = [
+        abs_float,
+        add2floats,
+        div2floats,
+        inv_float,
+        log_float,
+        max2floats,
+        min2floats,
+        mul2floats,
+        neg_float,
+        sqrt_float,
+        sub2floats,
+    ]
+
+    # Initialize SimpleEvolution instance
+    algo = SimpleEvolution(
+        Subpopulation(
+            creators=HalfCreator(
+                init_depth=(2, 4),
+                terminal_set=terminal_set,
+                function_set=function_set,
+                bloat_weight=0.0001,
+                root_type=float,
+            ),
+            population_size=10,
+            evaluator=ClassificationEvaluator(),
+            higher_is_better=True,
+            elitism_rate=0.05,
+            operators_sequence=[
+                SubtreeCrossover(probability=0.9, arity=2),
+                SubtreeMutation(probability=0.2, arity=1),
+            ],
+            selection_methods=[
+                (
+                    TournamentSelection(
+                        tournament_size=4, higher_is_better=True
+                    ),
+                    1,
+                )
+            ],
+        ),
+        max_workers=4,
+        max_generation=10,
+        termination_checker=ThresholdFromTargetTerminationChecker(
+            optimal=1, threshold=0.03
+        ),
+        statistics=BestAverageWorstStatistics(),
+    )
+    classifier = SKClassifier(algo)
+
+    print("Showcasing GridSearchCV...")
+
+    # Grid search parameters.
+    # The Grid Search model will fit the classifier with each parameter value
+    parameters = {"max_generation": [10, 20, 30]}
+
+    model = GridSearchCV(classifier, parameters)
+    model.fit(X, y)
+
+    print(f"best params: {model.best_params_}")
+    print(f"best score:  {model.best_score_}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/examples/vectorga/knapsack/knapsack_evaluator.py b/examples/vectorga/knapsack/knapsack_evaluator.py
index 5f07bc06..18e0102b 100644
--- a/examples/vectorga/knapsack/knapsack_evaluator.py
+++ b/examples/vectorga/knapsack/knapsack_evaluator.py
@@ -21,14 +21,22 @@ def __init__(self, items=None, max_weight=30):
         super().__init__()
 
         if items is None:
-            # Generate ramdom items for the problem (keys=weights, values=values)
-            items = {i: (random.randint(1, 10), random.uniform(0, 100)) for i in range(NUM_ITEMS)}
-        elif type(items) == list:
-
+            # Generate ramdom items (keys=weights, values=values)
+            items = {
+                i: (random.randint(1, 10), random.uniform(0, 100))
+                for i in range(NUM_ITEMS)
+            }
+        elif isinstance(items, list):
             for item in items:
-                if type(item) is not tuple or type(item[0]) is not int \
-                        or (type(item[1]) is not int and type(item[1]) is not float):
-                    raise ValueError('Elements in items list must be tuples of (weight: int, price: int or float)')
+                if (
+                    not isinstance(item, tuple)
+                    or not isinstance(item[0], int)
+                    or type(item[1] not in [int, float])
+                ):
+                    raise ValueError(
+                        """Elements in items list must be tuples of
+                        (weight: int, price: int or float)"""
+                    )
 
             # Convert items list to dictionary by adding item id
             items = {i: items[i] for i in range(len(items))}
diff --git a/requirements.txt b/requirements.txt
index 6293a88f..cc53526f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,3 @@
-numpy>=1.14.6
-overrides>=6.1.0
-pandas>=0.25.0
+numpy>=1.24.0
+overrides>=7.0.0
+pandas>=0.25.2
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 46ffe595..f40c98f1 100644
--- a/setup.py
+++ b/setup.py
@@ -6,6 +6,9 @@
 with open("README.md", "r", encoding="utf-8") as fh:
     long_description = fh.read()
 
+with open("requirements.txt", 'r', encoding="utf-8") as fh:
+    requirements = [line.strip() for line in fh]
+
 setup(
     name='eckity',
     version=VERSION,
@@ -20,5 +23,5 @@
     },
     license='GNU GPLv3',
     packages=find_packages(),
-    install_requires=['numpy>=1.14.6', 'overrides>=6.1.0', 'pandas>=0.25.0'],
+    install_requires=requirements,
 )


Test patch:
diff --git a/.github/workflows/test-python-publish.yml b/.github/workflows/test-python-publish.yml
index 641812a4..2bd6f0b3 100644
--- a/.github/workflows/test-python-publish.yml
+++ b/.github/workflows/test-python-publish.yml
@@ -16,6 +16,8 @@ on:
           required: false
   release:
     types: [published]
+  pull_request:
+    branches: [ main ]
 
 permissions:
   contents: read
@@ -26,9 +28,9 @@ jobs:
     runs-on: ubuntu-latest
     
     steps:
-    - uses: actions/checkout@master
-    - name: Set up Python 3.9
-      uses: actions/setup-python@v1
+    - uses: actions/checkout@v4.1.5
+    - name: Set up Python
+      uses: actions/setup-python@v5.1.0
       with:
         python-version: '3.x'
     - name: Install pypa/build
diff --git a/examples/multi_objective/moe_base_test/__init__.py b/eckity/algorithms/tests/__init__.py
similarity index 100%
rename from examples/multi_objective/moe_base_test/__init__.py
rename to eckity/algorithms/tests/__init__.py
diff --git a/eckity/algorithms/tests/test_simple_evolution.py b/eckity/algorithms/tests/test_simple_evolution.py
new file mode 100644
index 00000000..033094bc
--- /dev/null
+++ b/eckity/algorithms/tests/test_simple_evolution.py
@@ -0,0 +1,27 @@
+from eckity.subpopulation import Subpopulation
+from eckity.evaluators import SimpleIndividualEvaluator
+from eckity.creators import FullCreator
+from eckity.genetic_operators.mutations.identity_transformation import (
+    IdentityTransformation,
+)
+
+from ..simple_evolution import SimpleEvolution
+
+
+class DummyIndividualEvaluator(SimpleIndividualEvaluator):
+    def evaluate_individual(self, ind):
+        return 1
+
+
+def test_initialize():
+    # creates a Tree-GP experiment by default
+    algo = SimpleEvolution(
+        Subpopulation(
+            DummyIndividualEvaluator(),
+            creators=FullCreator(
+                function_set=[lambda x: x], terminal_set=["x"]
+            ),
+            operators_sequence=[IdentityTransformation()],
+        )
+    )
+    algo.initialize()
diff --git a/eckity/base/tests/test_base_utils.py b/eckity/base/tests/test_base_utils.py
new file mode 100644
index 00000000..df821dd9
--- /dev/null
+++ b/eckity/base/tests/test_base_utils.py
@@ -0,0 +1,18 @@
+import pytest
+
+from eckity.base.utils import arity
+from eckity.base.typed_functions import add2floats, or2floats
+from eckity.base.untyped_functions import f_mul, f_not
+
+
+@pytest.mark.parametrize(
+    "func, expected",
+    [
+        (add2floats, 2),
+        (or2floats, 2),
+        (f_mul, 2),
+        (f_not, 1),
+    ]
+)
+def test_arity(func, expected):
+    assert arity(func) == expected
diff --git a/eckity/base/tests/test_typed_functions.py b/eckity/base/tests/test_typed_functions.py
new file mode 100644
index 00000000..faf42071
--- /dev/null
+++ b/eckity/base/tests/test_typed_functions.py
@@ -0,0 +1,130 @@
+import numpy as np
+import pytest
+
+from eckity.base.typed_functions import *
+
+
+def test_add2floats():
+    assert add2floats(1.0, 2.0) == 3.0
+    assert add2floats(-1.0, -2.0) == -3.0
+    assert add2floats(0.0, 0.0) == 0.0
+
+
+def test_sub2floats():
+    assert sub2floats(5.0, 3.0) == 2.0
+    assert sub2floats(-1.0, -1.0) == 0.0
+    assert sub2floats(0.0, 1.0) == -1.0
+
+
+def test_mul2floats():
+    assert mul2floats(3.0, 2.0) == 6.0
+    assert mul2floats(-2.0, -2.0) == 4.0
+    assert mul2floats(0.0, 100.0) == 0.0
+
+
+def test_div2floats():
+    assert div2floats(6.0, 2.0) == 3.0
+    assert div2floats(1.0, 0.0) == 0.0
+    assert div2floats(0.0, 1.0) == 0.0
+
+
+def test_sqrt_float():
+    assert sqrt_float(4.0) == 2.0
+    assert sqrt_float(-4.0) == 2.0
+    assert sqrt_float(0.0) == 0.0
+
+
+def test_log_float():
+    assert log_float(1.0) == 0.0
+    assert log_float(0.0) == 0.0
+    assert log_float(-1.0) == 0.0
+
+
+def test_abs_float():
+    assert abs_float(-3.0) == 3.0
+    assert abs_float(3.0) == 3.0
+    assert abs_float(0.0) == 0.0
+
+
+def test_neg_float():
+    assert neg_float(5.0) == -5.0
+    assert neg_float(-5.0) == 5.0
+    assert neg_float(0.0) == 0.0
+
+
+def test_inv_float():
+    assert inv_float(2.0) == 0.5
+    assert inv_float(0.0) == 0.0
+    assert inv_float(-2.0) == -0.5
+
+
+def test_max2floats():
+    assert max2floats(3.0, 5.0) == 5.0
+    assert max2floats(-1.0, -2.0) == -1.0
+    assert max2floats(0.0, 0.0) == 0.0
+
+
+def test_min2floats():
+    assert min2floats(3.0, 5.0) == 3.0
+    assert min2floats(-1.0, -2.0) == -2.0
+    assert min2floats(0.0, 0.0) == 0.0
+
+
+def test_sin_float():
+    assert np.isclose(sin_float(np.pi / 2), 1.0)
+    assert np.isclose(sin_float(0.0), 0.0)
+    assert np.isclose(sin_float(-np.pi / 2), -1.0)
+
+
+def test_cos_float():
+    assert np.isclose(cos_float(0.0), 1.0)
+    assert np.isclose(cos_float(np.pi), -1.0)
+    assert np.isclose(cos_float(np.pi / 2), 0.0)
+
+
+def test_tan_float():
+    assert np.isclose(tan_float(0.0), 0.0)
+    assert np.isclose(tan_float(np.pi / 4), 1.0)
+    assert np.isclose(tan_float(-np.pi / 4), -1.0)
+
+
+def test_iflte0_floats():
+    assert iflte0_floats(0.0, 1.0, 2.0) == 1.0
+    assert iflte0_floats(-1.0, 1.0, 2.0) == 1.0
+    assert iflte0_floats(1.0, 1.0, 2.0) == 2.0
+
+
+def test_ifgt0_floats():
+    assert ifgt0_floats(1.0, 1.0, 2.0) == 1.0
+    assert ifgt0_floats(0.0, 1.0, 2.0) == 2.0
+    assert ifgt0_floats(-1.0, 1.0, 2.0) == 2.0
+
+
+def test_iflte_floats():
+    assert iflte_floats(1.0, 2.0, 3.0, 4.0) == 3.0
+    assert iflte_floats(2.0, 2.0, 3.0, 4.0) == 3.0
+    assert iflte_floats(3.0, 2.0, 3.0, 4.0) == 4.0
+
+
+def test_ifgt_floats():
+    assert ifgt_floats(3.0, 2.0, 3.0, 4.0) == 3.0
+    assert ifgt_floats(2.0, 2.0, 3.0, 4.0) == 4.0
+    assert ifgt_floats(1.0, 2.0, 3.0, 4.0) == 4.0
+
+
+def test_and2floats():
+    assert and2floats(1.0, 1.0) == 1.0
+    assert and2floats(0.0, 1.0) == 0.0
+    assert and2floats(0.0, 0.0) == 0.0
+
+
+def test_or2floats():
+    assert or2floats(1.0, 0.0) == 1.0
+    assert or2floats(0.0, 1.0) == 1.0
+    assert or2floats(0.0, 0.0) == 0.0
+
+
+def test_not2floats():
+    assert not2floats(0.0) == 1.0
+    assert not2floats(1.0) == 0.0
+    assert not2floats(-1.0) == 0.0
diff --git a/eckity/base/tests/test_untyped_functions.py b/eckity/base/tests/test_untyped_functions.py
new file mode 100644
index 00000000..67c50c0e
--- /dev/null
+++ b/eckity/base/tests/test_untyped_functions.py
@@ -0,0 +1,143 @@
+import numpy as np
+
+from eckity.base.untyped_functions import *
+
+
+def test_f_add():
+    assert np.array_equal(f_add(1, 2), 3)
+    assert np.array_equal(
+        f_add(np.array([1, 2]), np.array([3, 4])), np.array([4, 6])
+    )
+
+
+def test_f_sub():
+    assert np.array_equal(f_sub(5, 3), 2)
+    assert np.array_equal(
+        f_sub(np.array([10, 20]), np.array([5, 5])), np.array([5, 15])
+    )
+
+
+def test_f_mul():
+    assert np.array_equal(f_mul(3, 4), 12)
+    assert np.array_equal(
+        f_mul(np.array([2, 3]), np.array([4, 5])), np.array([8, 15])
+    )
+
+
+def test_f_div():
+    assert np.array_equal(f_div(6, 3), 2)
+    assert np.array_equal(
+        f_div(np.array([1, 2]), np.array([0.001, 0.002])),
+        np.array([0, 1000]),
+    )
+    assert np.array_equal(f_div(1, 0), 0)
+    assert np.array_equal(f_div(1, 0.0001), 0)
+
+
+def test_f_sqrt():
+    assert np.array_equal(f_sqrt(4), 2)
+    assert np.array_equal(f_sqrt(-4), 2)
+    assert np.array_equal(f_sqrt(np.array([4, -9])), np.array([2, 3]))
+
+
+def test_f_log():
+    assert np.array_equal(f_log(1), 0)
+    assert np.array_equal(f_log(0), 0)
+    assert np.array_equal(
+        f_log(np.array([0.001, 10])), np.array([0, np.log(10)])
+    )
+
+
+def test_f_abs():
+    assert np.array_equal(f_abs(-5), 5)
+    assert np.array_equal(f_abs(np.array([-1, 2, -3])), np.array([1, 2, 3]))
+
+
+def test_f_neg():
+    assert np.array_equal(f_neg(5), -5)
+    assert np.array_equal(f_neg(np.array([1, -2, 3])), np.array([-1, 2, -3]))
+
+
+def test_f_inv():
+    assert np.array_equal(f_inv(2), 0.5)
+    assert np.array_equal(f_inv(0), 0)
+    assert np.array_equal(f_inv(np.array([2, 0.0001])), np.array([0.5, 0]))
+
+
+def test_f_max():
+    assert np.array_equal(f_max(1, 2), 2)
+    assert np.array_equal(
+        f_max(np.array([1, 3]), np.array([2, 2])), np.array([2, 3])
+    )
+
+
+def test_f_min():
+    assert np.array_equal(f_min(1, 2), 1)
+    assert np.array_equal(
+        f_min(np.array([1, 3]), np.array([2, 2])), np.array([1, 2])
+    )
+
+
+def test_f_sin():
+    assert np.allclose(f_sin(0), 0)
+    assert np.allclose(f_sin(np.pi / 2), 1)
+
+
+def test_f_cos():
+    assert np.allclose(f_cos(0), 1)
+    assert np.allclose(f_cos(np.pi), -1)
+
+
+def test_f_tan():
+    assert np.allclose(f_tan(0), 0)
+    assert np.allclose(f_tan(np.pi / 4), 1)
+
+
+def test_f_iflte0():
+    assert np.array_equal(f_iflte0(-1, 2, 3), 2)
+    assert np.array_equal(f_iflte0(1, 2, 3), 3)
+
+
+def test_f_ifgt0():
+    assert np.array_equal(f_ifgt0(1, 2, 3), 2)
+    assert np.array_equal(f_ifgt0(-1, 2, 3), 3)
+
+
+def test_f_iflte():
+    assert np.array_equal(f_iflte(1, 2, 3, 4), 3)
+    assert np.array_equal(f_iflte(3, 2, 3, 4), 4)
+
+
+def test_f_ifgt():
+    assert np.array_equal(f_ifgt(3, 2, 3, 4), 3)
+    assert np.array_equal(f_ifgt(1, 2, 3, 4), 4)
+
+
+def test_f_and():
+    assert np.array_equal(f_and(1, 0), 0)
+    assert np.array_equal(
+        f_and(np.array([1, 0]), np.array([0, 1])), np.array([0, 0])
+    )
+
+
+def test_f_or():
+    assert np.array_equal(f_or(1, 0), 1)
+    assert np.array_equal(
+        f_or(np.array([1, 0]), np.array([0, 1])), np.array([1, 1])
+    )
+
+
+def test_f_not():
+    assert np.array_equal(f_not(1), 0)
+    assert np.array_equal(f_not(np.array([1, 0])), np.array([0, 1]))
+
+
+def test_f_if_then_else():
+    assert np.array_equal(f_if_then_else(True, 1, 0), 1)
+    assert np.array_equal(f_if_then_else(False, 1, 0), 0)
+    assert np.array_equal(
+        f_if_then_else(
+            np.array([True, False]), np.array([1, 2]), np.array([3, 4])
+        ),
+        np.array([1, 4]),
+    )
diff --git a/examples/treegp/non_sklearn_mode/multiplexer/__init__.py b/eckity/breeders/tests/__init__.py
similarity index 100%
rename from examples/treegp/non_sklearn_mode/multiplexer/__init__.py
rename to eckity/breeders/tests/__init__.py
diff --git a/eckity/breeders/tests/test_simple_breeder.py b/eckity/breeders/tests/test_simple_breeder.py
new file mode 100644
index 00000000..20170e47
--- /dev/null
+++ b/eckity/breeders/tests/test_simple_breeder.py
@@ -0,0 +1,45 @@
+import pytest
+from overrides import override
+
+from eckity.subpopulation import Subpopulation
+from eckity.algorithms import SimpleEvolution
+from eckity.breeders import SimpleBreeder
+from eckity.creators import FullCreator
+from eckity.evaluators import SimpleIndividualEvaluator
+from eckity.genetic_operators import GeneticOperator
+
+
+class DummyIndividualEvaluator(SimpleIndividualEvaluator):
+    @override
+    def evaluate_individual(self, individual) -> float:
+        return 0.0
+
+
+class DummyCrossover(GeneticOperator):
+    def __init__(self, probability=1.0, arity=2, events=None):
+        self.individuals = None
+        self.applied_individuals = None
+        super().__init__(probability=probability, arity=arity, events=events)
+
+    @override
+    def apply(self, individuals):
+        return individuals
+
+
+def test_incompatible_operator_arities():
+    breeder = SimpleBreeder()
+    algo = SimpleEvolution(
+        Subpopulation(
+            DummyIndividualEvaluator(),
+            creators=FullCreator(
+                function_set=[lambda x: x],
+                terminal_set=['x']
+            ),
+            population_size=10,
+            operators_sequence=[DummyCrossover(arity=3)],
+        ),
+        breeder=breeder,
+    )
+    with pytest.raises(ValueError) as err_info:
+        breeder.apply_breed(algo.population)
+    assert "arity" in str(err_info)
diff --git a/examples/treegp/non_sklearn_mode/symbolic_regression/__init__.py b/eckity/creators/gp_creators/tests/__init__.py
similarity index 100%
rename from examples/treegp/non_sklearn_mode/symbolic_regression/__init__.py
rename to eckity/creators/gp_creators/tests/__init__.py
diff --git a/eckity/creators/gp_creators/tests/test_full.py b/eckity/creators/gp_creators/tests/test_full.py
new file mode 100644
index 00000000..728c041c
--- /dev/null
+++ b/eckity/creators/gp_creators/tests/test_full.py
@@ -0,0 +1,27 @@
+from ....base.untyped_functions import f_add
+from ....fitness import SimpleFitness
+from ....genetic_encodings.gp import FunctionNode, TerminalNode, Tree
+from ..full import FullCreator
+
+
+def test_create_tree():
+    init_depth = (1, 1)
+    function_set = [f_add]
+    terminal_set = ["x"]
+
+    creator = FullCreator(init_depth, function_set, terminal_set)
+    tree_ind = Tree(
+        SimpleFitness(),
+        function_set=function_set,
+        terminal_set=terminal_set,
+        erc_range=None,
+    )
+    creator.create_tree(
+        tree_ind.tree, tree_ind.random_function, tree_ind.random_terminal
+    )
+
+    assert tree_ind.tree == [
+        FunctionNode(function_set[0]),
+        TerminalNode(terminal_set[0]),
+        TerminalNode(terminal_set[0]),
+    ]
diff --git a/eckity/creators/gp_creators/tests/test_grow.py b/eckity/creators/gp_creators/tests/test_grow.py
new file mode 100644
index 00000000..9bb21d4c
--- /dev/null
+++ b/eckity/creators/gp_creators/tests/test_grow.py
@@ -0,0 +1,60 @@
+from typing import Any, Callable, Dict, List, Union
+
+import pytest
+
+from eckity.base.typed_functions import sqrt_float
+from eckity.base.untyped_functions import f_log
+from eckity.creators import GrowCreator
+from eckity.genetic_encodings.gp import (
+    FunctionNode,
+    TerminalNode,
+    Tree,
+    TreeNode,
+)
+
+
+@pytest.mark.parametrize(
+    "function_set, terminal_set, expected",
+    [
+        (
+            [sqrt_float],
+            {1.0: float},
+            [FunctionNode(sqrt_float), TerminalNode(1.0, float)],
+        ),
+        (
+            [f_log],
+            [1.0],
+            [FunctionNode(f_log), TerminalNode(1.0)],
+        ),
+    ],
+)
+def test_add_children(
+    function_set: List[Callable],
+    terminal_set: Union[Dict[Any, type], List[Any]],
+    expected: List[TreeNode],
+):
+    grow_creator = GrowCreator(
+        init_depth=(1, 1),
+        function_set=function_set,
+        terminal_set=terminal_set,
+    )
+    
+    node_type = float if isinstance(terminal_set, dict) else None
+    tree_ind = Tree(
+        function_set=function_set,
+        terminal_set=terminal_set,
+        erc_range=None,
+        root_type=node_type,
+    )
+
+    node = tree_ind.random_function(node_type=node_type)
+    tree_ind.add_tree(node)
+
+    grow_creator._add_children(
+        tree_ind.tree,
+        node,
+        tree_ind.random_function,
+        tree_ind.random_terminal,
+        depth=0,
+    )
+    assert tree_ind.tree == expected
diff --git a/eckity/creators/gp_creators/tests/test_half.py b/eckity/creators/gp_creators/tests/test_half.py
new file mode 100644
index 00000000..a92cbab4
--- /dev/null
+++ b/eckity/creators/gp_creators/tests/test_half.py
@@ -0,0 +1,36 @@
+import pytest
+
+from eckity.base.untyped_functions import f_add
+from eckity.creators import HalfCreator
+
+
+def test_zero_group_size():
+    half_creator = HalfCreator(
+        init_depth=(2, 4), function_set=[f_add], terminal_set=["x"]
+    )
+    with pytest.raises(ValueError) as exc_info:
+        half_creator.create_individuals(4, True)
+
+    error_str = "Incompatible population size and init_depth."
+    assert error_str in str(exc_info.value)
+
+
+def test_create_individuals():
+    half_creator = HalfCreator(
+        init_depth=(3, 4), function_set=[f_add], terminal_set=["x"]
+    )
+    inds = half_creator.create_individuals(20, True)
+    assert len(inds) == 20
+
+    min_depth, max_depth = half_creator.init_depth
+    assert all(min_depth <= ind.depth() <= max_depth for ind in inds)
+
+    # assert at least 25% of the individuals are full trees of size 2^4
+    # and another 25% are full trees of size 2^5 - 1
+    sizes = [ind.size() for ind in inds]
+    max_depth = half_creator.init_depth[1]
+    assert sum(size == 2**max_depth - 1 for size in sizes) >= len(inds) / 4
+    assert (
+        sum(size == 2 ** (max_depth + 1) - 1 for size in sizes)
+        >= len(inds) / 4
+    )
diff --git a/eckity/genetic_encodings/ga/tests/test_bit_string_vector.py b/eckity/genetic_encodings/ga/tests/test_bit_string_vector.py
index d7fa813a..91115dad 100644
--- a/eckity/genetic_encodings/ga/tests/test_bit_string_vector.py
+++ b/eckity/genetic_encodings/ga/tests/test_bit_string_vector.py
@@ -75,7 +75,7 @@ def test_bit_get_rand_num_single_bounds(self):
 
         for _ in range(10 ** 3):
             num = vec1.get_random_number_in_bounds(0)
-            assert type(num) == int and num == bounds[0] or num == bounds[1]
+            assert isinstance(num, int) and num == bounds[0] or num == bounds[1]
 
     def test_get_vector_part_last_cell(self):
         length = 2
@@ -85,7 +85,9 @@ def test_get_vector_part_last_cell(self):
     def test_clone(self):
         cells = [0, 1]
         score = 0.1
-        v1 = BitStringVector(SimpleFitness(score), len(cells), vector=cells)
+        v1 = BitStringVector(SimpleFitness(score, cache=True),
+                             length=len(cells),
+                             vector=cells)
 
         v2 = v1.clone()
         assert v2.vector == v1.vector
@@ -98,3 +100,10 @@ def test_clone(self):
         # Check that fitness is evaluated and equal to original one
         assert v2.fitness.get_pure_fitness() == score
         assert v2.fitness.is_fitness_evaluated()
+
+    def test_parents(self):
+        v1 = BitStringVector(SimpleFitness(), length=2, update_parents=True)
+        v2 = BitStringVector(SimpleFitness(), length=2, update_parents=True)
+
+        v1.parents = [v2]
+        assert v1.parents == [v2]
diff --git a/eckity/genetic_encodings/ga/tests/test_float_vector.py b/eckity/genetic_encodings/ga/tests/test_float_vector.py
index 7eacde74..d2a63c01 100644
--- a/eckity/genetic_encodings/ga/tests/test_float_vector.py
+++ b/eckity/genetic_encodings/ga/tests/test_float_vector.py
@@ -5,42 +5,44 @@
 
 
 class TestFloatVector:
-	def test_vector_direct_initialization(self):
-		length = 5
-		cells = [0., 0., 1., 1., 1.]
-		vec = FloatVector(SimpleFitness(), length=length,vector=cells)
-		assert len(vec.vector) == length
-		assert vec.vector == cells
-	
-	def test_bad_bounds(self):
-		length = 5
-		bounds = [(i, i + 1) for i in range(length)]
-
-		with pytest.raises(ValueError):
-			FloatVector(SimpleFitness(), bounds=bounds, length=length + 1)
-		with pytest.raises(ValueError):
-			FloatVector(SimpleFitness(), bounds=bounds, length=length - 1)
-
-	def test_get_vector_part_last_cell(self):
-		length = 4
-		vec = FloatVector(SimpleFitness(), length, bounds=(1, 10))
-
-		vec.set_vector(list(range(1, 5)))
-		assert vec.get_vector_part(length - 1, length) == [4]
-
-	def test_clone(self):
-		cells = [1., 2.]
-		score = 0.1
-		v1 = FloatVector(SimpleFitness(score), len(cells), vector=cells)
-
-		v2 = v1.clone()
-		assert v2.vector == v1.vector
-		assert v2.length == v1.length
-		assert v2.bounds == v1.bounds
-		assert v2.get_pure_fitness() == v1.get_pure_fitness()
-		assert v1.id == v2.id - 1
-		assert v2.cloned_from == [v1.id]
-
-		# Check that fitness is evaluated and equal to original one
-		assert v2.fitness.get_pure_fitness() == score
-		assert v2.fitness.is_fitness_evaluated()
+    def test_vector_direct_initialization(self):
+        length = 5
+        cells = [0.0, 0.0, 1.0, 1.0, 1.0]
+        vec = FloatVector(SimpleFitness(), length=length, vector=cells)
+        assert len(vec.vector) == length
+        assert vec.vector == cells
+
+    def test_bad_bounds(self):
+        length = 5
+        bounds = [(i, i + 1) for i in range(length)]
+
+        with pytest.raises(ValueError):
+            FloatVector(SimpleFitness(), bounds=bounds, length=length + 1)
+        with pytest.raises(ValueError):
+            FloatVector(SimpleFitness(), bounds=bounds, length=length - 1)
+
+    def test_get_vector_part_last_cell(self):
+        length = 4
+        vec = FloatVector(SimpleFitness(), length, bounds=(1, 10))
+
+        vec.set_vector(list(range(1, 5)))
+        assert vec.get_vector_part(length - 1, length) == [4]
+
+    def test_clone(self):
+        cells = [1.0, 2.0]
+        score = 0.1
+        v1 = FloatVector(SimpleFitness(score, cache=True),
+                         length=len(cells),
+                         vector=cells)
+
+        v2 = v1.clone()
+        assert v2.vector == v1.vector
+        assert v2.length == v1.length
+        assert v2.bounds == v1.bounds
+        assert v2.get_pure_fitness() == v1.get_pure_fitness()
+        assert v1.id == v2.id - 1
+        assert v2.cloned_from == [v1.id]
+
+        # Check that fitness is evaluated and equal to original one
+        assert v2.fitness.get_pure_fitness() == score
+        assert v2.fitness.is_fitness_evaluated()
diff --git a/eckity/genetic_encodings/ga/tests/test_int_vector.py b/eckity/genetic_encodings/ga/tests/test_int_vector.py
index d952acb4..c3b1510e 100644
--- a/eckity/genetic_encodings/ga/tests/test_int_vector.py
+++ b/eckity/genetic_encodings/ga/tests/test_int_vector.py
@@ -5,61 +5,60 @@
 
 
 class TestIntVector:
-	def test_vector_direct_initialization(self):
-		length = 5
-		cells = [0, 1, 1, 1, 3]
-		vec = IntVector(SimpleFitness(), length=length, vector=cells)
-		assert len(vec.vector) == length
-		assert vec.vector == cells
-	
-	def test_bad_bounds(self):
-		length = 5
-		bounds = [(i, i + 1) for i in range(length)]
+    def test_vector_direct_initialization(self):
+        length = 5
+        cells = [0, 1, 1, 1, 3]
+        vec = IntVector(SimpleFitness(), length=length, vector=cells)
+        assert len(vec.vector) == length
+        assert vec.vector == cells
 
-		with pytest.raises(ValueError):
-			IntVector(SimpleFitness(), bounds=bounds, length=length + 1)
-		with pytest.raises(ValueError):
-			IntVector(SimpleFitness(), bounds=bounds, length=length - 1)
+    def test_bad_bounds(self):
+        length = 5
+        bounds = [(i, i + 1) for i in range(length)]
 
-	def test_int_get_rand_num_single_bounds(self):
-		bounds = (1, 5)
-		vec1 = IntVector(SimpleFitness(), bounds=bounds, length=5)
+        with pytest.raises(ValueError):
+            IntVector(SimpleFitness(), bounds=bounds, length=length + 1)
+        with pytest.raises(ValueError):
+            IntVector(SimpleFitness(), bounds=bounds, length=length - 1)
 
-		for _ in range(10 ** 3):
-			num = vec1.get_random_number_in_bounds(0)
-			assert type(num) == int and bounds[0] <= num <= bounds[1]
+    def test_int_get_rand_num_single_bounds(self):
+        bounds = (1, 5)
+        vec1 = IntVector(SimpleFitness(), bounds=bounds, length=5)
 
-	def test_int_get_rand_num_multi_bounds(self):
-		length = 5
-		bounds = [(i, i + 1) for i in range(length)]
-		vec1 = IntVector(SimpleFitness(), bounds=bounds, length=length)
+        for _ in range(10**3):
+            num = vec1.get_random_number_in_bounds(0)
+            assert isinstance(num, int) and bounds[0] <= num <= bounds[1]
 
-		for i in range(length):
-			num = vec1.get_random_number_in_bounds(i)
-			assert type(num) == int and bounds[i][0] <= num <= bounds[i][1]
+    def test_int_get_rand_num_multi_bounds(self):
+        length = 5
+        bounds = [(i, i + 1) for i in range(length)]
+        vec1 = IntVector(SimpleFitness(), bounds=bounds, length=length)
 
-	def test_get_vector_part_last_cell(self):
-		length = 4
-		v1 = IntVector(SimpleFitness(), length, bounds=(1, 10))
-		v1.set_vector(list(range(1, 5)))
-		assert v1.get_vector_part(length - 1, length) == [4]
+        for i in range(length):
+            num = vec1.get_random_number_in_bounds(i)
+            assert isinstance(num, int) and bounds[i][0] <= num <= bounds[i][1]
 
-	def test_clone(self):
-		cells = [0, 1]
-		score = 0.1
-		v1 = IntVector(SimpleFitness(score), len(cells), vector=cells)
-
-		v2 = v1.clone()
-		assert v2.vector == v1.vector
-		assert v2.length == v1.length
-		assert v2.bounds == v1.bounds
-		assert v2.get_pure_fitness() == v1.get_pure_fitness()
-		assert v1.id == v2.id - 1
-		assert v2.cloned_from == [v1.id]
-
-		# Check that fitness is evaluated and equal to original one
-		assert v2.fitness.get_pure_fitness() == score
-		assert v2.fitness.is_fitness_evaluated()
+    def test_get_vector_part_last_cell(self):
+        length = 4
+        v1 = IntVector(SimpleFitness(), length, bounds=(1, 10))
+        v1.set_vector(list(range(1, 5)))
+        assert v1.get_vector_part(length - 1, length) == [4]
 
+    def test_clone(self):
+        cells = [0, 1]
+        score = 0.1
+        v1 = IntVector(SimpleFitness(score, cache=True),
+                       length=len(cells),
+                       vector=cells)
 
+        v2 = v1.clone()
+        assert v2.vector == v1.vector
+        assert v2.length == v1.length
+        assert v2.bounds == v1.bounds
+        assert v2.get_pure_fitness() == v1.get_pure_fitness()
+        assert v1.id == v2.id - 1
+        assert v2.cloned_from == [v1.id]
 
+        # Check that fitness is evaluated and equal to original one
+        assert v2.fitness.get_pure_fitness() == score
+        assert v2.fitness.is_fitness_evaluated()
diff --git a/eckity/genetic_encodings/gp/tree/tests/test_tree.py b/eckity/genetic_encodings/gp/tree/tests/test_tree.py
new file mode 100644
index 00000000..2625be71
--- /dev/null
+++ b/eckity/genetic_encodings/gp/tree/tests/test_tree.py
@@ -0,0 +1,442 @@
+from typing import List, Optional
+
+import pytest
+
+from eckity.base.typed_functions import (
+    add2floats,
+    div2floats,
+    mul2floats,
+    sub2floats,
+    or2bools,
+    and2bools,
+)
+from eckity.base.untyped_functions import f_add, f_div, f_mul, f_sub
+from eckity.fitness.gp_fitness import GPFitness
+from eckity.genetic_encodings.gp import (
+    FunctionNode,
+    TerminalNode,
+    Tree,
+    TreeNode,
+)
+
+
+class TestTree:
+    """
+    This class contains test cases for Tree and TreeNode classes
+    """
+
+    typed_functions = [add2floats, sub2floats, mul2floats, div2floats]
+    typed_terminals = {"x": float, "y": float}
+
+    untyped_functions = [f_add, f_sub, f_mul, f_div]
+    untyped_terminals = ["x", "y"]
+    typed_tree = Tree(
+        fitness=GPFitness(),
+        function_set=typed_functions,
+        terminal_set=typed_terminals,
+        root_type=float,
+    )
+    untyped_tree = Tree(
+        fitness=GPFitness(),
+        function_set=untyped_functions,
+        terminal_set=untyped_terminals,
+    )
+
+    @pytest.fixture
+    def setup(self) -> None:
+        """
+        Empties both trees before each test
+        Returns
+        -------
+        None
+        """
+        self.typed_tree.empty_tree()
+        self.untyped_tree.empty_tree()
+
+    def test_add_tree_root_typed(self, setup):
+        """
+        Test that add_child method adds child to the tree
+        """
+        typed_child = TerminalNode(1.0, float)
+        self.typed_tree.add_tree(typed_child)
+        assert self.typed_tree.size() == 1
+        assert self.typed_tree.root == typed_child
+        assert self.typed_tree.root.node_type is float
+
+    def test_add_tree_root_untyped(self, setup):
+        """
+        Test that add_child method adds child to the tree
+        """
+        untyped_child = TerminalNode(1)
+        self.untyped_tree.add_tree(untyped_child)
+        assert self.untyped_tree.root == untyped_child
+        assert self.untyped_tree.size() == 1
+        assert self.untyped_tree.root.node_type is None
+
+    def test_add_tree_inner_typed(self, setup):
+        """
+        Test that add_child method adds child to the tree
+        """
+        typed_parent = FunctionNode(add2floats)
+        children = [TerminalNode(float(i), float) for i in range(2)]
+
+        self.typed_tree.add_tree(typed_parent)
+        assert self.typed_tree.size() == 1
+        assert self.typed_tree.root == typed_parent
+        assert self.typed_tree.root.node_type is float
+
+        for i, child in enumerate(children):
+            self.typed_tree.add_tree(child)
+            assert self.typed_tree.size() == i + 2
+            assert self.typed_tree.tree[i + 1] == child
+            assert self.typed_tree.tree[i + 1].node_type is float
+
+    def test_add_tree_inner_untyped(self, setup):
+        """
+        Test that add_tree method adds child to the tree
+        """
+        untyped_child = TerminalNode(1)
+        self.untyped_tree.add_tree(untyped_child)
+        assert self.untyped_tree.root == untyped_child
+        assert self.untyped_tree.size() == 1
+        assert self.untyped_tree.root.node_type is None
+
+    @pytest.mark.parametrize(
+        "root, child",
+        [
+            (
+                FunctionNode(add2floats),
+                TerminalNode("1", str),
+            ),
+            (
+                FunctionNode(add2floats),
+                TerminalNode(1, None),
+            ),
+            (
+                FunctionNode(f_add),
+                TerminalNode(1, int),
+            ),
+        ],
+    )
+    def test_add_tree_bad_type(self, root, child):
+        tree = Tree(
+            erc_range=None,
+            function_set=[add2floats],
+            terminal_set={"x": float},
+            root_type=float,
+        )
+        tree.add_tree(root)
+
+        with pytest.raises(ValueError) as e:
+            tree.add_tree(child)
+        assert "Could not add node" in str(e.value)
+
+    def test_replace_subtree(self, setup):
+        """
+        Test that replace_subtree replaces subtree with another subtree
+        """
+
+        self.typed_tree.tree = list(
+            [
+                FunctionNode(add2floats),
+                TerminalNode(1.0, float),
+                TerminalNode(2.0, float),
+            ],
+        )
+
+        new_subtree = list(
+            [
+                FunctionNode(sub2floats),
+                TerminalNode(3.0, float),
+                TerminalNode(4.0, float),
+            ],
+        )
+
+        old_subtree = [self.typed_tree.tree[1]]
+
+        self.typed_tree.replace_subtree(old_subtree, new_subtree)
+        assert self.typed_tree.tree[1 : 1 + len(new_subtree)] == new_subtree
+        assert self.typed_tree.size() == 5
+
+    @pytest.mark.parametrize(
+        "typed, tree, expected",
+        [
+            (True, [TerminalNode(1, int)], 0),
+            (False, [TerminalNode(1)], 0),
+            (
+                True,
+                [
+                    FunctionNode(add2floats),
+                    TerminalNode(1.0, float),
+                    TerminalNode(2.0, float),
+                ],
+                1,
+            ),
+            (
+                False,
+                [
+                    FunctionNode(f_add),
+                    TerminalNode(1),
+                    TerminalNode(2),
+                ],
+                1,
+            ),
+            (
+                True,
+                [
+                    FunctionNode(add2floats),
+                    FunctionNode(add2floats),
+                    TerminalNode(1.0, float),
+                    TerminalNode(2.0, float),
+                    TerminalNode(2.0, float),
+                ],
+                2,
+            ),
+            (
+                False,
+                [
+                    FunctionNode(f_add),
+                    FunctionNode(f_add),
+                    TerminalNode(1),
+                    TerminalNode(2),
+                    TerminalNode(2),
+                ],
+                2,
+            ),
+        ],
+    )
+    def test_depth(
+        self, setup, typed: bool, tree: List[TerminalNode], expected: bool
+    ):
+        tree_ind = self.typed_tree if typed else self.untyped_tree
+        tree_ind.tree = tree
+        assert tree_ind.depth() == expected
+
+    @pytest.mark.parametrize(
+        "typed, tree, expected",
+        [
+            (
+                True,
+                [TerminalNode(1, int)],
+                f"def func_{typed_tree.id}(x: float, y: float) -> int:\n  return 1",
+            ),
+            (
+                False,
+                [TerminalNode(1)],
+                f"def func_{untyped_tree.id}(x, y):\n  return 1",
+            ),
+            (
+                True,
+                [
+                    FunctionNode(add2floats),
+                    TerminalNode(1.0, float),
+                    TerminalNode(2.0, float),
+                ],
+                f"def func_{typed_tree.id}(x: float, y: float) -> float:\n  return add2floats(\n    1.0,\n    2.0\n  )"
+            ),
+            (
+                False,
+                [
+                    FunctionNode(f_add),
+                    TerminalNode(1),
+                    TerminalNode(2),
+                ],
+                f"def func_{untyped_tree.id}(x, y):\n  return f_add(\n    1,\n    2\n  )"
+            ),
+            (
+                True,
+                [
+                    FunctionNode(add2floats),
+                    TerminalNode(1.0, float),
+                    FunctionNode(sub2floats),
+                    TerminalNode(2.0, float),
+                    TerminalNode(3.0, float),
+                ],
+                f"def func_{typed_tree.id}(x: float, y: float) -> float:\n  return add2floats(\n    1.0,\n    sub2floats(\n      2.0,\n      3.0\n    )\n  )"
+            ),
+            (
+                False,
+                [
+                    FunctionNode(f_add),
+                    TerminalNode(1),
+                    FunctionNode(f_sub),
+                    TerminalNode(2),
+                    TerminalNode(3),
+                ],
+                f"def func_{untyped_tree.id}(x, y):\n  return f_add(\n    1,\n    f_sub(\n      2,\n      3\n    )\n  )"
+            ),
+        ],
+    )
+    def test_str(self, setup, typed, tree, expected):
+        """
+        Test that str method returns a string representation of the tree
+        """
+        tree_ind = self.typed_tree if typed else self.untyped_tree
+        tree_ind.tree = tree
+
+        tree_str = str(tree_ind)
+        assert tree_str == expected
+
+    @pytest.mark.parametrize(
+        "typed, tree, root_indices, expected_results",
+        [
+            (
+                True,
+                [
+                    FunctionNode(add2floats),
+                    TerminalNode(1.0, float),
+                    FunctionNode(sub2floats),
+                    TerminalNode(2.0, float),
+                    TerminalNode(3.0, float),
+                ],
+                [0, 1, 2, 3, 4],
+                [
+                    [
+                        FunctionNode(add2floats),
+                        TerminalNode(1.0, float),
+                        FunctionNode(sub2floats),
+                        TerminalNode(2.0, float),
+                        TerminalNode(3.0, float),
+                    ],
+                    [TerminalNode(1.0, float)],
+                    [
+                        FunctionNode(sub2floats),
+                        TerminalNode(2.0, float),
+                        TerminalNode(3.0, float),
+                    ],
+                    [TerminalNode(2.0, float)],
+                    [TerminalNode(3.0, float)],
+                ],
+            ),
+            (
+                False,
+                [
+                    FunctionNode(f_add),
+                    TerminalNode(1),
+                    FunctionNode(f_sub),
+                    TerminalNode(2),
+                    TerminalNode(3),
+                ],
+                [0, 1, 2, 3, 4],
+                [
+                    [
+                        FunctionNode(f_add),
+                        TerminalNode(1),
+                        FunctionNode(f_sub),
+                        TerminalNode(2),
+                        TerminalNode(3),
+                    ],
+                    [TerminalNode(1)],
+                    [
+                        FunctionNode(f_sub),
+                        TerminalNode(2),
+                        TerminalNode(3),
+                    ],
+                    [TerminalNode(2)],
+                    [TerminalNode(3)],
+                ],
+            ),
+        ],
+    )
+    def test_get_subtree_by_root(
+        self,
+        setup,
+        typed,
+        tree,
+        root_indices: List[int],
+        expected_results: List[List[TreeNode]],
+    ):
+        tree_ind = self.typed_tree if typed else self.untyped_tree
+        tree_ind.tree = tree
+
+        for root_idx, expected in zip(root_indices, expected_results):
+            subtree_root = tree[root_idx]
+            assert tree_ind._get_subtree_by_root(subtree_root) == expected
+
+    @pytest.mark.parametrize(
+        "typed",
+        [True, False]
+    )
+    def test_random_function(
+        self,
+        typed: bool,
+    ):
+        tree = self.typed_tree if typed else self.untyped_tree
+        function_set = (
+            self.typed_functions
+            if typed
+            else self.untyped_functions
+        )
+        node_type = float if typed else None
+
+        for i in range(10):
+            function_node = tree.random_function(node_type)
+            assert function_node.function in function_set
+
+        class MyType:
+            pass
+
+        assert tree.random_function(MyType) is None
+
+    @pytest.mark.parametrize(
+        "typed",
+        [True, False]
+    )
+    def test_random_terminal(
+        self,
+        typed: bool,
+    ):
+        tree = self.typed_tree if typed else self.untyped_tree
+        terminal_set = (
+            self.typed_terminals
+            if typed
+            else self.untyped_terminals
+        )
+        node_type = float if typed else None
+
+        for i in range(10):
+            terminal = tree.random_terminal(node_type)
+            assert terminal.value in terminal_set
+
+        class MyType:
+            pass
+
+        assert tree.random_terminal(MyType) is None
+
+    @pytest.mark.parametrize(
+        "kwargs, error_message_substring",
+        [
+            # supplying int terminal with boolean functions only
+            (
+                {
+                    "function_set": [and2bools, or2bools],
+                    "terminal_set": {1: int, False: bool},
+                    "root_type": bool
+                },
+                "must match terminal types"
+            ),
+            # supplying root_type int with no functions that return int
+            (
+                {
+                    "function_set": [and2bools, or2bools],
+                    "terminal_set": {True: bool, False: bool},
+                    "root_type": int
+                },
+                "Root type must be the return type of at least one function."
+            ),
+            # supplying erc_range with no numeric functions
+            (
+                {
+                    "erc_range": (1, 2),
+                    "function_set": [and2bools, or2bools],
+                    "terminal_set": {True: bool, False: bool},
+                    "root_type": bool
+                },
+                "ERC range should not be defined if there are no numeric functions."
+            ),
+        ],
+    )
+    def test_init_bad_args(self, kwargs, error_message_substring):
+        with pytest.raises(ValueError) as e:
+            Tree(**kwargs)
+        assert error_message_substring in str(e.value)
diff --git a/eckity/genetic_encodings/gp/tree/tests/test_tree_node.py b/eckity/genetic_encodings/gp/tree/tests/test_tree_node.py
new file mode 100644
index 00000000..c30b55d7
--- /dev/null
+++ b/eckity/genetic_encodings/gp/tree/tests/test_tree_node.py
@@ -0,0 +1,61 @@
+import pytest
+
+from eckity.base.typed_functions import add2floats
+from eckity.base.untyped_functions import f_add
+from eckity.genetic_encodings.gp import FunctionNode, TerminalNode
+from eckity.genetic_encodings.gp.tree.utils import get_func_types
+
+
+@pytest.mark.parametrize(
+    "function, expected_types",
+    [
+        (add2floats, [float, float, float]),
+        (f_add, [None, None, None]),
+    ],
+)
+def test_get_func_types(function, expected_types):
+    """
+    Test that get_func_types returns the correct function types
+    """
+    func_types = get_func_types(function)
+    assert func_types == expected_types
+
+
+def test_missing_type_hints():
+    """
+    define some bad functions
+    not using pytest decorator because lambda expressions have no type hints
+    """
+
+    # missing return type hint
+    def add_no_return_type(x: int, y: int):
+        return x + y
+
+    # missing x type hint
+    def add_no_x_type(x, y: int) -> int:
+        return x + y
+
+    # missing y type hint
+    def add_no_y_type(x: int, y) -> int:
+        return x + y
+
+    for func in [add_no_return_type, add_no_x_type, add_no_y_type]:
+        with pytest.raises(ValueError) as excinfo:
+            FunctionNode(func)
+        assert "missing type hints" in str(excinfo)
+
+
+@pytest.mark.parametrize(
+    "node1, node2, expected",
+    [
+        (TerminalNode(1, int), TerminalNode(1, int), True),
+        (TerminalNode(1, int), TerminalNode(True, bool), False),
+        (TerminalNode(0, int), TerminalNode(False, bool), False),
+        (TerminalNode(1, int), TerminalNode(2, int), False),
+        (TerminalNode(1), TerminalNode(1), True),
+        (TerminalNode(1), TerminalNode(True), False),
+    ],
+)
+def test_eq(node1, node2, expected):
+    assert (node1 == node2) == expected
+    assert (node2 == node1) == expected
diff --git a/eckity/genetic_encodings/gp/tree/tests/test_tree_utils.py b/eckity/genetic_encodings/gp/tree/tests/test_tree_utils.py
new file mode 100644
index 00000000..7512e179
--- /dev/null
+++ b/eckity/genetic_encodings/gp/tree/tests/test_tree_utils.py
@@ -0,0 +1,39 @@
+import numpy as np
+import pytest
+from eckity.genetic_encodings.gp.tree.utils import (
+    create_terminal_set,
+    generate_args,
+)
+
+
+@pytest.mark.parametrize(
+    "X, typed, expected",
+    [
+        (
+            np.array([[4, 7, -7, -10], [7, -3, 3, -8], [8, -5, -3, -1]]),
+            False,
+            ["x0", "x1", "x2", "x3"],
+        ),
+        (
+            np.array([[4, 7, -7, -10], [7, -3, 3, -8], [8, -5, -3, -1]]),
+            True,
+            {"x0": int, "x1": int, "x2": int, "x3": int},
+        ),
+    ],
+)
+def test_create_terminal_set(X, typed, expected):
+    assert create_terminal_set(X, typed) == expected
+
+
+def test_generate_args():
+    X = np.array([[4, 7, -7, -10], [7, -3, 3, -8], [8, -5, -3, -1]])
+    expected = {
+        "x0": np.array([4, 7, 8]),
+        "x1": np.array([7, -3, -5]),
+        "x2": np.array([-7, 3, -3]),
+        "x3": np.array([-10, -8, -1]),
+    }
+    args = generate_args(X)
+    assert args.keys() == expected.keys()
+    for key in args:
+        assert np.array_equal(args[key], expected[key])
diff --git a/eckity/genetic_operators/crossovers/tests/test_crossover.py b/eckity/genetic_operators/crossovers/tests/test_crossover.py
deleted file mode 100644
index 9fbbc0dc..00000000
--- a/eckity/genetic_operators/crossovers/tests/test_crossover.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import random
-from eckity.genetic_encodings.ga.int_vector import IntVector
-from eckity.genetic_operators.crossovers.vector_k_point_crossover \
-    import VectorKPointsCrossover
-from eckity.fitness.simple_fitness import SimpleFitness
-
-
-class TestCrossover:
-    def test_vector_two_point_crossover(self):
-        random.seed(0)
-        length = 4
-        v1 = IntVector(SimpleFitness(), length, bounds=(1, 10))
-        v1.set_vector(list(range(1, 5)))
-        v2 = IntVector(SimpleFitness(), length, bounds=(1, 10))
-        v2.set_vector(list(range(5, 9)))
-
-        # random sample will return [2, 3]
-        expected_v1 = [5, 2, 7, 8]
-        expected_v2 = [1, 6, 3, 4]
-
-        crossover = VectorKPointsCrossover(k=2)
-        crossover.apply_operator([v1, v2])
-        assert v1.vector == expected_v1
-        assert v2.vector == expected_v2
-        assert v1.applied_operators == ['VectorKPointsCrossover']
-        assert v2.applied_operators == ['VectorKPointsCrossover']
diff --git a/eckity/genetic_operators/crossovers/tests/test_subtree_crossover.py b/eckity/genetic_operators/crossovers/tests/test_subtree_crossover.py
new file mode 100644
index 00000000..6c52196e
--- /dev/null
+++ b/eckity/genetic_operators/crossovers/tests/test_subtree_crossover.py
@@ -0,0 +1,229 @@
+from typing import List
+
+import pytest
+
+from eckity.fitness import GPFitness
+from eckity.genetic_encodings.gp import FunctionNode, TerminalNode, Tree, TreeNode
+from eckity.genetic_operators import SubtreeCrossover
+
+
+# Define custom functions
+def typed_optional_inc(n: int, inc: bool) -> int:
+    return n + 1 if inc else n
+
+
+def untyped_optional_inc(n, inc):
+    return n + 1 if inc else n
+
+
+@pytest.mark.parametrize(
+    "individuals, subtrees, expected",
+    [
+        (
+            [
+                Tree(
+                    fitness=GPFitness(),
+                    function_set=[typed_optional_inc],
+                    terminal_set={True: bool, False: bool},
+                    erc_range=(1, 3),
+                    tree=[
+                        FunctionNode(typed_optional_inc),
+                        TerminalNode(1, int),
+                        TerminalNode(True, bool),
+                    ],
+                    root_type=int,
+                ),
+                Tree(
+                    fitness=GPFitness(),
+                    function_set=[typed_optional_inc],
+                    terminal_set={True: bool, False: bool},
+                    erc_range=(1, 3),
+                    tree=[
+                        FunctionNode(typed_optional_inc),
+                        TerminalNode(1, int),
+                        TerminalNode(False, bool),
+                    ],
+                    root_type=int,
+                ),
+            ],
+            [
+                [
+                    FunctionNode(typed_optional_inc),
+                    TerminalNode(1, int),
+                    TerminalNode(True, bool),
+                ],
+                [
+                    FunctionNode(typed_optional_inc),
+                    TerminalNode(1, int),
+                    TerminalNode(False, bool),
+                ],
+            ],
+            [
+                [
+                    FunctionNode(typed_optional_inc),
+                    TerminalNode(1, int),
+                    TerminalNode(False, bool),
+                ],
+                [
+                    FunctionNode(typed_optional_inc),
+                    TerminalNode(1, int),
+                    TerminalNode(True, bool),
+                ],
+            ]
+        ),
+        (
+            [
+                Tree(
+                    fitness=GPFitness(),
+                    function_set=[typed_optional_inc],
+                    terminal_set={True: bool, False: bool},
+                    erc_range=(1, 3),
+                    tree=[
+                        FunctionNode(typed_optional_inc),
+                        TerminalNode(1, int),
+                        TerminalNode(True, bool),
+                    ],
+                    root_type=int,
+                ),
+                Tree(
+                    fitness=GPFitness(),
+                    function_set=[typed_optional_inc],
+                    terminal_set={True: bool, False: bool},
+                    erc_range=(1, 3),
+                    tree=[
+                        FunctionNode(typed_optional_inc),
+                        TerminalNode(1, int),
+                        TerminalNode(False, bool),
+                    ],
+                    root_type=int,
+                ),
+            ],
+            [
+                [
+                    TerminalNode(True, bool),
+                ],
+                [
+                    TerminalNode(False, bool),
+                ],
+            ],
+            [
+                [
+                    FunctionNode(typed_optional_inc),
+                    TerminalNode(1, int),
+                    TerminalNode(False, bool),
+                ],
+                [
+                    FunctionNode(typed_optional_inc),
+                    TerminalNode(1, int),
+                    TerminalNode(True, bool),
+                ],
+            ]
+        ),
+        ######################################################################
+        # Untyped case
+        ######################################################################
+        (
+            [
+                Tree(
+                    fitness=GPFitness(),
+                    function_set=[untyped_optional_inc],
+                    terminal_set=[True, False],
+                    erc_range=(1, 3),
+                    tree=[
+                        FunctionNode(untyped_optional_inc),
+                        TerminalNode(1),
+                        TerminalNode(True),
+                    ],
+                ),
+                Tree(
+                    fitness=GPFitness(),
+                    function_set=[untyped_optional_inc],
+                    terminal_set=[True, False],
+                    erc_range=(1, 3),
+                    tree=[
+                        FunctionNode(untyped_optional_inc),
+                        TerminalNode(1),
+                        TerminalNode(False),
+                    ],
+                ),
+            ],
+            [
+                [
+                    FunctionNode(untyped_optional_inc),
+                    TerminalNode(1),
+                    TerminalNode(True),
+                ],
+                [
+                    FunctionNode(untyped_optional_inc),
+                    TerminalNode(1),
+                    TerminalNode(False),
+                ],
+            ],
+            [
+                [
+                    FunctionNode(untyped_optional_inc),
+                    TerminalNode(1),
+                    TerminalNode(False),
+                ],
+                [
+                    FunctionNode(untyped_optional_inc),
+                    TerminalNode(1),
+                    TerminalNode(True),
+                ],
+            ]
+        ),
+        (
+            [
+                Tree(
+                    fitness=GPFitness(),
+                    function_set=[untyped_optional_inc],
+                    terminal_set=[True, False],
+                    erc_range=(1, 3),
+                    tree=[
+                        FunctionNode(untyped_optional_inc),
+                        TerminalNode(1),
+                        TerminalNode(True),
+                    ],
+                ),
+                Tree(
+                    fitness=GPFitness(),
+                    function_set=[untyped_optional_inc],
+                    terminal_set=[True, False],
+                    erc_range=(1, 3),
+                    tree=[
+                        FunctionNode(untyped_optional_inc),
+                        TerminalNode(1),
+                        TerminalNode(False),
+                    ],
+                ),
+            ],
+            [
+                [
+                    TerminalNode(True),
+                ],
+                [
+                    TerminalNode(False),
+                ],
+            ],
+            [
+                [
+                    FunctionNode(untyped_optional_inc),
+                    TerminalNode(1),
+                    TerminalNode(False),
+                ],
+                [
+                    FunctionNode(untyped_optional_inc),
+                    TerminalNode(1),
+                    TerminalNode(True),
+                ],
+            ]
+        ),
+    ],
+)
+def test_swap_subtrees(
+    individuals: List[Tree],
+    subtrees: List[List[TreeNode]],
+    expected: List[Tree],
+):
+    SubtreeCrossover._swap_subtrees(individuals, subtrees)
+    assert [ind.tree for ind in individuals] == expected
diff --git a/eckity/genetic_operators/crossovers/tests/test_vector_crossover.py b/eckity/genetic_operators/crossovers/tests/test_vector_crossover.py
new file mode 100644
index 00000000..fcaf3033
--- /dev/null
+++ b/eckity/genetic_operators/crossovers/tests/test_vector_crossover.py
@@ -0,0 +1,26 @@
+import random
+from eckity.genetic_encodings.ga.int_vector import IntVector
+from eckity.genetic_operators.crossovers.vector_k_point_crossover import (
+    VectorKPointsCrossover,
+)
+from eckity.fitness.simple_fitness import SimpleFitness
+
+
+def test_vector_two_point_crossover():
+    random.seed(0)
+    length = 4
+    v1 = IntVector(SimpleFitness(), length, bounds=(1, 10))
+    v1.set_vector(list(range(1, 5)))
+    v2 = IntVector(SimpleFitness(), length, bounds=(1, 10))
+    v2.set_vector(list(range(5, 9)))
+
+    # random sample will return [2, 3]
+    expected_v1 = [5, 2, 7, 8]
+    expected_v2 = [1, 6, 3, 4]
+
+    crossover = VectorKPointsCrossover(k=2)
+    crossover.apply_operator([v1, v2])
+    assert v1.vector == expected_v1
+    assert v2.vector == expected_v2
+    assert v1.applied_operators == ["VectorKPointsCrossover"]
+    assert v2.applied_operators == ["VectorKPointsCrossover"]
diff --git a/eckity/genetic_operators/crossovers/tests/test_vector_k_points_crossover.py b/eckity/genetic_operators/crossovers/tests/test_vector_k_points_crossover.py
new file mode 100644
index 00000000..8855a323
--- /dev/null
+++ b/eckity/genetic_operators/crossovers/tests/test_vector_k_points_crossover.py
@@ -0,0 +1,43 @@
+from eckity.genetic_encodings.ga.int_vector import IntVector
+from eckity.genetic_operators.crossovers.vector_k_point_crossover import (
+    VectorKPointsCrossover,
+)
+from eckity.fitness.simple_fitness import SimpleFitness
+
+
+class TestVectorKPointCrossover:
+    def test_one_point_crossover(self):
+        v1 = [1, 2, 3, 4]
+        v2 = [5, 6, 7, 8]
+        xo_points = [2]
+
+        crossover = VectorKPointsCrossover(k=1)
+        crossover._swap_vector_parts(v1, v2, xo_points)
+
+        assert v1 == [5, 6, 3, 4]
+        assert v2 == [1, 2, 7, 8]
+
+    def test_two_point_crossover(self):
+        v1 = [1, 2, 3, 4]
+        v2 = [5, 6, 7, 8]
+        xo_points = [1, 3]
+
+        crossover = VectorKPointsCrossover(k=2)
+        crossover._swap_vector_parts(v1, v2, xo_points)
+
+        assert v1 == [5, 2, 3, 8]
+        assert v2 == [1, 6, 7, 4]
+
+    def test_applied_operators(self):
+        length = 4
+        v1 = IntVector(
+            SimpleFitness(), length, bounds=(1, 10), vector=[1, 2, 3, 4]
+        )
+        v2 = IntVector(
+            SimpleFitness(), length, bounds=(1, 10), vector=[5, 6, 7, 8]
+        )
+
+        crossover = VectorKPointsCrossover(k=1)
+        crossover.apply_operator([v1, v2])
+        assert v1.applied_operators == ["VectorKPointsCrossover"]
+        assert v2.applied_operators == ["VectorKPointsCrossover"]
\ No newline at end of file
diff --git a/eckity/genetic_operators/mutations/tests/test_subtree_mutation.py b/eckity/genetic_operators/mutations/tests/test_subtree_mutation.py
new file mode 100644
index 00000000..06260f0c
--- /dev/null
+++ b/eckity/genetic_operators/mutations/tests/test_subtree_mutation.py
@@ -0,0 +1,55 @@
+import pytest
+
+from eckity.fitness import GPFitness
+from eckity.genetic_encodings.gp import FunctionNode, TerminalNode, Tree
+from eckity.genetic_operators import SubtreeMutation
+
+
+def equal2ints(a: int, b: int) -> bool:
+    return a == b
+
+
+def inc_int(x: int) -> int:
+    return x + 1
+
+
+def f_equal(a, b):
+    return a == b
+
+
+@pytest.mark.parametrize(
+    "tree",
+    [
+        Tree(
+            fitness=GPFitness(),
+            function_set=[equal2ints, inc_int],
+            terminal_set={"x": int, "y": int},
+            tree=[
+                FunctionNode(function=equal2ints),
+                TerminalNode(value=1, node_type=int),
+                TerminalNode(value=2, node_type=int),
+            ],
+            root_type=bool,
+        ),
+        Tree(
+            fitness=GPFitness(),
+            function_set=[f_equal],
+            terminal_set=["x", "y"],
+            tree=[
+                FunctionNode(function=equal2ints),
+                TerminalNode(value=1),
+                TerminalNode(value=2),
+            ],
+        ),
+    ],
+)
+def test_subtree_mutation_success(tree):
+    subtree_mutation = SubtreeMutation(probability=1.0)
+
+    tree_copy = tree.clone()
+
+    # Perform subtree crossover
+    subtree_mutation.apply([tree_copy])
+
+    assert tree.root == tree_copy.root
+    assert tree != tree_copy
diff --git a/eckity/genetic_operators/mutations/tests/test_mutations.py b/eckity/genetic_operators/mutations/tests/test_vector_mutation.py
similarity index 61%
rename from eckity/genetic_operators/mutations/tests/test_mutations.py
rename to eckity/genetic_operators/mutations/tests/test_vector_mutation.py
index 09e91781..24bd90dd 100644
--- a/eckity/genetic_operators/mutations/tests/test_mutations.py
+++ b/eckity/genetic_operators/mutations/tests/test_vector_mutation.py
@@ -1,7 +1,9 @@
 from eckity.genetic_encodings.ga.float_vector import FloatVector
-from eckity.genetic_operators.mutations.vector_random_mutation \
-    import FloatVectorUniformNPointMutation, \
-    FloatVectorGaussNPointMutation, FloatVectorGaussOnePointMutation
+from eckity.genetic_operators.mutations.vector_random_mutation import (
+    FloatVectorUniformNPointMutation,
+    FloatVectorGaussNPointMutation,
+    FloatVectorGaussOnePointMutation,
+)
 
 from eckity.fitness.simple_fitness import SimpleFitness
 from collections import Counter
@@ -11,8 +13,12 @@ class TestMutations:
     def test_uniform_float_n_point_mut(self):
         length = 5
         n_points = 3
-        vec1 = FloatVector(SimpleFitness(0.),
-                           length=length, bounds=(-100.0, 100.0))
+        vec1 = FloatVector(
+            SimpleFitness(0.0),
+            length=length,
+            bounds=(-100.0, 100.0),
+            update_parents=True,
+        )
         init_vec = [0.0] * length
         vec1.vector = init_vec.copy()
         mut = FloatVectorUniformNPointMutation(n=n_points, probability=1.0)
@@ -22,13 +28,17 @@ def test_uniform_float_n_point_mut(self):
 
         assert len(cnt.keys()) == n_points + 1
         assert cnt[0.0] == length - n_points
-        assert vec1.applied_operators == ['FloatVectorUniformNPointMutation']
+        assert vec1.applied_operators == ["FloatVectorUniformNPointMutation"]
+
+        assert len(vec1.parents) == 1
+        assert vec1.id in vec1.parents
 
     def test_gauss_float_n_point_mut(self):
         length = 5
         n_points = 3
-        vec = FloatVector(SimpleFitness(0.),
-                          length=length, bounds=(-100.0, 100.0))
+        vec = FloatVector(
+            SimpleFitness(0.0), length=length, bounds=(-100.0, 100.0)
+        )
         init_vec = [0.0] * length
         vec.vector = init_vec.copy()
         mut = FloatVectorGaussNPointMutation(n=n_points, probability=1.0)
@@ -39,12 +49,13 @@ def test_gauss_float_n_point_mut(self):
         assert vec.vector != init_vec
         assert len(cnt.keys()) == n_points + 1
         assert cnt[0.0] == length - n_points
-        assert vec.applied_operators == ['FloatVectorGaussNPointMutation']
+        assert vec.applied_operators == ["FloatVectorGaussNPointMutation"]
 
     def test_gauss_mutation_success(self):
         length = 4
-        vec = FloatVector(SimpleFitness(0.),
-                          length=length, bounds=(-100.0, 100.0))
+        vec = FloatVector(
+            SimpleFitness(0.0), length=length, bounds=(-100.0, 100.0)
+        )
         init_vec = [2.0] * length
         vec.vector = init_vec.copy()
         mut = FloatVectorGaussOnePointMutation(probability=1.0)
@@ -55,13 +66,15 @@ def test_gauss_mutation_success(self):
         assert vec.vector != init_vec
         assert len(cnt.keys()) == 2
         assert cnt[2.0] == length - 1
-        assert vec.applied_operators == ['FloatVectorGaussOnePointMutation']
+        assert vec.applied_operators == ["FloatVectorGaussOnePointMutation"]
 
     def test_gauss_mutation_fail(self):
         length = 4
-        vec = FloatVector(SimpleFitness(0.), length=length, bounds=(-1.0, 1.0))
+        vec = FloatVector(
+            SimpleFitness(0.0), length=length, bounds=(-1.0, 1.0)
+        )
         assert vec.fitness.is_fitness_evaluated()
-        
+
         init_vec = [1.0] * length
         vec.vector = init_vec.copy()
         mut = FloatVectorGaussOnePointMutation(mu=1000, probability=1.0)
@@ -72,4 +85,7 @@ def test_gauss_mutation_fail(self):
 
         assert len(cnt.keys()) == 2
         assert cnt[1.0] == length - 1
-        assert vec.applied_operators == ['FloatVectorUniformOnePointMutation', 'FloatVectorGaussOnePointMutation']
+        assert vec.applied_operators == [
+            "FloatVectorUniformOnePointMutation",
+            "FloatVectorGaussOnePointMutation",
+        ]
diff --git a/examples/treegp/sklearn_mode/breast_cancer/__init__.py b/eckity/genetic_operators/selections/tests/__init__.py
similarity index 100%
rename from examples/treegp/sklearn_mode/breast_cancer/__init__.py
rename to eckity/genetic_operators/selections/tests/__init__.py
diff --git a/eckity/genetic_operators/selections/tests/test_elitism.py b/eckity/genetic_operators/selections/tests/test_elitism.py
index 39fb8339..4c427d1b 100644
--- a/eckity/genetic_operators/selections/tests/test_elitism.py
+++ b/eckity/genetic_operators/selections/tests/test_elitism.py
@@ -1,17 +1,15 @@
-import pytest
-
 from eckity.fitness.simple_fitness import SimpleFitness
-from eckity.genetic_operators.selections.elitism_selection import ElitismSelection
+from eckity.genetic_operators import ElitismSelection
 from eckity.genetic_encodings.ga.bit_string_vector import BitStringVector
 
-class TestElitism:
-	def test_selected_by(self):
-		elitism = ElitismSelection(2, higher_is_better=True)
-		inds = [
-			BitStringVector(SimpleFitness(1), length=4),
-			BitStringVector(SimpleFitness(2), length=4)
-        ]
-		selected = elitism.select(inds, [])
-		chosen = selected[0]
-		assert chosen.selected_by == ['ElitismSelection']
-		assert chosen.cloned_from == [inds[1].id]
+
+def test_selected_by():
+    elitism = ElitismSelection(2, higher_is_better=True)
+    inds = [
+        BitStringVector(SimpleFitness(1), length=4),
+        BitStringVector(SimpleFitness(2), length=4),
+    ]
+    selected = elitism.select(inds, [])
+    chosen = selected[0]
+    assert chosen.selected_by == ["ElitismSelection"]
+    assert chosen.cloned_from == [inds[1].id]
diff --git a/eckity/genetic_operators/selections/tests/test_fp.py b/eckity/genetic_operators/selections/tests/test_fp.py
new file mode 100644
index 00000000..1ee5a12c
--- /dev/null
+++ b/eckity/genetic_operators/selections/tests/test_fp.py
@@ -0,0 +1,27 @@
+import pytest
+
+from eckity.fitness import SimpleFitness
+from eckity.genetic_operators import FitnessProportionateSelection
+from eckity.genetic_encodings.ga import BitStringVector
+
+
+@pytest.mark.parametrize("higher_is_better", [False, True])
+def test_selection_higher_is_better(higher_is_better):
+    fp_sel = FitnessProportionateSelection(higher_is_better=higher_is_better)
+    inds = [
+        BitStringVector(
+            SimpleFitness(1 / 1e6, higher_is_better=higher_is_better),
+            length=4,
+        ),
+        BitStringVector(
+            SimpleFitness(1e6, higher_is_better=higher_is_better), length=4
+        ),
+    ]
+    result = fp_sel.select(inds, [])
+    first_selected = result[0]
+
+    # index of the expected individual to be selected
+    expected_selected_idx = 1 if higher_is_better else 0
+
+    assert first_selected.selected_by == [type(fp_sel).__name__]
+    assert first_selected.cloned_from == [inds[expected_selected_idx].id]
diff --git a/eckity/genetic_operators/selections/tests/test_tournament.py b/eckity/genetic_operators/selections/tests/test_tournament.py
index 6ddf72b9..4bc15c6b 100644
--- a/eckity/genetic_operators/selections/tests/test_tournament.py
+++ b/eckity/genetic_operators/selections/tests/test_tournament.py
@@ -1,17 +1,49 @@
 import pytest
 
 from eckity.fitness.simple_fitness import SimpleFitness
-from eckity.genetic_operators.selections.tournament_selection import TournamentSelection
+from eckity.genetic_operators.selections.tournament_selection import (
+    TournamentSelection,
+)
 from eckity.genetic_encodings.ga.bit_string_vector import BitStringVector
 
+
 class TestTournament:
-	def test_selected_by(self):
-		tournament = TournamentSelection(100, higher_is_better=False)
-		inds = [
-			BitStringVector(SimpleFitness(1, higher_is_better=False), length=4),
-			BitStringVector(SimpleFitness(2, higher_is_better=False), length=4)
+    @pytest.fixture(scope="class")
+    def inds(self):
+        return [
+            BitStringVector(SimpleFitness(i),
+                            length=0)
+            for i in range(10)
         ]
-		selected = tournament.select(inds, [])
-		winner = selected[0]
-		assert winner.selected_by == ['TournamentSelection']
-		assert winner.cloned_from == [inds[0].id]
+
+    def test_with_replacement(self, inds):
+        tournament = TournamentSelection(
+            tournament_size=30, higher_is_better=True, replace=True
+        )
+
+        selected = tournament.select(inds[:2], [])
+        first_winner = selected[0]
+        assert first_winner.selected_by == [type(tournament).__name__]
+        assert first_winner.cloned_from[0] == inds[0].id
+
+    def test_without_replacement(self, inds):
+        tournament = TournamentSelection(
+            tournament_size=len(inds), higher_is_better=False, replace=False
+        )
+
+        selected = tournament.select(inds, [])
+        first_winner = selected[0]
+        assert first_winner.selected_by == [type(tournament).__name__]
+        assert first_winner.cloned_from[0] == inds[0].id
+
+    def test_tournament_too_big(self, inds):
+        tournament = TournamentSelection(
+            tournament_size=len(inds) + 1,
+            higher_is_better=False,
+            replace=False,
+        )
+
+        with pytest.raises(ValueError) as err_info:
+            tournament.select(inds, [])
+        
+        assert "tournament size" in str(err_info).lower()
diff --git a/eckity/sklearn_compatible/tests/test_sklearn_compatibility.py b/eckity/sklearn_compatible/tests/test_sklearn_compatibility.py
new file mode 100644
index 00000000..a9b8df5d
--- /dev/null
+++ b/eckity/sklearn_compatible/tests/test_sklearn_compatibility.py
@@ -0,0 +1,37 @@
+"""
+Tests for EC-KitY are currently under active development.
+For now, this class is a placeholder for future tests to be added.
+"""
+
+import pytest
+from sklearn.exceptions import NotFittedError
+
+from eckity.subpopulation import Subpopulation
+from eckity.algorithms import SimpleEvolution
+from eckity.base.typed_functions import sqrt_float
+from eckity.creators import GrowCreator
+from eckity.genetic_operators import SubtreeCrossover, SubtreeMutation
+from eckity.sklearn_compatible import ClassificationEvaluator, SKClassifier
+
+
+class TestSklearnCompatibility:
+    algo = SimpleEvolution(
+        Subpopulation(
+            evaluator=ClassificationEvaluator(),
+            creators=GrowCreator(
+                init_depth=(1, 1),
+                function_set=[sqrt_float],
+                terminal_set={"x": float},
+            ),
+            operators_sequence=[
+                SubtreeCrossover(),
+                SubtreeMutation(),
+            ],
+        )
+    )
+    clf = SKClassifier(algo)
+
+    def test_predict_without_fit(self):
+        X = [1, 2, 3]
+        with pytest.raises(NotFittedError):
+            self.clf.predict(X)
diff --git a/examples/multi_objective/moe_base_test/nsga2_basic_test.py b/examples/multi_objective/moe_base_test/nsga2_basic_test.py
deleted file mode 100644
index 46360dee..00000000
--- a/examples/multi_objective/moe_base_test/nsga2_basic_test.py
+++ /dev/null
@@ -1,87 +0,0 @@
-import math
-import time
-
-from eckity.creators.ga_creators.simple_vector_creator import GAVectorCreator
-from eckity.multi_objective_evolution.nsga2_fitness import NSGA2Fitness
-from eckity.multi_objective_evolution.nsga2_plot import NSGA2Plot
-from eckity.multi_objective_evolution.nsga2_evolution import NSGA2Evolution
-from eckity.multi_objective_evolution.nsga2_breeder import NSGA2Breeder
-from eckity.evaluators.simple_individual_evaluator import SimpleIndividualEvaluator
-from eckity.genetic_operators.crossovers.vector_k_point_crossover import VectorKPointsCrossover
-from eckity.genetic_operators.mutations.vector_random_mutation import FloatVectorUniformNPointMutation
-from eckity.genetic_operators.selections.tournament_selection import TournamentSelection
-
-from eckity.population import Population
-from eckity.statistics.minimal_print_statistics import MinimalPrintStatistics
-from eckity.subpopulation import Subpopulation
-from eckity.genetic_encodings.ga.float_vector import FloatVector
-from eckity.multi_objective_evolution.crowding_termination_checker import CrowdingTerminationChecker
-
-
-import random
-random.seed(0)
-
-
-class NSGA2BasicTestEvaluator(SimpleIndividualEvaluator):
-	def evaluate_individual(self, individual):
-		"""
-            Compute the fitness value of a given individual.
-
-            Parameters
-            ----------
-            individual: Vector
-                The individual to compute the fitness value for.
-
-            Returns
-            -------
-            list
-                The evaluated fitness value for each of the objectives of the given individual.
-        """
-		n = len(individual.vector)
-		fit1 = 1 - math.exp(-sum([(x - 1 / math.sqrt(n)) ** 2 for x in individual.vector]))
-		fit2 = 1 - math.exp(-sum([(x + 1 / math.sqrt(n)) ** 2 for x in individual.vector]))
-		return [fit1, fit2]
-
-
-def main():
-	# Initialize the evolutionary algorithm
-	algo = NSGA2Evolution(
-		Population([Subpopulation(
-			creators=GAVectorCreator(length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector),
-			population_size=150,
-			# user-defined fitness evaluation method
-			evaluator=NSGA2BasicTestEvaluator(),
-			# maximization problem (fitness is sum of values), so higher fitness is better
-			higher_is_better=False,
-			elitism_rate=1 / 300,
-			# genetic operators sequence to be applied in each generation
-			operators_sequence=[
-				VectorKPointsCrossover(probability=0.7, k=1),
-				FloatVectorUniformNPointMutation(probability=0.3, n=3)  # maybe chnge mutation
-			],
-			selection_methods=[
-				# (selection method, selection probability) tuple
-				(TournamentSelection(tournament_size=3, higher_is_better=True), 1)
-			]
-		)]),
-		breeder=NSGA2Breeder(),
-		max_workers=4,
-		max_generation=150,
-
-		termination_checker=CrowdingTerminationChecker(0.01),
-		statistics=MinimalPrintStatistics()
-	)
-	ploter = NSGA2Plot()
-	algo.register('evolution_finished', ploter.print_plots)
-	# evolve the generated initial population
-	start_time = time.time()
-	algo.evolve()
-	total_time = time.time() - start_time
-	print(f"the total time is :{total_time}")
-
-	# Execute (show) the best solution
-	print(algo.execute())
-
-
-if __name__ == '__main__':
-	main()
diff --git a/tests/moe_test/test_moe_base_example_eval.py b/tests/moe_test/test_moe_base_example_eval.py
new file mode 100644
index 00000000..eb281eee
--- /dev/null
+++ b/tests/moe_test/test_moe_base_example_eval.py
@@ -0,0 +1,101 @@
+from typing import List
+
+import pytest
+import math
+import numpy as np
+
+from eckity.creators.ga_creators.simple_vector_creator import GAVectorCreator
+from eckity.genetic_encodings.ga.float_vector import FloatVector
+from eckity.multi_objective_evolution.nsga2_fitness import NSGA2Fitness
+from examples.multi_objective.moe_base_example.nsga2_basic_example import (
+    NSGA2BasicExampleEvaluator,
+)
+
+
+class TestNSGA2BasicExampleEvaluator:
+
+    @classmethod
+    def setup_class(self):
+        """setup any state specific to the execution of the given class (which
+        usually contains tests).
+        """
+        self.evaluator = NSGA2BasicExampleEvaluator()
+
+    @classmethod
+    def teardown_class(self):
+        """teardown any state that was previously setup with a call to
+        setup_class.
+        """
+        pass
+
+    def assert_vector_almost_equal(self, vec1, vec2, tolerance=1e-15):
+        for val1, val2 in zip(vec1, vec2):
+            assert val1 == pytest.approx(val2, tolerance)
+
+    def evaluation_valid(self, my_list: List[float]) -> List[float]:
+        n = 3  # use the instance with n = 3
+        chromosome = np.array(my_list)
+        ans = []
+        ans.append(1 - math.exp(-sum((chromosome - 1 / math.sqrt(n)) ** 2)))
+        ans.append(1 - math.exp(-sum((chromosome + 1 / math.sqrt(n)) ** 2)))
+        return ans
+
+    def test_eval_fixed_vals(self):
+        ind = GAVectorCreator(
+            length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector
+        )
+        ind.vector = [-1.04, 2.844, -2.54]
+        actual = self.evaluator.evaluate_individual(ind)
+        expected = [0.999, 0.999]
+        self.assert_vector_almost_equal(actual, expected, 0.01)
+
+    def test_eval_fixed_vals_2_first_half(self):
+        ind = GAVectorCreator(
+            length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector
+        )
+        ind.vector = [-3.5158362080315237, 0.06631935351665152, -2.921231400536878]
+        actual = self.evaluator.evaluate_individual(ind)
+        expected = [0.9999999999998029, 0.999999516777658]
+        self.assert_vector_almost_equal(actual, expected, tolerance=0.000000000000001)
+
+    def test_eval_fixed_5_vals_grate_values(self):
+        ind = GAVectorCreator(
+            length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector
+        )
+        ind.vector = [-3.4817449392563073, -2.728885522520228, 3.7399170615305213]
+        actual = self.evaluator.evaluate_individual(ind)
+        expected = [1.0, 0.9999999999999993]
+        self.assert_vector_almost_equal(actual, expected, tolerance=0.000000000000001)
+
+    def test_eval_fixed_vals_6_first_half(self):
+        ind = GAVectorCreator(
+            length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector
+        )
+        ind.vector = [-2.841398594290565, -1.4434725567566344, 3.6928271437413196]
+        actual = self.evaluator.evaluate_individual(ind)
+        expected = [0.9999999999913871, 0.9999999999661979]
+        self.assert_vector_almost_equal(actual, expected, tolerance=0.000000000000001)
+
+    def test_eval_fixed_vals_7_second_half(self):
+        ind = GAVectorCreator(
+            length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector
+        )
+        ind.vector = [3.768444634957887, -1.2647927144943072, 3.5023114497554335]
+        actual = self.evaluator.evaluate_individual(ind)
+        expected = [0.9999999997555407, 0.9999999999999998]
+        self.assert_vector_almost_equal(actual, expected, tolerance=0.000000000000001)
+
+    def test_eval_random_vals(self):
+        random_tests_count = 10
+        for i in range(random_tests_count):
+            vector = list(np.random.uniform(-4, 4, 3))
+            self.check_sigel_vector(vector)
+
+    def check_sigel_vector(self, initial_vector):
+        ind = GAVectorCreator(
+            length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector
+        )
+        ind.vector = initial_vector
+        actual = self.evaluator.evaluate_individual(ind)
+        expected = self.evaluation_valid(initial_vector)
+        self.assert_vector_almost_equal(actual, expected)
diff --git a/tests/moe_test/test_moe_base_test_eval.py b/tests/moe_test/test_moe_base_test_eval.py
deleted file mode 100644
index 257bd289..00000000
--- a/tests/moe_test/test_moe_base_test_eval.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from typing import List
-
-import pytest
-import math
-import numpy as np
-
-from eckity.creators.ga_creators.simple_vector_creator import GAVectorCreator
-from eckity.genetic_encodings.ga.float_vector import FloatVector
-from eckity.multi_objective_evolution.nsga2_fitness import NSGA2Fitness
-from examples.multi_objective.moe_base_test.nsga2_basic_test import NSGA2BasicTestEvaluator
-
-
-class TestNSGA2BasicTestEvaluator:
-
-	@classmethod
-	def setup_class(self):
-		""" setup any state specific to the execution of the given class (which
-		usually contains tests).
-		"""
-		self.evaluator = NSGA2BasicTestEvaluator()
-
-	@classmethod
-	def teardown_class(self):
-		""" teardown any state that was previously setup with a call to
-		setup_class.
-		"""
-		pass
-
-	def assert_vector_almost_equal(self, vec1, vec2, tolerance=0.000000000000001):
-		for val1, val2 in zip(vec1, vec2):
-			assert val1 == pytest.approx(val2, tolerance)
-
-	def evaluation_valid(self, my_list: List[float]) -> List[float]:
-		n = 3  # use the instance with n = 3
-		chromosome = np.array(my_list)
-		ans = []
-		ans.append(1 - math.exp(-sum((chromosome - 1 / math.sqrt(n)) ** 2)))
-		ans.append(1 - math.exp(-sum((chromosome + 1 / math.sqrt(n)) ** 2)))
-		return ans
-
-	def test_eval_fixed_vals(self):
-		ind = GAVectorCreator(length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector)
-		ind.vector = [-1.04, 2.844, -2.54]
-		actual = self.evaluator.evaluate_individual(ind)
-		expected = [0.999, 0.999]
-		self.assert_vector_almost_equal(actual, expected, 0.01)
-
-	def test_eval_fixed_vals_2_first_half(self):
-		ind = GAVectorCreator(length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector)
-		ind.vector = [-3.5158362080315237, 0.06631935351665152, -2.921231400536878]
-		actual = self.evaluator.evaluate_individual(ind)
-		expected = [0.9999999999998029, 0.999999516777658]
-		self.assert_vector_almost_equal(actual, expected, tolerance=0.000000000000001)
-
-	def test_eval_fixed_5_vals_grate_values(self):
-		ind = GAVectorCreator(length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector)
-		ind.vector = [-3.4817449392563073, -2.728885522520228, 3.7399170615305213]
-		actual = self.evaluator.evaluate_individual(ind)
-		expected = [1.0, 0.9999999999999993]
-		self.assert_vector_almost_equal(actual, expected, tolerance=0.000000000000001)
-
-	def test_eval_fixed_vals_6_first_half(self):
-		ind = GAVectorCreator(length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector)
-		ind.vector = [-2.841398594290565, -1.4434725567566344, 3.6928271437413196]
-		actual = self.evaluator.evaluate_individual(ind)
-		expected = [0.9999999999913871, 0.9999999999661979]
-		self.assert_vector_almost_equal(actual, expected, tolerance=0.000000000000001)
-
-	def test_eval_fixed_vals_7_second_half(self):
-		ind = GAVectorCreator(length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector)
-		ind.vector = [3.768444634957887, -1.2647927144943072, 3.5023114497554335]
-		actual = self.evaluator.evaluate_individual(ind)
-		expected = [0.9999999997555407, 0.9999999999999998]
-		self.assert_vector_almost_equal(actual, expected, tolerance=0.000000000000001)
-
-	def test_eval_random_vals(self):
-		random_tests_count = 10
-		for i in range(random_tests_count):
-			vector = list(np.random.uniform(-4, 4, 3))
-			self.check_sigel_vector(vector)
-
-	def check_sigel_vector(self, initial_vector):
-		ind = GAVectorCreator(length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector)
-		ind.vector = initial_vector
-		actual = self.evaluator.evaluate_individual(ind)
-		expected = self.evaluation_valid(initial_vector)
-		self.assert_vector_almost_equal(actual, expected)
diff --git a/tests/moe_test/test_moe_front_selection.py b/tests/moe_test/test_moe_front_selection.py
index 13ee2d49..2bd17d5e 100644
--- a/tests/moe_test/test_moe_front_selection.py
+++ b/tests/moe_test/test_moe_front_selection.py
@@ -14,121 +14,136 @@
 
 
 class FitnessIsVectorEval(SimpleIndividualEvaluator):
-	def evaluate_individual(self, individual):
-		''' sets the fitness to be the value of the vector'''
-		return individual.vector
+    def evaluate_individual(self, individual):
+        """sets the fitness to be the value of the vector"""
+        return individual.vector
 
 
 class TestNSGA2FrontSelection:
 
-	@classmethod
-	def setup_class(self):
-		""" setup any state specific to the execution of the given class (which
-		usually contains tests).
-		"""
-		self.selection = NSGA2FrontSorting()
-
-
-	def _init_pop(self, values):
-		self.sub_pop = Subpopulation(creators=GAVectorCreator(length=3, bounds=(-4, 4), fitness_type=NSGA2Fitness, vector_type=FloatVector),
-									 population_size=len(values),
-									 # user-defined fitness evaluation method
-									 evaluator=FitnessIsVectorEval(),
-									 # maximization problem (fitness is sum of values), so higher fitness is better
-									 higher_is_better=[True,True],
-									 elitism_rate=1 / 300,
-									 # genetic operators sequence to be applied in each generation
-									 operators_sequence=[],
-									 selection_methods=[
-										 # (selection method, selection probability) tuple
-										 (TournamentSelection(tournament_size=3, higher_is_better=True), 1)
-									 ]
-									 )
-
-		self.pop = Population([self.sub_pop])
-		individuals = []
-		for vector in values:
-			fitness = NSGA2Fitness(vector, higher_is_better=True)
-			ind = FloatVector(fitness=fitness, bounds=(float("-inf"), float("inf")), length=len(vector))
-			ind.set_vector(vector)
-			individuals.append(ind)
-		self.sub_pop.individuals = individuals
-
-	@classmethod
-	def teardown_class(self):
-		""" teardown any state that was previously setup with a call to
-		setup_class.
-		"""
-		self.pop = None
-		self.sub_pop = None
-
-	def assert_vector_almost_equal(self, vec1, vec2, tolerance=0.01):
-		for val1, val2 in zip(vec1, vec2):
-			assert val1 == pytest.approx(val2, tolerance)
-
-	def test_pareto_front_finding_pop_is_1(self):
-		self._init_pop([[1, 1]])
-		self.selection._init_domination_dict(self.sub_pop.individuals)
-		pareto_front = self.selection._pareto_front_finding(self.sub_pop.individuals)
-		assert pareto_front[0].fitness.fitness == [1, 1]
-
-	def test_pareto_front_finding_pop_is_2(self):
-		self._init_pop([[1, 1], [2, 2]])
-		self.selection._init_domination_dict(self.sub_pop.individuals)
-		pareto_front = self.selection._pareto_front_finding(self.sub_pop.individuals)
-		assert len(pareto_front) == 1
-		assert pareto_front[0].fitness.fitness == [2, 2]
-
-	def test_pareto_front_finding_pop_is_151(self):
-		self._init_pop([[k, k] for k in range(151)])
-		self.selection._init_domination_dict(self.sub_pop.individuals)
-		pareto_front = self.selection._pareto_front_finding(self.sub_pop.individuals)
-		assert len(pareto_front) == 1
-		assert pareto_front[0].fitness.fitness == [150, 150]
-
-	def test_pareto_front_finding_front_size_3(self):
-		pop = [[4, 1], [3, 2], [3, 3], [2, 3], [2, 4]]
-		expected_front = [[4, 1], [3, 3], [2, 4]]
-		self._init_pop(pop)
-		self.selection._init_domination_dict(self.sub_pop.individuals)
-		actual_pareto_front = self.selection._pareto_front_finding(self.sub_pop.individuals)
-		self.check_same_front(actual_pareto_front, expected_front)
-
-	def check_same_front(self, actual, expected):
-		actual = [list(ind.fitness.fitness) for ind in actual]
-		assert len(actual) == len(expected)
-		actual = sorted(actual)
-		expected = sorted(expected)
-		expected = [list(x) for x in expected]
-		assert all([a == b for a, b in zip(actual, expected)])
-
-	def test_pareto_front_finding_front_size_3_float(self):
-		pop = [[4.1, 1.1], [3.3, 2.5], [3.3, 3.3], [2, 3], [2, 4.5]]
-		expected_front = [[4.1, 1.1], [3.3, 3.3], [2, 4.5]]
-		self._init_pop(pop)
-		self.selection._init_domination_dict(self.sub_pop.individuals)
-		actual_pareto_front = self.selection._pareto_front_finding(self.sub_pop.individuals)
-		self.check_same_front(actual_pareto_front, expected_front)
-
-	def test_select_two_fronts(self):
-		pop = [[4, 1], [3, 2], [3, 3], [2, 3], [2, 4], [2, 2], [1, 1]]
-		expected_front = [[4, 1], [3, 3], [2, 4], [3, 2], [2, 3]]
-		self._init_pop(pop)
-		self.selection.select_for_population(self.pop, 5)
-		self.check_same_front(self.sub_pop.individuals, expected_front)
-
-	def test_select_for_population_simple_test(self):
-		# Check that the function returns the expected pareto front for a simple case
-		size = 4
-		pop = [(1, 5), (2, 4), (3, 3), (4, 2)]
-		self._init_pop(pop)
-		self.selection.select_for_population(self.pop, size)
-		self.check_same_front(self.sub_pop.individuals, [[1, 5], [2, 4], [3, 3], [4, 2]])
-
-	def test_select_for_population_single_indv(self):
-		# Check that the function handles lists with a single point correctly
-		size = 1
-		pop = [(1, 5)]
-		self._init_pop(pop)
-		self.selection.select_for_population(self.pop, size)
-		self.check_same_front(self.sub_pop.individuals, [[1, 5]])
+    @classmethod
+    def setup_class(self):
+        """setup any state specific to the execution of the given class (which
+        usually contains tests).
+        """
+        self.selection = NSGA2FrontSorting()
+
+    def _init_pop(self, values):
+        self.sub_pop = Subpopulation(
+            creators=GAVectorCreator(
+                length=3,
+                bounds=(-4, 4),
+                fitness_type=NSGA2Fitness,
+                vector_type=FloatVector,
+            ),
+            population_size=len(values),
+            # user-defined fitness evaluation method
+            evaluator=FitnessIsVectorEval(),
+            # maximization problem (fitness is sum of values), so higher fitness is better
+            higher_is_better=[True, True],
+            elitism_rate=1 / 300,
+            # genetic operators sequence to be applied in each generation
+            operators_sequence=[],
+            selection_methods=[
+                # (selection method, selection probability) tuple
+                (TournamentSelection(tournament_size=3, higher_is_better=True), 1)
+            ],
+        )
+
+        self.pop = Population([self.sub_pop])
+        individuals = []
+        for vector in values:
+            fitness = NSGA2Fitness(vector, higher_is_better=True)
+            ind = FloatVector(
+                fitness=fitness,
+                bounds=(float("-inf"), float("inf")),
+                length=len(vector),
+            )
+            ind.set_vector(vector)
+            individuals.append(ind)
+        self.sub_pop.individuals = individuals
+
+    @classmethod
+    def teardown_class(self):
+        """teardown any state that was previously setup with a call to
+        setup_class.
+        """
+        self.pop = None
+        self.sub_pop = None
+
+    def assert_vector_almost_equal(self, vec1, vec2, tolerance=0.01):
+        for val1, val2 in zip(vec1, vec2):
+            assert val1 == pytest.approx(val2, tolerance)
+
+    def test_pareto_front_finding_pop_is_1(self):
+        self._init_pop([[1, 1]])
+        self.selection._init_domination_dict(self.sub_pop.individuals)
+        pareto_front = self.selection._pareto_front_finding(self.sub_pop.individuals)
+        assert pareto_front[0].fitness.fitness == [1, 1]
+
+    def test_pareto_front_finding_pop_is_2(self):
+        self._init_pop([[1, 1], [2, 2]])
+        self.selection._init_domination_dict(self.sub_pop.individuals)
+        pareto_front = self.selection._pareto_front_finding(self.sub_pop.individuals)
+        assert len(pareto_front) == 1
+        assert pareto_front[0].fitness.fitness == [2, 2]
+
+    def test_pareto_front_finding_pop_is_151(self):
+        self._init_pop([[k, k] for k in range(151)])
+        self.selection._init_domination_dict(self.sub_pop.individuals)
+        pareto_front = self.selection._pareto_front_finding(self.sub_pop.individuals)
+        assert len(pareto_front) == 1
+        assert pareto_front[0].fitness.fitness == [150, 150]
+
+    def test_pareto_front_finding_front_size_3(self):
+        pop = [[4, 1], [3, 2], [3, 3], [2, 3], [2, 4]]
+        expected_front = [[4, 1], [3, 3], [2, 4]]
+        self._init_pop(pop)
+        self.selection._init_domination_dict(self.sub_pop.individuals)
+        actual_pareto_front = self.selection._pareto_front_finding(
+            self.sub_pop.individuals
+        )
+        self.check_same_front(actual_pareto_front, expected_front)
+
+    def check_same_front(self, actual, expected):
+        actual = [list(ind.fitness.fitness) for ind in actual]
+        assert len(actual) == len(expected)
+        actual = sorted(actual)
+        expected = sorted(expected)
+        expected = [list(x) for x in expected]
+        assert all([a == b for a, b in zip(actual, expected)])
+
+    def test_pareto_front_finding_front_size_3_float(self):
+        pop = [[4.1, 1.1], [3.3, 2.5], [3.3, 3.3], [2, 3], [2, 4.5]]
+        expected_front = [[4.1, 1.1], [3.3, 3.3], [2, 4.5]]
+        self._init_pop(pop)
+        self.selection._init_domination_dict(self.sub_pop.individuals)
+        actual_pareto_front = self.selection._pareto_front_finding(
+            self.sub_pop.individuals
+        )
+        self.check_same_front(actual_pareto_front, expected_front)
+
+    def test_select_two_fronts(self):
+        pop = [[4, 1], [3, 2], [3, 3], [2, 3], [2, 4], [2, 2], [1, 1]]
+        expected_front = [[4, 1], [3, 3], [2, 4], [3, 2], [2, 3]]
+        self._init_pop(pop)
+        self.selection.select_for_population(self.pop, 5)
+        self.check_same_front(self.sub_pop.individuals, expected_front)
+
+    def test_select_for_population_simple_test(self):
+        # Check that the function returns the expected pareto front for a simple case
+        size = 4
+        pop = [(1, 5), (2, 4), (3, 3), (4, 2)]
+        self._init_pop(pop)
+        self.selection.select_for_population(self.pop, size)
+        self.check_same_front(
+            self.sub_pop.individuals, [[1, 5], [2, 4], [3, 3], [4, 2]]
+        )
+
+    def test_select_for_population_single_indv(self):
+        # Check that the function handles lists with a single point correctly
+        size = 1
+        pop = [(1, 5)]
+        self._init_pop(pop)
+        self.selection.select_for_population(self.pop, size)
+        self.check_same_front(self.sub_pop.individuals, [[1, 5]])
diff --git a/tests/test_reproducibility.py b/tests/test_reproducibility.py
new file mode 100644
index 00000000..3c9efbb2
--- /dev/null
+++ b/tests/test_reproducibility.py
@@ -0,0 +1,82 @@
+import itertools
+import random
+from time import sleep
+
+import numpy as np
+
+from eckity.subpopulation import Subpopulation
+from eckity.algorithms import SimpleEvolution
+from eckity.base.untyped_functions import f_add, f_div, f_mul, f_sub
+from eckity.creators import FullCreator
+from eckity.evaluators import SimpleIndividualEvaluator
+from eckity.genetic_operators import SubtreeCrossover, SubtreeMutation
+
+
+class RandomIndividualEvaluator(SimpleIndividualEvaluator):
+    def evaluate_individual(self, ind):
+        return random.random() + np.random.random()
+
+
+class TestReproducibility:
+    def test_reproducibility(self):
+        # creates a Tree-GP algorithm
+        algo1 = SimpleEvolution(
+            Subpopulation(
+                RandomIndividualEvaluator(),
+                population_size=10,
+                creators=FullCreator(
+                    function_set=[f_add, f_sub, f_mul, f_div],
+                    terminal_set=["x", "y", "z"],
+                ),
+                operators_sequence=[SubtreeCrossover(), SubtreeMutation()],
+            ),
+            random_seed=42,
+            max_generation=10,
+        )
+        algo1.evolve()
+        bor1 = algo1.best_of_run_
+
+        # creates a Tree-GP experiment by default
+        algo2 = SimpleEvolution(
+            Subpopulation(
+                RandomIndividualEvaluator(),
+                population_size=10,
+                creators=FullCreator(
+                    function_set=[f_add, f_sub, f_mul, f_div],
+                    terminal_set=["x", "y", "z"],
+                ),
+                operators_sequence=[SubtreeCrossover(), SubtreeMutation()],
+            ),
+            random_seed=42,
+            max_generation=10,
+        )
+        algo2.evolve()
+        bor2 = algo2.best_of_run_
+
+        xs = list(range(10))
+        ys = list(range(10))
+        zs = list(range(10))
+
+        for x, y, z in itertools.product(xs, ys, zs):
+            assert bor1.execute(x=x, y=y, z=z) == bor2.execute(x=x, y=y, z=z)
+
+    def test_random_seeds(self):
+        n_reps = 100
+        seeds = []
+        for i in range(n_reps):
+            seed = SimpleEvolution(
+                Subpopulation(
+                    RandomIndividualEvaluator(),
+                    creators=FullCreator(
+                        function_set=[f_add, f_sub, f_mul, f_div],
+                        terminal_set=["x", "y", "z"],
+                    ),
+                    operators_sequence=[SubtreeCrossover()],
+                ),
+            ).random_seed
+            seeds.append(seed)
+
+            # test fails when sleeping for 1e-324 seconds
+            sleep(1e-323)
+
+        assert len(set(seeds)) == n_reps
diff --git a/tests/test_sanity.py b/tests/test_sanity.py
new file mode 100644
index 00000000..e9f107f8
--- /dev/null
+++ b/tests/test_sanity.py
@@ -0,0 +1,50 @@
+"""
+Sanity tests for some basic examples.
+"""
+
+import pytest
+import subprocess
+
+
+def run_example(example_path: str):
+    result = subprocess.run(
+        ["python", "-m", example_path], capture_output=True
+    )
+    return result
+
+
+@pytest.mark.parametrize(
+    "module_path, n_reps, expected_fitness, higher_is_better",
+    [
+        ("examples.treegp.basic_mode.multiplexer", 3, 0.7, True),
+    ],
+)
+def test_example(
+    module_path: str,
+    n_reps: int,
+    expected_fitness: float,
+    higher_is_better: bool,
+):
+    fitness_scores = []
+    for _ in range(n_reps):
+        # run the example
+        result = subprocess.run(
+            ["python", "-m", module_path],
+            capture_output=True,
+        )
+        assert result.returncode == 0
+
+        # parse best pure fitness:
+        output = result.stdout.decode("utf-8")
+        lines = output.split("\n")
+        for line in lines:
+            if "best pure fitness:" in line:
+                best_pure_fitness = float(line.split(":")[-1].strip())
+                fitness_scores.append(best_pure_fitness)
+
+    mean_fitness = sum(fitness_scores) / n_reps
+
+    if higher_is_better:
+        assert mean_fitness >= expected_fitness
+    else:
+        assert mean_fitness <= expected_fitness
diff --git a/tests/test_sklearn_compatibility.py b/tests/test_sklearn_compatibility.py
deleted file mode 100644
index 4c4d8f4f..00000000
--- a/tests/test_sklearn_compatibility.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""
-Tests for EC-KitY are currently under active development.
-For now, this class is a placeholder for future tests to be added.
-"""
-import pytest
-from sklearn.exceptions import NotFittedError
-
-from eckity.algorithms.simple_evolution import SimpleEvolution
-from eckity.sklearn_compatible.classification_evaluator import ClassificationEvaluator
-from eckity.sklearn_compatible.sk_classifier import SKClassifier
-from eckity.subpopulation import Subpopulation
-
-
-class TestSklearnCompatibility:
-	def test_predict_without_fit(self):
-		algo = SimpleEvolution(Subpopulation(evaluator=ClassificationEvaluator()))
-		clf = SKClassifier(algo)
-		X = [1, 2, 3]
-		with pytest.raises(NotFittedError):
-			clf.predict(X)
diff --git a/tests/test_subpopulation.py b/tests/test_subpopulation.py
new file mode 100644
index 00000000..3690150b
--- /dev/null
+++ b/tests/test_subpopulation.py
@@ -0,0 +1,23 @@
+import logging
+from eckity.subpopulation import Subpopulation
+from eckity.creators import FullCreator
+from eckity.genetic_operators import IdentityTransformation
+from examples.treegp.basic_mode.symbolic_regression import (
+    SymbolicRegressionEvaluator,
+)
+
+
+def test_zero_elites_warnning(caplog):
+    with caplog.at_level(logging.WARNING):
+        Subpopulation(
+            SymbolicRegressionEvaluator(),
+            creators=FullCreator(
+                function_set=[lambda x: x],
+                terminal_set=['x'],
+            ),
+            operators_sequence=[IdentityTransformation()],
+            population_size=10,
+            elitism_rate=0.01,
+        )
+    assert len(caplog.records) == 1
+    assert "elitism_rate" in caplog.text