Spaces:
Runtime error
Runtime error
| """ | |
| Generic entropy metrics for WorldSmithAI. | |
| This module computes Shannon entropy over arbitrary world object attributes. | |
| It deliberately avoids hardcoded species, ecosystem, market, civilization, or | |
| research-domain assumptions. | |
| A category can be any DSL-defined value reachable by path: | |
| - type | |
| - state.current_goal | |
| - memory.active_options.strategy.option_id | |
| - metadata.faction | |
| - state.status | |
| - memory.latest_behavior | |
| - amount bucket labels supplied through custom counts | |
| Example: | |
| metric = EntropyMetric(collection="agents", value_path="type") | |
| result = metric.compute(world) | |
| print(result.entropy) | |
| print(result.normalized_entropy) | |
| Weighted resource entropy: | |
| metric = EntropyMetric( | |
| collection="resources", | |
| value_path="type", | |
| weight_path="amount", | |
| ) | |
| result = metric(world) | |
| Future extensibility: | |
| - Add conditional entropy between two paths. | |
| - Add mutual information between agent attributes. | |
| - Add rolling entropy windows. | |
| - Add spatial entropy over grid cells or regions. | |
| - Add behavior entropy from action histories. | |
| - Feed normalized entropy into interestingness and God-Agent metrics. | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| import logging | |
| import math | |
| from collections.abc import Iterable, Mapping, MutableSequence, Sequence | |
| from dataclasses import dataclass, field | |
| from enum import Enum | |
| from numbers import Real | |
| from types import MappingProxyType | |
| from typing import TYPE_CHECKING, Any, ClassVar | |
| import numpy as np | |
| if TYPE_CHECKING: | |
| from core.world import World | |
| logger = logging.getLogger(__name__) | |
| _MISSING = object() | |
| _EPSILON = 1.0e-12 | |
| class EntropyCollection(str, Enum): | |
| """Collections over which entropy may be computed.""" | |
| AGENTS = "agents" | |
| RESOURCES = "resources" | |
| BOTH = "both" | |
| class EntropyLogBase(str, Enum): | |
| """Common logarithm bases for entropy.""" | |
| NATURAL = "e" | |
| BASE_2 = "2" | |
| BASE_10 = "10" | |
| class EntropyResult: | |
| """Result produced by entropy metric computation. | |
| Attributes: | |
| metric_name: Stable metric name. | |
| collection: World collection analyzed. | |
| value_path: Path used to extract category values. | |
| weight_path: Optional path used to weight observations. | |
| base: Logarithm base used for entropy. | |
| item_count: Number of included objects. | |
| total_weight: Sum of non-negative observation weights. | |
| outcome_count: Number of distinct outcomes. | |
| counts: Outcome weights keyed by outcome label. | |
| probabilities: Outcome probabilities keyed by outcome label. | |
| entropy: Shannon entropy. | |
| max_entropy: Maximum entropy possible with this number of outcomes. | |
| normalized_entropy: Entropy divided by maximum entropy. | |
| effective_number: Perplexity, equal to base ** entropy. | |
| redundancy: One minus normalized entropy. | |
| dominant_outcome: Most probable outcome label. | |
| dominance: Probability of the dominant outcome. | |
| step: Optional world step. | |
| metadata: Additional JSON-compatible details. | |
| """ | |
| metric_name: str | |
| collection: str | |
| value_path: str | |
| weight_path: str | None | |
| base: str | |
| item_count: int | |
| total_weight: float | |
| outcome_count: int | |
| counts: Mapping[str, float] = field(default_factory=dict) | |
| probabilities: Mapping[str, float] = field(default_factory=dict) | |
| entropy: float = 0.0 | |
| max_entropy: float = 0.0 | |
| normalized_entropy: float = 0.0 | |
| effective_number: float = 0.0 | |
| redundancy: float = 0.0 | |
| dominant_outcome: str | None = None | |
| dominance: float = 0.0 | |
| step: int | None = None | |
| metadata: Mapping[str, Any] = field(default_factory=dict) | |
| def is_empty(self) -> bool: | |
| """Return whether no observations contributed to entropy.""" | |
| return self.item_count == 0 or self.total_weight <= 0.0 or self.outcome_count == 0 | |
| def is_degenerate(self) -> bool: | |
| """Return whether all probability mass belongs to one outcome.""" | |
| return self.outcome_count <= 1 or self.entropy <= _EPSILON | |
| def to_dict(self) -> dict[str, Any]: | |
| """Return a JSON-friendly representation of the entropy result.""" | |
| return { | |
| "metric_name": self.metric_name, | |
| "collection": self.collection, | |
| "value_path": self.value_path, | |
| "weight_path": self.weight_path, | |
| "base": self.base, | |
| "item_count": self.item_count, | |
| "total_weight": self.total_weight, | |
| "outcome_count": self.outcome_count, | |
| "counts": copy.deepcopy(dict(self.counts)), | |
| "probabilities": copy.deepcopy(dict(self.probabilities)), | |
| "entropy": self.entropy, | |
| "max_entropy": self.max_entropy, | |
| "normalized_entropy": self.normalized_entropy, | |
| "effective_number": self.effective_number, | |
| "redundancy": self.redundancy, | |
| "dominant_outcome": self.dominant_outcome, | |
| "dominance": self.dominance, | |
| "is_empty": self.is_empty, | |
| "is_degenerate": self.is_degenerate, | |
| "step": self.step, | |
| "metadata": copy.deepcopy(dict(self.metadata)), | |
| } | |
| class EntropyMetric: | |
| """Compute Shannon entropy over a generic world collection. | |
| By default, this computes entropy over alive agents grouped by ``type``. | |
| The same class can compute entropy over resources, goals, statuses, | |
| strategies, factions, memory categories, or any DSL-defined path. | |
| Examples: | |
| Agent type entropy: | |
| metric = EntropyMetric() | |
| result = metric.compute(world) | |
| Resource entropy weighted by amount: | |
| metric = EntropyMetric( | |
| collection="resources", | |
| value_path="type", | |
| weight_path="amount", | |
| ) | |
| result = metric.compute(world) | |
| Current-goal entropy: | |
| metric = EntropyMetric( | |
| collection="agents", | |
| value_path="state.current_goal", | |
| include_missing=True, | |
| ) | |
| result = metric.compute(world) | |
| """ | |
| name: ClassVar[str] = "entropy" | |
| collection: EntropyCollection | str = EntropyCollection.AGENTS | |
| value_path: str = "type" | |
| weight_path: str | None = None | |
| base: EntropyLogBase | str | float = EntropyLogBase.NATURAL | |
| alive_only: bool = True | |
| include_missing: bool = False | |
| missing_label: str = "unknown" | |
| include_values: tuple[str, ...] = () | |
| exclude_values: tuple[str, ...] = () | |
| case_sensitive: bool = True | |
| strip_labels: bool = True | |
| include_collection_prefix: bool = False | |
| minimum_weight: float = 0.0 | |
| metadata: Mapping[str, Any] = field(default_factory=dict) | |
| def compute(self, world: World) -> EntropyResult: | |
| """Compute entropy for the configured world collection. | |
| Args: | |
| world: Runtime world object. | |
| Returns: | |
| ``EntropyResult`` containing entropy and distribution statistics. | |
| """ | |
| collection = _normalize_collection(self.collection) | |
| items = _iter_world_items(world, collection) | |
| counts, included_count, skipped_count = self._counts(items, collection) | |
| result = entropy_from_counts( | |
| counts, | |
| metric_name=self.name, | |
| collection=collection.value, | |
| value_path=self.value_path, | |
| weight_path=self.weight_path, | |
| base=self.base, | |
| item_count=included_count, | |
| step=_world_step(world), | |
| metadata={ | |
| **copy.deepcopy(dict(self.metadata)), | |
| "skipped_count": skipped_count, | |
| "alive_only": self.alive_only, | |
| "include_missing": self.include_missing, | |
| "minimum_weight": self.minimum_weight, | |
| }, | |
| ) | |
| logger.debug( | |
| "Computed entropy over %s path %s: entropy=%.6f normalized=%.6f", | |
| collection.value, | |
| self.value_path, | |
| result.entropy, | |
| result.normalized_entropy, | |
| ) | |
| return result | |
| def __call__(self, world: World) -> EntropyResult: | |
| """Compute entropy, allowing metric instances to be called directly.""" | |
| return self.compute(world) | |
| def _counts( | |
| self, | |
| items: Sequence[Any], | |
| collection: EntropyCollection, | |
| ) -> tuple[dict[str, float], int, int]: | |
| """Return outcome weights, included item count, and skipped item count.""" | |
| counts: dict[str, float] = {} | |
| included_count = 0 | |
| skipped_count = 0 | |
| include_values = { | |
| _normalize_label(value, case_sensitive=self.case_sensitive, strip=True) | |
| for value in self.include_values | |
| } | |
| exclude_values = { | |
| _normalize_label(value, case_sensitive=self.case_sensitive, strip=True) | |
| for value in self.exclude_values | |
| } | |
| for item in items: | |
| if self.alive_only and _is_agent_like(item) and not _is_alive(item): | |
| skipped_count += 1 | |
| continue | |
| label = self._label_for(item, collection) | |
| if label is None: | |
| skipped_count += 1 | |
| continue | |
| normalized_label = _normalize_label( | |
| label, | |
| case_sensitive=self.case_sensitive, | |
| strip=self.strip_labels, | |
| ) | |
| if include_values and normalized_label not in include_values: | |
| skipped_count += 1 | |
| continue | |
| if exclude_values and normalized_label in exclude_values: | |
| skipped_count += 1 | |
| continue | |
| weight = self._weight_for(item) | |
| if weight <= max(0.0, float(self.minimum_weight)): | |
| skipped_count += 1 | |
| continue | |
| counts[normalized_label] = counts.get(normalized_label, 0.0) + weight | |
| included_count += 1 | |
| return counts, included_count, skipped_count | |
| def _label_for(self, item: Any, collection: EntropyCollection) -> str | None: | |
| """Return the categorical label for an item.""" | |
| raw_label = _read_path(item, self.value_path, _MISSING) | |
| if raw_label is _MISSING or raw_label is None or str(raw_label) == "": | |
| if not self.include_missing: | |
| return None | |
| raw_label = self.missing_label | |
| label = _stable_label(raw_label) | |
| if self.include_collection_prefix and collection is EntropyCollection.BOTH: | |
| return f"{_item_collection_name(item)}:{label}" | |
| return label | |
| def _weight_for(self, item: Any) -> float: | |
| """Return the non-negative observation weight for an item.""" | |
| if self.weight_path is None: | |
| return 1.0 | |
| raw_weight = _read_path(item, self.weight_path, 0.0) | |
| if not _is_number(raw_weight): | |
| return 0.0 | |
| return max(0.0, float(raw_weight)) | |
| class EntropyTracker: | |
| """Track entropy results over time. | |
| This is a lightweight helper for simulations that want entropy curves | |
| before the full metrics subsystem is introduced. | |
| """ | |
| metric: EntropyMetric = field(default_factory=EntropyMetric) | |
| max_history: int = 1000 | |
| history: list[EntropyResult] = field(default_factory=list) | |
| def update(self, world: World) -> EntropyResult: | |
| """Compute entropy for the current world and append it to history.""" | |
| result = self.metric.compute(world) | |
| _append_bounded(self.history, result, self.max_history) | |
| return result | |
| def latest(self) -> EntropyResult | None: | |
| """Return the latest entropy result, if any.""" | |
| if not self.history: | |
| return None | |
| return self.history[-1] | |
| def to_series(self, value_key: str = "entropy") -> list[dict[str, Any]]: | |
| """Return a JSON-friendly time series for one result field. | |
| Args: | |
| value_key: Name of an ``EntropyResult`` attribute to extract. | |
| Returns: | |
| List of dictionaries with ``index``, ``step``, and ``value``. | |
| """ | |
| series: list[dict[str, Any]] = [] | |
| for index, result in enumerate(self.history): | |
| value = getattr(result, value_key, None) | |
| if not _is_number(value): | |
| continue | |
| series.append( | |
| { | |
| "index": index, | |
| "step": result.step, | |
| "value": float(value), | |
| } | |
| ) | |
| return series | |
| def to_dict(self) -> dict[str, Any]: | |
| """Return a JSON-friendly tracker representation.""" | |
| return { | |
| "metric": { | |
| "name": self.metric.name, | |
| "collection": _normalize_collection(self.metric.collection).value, | |
| "value_path": self.metric.value_path, | |
| "weight_path": self.metric.weight_path, | |
| "base": _base_label(self.metric.base), | |
| }, | |
| "max_history": self.max_history, | |
| "history": [result.to_dict() for result in self.history], | |
| } | |
| def compute_entropy( | |
| world: World, | |
| *, | |
| collection: EntropyCollection | str = EntropyCollection.AGENTS, | |
| value_path: str = "type", | |
| weight_path: str | None = None, | |
| base: EntropyLogBase | str | float = EntropyLogBase.NATURAL, | |
| alive_only: bool = True, | |
| include_missing: bool = False, | |
| ) -> EntropyResult: | |
| """Convenience function for computing generic world entropy.""" | |
| metric = EntropyMetric( | |
| collection=collection, | |
| value_path=value_path, | |
| weight_path=weight_path, | |
| base=base, | |
| alive_only=alive_only, | |
| include_missing=include_missing, | |
| ) | |
| return metric.compute(world) | |
| def compute_agent_type_entropy( | |
| world: World, | |
| *, | |
| base: EntropyLogBase | str | float = EntropyLogBase.NATURAL, | |
| alive_only: bool = True, | |
| include_missing: bool = False, | |
| ) -> EntropyResult: | |
| """Compute entropy over agent ``type`` labels.""" | |
| return compute_entropy( | |
| world, | |
| collection=EntropyCollection.AGENTS, | |
| value_path="type", | |
| base=base, | |
| alive_only=alive_only, | |
| include_missing=include_missing, | |
| ) | |
| def compute_resource_type_entropy( | |
| world: World, | |
| *, | |
| weight_by_amount: bool = False, | |
| base: EntropyLogBase | str | float = EntropyLogBase.NATURAL, | |
| include_missing: bool = False, | |
| ) -> EntropyResult: | |
| """Compute entropy over resource ``type`` labels.""" | |
| return compute_entropy( | |
| world, | |
| collection=EntropyCollection.RESOURCES, | |
| value_path="type", | |
| weight_path="amount" if weight_by_amount else None, | |
| base=base, | |
| alive_only=False, | |
| include_missing=include_missing, | |
| ) | |
| def entropy_from_counts( | |
| counts: Mapping[str, float], | |
| *, | |
| metric_name: str = "entropy", | |
| collection: str = "custom", | |
| value_path: str = "value", | |
| weight_path: str | None = None, | |
| base: EntropyLogBase | str | float = EntropyLogBase.NATURAL, | |
| item_count: int | None = None, | |
| step: int | None = None, | |
| metadata: Mapping[str, Any] | None = None, | |
| ) -> EntropyResult: | |
| """Compute entropy statistics from outcome counts or weights. | |
| Args: | |
| counts: Mapping from outcome label to non-negative count or weight. | |
| metric_name: Name stored in the result. | |
| collection: Collection label stored in the result. | |
| value_path: Value path stored in the result. | |
| weight_path: Optional weight path stored in the result. | |
| base: Logarithm base. | |
| item_count: Optional number of contributing items. | |
| step: Optional world step. | |
| metadata: Optional result metadata. | |
| Returns: | |
| ``EntropyResult`` with entropy and distribution statistics. | |
| """ | |
| cleaned_counts = { | |
| str(label): max(0.0, float(weight)) | |
| for label, weight in counts.items() | |
| if _is_number(weight) and float(weight) > 0.0 | |
| } | |
| sorted_counts = dict(sorted(cleaned_counts.items(), key=lambda item: item[0])) | |
| total_weight = float(sum(sorted_counts.values())) | |
| outcome_count = len(sorted_counts) | |
| if total_weight <= _EPSILON or outcome_count == 0: | |
| return EntropyResult( | |
| metric_name=metric_name, | |
| collection=collection, | |
| value_path=value_path, | |
| weight_path=weight_path, | |
| base=_base_label(base), | |
| item_count=0 if item_count is None else int(item_count), | |
| total_weight=0.0, | |
| outcome_count=0, | |
| counts={}, | |
| probabilities={}, | |
| step=step, | |
| metadata=metadata or {}, | |
| ) | |
| probabilities = { | |
| label: weight / total_weight | |
| for label, weight in sorted_counts.items() | |
| } | |
| entropy_result = entropy_from_probabilities( | |
| probabilities, | |
| metric_name=metric_name, | |
| collection=collection, | |
| value_path=value_path, | |
| weight_path=weight_path, | |
| base=base, | |
| counts=sorted_counts, | |
| item_count=outcome_count if item_count is None else int(item_count), | |
| total_weight=total_weight, | |
| step=step, | |
| metadata=metadata, | |
| ) | |
| return entropy_result | |
| def entropy_from_probabilities( | |
| probabilities: Mapping[str, float], | |
| *, | |
| metric_name: str = "entropy", | |
| collection: str = "custom", | |
| value_path: str = "value", | |
| weight_path: str | None = None, | |
| base: EntropyLogBase | str | float = EntropyLogBase.NATURAL, | |
| counts: Mapping[str, float] | None = None, | |
| item_count: int | None = None, | |
| total_weight: float | None = None, | |
| step: int | None = None, | |
| metadata: Mapping[str, Any] | None = None, | |
| ) -> EntropyResult: | |
| """Compute entropy statistics from probabilities. | |
| Probabilities are normalized defensively, so callers may pass values that | |
| sum approximately but not exactly to one. | |
| """ | |
| cleaned_probabilities = { | |
| str(label): max(0.0, float(probability)) | |
| for label, probability in probabilities.items() | |
| if _is_number(probability) and float(probability) > 0.0 | |
| } | |
| probability_sum = float(sum(cleaned_probabilities.values())) | |
| if probability_sum <= _EPSILON: | |
| return EntropyResult( | |
| metric_name=metric_name, | |
| collection=collection, | |
| value_path=value_path, | |
| weight_path=weight_path, | |
| base=_base_label(base), | |
| item_count=0 if item_count is None else int(item_count), | |
| total_weight=0.0 if total_weight is None else float(total_weight), | |
| outcome_count=0, | |
| counts={}, | |
| probabilities={}, | |
| step=step, | |
| metadata=metadata or {}, | |
| ) | |
| normalized_probabilities = { | |
| label: probability / probability_sum | |
| for label, probability in sorted(cleaned_probabilities.items(), key=lambda item: item[0]) | |
| } | |
| probability_values = np.asarray(list(normalized_probabilities.values()), dtype=float) | |
| log_base = _base_value(base) | |
| entropy = _shannon_entropy_array(probability_values, log_base=log_base) | |
| outcome_count = len(normalized_probabilities) | |
| max_entropy = _log(outcome_count, log_base) if outcome_count > 1 else 0.0 | |
| normalized_entropy = entropy / max_entropy if max_entropy > _EPSILON else 0.0 | |
| normalized_entropy = min(max(float(normalized_entropy), 0.0), 1.0) | |
| effective_number = float(log_base**entropy) if log_base > 0 and log_base != 1.0 else math.exp(entropy) | |
| redundancy = 1.0 - normalized_entropy | |
| dominant_outcome, dominance = max( | |
| normalized_probabilities.items(), | |
| key=lambda item: (item[1], item[0]), | |
| ) | |
| return EntropyResult( | |
| metric_name=metric_name, | |
| collection=collection, | |
| value_path=value_path, | |
| weight_path=weight_path, | |
| base=_base_label(base), | |
| item_count=outcome_count if item_count is None else int(item_count), | |
| total_weight=float(probability_sum if total_weight is None else total_weight), | |
| outcome_count=outcome_count, | |
| counts={} if counts is None else copy.deepcopy(dict(counts)), | |
| probabilities=normalized_probabilities, | |
| entropy=float(entropy), | |
| max_entropy=float(max_entropy), | |
| normalized_entropy=float(normalized_entropy), | |
| effective_number=float(effective_number), | |
| redundancy=float(redundancy), | |
| dominant_outcome=dominant_outcome, | |
| dominance=float(dominance), | |
| step=step, | |
| metadata=metadata or {}, | |
| ) | |
| def shannon_entropy( | |
| probabilities: Sequence[float], | |
| *, | |
| base: EntropyLogBase | str | float = EntropyLogBase.NATURAL, | |
| normalize: bool = True, | |
| ) -> float: | |
| """Compute Shannon entropy from a sequence of probabilities. | |
| Args: | |
| probabilities: Non-negative probability-like values. | |
| base: Logarithm base. Use ``"e"``, ``"2"``, ``"10"``, or a positive | |
| numeric base not equal to one. | |
| normalize: Whether to normalize values to sum to one. | |
| Returns: | |
| Shannon entropy. | |
| """ | |
| array = np.asarray(probabilities, dtype=float).reshape(-1) | |
| array = np.nan_to_num(array, nan=0.0, posinf=0.0, neginf=0.0) | |
| array = np.clip(array, 0.0, None) | |
| if normalize: | |
| total = float(np.sum(array)) | |
| if total <= _EPSILON: | |
| return 0.0 | |
| array = array / total | |
| return _shannon_entropy_array(array, log_base=_base_value(base)) | |
| def normalized_entropy( | |
| probabilities: Sequence[float], | |
| *, | |
| base: EntropyLogBase | str | float = EntropyLogBase.NATURAL, | |
| ) -> float: | |
| """Compute Shannon entropy normalized to the interval [0, 1].""" | |
| array = np.asarray(probabilities, dtype=float).reshape(-1) | |
| array = np.nan_to_num(array, nan=0.0, posinf=0.0, neginf=0.0) | |
| array = np.clip(array, 0.0, None) | |
| total = float(np.sum(array)) | |
| if total <= _EPSILON: | |
| return 0.0 | |
| positive_count = int(np.sum(array > 0.0)) | |
| if positive_count <= 1: | |
| return 0.0 | |
| entropy = shannon_entropy(array, base=base, normalize=True) | |
| maximum = _log(positive_count, _base_value(base)) | |
| if maximum <= _EPSILON: | |
| return 0.0 | |
| return min(max(float(entropy / maximum), 0.0), 1.0) | |
| def effective_number( | |
| probabilities: Sequence[float], | |
| *, | |
| base: EntropyLogBase | str | float = EntropyLogBase.NATURAL, | |
| ) -> float: | |
| """Return entropy effective number, also known as perplexity.""" | |
| entropy = shannon_entropy(probabilities, base=base, normalize=True) | |
| base_value = _base_value(base) | |
| if base_value > 0 and base_value != 1.0: | |
| return float(base_value**entropy) | |
| return float(math.exp(entropy)) | |
| def _normalize_collection(value: EntropyCollection | str) -> EntropyCollection: | |
| """Normalize an entropy collection value.""" | |
| if isinstance(value, EntropyCollection): | |
| return value | |
| return EntropyCollection(str(value)) | |
| def _normalize_base(value: EntropyLogBase | str | float) -> EntropyLogBase | float: | |
| """Normalize a log-base value.""" | |
| if isinstance(value, EntropyLogBase): | |
| return value | |
| if isinstance(value, str): | |
| lowered = value.strip().lower() | |
| if lowered in {"e", "natural", "nat", "ln"}: | |
| return EntropyLogBase.NATURAL | |
| if lowered in {"2", "base2", "bits", "bit"}: | |
| return EntropyLogBase.BASE_2 | |
| if lowered in {"10", "base10"}: | |
| return EntropyLogBase.BASE_10 | |
| numeric_base = float(lowered) | |
| else: | |
| numeric_base = float(value) | |
| if numeric_base <= 0.0 or math.isclose(numeric_base, 1.0): | |
| raise ValueError("Entropy log base must be positive and not equal to 1") | |
| return numeric_base | |
| def _base_value(value: EntropyLogBase | str | float) -> float: | |
| """Return numeric logarithm base.""" | |
| normalized = _normalize_base(value) | |
| if normalized is EntropyLogBase.NATURAL: | |
| return math.e | |
| if normalized is EntropyLogBase.BASE_2: | |
| return 2.0 | |
| if normalized is EntropyLogBase.BASE_10: | |
| return 10.0 | |
| return float(normalized) | |
| def _base_label(value: EntropyLogBase | str | float) -> str: | |
| """Return a stable label for a logarithm base.""" | |
| normalized = _normalize_base(value) | |
| if isinstance(normalized, EntropyLogBase): | |
| return normalized.value | |
| numeric = float(normalized) | |
| if numeric.is_integer(): | |
| return str(int(numeric)) | |
| return str(numeric) | |
| def _log(value: float, log_base: float) -> float: | |
| """Return logarithm with a configured base.""" | |
| if value <= 0.0: | |
| return 0.0 | |
| if math.isclose(log_base, math.e): | |
| return math.log(value) | |
| return math.log(value) / math.log(log_base) | |
| def _shannon_entropy_array(probabilities: np.ndarray, *, log_base: float) -> float: | |
| """Compute Shannon entropy from a cleaned probability array.""" | |
| positive = probabilities[probabilities > 0.0] | |
| if positive.size == 0: | |
| return 0.0 | |
| logs = np.log(positive) | |
| if not math.isclose(log_base, math.e): | |
| logs = logs / math.log(log_base) | |
| return float(-np.sum(positive * logs)) | |
| def _world_step(world: World) -> int | None: | |
| """Return the current world step if available.""" | |
| value = getattr(world, "step_count", None) | |
| if isinstance(value, Real) and not isinstance(value, bool): | |
| return int(value) | |
| return None | |
| def _is_number(value: Any) -> bool: | |
| """Return whether a value is a real numeric scalar, excluding booleans.""" | |
| return isinstance(value, (Real, np.integer, np.floating)) and not isinstance(value, bool) | |
| def _is_alive(item: Any) -> bool: | |
| """Return whether an item is alive when it exposes an ``alive`` field.""" | |
| return bool(getattr(item, "alive", True)) | |
| def _is_agent_like(item: Any) -> bool: | |
| """Return whether an item looks like a runtime agent.""" | |
| return hasattr(item, "alive") or hasattr(item, "behaviors") or hasattr(item, "policy") | |
| def _item_collection_name(item: Any) -> str: | |
| """Return a best-effort collection name for an item.""" | |
| if _is_agent_like(item): | |
| return EntropyCollection.AGENTS.value | |
| if hasattr(item, "amount"): | |
| return EntropyCollection.RESOURCES.value | |
| return "items" | |
| def _iter_world_items(world: World, collection: EntropyCollection) -> tuple[Any, ...]: | |
| """Return world items for a configured entropy collection.""" | |
| if collection is EntropyCollection.AGENTS: | |
| return _iter_collection(getattr(world, "agents", ())) | |
| if collection is EntropyCollection.RESOURCES: | |
| return _iter_collection(getattr(world, "resources", ())) | |
| agents = _iter_collection(getattr(world, "agents", ())) | |
| resources = _iter_collection(getattr(world, "resources", ())) | |
| return agents + resources | |
| def _iter_collection(raw_collection: Any) -> tuple[Any, ...]: | |
| """Return items from a mapping-backed or sequence-backed collection.""" | |
| if raw_collection is None: | |
| return () | |
| if isinstance(raw_collection, Mapping): | |
| values = raw_collection.values() | |
| elif isinstance(raw_collection, Iterable) and not isinstance(raw_collection, (str, bytes)): | |
| values = raw_collection | |
| else: | |
| values = (raw_collection,) | |
| return tuple(item for item in values if item is not None) | |
| def _split_path(path: str) -> tuple[str, ...]: | |
| """Split a dot-separated path into components.""" | |
| return tuple(part for part in str(path).split(".") if part) | |
| def _get_mapping_path(container: Mapping[str, Any], path: str, default: Any = _MISSING) -> Any: | |
| """Read a nested mapping value using dot notation.""" | |
| parts = _split_path(path) | |
| if not parts: | |
| return default | |
| current: Any = container | |
| for part in parts: | |
| if not isinstance(current, Mapping) or part not in current: | |
| return default | |
| current = current[part] | |
| return current | |
| def _get_object_path(root: Any, path: str, default: Any = _MISSING) -> Any: | |
| """Read nested values from mappings, sequences, or object attributes.""" | |
| parts = _split_path(path) | |
| if not parts: | |
| return root | |
| current: Any = root | |
| for part in parts: | |
| if isinstance(current, Mapping): | |
| if part not in current: | |
| return default | |
| current = current[part] | |
| continue | |
| if isinstance(current, Sequence) and not isinstance(current, (str, bytes)) and part.isdigit(): | |
| index = int(part) | |
| if index >= len(current): | |
| return default | |
| current = current[index] | |
| continue | |
| if not hasattr(current, part): | |
| return default | |
| current = getattr(current, part) | |
| return current | |
| def _read_path(item: Any, path: str, default: Any = _MISSING) -> Any: | |
| """Read a path from an item. | |
| Supported special prefixes: | |
| - ``state.foo`` | |
| - ``state:foo`` | |
| - ``memory.foo`` | |
| - ``memory:foo`` | |
| - ``metadata.foo`` | |
| - ``metadata:foo`` | |
| Without a prefix, object attributes and mapping keys are read directly. | |
| """ | |
| normalized_path = str(path) | |
| if normalized_path.startswith("state."): | |
| state = getattr(item, "state", {}) | |
| return _get_mapping_path( | |
| state if isinstance(state, Mapping) else {}, | |
| normalized_path.removeprefix("state."), | |
| default, | |
| ) | |
| if normalized_path.startswith("state:"): | |
| state = getattr(item, "state", {}) | |
| return _get_mapping_path( | |
| state if isinstance(state, Mapping) else {}, | |
| normalized_path.removeprefix("state:"), | |
| default, | |
| ) | |
| if normalized_path.startswith("memory."): | |
| memory = getattr(item, "memory", {}) | |
| return _get_mapping_path( | |
| memory if isinstance(memory, Mapping) else {}, | |
| normalized_path.removeprefix("memory."), | |
| default, | |
| ) | |
| if normalized_path.startswith("memory:"): | |
| memory = getattr(item, "memory", {}) | |
| return _get_mapping_path( | |
| memory if isinstance(memory, Mapping) else {}, | |
| normalized_path.removeprefix("memory:"), | |
| default, | |
| ) | |
| if normalized_path.startswith("metadata."): | |
| metadata = getattr(item, "metadata", {}) | |
| return _get_mapping_path( | |
| metadata if isinstance(metadata, Mapping) else {}, | |
| normalized_path.removeprefix("metadata."), | |
| default, | |
| ) | |
| if normalized_path.startswith("metadata:"): | |
| metadata = getattr(item, "metadata", {}) | |
| return _get_mapping_path( | |
| metadata if isinstance(metadata, Mapping) else {}, | |
| normalized_path.removeprefix("metadata:"), | |
| default, | |
| ) | |
| return _get_object_path(item, normalized_path, default) | |
| def _stable_label(value: Any) -> str: | |
| """Return a stable string label for an outcome value.""" | |
| if value is None: | |
| return "none" | |
| if isinstance(value, bool): | |
| return "true" if value else "false" | |
| if _is_number(value): | |
| numeric_value = float(value) | |
| if numeric_value.is_integer(): | |
| return str(int(numeric_value)) | |
| return str(numeric_value) | |
| if isinstance(value, Mapping): | |
| parts = [f"{key}={_stable_label(value[key])}" for key in sorted(value.keys(), key=str)] | |
| return "{" + ",".join(parts) + "}" | |
| if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): | |
| return "[" + ",".join(_stable_label(item) for item in value) + "]" | |
| return str(value) | |
| def _normalize_label(value: Any, *, case_sensitive: bool, strip: bool) -> str: | |
| """Normalize an outcome label for deterministic comparisons.""" | |
| label = _stable_label(value) | |
| if strip: | |
| label = label.strip() | |
| if not case_sensitive: | |
| label = label.lower() | |
| return label | |
| def _append_bounded(items: MutableSequence[Any], value: Any, max_items: int) -> None: | |
| """Append an item while enforcing an optional maximum history length.""" | |
| items.append(value) | |
| if max_items > 0 and len(items) > max_items: | |
| del items[: len(items) - max_items] | |
| METRIC_REGISTRY: Mapping[str, type[EntropyMetric]] = MappingProxyType( | |
| { | |
| EntropyMetric.name: EntropyMetric, | |
| } | |
| ) | |
| __all__ = [ | |
| "EntropyCollection", | |
| "EntropyLogBase", | |
| "EntropyMetric", | |
| "EntropyResult", | |
| "EntropyTracker", | |
| "METRIC_REGISTRY", | |
| "compute_agent_type_entropy", | |
| "compute_entropy", | |
| "compute_resource_type_entropy", | |
| "effective_number", | |
| "entropy_from_counts", | |
| "entropy_from_probabilities", | |
| "normalized_entropy", | |
| "shannon_entropy", | |
| ] |