Skip to content

modifiers Module

Genetic and fitness modifiers.

Overview

The modifiers module defines how various genetic and external factors influence individual fitness and allele inheritance.

Complete Module Reference

natal.modifiers

Modifier system for population simulations.

This module defines protocols and helper functions for constructing and wrapping modifiers that alter gamete or zygote production in the simulation. Modifiers are callable objects that return frequency distributions, and are converted into tensor‑level functions that directly update NumPy arrays.

Two modifier types are supported: - Gamete modifiers: alter the mapping from (sex, diploid genotype) to haploid gamete frequencies. - Zygote modifiers: alter the mapping from a pair of haploid gametes (with gamete labels) to a diploid zygote genotype.

The wrapper factories (wrap_gamete_modifier, wrap_zygote_modifier) take high‑level modifiers that return domain‑object dictionaries and produce callables that operate on NumPy tensors.

GameteModifier

Bases: Protocol

Protocol for a bulk gamete modifier.

Implementations should provide a callable that accepts either zero or one argument (an optional population object) and returns a nested mapping of gamete frequency updates. The canonical return type is::

Dict[Tuple[int, int], Dict[int, float]]

where the outer key is (sex_idx, genotype_idx) and the inner mapping is { compressed_hg_glab_idx: frequency, ... }. Keys may be flexible types in wrappers (for convenience) but should ultimately resolve to integers.

sex_idx is an int. genotype_idx may be an int, a Genotype object, or a string produced by Genotype.to_string().

Examples:

return {(0, 5): {3: 0.2, 4: 0.8}, (1, 5): {3: 1.0}}

The result writes frequency distributions for compressed indices directly back into numeric tensors.

ZygoteModifier

Bases: Protocol

Protocol for a bulk zygote modifier.

Implementations should provide a callable that accepts zero or one argument (an optional population) and returns a mapping from a flexible key to a replacement. The key identifies the zygote pairing and may take one of several forms that wrappers can resolve into compressed coordinate pairs (c1, c2).

Supported key representations include
  • compressed index pair (c1, c2)
  • nested tuples ((hg_obj|hg_str|idx_hg, glab_label?), (hg_obj|hg_str|idx_hg, glab_label?))
  • other wrapper-resolvable representations
Replacement values may be one of
  • an integer index idx_modified (index into diploid genotype list)
  • a Genotype instance (wrappers will convert to an index)
  • a dict { idx_modified: probability, ... } specifying a distribution

The protocol returns::

Dict[Any, Union[int, Genotype, Dict[int, float]]]

evaluate_genotype_filter

evaluate_genotype_filter(genotype_filter: GenotypeFilter, genotype: Genotype, compiled_filter: Optional[Callable[[Genotype], bool]]) -> Tuple[bool, Optional[Callable[[Genotype], bool]]]

Evaluate genotype_filter and lazily compile pattern-string filters.

The function supports three filter forms: - None: always pass - callable: evaluate directly - string pattern: compile once via GenotypePatternParser then reuse

Source code in src/natal/modifiers.py
def evaluate_genotype_filter(
    genotype_filter: GenotypeFilter,
    genotype: Genotype,
    compiled_filter: Optional[Callable[[Genotype], bool]],
) -> Tuple[bool, Optional[Callable[[Genotype], bool]]]:
    """Evaluate genotype_filter and lazily compile pattern-string filters.

    The function supports three filter forms:
    - ``None``: always pass
    - callable: evaluate directly
    - string pattern: compile once via ``GenotypePatternParser`` then reuse
    """
    if genotype_filter is None:
        return True, compiled_filter

    if callable(genotype_filter):
        return genotype_filter(genotype), compiled_filter

    if compiled_filter is None:
        from natal.genetic_patterns import GenotypePatternParser
        try:
            pattern = GenotypePatternParser(genotype.species).parse(genotype_filter)
        except Exception as exc:
            raise ValueError(
                f"Invalid genotype_filter pattern: {genotype_filter}"
            ) from exc
        compiled_filter = pattern.to_filter()
    return compiled_filter(genotype), compiled_filter

    raise TypeError("genotype_filter must be a callable, pattern string, or None")

