Source code for modelly.behaviour.meta_agents.combinations

"""Candidate meta-agent search: enumerate agent combinations and score them.

``evaluate_combination``, ``find_combinations`` and ``cache_build`` are exposed
as static methods of :class:`modelly.behaviour.meta_agents.MetaAgents`.
"""

from __future__ import annotations

import itertools
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from modelly.core.agent import Agent


[docs] def evaluate_combination( candidate_group: tuple[Agent, ...], evaluation_func: Callable[[tuple[Agent, ...]], float] | None, ) -> tuple[tuple[Agent, ...], float] | None: """Evaluate a candidate meta-agent group with a user-supplied function.""" if evaluation_func is None: return None return candidate_group, evaluation_func(candidate_group)
[docs] def find_combinations( group: Iterable, size: int | tuple[int, int] = (2, 5), evaluation_func: Callable[[tuple[Agent, ...]], float] | None = None, filter_func: Callable[ [list[tuple[tuple[Agent, ...], float]]], list[tuple[tuple[Agent, ...], float]], ] | None = None, ) -> list[tuple[tuple[Agent, ...], float]]: """Find candidate agent groups and score them with ``evaluation_func``. The helper discovers potential meta-agents before creating them. It deliberately does not mutate model or membership state. Args: group: The set of agents to find combinations in. size: The size or range of sizes for combinations. Defaults to (2, 5). evaluation_func: The function to evaluate combinations. Defaults to None. filter_func: Allows the user to specify how agents are filtered to form groups. Defaults to None. Returns: List: The list of valuable combinations, in a tuple first agentset of valuable combination and then the value of the combination. """ if isinstance(size, int): size_range = range(size, size + 1) else: min_size, max_size = size size_range = range(min_size, max_size + 1) combinations = [] for candidate_group in itertools.chain.from_iterable( itertools.combinations(group, combination_size) for combination_size in size_range ): evaluation_result = evaluate_combination(candidate_group, evaluation_func) if evaluation_result is not None: _evaluated_group, result = evaluation_result if result is not None: combinations.append(evaluation_result) if combinations and filter_func is not None: return filter_func(combinations) return combinations
[docs] class CachedEvaluation: """Memoising wrapper around an ``evaluation_func``; built by ``cache_build``. Scores are stored per candidate group, keyed by the exact tuple ``find_combinations`` passes in, so a repeated search over the same agents only calls the wrapped function for groups it has not seen. The cache never expires on its own: call :meth:`invalidate` when the state a score depends on changes (for example when a member is removed or mutated). """ def __init__( self, evaluation_func: Callable[[tuple[Agent, ...]], Any], maxsize: int | None = None, ) -> None: """Wrap ``evaluation_func``; keep at most ``maxsize`` scores (no limit if None).""" if not callable(evaluation_func): raise TypeError("evaluation_func must be callable") if maxsize is not None and maxsize < 1: raise ValueError(f"maxsize must be a positive integer, got {maxsize}") self.evaluation_func = evaluation_func self.maxsize = maxsize self.hits = 0 self.misses = 0 self._scores: dict[tuple[Agent, ...], Any] = {} def __call__(self, candidate_group: tuple[Agent, ...]) -> Any: """Return the cached score of ``candidate_group``, computing it once.""" key = tuple(candidate_group) if key in self._scores: self.hits += 1 return self._scores[key] self.misses += 1 score = self.evaluation_func(candidate_group) if self.maxsize is not None and len(self._scores) >= self.maxsize: # Evict the oldest entry: insertion order keeps eviction deterministic. del self._scores[next(iter(self._scores))] self._scores[key] = score return score def __len__(self) -> int: """Return the number of cached scores.""" return len(self._scores)
[docs] def invalidate(self, *agents: Agent) -> int: """Drop the scores of groups containing any of ``agents``; all if none given. Returns: The number of scores dropped. """ if not agents: dropped = len(self._scores) self._scores.clear() return dropped targets = set(agents) stale = [key for key in self._scores if not targets.isdisjoint(key)] for key in stale: del self._scores[key] return len(stale)
[docs] def cache_build( evaluation_func: Callable[[tuple[Agent, ...]], Any], maxsize: int | None = None, ) -> CachedEvaluation: """Return a cached ``evaluation_func`` for repeated ``find_combinations`` calls. Models that search the same agents for meta-agents every step otherwise re-score every candidate group each time. Build the cache once and pass it as ``evaluation_func``; results and their order are unchanged as long as the wrapped function is deterministic for a given group. Args: evaluation_func: Scores one candidate group, as for ``find_combinations``. maxsize: Maximum number of cached scores; the oldest is evicted first. ``None`` (default) keeps every score. Returns: A callable :class:`CachedEvaluation` with ``hits``, ``misses`` and :meth:`~CachedEvaluation.invalidate`. Examples: >>> score = model.meta_agents.cache_build(shapley_value) >>> model.meta_agents.find_combinations(agents, size=2, evaluation_func=score) >>> score.invalidate(departed_agent) # its groups are re-scored next time """ return CachedEvaluation(evaluation_func, maxsize=maxsize)