WorldSmithAI / metrics /entropy.py
Srishti280992's picture
Upload 39 files
caad8d0 verified
Raw
History Blame Contribute Delete
32.8 kB
"""
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"
@dataclass(frozen=True)
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)
@property
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
@property
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)),
}
@dataclass
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))
@dataclass
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",
]