resolve_optional_glab_index

resolve_optional_glab_index(value: GlabSelector, glab_to_index: Mapping[str, int]) -> Optional[int]

Resolve an optional glab selector to an integer index.

Parameters:

Name Type Description Default
value GlabSelector

Glab selector as None, integer index, or string label.

required
glab_to_index Mapping[str, int]

Mapping from glab labels to integer indices.

required

Returns:

Type Description
Optional[int]

The resolved glab index. Returns None when value is None.

Raises:

Type Description
KeyError

If value is a string label not present in glab_to_index.

Source code in src/natal/modifiers.py
def resolve_optional_glab_index(
    value: GlabSelector,
    glab_to_index: Mapping[str, int],
) -> Optional[int]:
    """Resolve an optional glab selector to an integer index.

    Args:
        value: Glab selector as ``None``, integer index, or string label.
        glab_to_index: Mapping from glab labels to integer indices.

    Returns:
        The resolved glab index. Returns ``None`` when ``value`` is ``None``.

    Raises:
        KeyError: If ``value`` is a string label not present in ``glab_to_index``.
    """
    if value is None:
        return None
    if isinstance(value, int):
        return value
    return int(glab_to_index[value])

wrap_gamete_modifier

wrap_gamete_modifier(mod: GameteModifier, population: Any, index_registry: Any, haploid_genotypes: List[HaploidGenotype], diploid_genotypes: List[Genotype], n_glabs: int) -> Callable[[np.ndarray], np.ndarray]

Wrap a high-level GameteModifier into a tensor-level callable.

The returned callable accepts a tensor of shape (n_sexes, n_genotypes, n_hg_glabs) and returns a modified copy.

Parameters:

Name Type Description Default
mod GameteModifier

A GameteModifier callable (returns dict mapping keys to freq dicts).

required
population Any

The population object (passed to mod if it takes an argument).

required
index_registry Any

IndexRegistry instance for key resolution.

required
haploid_genotypes List[HaploidGenotype]

List of all HaploidGenotype objects.

required
diploid_genotypes List[Genotype]

List of all Genotype objects.

required
n_glabs int

Number of gamete-label variants.

required

Returns:

Type Description
Callable[[ndarray], ndarray]

A callable (np.ndarray) -> np.ndarray.

Source code in src/natal/modifiers.py
def wrap_gamete_modifier(
    mod: GameteModifier,
    population: Any,
    index_registry: Any,
    haploid_genotypes: List[HaploidGenotype],
    diploid_genotypes: List[Genotype],
    n_glabs: int,
) -> Callable[[np.ndarray], np.ndarray]:
    """Wrap a high-level GameteModifier into a tensor-level callable.

    The returned callable accepts a tensor of shape (n_sexes, n_genotypes, n_hg_glabs)
    and returns a modified copy.

    Args:
        mod: A GameteModifier callable (returns dict mapping keys to freq dicts).
        population: The population object (passed to mod if it takes an argument).
        index_registry: IndexRegistry instance for key resolution.
        haploid_genotypes: List of all HaploidGenotype objects.
        diploid_genotypes: List of all Genotype objects.
        n_glabs: Number of gamete-label variants.

    Returns:
        A callable (np.ndarray) -> np.ndarray.
    """
    def tensor_modifier(tensor: np.ndarray) -> np.ndarray:
        modified = tensor.copy()
        n_sexes, n_genotypes, n_hg_glabs = modified.shape

        bulk_obj = _invoke_modifier(mod, population)

        if not isinstance(bulk_obj, Mapping):
            raise TypeError("Gamete modifier must return a mapping from keys to compressed-index->freq mappings")
        bulk = cast(Mapping[object, object], bulk_obj)

        for key, val in bulk.items():
            # Case A: top-level sex-name ('male'/'female')
            sex_idx = _resolve_sex_name(key) if isinstance(key, str) else None
            if sex_idx is not None and isinstance(val, Mapping):
                sex_val = cast(Mapping[object, object], val)
                for gk, comp_map in sex_val.items():
                    try:
                        gidx = gk if isinstance(gk, int) else index_registry.resolve_genotype_index(diploid_genotypes, gk, strict=True)
                    except KeyError:
                        continue
                    if not (0 <= sex_idx < n_sexes and 0 <= gidx < n_genotypes):
                        continue
                    if isinstance(comp_map, Mapping):
                        _apply_comp_map(
                            modified,
                            sex_idx,
                            gidx,
                            cast(Mapping[object, object], comp_map),
                            index_registry,
                            haploid_genotypes,
                            n_glabs,
                            n_hg_glabs,
                        )
                continue

            # Case B: explicit (sex_idx, genotype_key) tuple
            key_tuple = _as_pair(key)
            if key_tuple is not None:
                sex_obj, gk = key_tuple
                if not isinstance(sex_obj, int):
                    continue
                sex_idx = sex_obj
                gidx = gk if isinstance(gk, int) else index_registry.resolve_genotype_index(diploid_genotypes, gk, strict=True)
                if not (0 <= sex_idx < n_sexes and 0 <= gidx < n_genotypes):
                    continue
                _apply_comp_map(
                    modified,
                    sex_idx,
                    gidx,
                    cast(Mapping[object, object], val),
                    index_registry,
                    haploid_genotypes,
                    n_glabs,
                    n_hg_glabs,
                )
                continue

            # Case C: key is genotype_key applied to all sexes
            try:
                gidx = key if isinstance(key, int) else index_registry.resolve_genotype_index(diploid_genotypes, key, strict=True)
            except KeyError:
                continue
            if not isinstance(val, Mapping):
                continue
            for sex_idx in range(n_sexes):
                _apply_comp_map(
                    modified,
                    sex_idx,
                    gidx,
                    cast(Mapping[object, object], val),
                    index_registry,
                    haploid_genotypes,
                    n_glabs,
                    n_hg_glabs,
                )

        return modified
    return tensor_modifier

wrap_zygote_modifier

wrap_zygote_modifier(mod: ZygoteModifier, population: Any, index_registry: Any, haploid_genotypes: List[HaploidGenotype], diploid_genotypes: List[Genotype], n_glabs: int) -> Callable[[np.ndarray], np.ndarray]

Wrap a high-level ZygoteModifier into a tensor-level callable.

The returned callable accepts a tensor of shape (n_hg_glabs, n_hg_glabs, n_genotypes) and returns a modified copy.

Parameters:

Name Type Description Default
mod ZygoteModifier

A ZygoteModifier callable.

required
population Any

The population object (passed to mod if it takes an argument).

required
index_registry Any

IndexRegistry instance for key resolution.

required
haploid_genotypes List[HaploidGenotype]

List of all HaploidGenotype objects.

required
diploid_genotypes List[Genotype]

List of all Genotype objects.

required
n_glabs int

Number of gamete-label variants.

required

Returns:

Type Description
Callable[[ndarray], ndarray]

A callable (np.ndarray) -> np.ndarray.

Source code in src/natal/modifiers.py
def wrap_zygote_modifier(
    mod: ZygoteModifier,
    population: Any,
    index_registry: Any,
    haploid_genotypes: List[HaploidGenotype],
    diploid_genotypes: List[Genotype],
    n_glabs: int,
) -> Callable[[np.ndarray], np.ndarray]:
    """Wrap a high-level ZygoteModifier into a tensor-level callable.

    The returned callable accepts a tensor of shape (n_hg_glabs, n_hg_glabs, n_genotypes)
    and returns a modified copy.

    Args:
        mod: A ZygoteModifier callable.
        population: The population object (passed to mod if it takes an argument).
        index_registry: IndexRegistry instance for key resolution.
        haploid_genotypes: List of all HaploidGenotype objects.
        diploid_genotypes: List of all Genotype objects.
        n_glabs: Number of gamete-label variants.

    Returns:
        A callable (np.ndarray) -> np.ndarray.
    """
    def tensor_modifier(tensor: np.ndarray) -> np.ndarray:
        modified = tensor.copy()

        bulk_obj = _invoke_modifier(mod, population)

        if not isinstance(bulk_obj, Mapping):
            raise TypeError("Zygote modifier must return a mapping from keys to replacements")
        bulk = cast(Mapping[object, object], bulk_obj)

        for key, val in bulk.items():
            c1, c2 = _parse_zygote_key(key, index_registry, haploid_genotypes, n_glabs)
            mapping = _normalize_zygote_val(val, index_registry, diploid_genotypes)
            _write_zygote_mapping(modified, c1, c2, mapping)

        return modified
    return tensor_modifier

build_modifier_wrappers

build_modifier_wrappers(gamete_modifiers: List[Tuple[int, Optional[str], GameteModifier]], zygote_modifiers: List[Tuple[int, Optional[str], ZygoteModifier]], population: Any, index_registry: Any, haploid_genotypes: List[HaploidGenotype], diploid_genotypes: List[Genotype], n_glabs: int = 1) -> Tuple[List[Callable[[np.ndarray], np.ndarray]], List[Callable[[np.ndarray], np.ndarray]]]

Wrap high-level gamete/zygote modifiers into tensor-level callables.

This is the shared implementation used by BasePopulation and any external modifier systems that need to convert high-level modifiers to tensor ops.

Parameters:

Name Type Description Default
gamete_modifiers List[Tuple[int, Optional[str], GameteModifier]]

List of (modifier_id, name, modifier) tuples for gamete modifiers.

required
zygote_modifiers List[Tuple[int, Optional[str], ZygoteModifier]]

List of (modifier_id, name, modifier) tuples for zygote modifiers.

required
population Any

The population object.

required
index_registry Any

IndexRegistry instance.

required
haploid_genotypes List[HaploidGenotype]

List of all HaploidGenotype objects.

required
diploid_genotypes List[Genotype]

List of all Genotype objects.

required
n_glabs int

Number of gamete-label variants.

1

Returns:

Type Description
List[Callable[[ndarray], ndarray]]

Tuple of (gamete_modifier_funcs, zygote_modifier_funcs), each a list

List[Callable[[ndarray], ndarray]]

of callables that accept and return NumPy tensors.

Source code in src/natal/modifiers.py
def build_modifier_wrappers(
    gamete_modifiers: List[Tuple[int, Optional[str], GameteModifier]],
    zygote_modifiers: List[Tuple[int, Optional[str], ZygoteModifier]],
    population: Any,
    index_registry: Any,
    haploid_genotypes: List[HaploidGenotype],
    diploid_genotypes: List[Genotype],
    n_glabs: int = 1,
) -> Tuple[List[Callable[[np.ndarray], np.ndarray]], List[Callable[[np.ndarray], np.ndarray]]]:
    """Wrap high-level gamete/zygote modifiers into tensor-level callables.

    This is the shared implementation used by BasePopulation and any external
    modifier systems that need to convert high-level modifiers to tensor ops.

    Args:
        gamete_modifiers: List of (modifier_id, name, modifier) tuples for gamete modifiers.
        zygote_modifiers: List of (modifier_id, name, modifier) tuples for zygote modifiers.
        population: The population object.
        index_registry: IndexRegistry instance.
        haploid_genotypes: List of all HaploidGenotype objects.
        diploid_genotypes: List of all Genotype objects.
        n_glabs: Number of gamete-label variants.

    Returns:
        Tuple of (gamete_modifier_funcs, zygote_modifier_funcs), each a list
        of callables that accept and return NumPy tensors.
    """
    gamete_modifier_funcs: List[Callable[[np.ndarray], np.ndarray]] = []
    zygote_modifier_funcs: List[Callable[[np.ndarray], np.ndarray]] = []

    for _, _, mod in zygote_modifiers:
        zygote_modifier_funcs.append(
            wrap_zygote_modifier(mod, population, index_registry, haploid_genotypes, diploid_genotypes, n_glabs)
        )

    for _, _, mod in gamete_modifiers:
        gamete_modifier_funcs.append(
            wrap_gamete_modifier(mod, population, index_registry, haploid_genotypes, diploid_genotypes, n_glabs)
        )

    return gamete_modifier_funcs, zygote_modifier_funcs