cogames-agents 0.0.0.7__cp312-cp312-macosx_11_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cogames_agents/__init__.py +0 -0
- cogames_agents/evals/__init__.py +5 -0
- cogames_agents/evals/planky_evals.py +415 -0
- cogames_agents/policy/__init__.py +0 -0
- cogames_agents/policy/evolution/__init__.py +0 -0
- cogames_agents/policy/evolution/cogsguard/__init__.py +0 -0
- cogames_agents/policy/evolution/cogsguard/evolution.py +695 -0
- cogames_agents/policy/evolution/cogsguard/evolutionary_coordinator.py +540 -0
- cogames_agents/policy/nim_agents/__init__.py +20 -0
- cogames_agents/policy/nim_agents/agents.py +98 -0
- cogames_agents/policy/nim_agents/bindings/generated/libnim_agents.dylib +0 -0
- cogames_agents/policy/nim_agents/bindings/generated/nim_agents.py +215 -0
- cogames_agents/policy/nim_agents/cogsguard_agents.nim +555 -0
- cogames_agents/policy/nim_agents/cogsguard_align_all_agents.nim +569 -0
- cogames_agents/policy/nim_agents/common.nim +1054 -0
- cogames_agents/policy/nim_agents/install.sh +1 -0
- cogames_agents/policy/nim_agents/ladybug_agent.nim +954 -0
- cogames_agents/policy/nim_agents/nim_agents.nim +68 -0
- cogames_agents/policy/nim_agents/nim_agents.nims +14 -0
- cogames_agents/policy/nim_agents/nimby.lock +3 -0
- cogames_agents/policy/nim_agents/racecar_agents.nim +844 -0
- cogames_agents/policy/nim_agents/random_agents.nim +68 -0
- cogames_agents/policy/nim_agents/test_agents.py +53 -0
- cogames_agents/policy/nim_agents/thinky_agents.nim +677 -0
- cogames_agents/policy/nim_agents/thinky_eval.py +230 -0
- cogames_agents/policy/scripted_agent/README.md +360 -0
- cogames_agents/policy/scripted_agent/__init__.py +0 -0
- cogames_agents/policy/scripted_agent/baseline_agent.py +1031 -0
- cogames_agents/policy/scripted_agent/cogas/__init__.py +5 -0
- cogames_agents/policy/scripted_agent/cogas/context.py +68 -0
- cogames_agents/policy/scripted_agent/cogas/entity_map.py +152 -0
- cogames_agents/policy/scripted_agent/cogas/goal.py +115 -0
- cogames_agents/policy/scripted_agent/cogas/goals/__init__.py +27 -0
- cogames_agents/policy/scripted_agent/cogas/goals/aligner.py +160 -0
- cogames_agents/policy/scripted_agent/cogas/goals/gear.py +197 -0
- cogames_agents/policy/scripted_agent/cogas/goals/miner.py +441 -0
- cogames_agents/policy/scripted_agent/cogas/goals/scout.py +40 -0
- cogames_agents/policy/scripted_agent/cogas/goals/scrambler.py +174 -0
- cogames_agents/policy/scripted_agent/cogas/goals/shared.py +160 -0
- cogames_agents/policy/scripted_agent/cogas/goals/stem.py +60 -0
- cogames_agents/policy/scripted_agent/cogas/goals/survive.py +100 -0
- cogames_agents/policy/scripted_agent/cogas/navigator.py +401 -0
- cogames_agents/policy/scripted_agent/cogas/obs_parser.py +238 -0
- cogames_agents/policy/scripted_agent/cogas/policy.py +525 -0
- cogames_agents/policy/scripted_agent/cogas/trace.py +69 -0
- cogames_agents/policy/scripted_agent/cogsguard/CLAUDE.md +517 -0
- cogames_agents/policy/scripted_agent/cogsguard/README.md +252 -0
- cogames_agents/policy/scripted_agent/cogsguard/__init__.py +74 -0
- cogames_agents/policy/scripted_agent/cogsguard/aligned_junction_held_investigation.md +152 -0
- cogames_agents/policy/scripted_agent/cogsguard/aligner.py +333 -0
- cogames_agents/policy/scripted_agent/cogsguard/behavior_hooks.py +44 -0
- cogames_agents/policy/scripted_agent/cogsguard/control_agent.py +323 -0
- cogames_agents/policy/scripted_agent/cogsguard/debug_agent.py +533 -0
- cogames_agents/policy/scripted_agent/cogsguard/miner.py +589 -0
- cogames_agents/policy/scripted_agent/cogsguard/options.py +67 -0
- cogames_agents/policy/scripted_agent/cogsguard/parity_metrics.py +36 -0
- cogames_agents/policy/scripted_agent/cogsguard/policy.py +1967 -0
- cogames_agents/policy/scripted_agent/cogsguard/prereq_trace.py +33 -0
- cogames_agents/policy/scripted_agent/cogsguard/role_trace.py +50 -0
- cogames_agents/policy/scripted_agent/cogsguard/roles.py +31 -0
- cogames_agents/policy/scripted_agent/cogsguard/rollout_trace.py +40 -0
- cogames_agents/policy/scripted_agent/cogsguard/scout.py +69 -0
- cogames_agents/policy/scripted_agent/cogsguard/scrambler.py +350 -0
- cogames_agents/policy/scripted_agent/cogsguard/targeted_agent.py +418 -0
- cogames_agents/policy/scripted_agent/cogsguard/teacher.py +224 -0
- cogames_agents/policy/scripted_agent/cogsguard/types.py +381 -0
- cogames_agents/policy/scripted_agent/cogsguard/v2_agent.py +49 -0
- cogames_agents/policy/scripted_agent/common/__init__.py +0 -0
- cogames_agents/policy/scripted_agent/common/geometry.py +24 -0
- cogames_agents/policy/scripted_agent/common/roles.py +34 -0
- cogames_agents/policy/scripted_agent/common/tag_utils.py +48 -0
- cogames_agents/policy/scripted_agent/demo_policy.py +242 -0
- cogames_agents/policy/scripted_agent/pathfinding.py +126 -0
- cogames_agents/policy/scripted_agent/pinky/DESIGN.md +317 -0
- cogames_agents/policy/scripted_agent/pinky/__init__.py +5 -0
- cogames_agents/policy/scripted_agent/pinky/behaviors/__init__.py +17 -0
- cogames_agents/policy/scripted_agent/pinky/behaviors/aligner.py +400 -0
- cogames_agents/policy/scripted_agent/pinky/behaviors/base.py +119 -0
- cogames_agents/policy/scripted_agent/pinky/behaviors/miner.py +632 -0
- cogames_agents/policy/scripted_agent/pinky/behaviors/scout.py +138 -0
- cogames_agents/policy/scripted_agent/pinky/behaviors/scrambler.py +433 -0
- cogames_agents/policy/scripted_agent/pinky/policy.py +570 -0
- cogames_agents/policy/scripted_agent/pinky/services/__init__.py +7 -0
- cogames_agents/policy/scripted_agent/pinky/services/map_tracker.py +808 -0
- cogames_agents/policy/scripted_agent/pinky/services/navigator.py +864 -0
- cogames_agents/policy/scripted_agent/pinky/services/safety.py +189 -0
- cogames_agents/policy/scripted_agent/pinky/state.py +299 -0
- cogames_agents/policy/scripted_agent/pinky/types.py +138 -0
- cogames_agents/policy/scripted_agent/planky/CLAUDE.md +124 -0
- cogames_agents/policy/scripted_agent/planky/IMPROVEMENTS.md +160 -0
- cogames_agents/policy/scripted_agent/planky/NOTES.md +153 -0
- cogames_agents/policy/scripted_agent/planky/PLAN.md +254 -0
- cogames_agents/policy/scripted_agent/planky/README.md +214 -0
- cogames_agents/policy/scripted_agent/planky/STRATEGY.md +100 -0
- cogames_agents/policy/scripted_agent/planky/__init__.py +5 -0
- cogames_agents/policy/scripted_agent/planky/context.py +68 -0
- cogames_agents/policy/scripted_agent/planky/entity_map.py +152 -0
- cogames_agents/policy/scripted_agent/planky/goal.py +107 -0
- cogames_agents/policy/scripted_agent/planky/goals/__init__.py +27 -0
- cogames_agents/policy/scripted_agent/planky/goals/aligner.py +168 -0
- cogames_agents/policy/scripted_agent/planky/goals/gear.py +179 -0
- cogames_agents/policy/scripted_agent/planky/goals/miner.py +416 -0
- cogames_agents/policy/scripted_agent/planky/goals/scout.py +40 -0
- cogames_agents/policy/scripted_agent/planky/goals/scrambler.py +174 -0
- cogames_agents/policy/scripted_agent/planky/goals/shared.py +160 -0
- cogames_agents/policy/scripted_agent/planky/goals/stem.py +49 -0
- cogames_agents/policy/scripted_agent/planky/goals/survive.py +96 -0
- cogames_agents/policy/scripted_agent/planky/navigator.py +388 -0
- cogames_agents/policy/scripted_agent/planky/obs_parser.py +238 -0
- cogames_agents/policy/scripted_agent/planky/policy.py +485 -0
- cogames_agents/policy/scripted_agent/planky/tests/__init__.py +0 -0
- cogames_agents/policy/scripted_agent/planky/tests/conftest.py +66 -0
- cogames_agents/policy/scripted_agent/planky/tests/helpers.py +152 -0
- cogames_agents/policy/scripted_agent/planky/tests/test_aligner.py +24 -0
- cogames_agents/policy/scripted_agent/planky/tests/test_miner.py +30 -0
- cogames_agents/policy/scripted_agent/planky/tests/test_scout.py +15 -0
- cogames_agents/policy/scripted_agent/planky/tests/test_scrambler.py +29 -0
- cogames_agents/policy/scripted_agent/planky/tests/test_stem.py +36 -0
- cogames_agents/policy/scripted_agent/planky/trace.py +69 -0
- cogames_agents/policy/scripted_agent/types.py +239 -0
- cogames_agents/policy/scripted_agent/unclipping_agent.py +461 -0
- cogames_agents/policy/scripted_agent/utils.py +381 -0
- cogames_agents/policy/scripted_registry.py +80 -0
- cogames_agents/py.typed +0 -0
- cogames_agents-0.0.0.7.dist-info/METADATA +98 -0
- cogames_agents-0.0.0.7.dist-info/RECORD +128 -0
- cogames_agents-0.0.0.7.dist-info/WHEEL +6 -0
- cogames_agents-0.0.0.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Evolutionary role system for CogsGuard agents.
|
|
3
|
+
|
|
4
|
+
This module implements evolutionary role recombination based on tribal-village's
|
|
5
|
+
evolution.nim. It provides mechanisms for:
|
|
6
|
+
- Sampling new roles with random behavior tiers
|
|
7
|
+
- Crossover (recombination) of successful roles
|
|
8
|
+
- Point mutations of roles
|
|
9
|
+
- Fitness tracking using exponential moving average
|
|
10
|
+
- Fitness-weighted selection for reproduction
|
|
11
|
+
|
|
12
|
+
The system allows roles to evolve over time based on game performance,
|
|
13
|
+
creating new role variations that can be tested and refined.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import random
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
22
|
+
|
|
23
|
+
from mettagrid.simulator import Action
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from cogames_agents.policy.scripted_agent.cogsguard.types import CogsguardAgentState
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BehaviorSource(Enum):
|
|
30
|
+
"""Source category for behaviors."""
|
|
31
|
+
|
|
32
|
+
MINER = "miner"
|
|
33
|
+
SCOUT = "scout"
|
|
34
|
+
ALIGNER = "aligner"
|
|
35
|
+
SCRAMBLER = "scrambler"
|
|
36
|
+
COMMON = "common" # Shared behaviors like explore, recharge
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TierSelection(Enum):
|
|
40
|
+
"""How to select behavior order within a tier."""
|
|
41
|
+
|
|
42
|
+
FIXED = "fixed" # Keep behavior order as provided
|
|
43
|
+
SHUFFLE = "shuffle" # Shuffle behavior order per materialization
|
|
44
|
+
WEIGHTED = "weighted" # Weighted shuffle using tier weights
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class EvolutionConfig:
|
|
49
|
+
"""Configuration for evolutionary role sampling and mutation.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
min_tiers: Minimum number of tiers per role (default: 2)
|
|
53
|
+
max_tiers: Maximum number of tiers per role (default: 4)
|
|
54
|
+
min_tier_size: Minimum behaviors per tier (default: 1)
|
|
55
|
+
max_tier_size: Maximum behaviors per tier (default: 3)
|
|
56
|
+
mutation_rate: Probability of mutating each tier (default: 0.15)
|
|
57
|
+
lock_fitness_threshold: Fitness level at which to lock successful roles (default: 0.7)
|
|
58
|
+
max_behaviors_per_role: Maximum total behaviors across all tiers (default: 12)
|
|
59
|
+
fitness_alpha: EMA alpha for fitness updates (default: 0.2)
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
min_tiers: int = 2
|
|
63
|
+
max_tiers: int = 4
|
|
64
|
+
min_tier_size: int = 1
|
|
65
|
+
max_tier_size: int = 3
|
|
66
|
+
mutation_rate: float = 0.15
|
|
67
|
+
lock_fitness_threshold: float = 0.7
|
|
68
|
+
max_behaviors_per_role: int = 12
|
|
69
|
+
fitness_alpha: float = 0.2
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
BehaviorFunc = Callable[[CogsguardAgentState], Action]
|
|
74
|
+
else:
|
|
75
|
+
BehaviorFunc = Callable[[Any], Action]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class BehaviorDef:
|
|
80
|
+
"""Definition of a single behavior in the role system.
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
id: Unique identifier for this behavior
|
|
84
|
+
name: Human-readable name (e.g., "mine_resource", "explore")
|
|
85
|
+
source: Category this behavior belongs to
|
|
86
|
+
can_start: Predicate to check if behavior can start
|
|
87
|
+
act: The action function to execute
|
|
88
|
+
should_terminate: Predicate to check if behavior should end
|
|
89
|
+
interruptible: Whether higher-priority behaviors can interrupt this one
|
|
90
|
+
fitness: Tracked fitness score (0.0 to 1.0)
|
|
91
|
+
games: Number of games this behavior has been used in
|
|
92
|
+
uses: Total number of times this behavior has been executed
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
id: int
|
|
96
|
+
name: str
|
|
97
|
+
source: BehaviorSource
|
|
98
|
+
can_start: Callable[[CogsguardAgentState], bool]
|
|
99
|
+
act: BehaviorFunc
|
|
100
|
+
should_terminate: Callable[[CogsguardAgentState], bool]
|
|
101
|
+
interruptible: bool = True
|
|
102
|
+
fitness: float = 0.0
|
|
103
|
+
games: int = 0
|
|
104
|
+
uses: int = 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class RoleTier:
|
|
109
|
+
"""A priority tier within a role definition.
|
|
110
|
+
|
|
111
|
+
Behaviors within a tier are evaluated in order (or shuffled/weighted).
|
|
112
|
+
Higher tiers (earlier in the list) have higher priority.
|
|
113
|
+
|
|
114
|
+
Attributes:
|
|
115
|
+
behavior_ids: List of behavior IDs in this tier
|
|
116
|
+
weights: Optional weights for weighted selection
|
|
117
|
+
selection: How to order behaviors when materializing
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
behavior_ids: list[int] = field(default_factory=list)
|
|
121
|
+
weights: list[float] = field(default_factory=list)
|
|
122
|
+
selection: TierSelection = TierSelection.FIXED
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class RoleDef:
|
|
127
|
+
"""Definition of an evolutionary role.
|
|
128
|
+
|
|
129
|
+
A role consists of multiple tiers of behaviors, where each tier
|
|
130
|
+
represents a priority level. The role also tracks its performance
|
|
131
|
+
for evolutionary selection.
|
|
132
|
+
|
|
133
|
+
Attributes:
|
|
134
|
+
id: Unique identifier for this role
|
|
135
|
+
name: Human-readable name (may be auto-generated)
|
|
136
|
+
tiers: Priority-ordered list of behavior tiers
|
|
137
|
+
origin: How this role was created ("sampled", "recombined", "manual")
|
|
138
|
+
locked_name: Whether to preserve name (for successful roles)
|
|
139
|
+
fitness: Tracked fitness score (0.0 to 1.0)
|
|
140
|
+
games: Number of games this role has participated in
|
|
141
|
+
wins: Number of wins with this role
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
id: int
|
|
145
|
+
name: str
|
|
146
|
+
tiers: list[RoleTier] = field(default_factory=list)
|
|
147
|
+
origin: str = "manual"
|
|
148
|
+
locked_name: bool = False
|
|
149
|
+
fitness: float = 0.0
|
|
150
|
+
games: int = 0
|
|
151
|
+
wins: int = 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class RoleCatalog:
|
|
156
|
+
"""Registry of behaviors and roles for evolutionary selection.
|
|
157
|
+
|
|
158
|
+
The catalog maintains all known behaviors and roles, supporting
|
|
159
|
+
operations like sampling new roles, crossover, and mutation.
|
|
160
|
+
|
|
161
|
+
Attributes:
|
|
162
|
+
behaviors: All registered behaviors
|
|
163
|
+
roles: All registered roles
|
|
164
|
+
next_role_id: Counter for generating unique role IDs
|
|
165
|
+
next_name_id: Counter for generating unique role names
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
behaviors: list[BehaviorDef] = field(default_factory=list)
|
|
169
|
+
roles: list[RoleDef] = field(default_factory=list)
|
|
170
|
+
next_role_id: int = 0
|
|
171
|
+
next_name_id: int = 0
|
|
172
|
+
|
|
173
|
+
def find_behavior_id(self, name: str) -> int:
|
|
174
|
+
"""Find behavior ID by name, returns -1 if not found."""
|
|
175
|
+
for behavior in self.behaviors:
|
|
176
|
+
if behavior.name == name:
|
|
177
|
+
return behavior.id
|
|
178
|
+
return -1
|
|
179
|
+
|
|
180
|
+
def add_behavior(
|
|
181
|
+
self,
|
|
182
|
+
name: str,
|
|
183
|
+
source: BehaviorSource,
|
|
184
|
+
can_start: Callable[[CogsguardAgentState], bool],
|
|
185
|
+
act: BehaviorFunc,
|
|
186
|
+
should_terminate: Callable[[CogsguardAgentState], bool],
|
|
187
|
+
interruptible: bool = True,
|
|
188
|
+
) -> int:
|
|
189
|
+
"""Add a behavior to the catalog, returns behavior ID."""
|
|
190
|
+
existing = self.find_behavior_id(name)
|
|
191
|
+
if existing >= 0:
|
|
192
|
+
return existing
|
|
193
|
+
|
|
194
|
+
behavior_id = len(self.behaviors)
|
|
195
|
+
self.behaviors.append(
|
|
196
|
+
BehaviorDef(
|
|
197
|
+
id=behavior_id,
|
|
198
|
+
name=name,
|
|
199
|
+
source=source,
|
|
200
|
+
can_start=can_start,
|
|
201
|
+
act=act,
|
|
202
|
+
should_terminate=should_terminate,
|
|
203
|
+
interruptible=interruptible,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
return behavior_id
|
|
207
|
+
|
|
208
|
+
def find_role_id(self, name: str) -> int:
|
|
209
|
+
"""Find role ID by name, returns -1 if not found."""
|
|
210
|
+
for role in self.roles:
|
|
211
|
+
if role.name == name:
|
|
212
|
+
return role.id
|
|
213
|
+
return -1
|
|
214
|
+
|
|
215
|
+
def register_role(self, role: RoleDef) -> int:
|
|
216
|
+
"""Register a role in the catalog, returns role ID."""
|
|
217
|
+
role_id = len(self.roles)
|
|
218
|
+
role.id = role_id
|
|
219
|
+
self.roles.append(role)
|
|
220
|
+
self.next_role_id = len(self.roles)
|
|
221
|
+
return role_id
|
|
222
|
+
|
|
223
|
+
def generate_role_name(self, tiers: list[RoleTier]) -> str:
|
|
224
|
+
"""Generate a unique role name based on primary behavior."""
|
|
225
|
+
base_name = "Role"
|
|
226
|
+
if tiers and tiers[0].behavior_ids:
|
|
227
|
+
first_id = tiers[0].behavior_ids[0]
|
|
228
|
+
if 0 <= first_id < len(self.behaviors):
|
|
229
|
+
# Use shortened behavior name
|
|
230
|
+
full_name = self.behaviors[first_id].name
|
|
231
|
+
base_name = self._short_behavior_name(full_name)
|
|
232
|
+
|
|
233
|
+
suffix = self.next_name_id
|
|
234
|
+
self.next_name_id += 1
|
|
235
|
+
return f"{base_name}-{suffix}"
|
|
236
|
+
|
|
237
|
+
def _short_behavior_name(self, name: str) -> str:
|
|
238
|
+
"""Create a shortened behavior name for role naming."""
|
|
239
|
+
# Remove common prefixes
|
|
240
|
+
for prefix in ["behavior_", "miner_", "scout_", "aligner_", "scrambler_"]:
|
|
241
|
+
if name.lower().startswith(prefix):
|
|
242
|
+
name = name[len(prefix) :]
|
|
243
|
+
break
|
|
244
|
+
# Capitalize first letter
|
|
245
|
+
return name.capitalize() if name else "Behavior"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def behavior_selection_weight(behavior: BehaviorDef) -> float:
|
|
249
|
+
"""Calculate selection weight for a behavior based on fitness.
|
|
250
|
+
|
|
251
|
+
Behaviors with no games get weight 1.0 (explore new behaviors).
|
|
252
|
+
Otherwise, weight is based on fitness with minimum 0.1.
|
|
253
|
+
"""
|
|
254
|
+
if behavior.games <= 0:
|
|
255
|
+
return 1.0
|
|
256
|
+
return max(0.1, behavior.fitness)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def role_selection_weight(role: RoleDef) -> float:
|
|
260
|
+
"""Calculate selection weight for a role based on fitness.
|
|
261
|
+
|
|
262
|
+
Roles with no games get weight 0.1 (slight exploration).
|
|
263
|
+
Otherwise, weight is based on fitness with minimum 0.1.
|
|
264
|
+
"""
|
|
265
|
+
if role.games <= 0:
|
|
266
|
+
return 0.1
|
|
267
|
+
return max(0.1, role.fitness)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def record_behavior_score(behavior: BehaviorDef, score: float, alpha: float = 0.2, weight: int = 1) -> None:
|
|
271
|
+
"""Update behavior fitness using exponential moving average.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
behavior: The behavior to update
|
|
275
|
+
score: The score to record (0.0 to 1.0)
|
|
276
|
+
alpha: EMA smoothing factor (higher = more recent weight)
|
|
277
|
+
weight: Number of times to apply this score
|
|
278
|
+
"""
|
|
279
|
+
count = max(1, weight)
|
|
280
|
+
for _ in range(count):
|
|
281
|
+
behavior.games += 1
|
|
282
|
+
if behavior.games == 1:
|
|
283
|
+
behavior.fitness = score
|
|
284
|
+
else:
|
|
285
|
+
behavior.fitness = behavior.fitness * (1 - alpha) + score * alpha
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def record_role_score(role: RoleDef, score: float, won: bool, alpha: float = 0.2, weight: int = 1) -> None:
|
|
289
|
+
"""Update role fitness using exponential moving average.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
role: The role to update
|
|
293
|
+
score: The score to record (0.0 to 1.0)
|
|
294
|
+
won: Whether the game was won
|
|
295
|
+
alpha: EMA smoothing factor (higher = more recent weight)
|
|
296
|
+
weight: Number of times to apply this score
|
|
297
|
+
"""
|
|
298
|
+
count = max(1, weight)
|
|
299
|
+
for _ in range(count):
|
|
300
|
+
role.games += 1
|
|
301
|
+
if won:
|
|
302
|
+
role.wins += 1
|
|
303
|
+
if role.games == 1:
|
|
304
|
+
role.fitness = score
|
|
305
|
+
else:
|
|
306
|
+
role.fitness = role.fitness * (1 - alpha) + score * alpha
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def lock_role_name_if_fit(role: RoleDef, threshold: float = 0.7) -> None:
|
|
310
|
+
"""Lock a role's name if it has achieved sufficient fitness."""
|
|
311
|
+
if role.fitness >= threshold:
|
|
312
|
+
role.locked_name = True
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _weighted_pick_index(weights: list[float], rng: Optional[random.Random] = None) -> int:
|
|
316
|
+
"""Pick an index weighted by the given weights.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
weights: List of weights (higher = more likely)
|
|
320
|
+
rng: Random number generator (uses module random if None)
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Selected index
|
|
324
|
+
"""
|
|
325
|
+
if not weights:
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
total = sum(max(0, w) for w in weights)
|
|
329
|
+
if total <= 0:
|
|
330
|
+
# All weights zero or negative, pick uniformly
|
|
331
|
+
if rng:
|
|
332
|
+
return rng.randint(0, len(weights) - 1)
|
|
333
|
+
return random.randint(0, len(weights) - 1)
|
|
334
|
+
|
|
335
|
+
if rng:
|
|
336
|
+
roll = rng.random() * total
|
|
337
|
+
else:
|
|
338
|
+
roll = random.random() * total
|
|
339
|
+
|
|
340
|
+
acc = 0.0
|
|
341
|
+
for i, w in enumerate(weights):
|
|
342
|
+
if w <= 0:
|
|
343
|
+
continue
|
|
344
|
+
acc += w
|
|
345
|
+
if roll <= acc:
|
|
346
|
+
return i
|
|
347
|
+
|
|
348
|
+
return len(weights) - 1
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _sample_unique_ids_weighted(
|
|
352
|
+
catalog: RoleCatalog,
|
|
353
|
+
count: int,
|
|
354
|
+
used: set[int],
|
|
355
|
+
rng: Optional[random.Random] = None,
|
|
356
|
+
) -> list[int]:
|
|
357
|
+
"""Sample unique behavior IDs weighted by fitness.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
catalog: The role catalog to sample from
|
|
361
|
+
count: Number of IDs to sample
|
|
362
|
+
used: Set of already-used IDs to exclude
|
|
363
|
+
rng: Random number generator
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
List of sampled behavior IDs
|
|
367
|
+
"""
|
|
368
|
+
if not catalog.behaviors or count <= 0:
|
|
369
|
+
return []
|
|
370
|
+
|
|
371
|
+
# Build candidates and weights
|
|
372
|
+
candidates: list[int] = []
|
|
373
|
+
weights: list[float] = []
|
|
374
|
+
for behavior in catalog.behaviors:
|
|
375
|
+
if behavior.id in used:
|
|
376
|
+
continue
|
|
377
|
+
candidates.append(behavior.id)
|
|
378
|
+
weights.append(behavior_selection_weight(behavior))
|
|
379
|
+
|
|
380
|
+
result: list[int] = []
|
|
381
|
+
while len(result) < count and candidates:
|
|
382
|
+
idx = _weighted_pick_index(weights, rng)
|
|
383
|
+
result.append(candidates[idx])
|
|
384
|
+
used.add(candidates[idx])
|
|
385
|
+
candidates.pop(idx)
|
|
386
|
+
weights.pop(idx)
|
|
387
|
+
|
|
388
|
+
# Fallback: if no result but behaviors exist, pick any unused one
|
|
389
|
+
if not result and catalog.behaviors:
|
|
390
|
+
for behavior in catalog.behaviors:
|
|
391
|
+
if behavior.id not in used:
|
|
392
|
+
result.append(behavior.id)
|
|
393
|
+
used.add(behavior.id)
|
|
394
|
+
break
|
|
395
|
+
# Last resort: pick any behavior
|
|
396
|
+
if not result:
|
|
397
|
+
max_idx = len(catalog.behaviors) - 1
|
|
398
|
+
fallback_id = rng.randint(0, max_idx) if rng else random.randint(0, max_idx)
|
|
399
|
+
result.append(fallback_id)
|
|
400
|
+
|
|
401
|
+
return result
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def sample_role(
|
|
405
|
+
catalog: RoleCatalog,
|
|
406
|
+
config: Optional[EvolutionConfig] = None,
|
|
407
|
+
rng: Optional[random.Random] = None,
|
|
408
|
+
) -> RoleDef:
|
|
409
|
+
"""Create a new role by randomly sampling behaviors into tiers.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
catalog: The catalog to sample behaviors from
|
|
413
|
+
config: Evolution configuration (uses defaults if None)
|
|
414
|
+
rng: Random number generator
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
A new randomly-sampled RoleDef
|
|
418
|
+
"""
|
|
419
|
+
if config is None:
|
|
420
|
+
config = EvolutionConfig()
|
|
421
|
+
|
|
422
|
+
if not catalog.behaviors:
|
|
423
|
+
return RoleDef(id=-1, name="EmptyRole", origin="sampled")
|
|
424
|
+
|
|
425
|
+
if config.max_behaviors_per_role <= 0:
|
|
426
|
+
return RoleDef(id=-1, name="EmptyRole", origin="sampled")
|
|
427
|
+
|
|
428
|
+
# Sample number of tiers
|
|
429
|
+
if rng:
|
|
430
|
+
tier_count = rng.randint(config.min_tiers, config.max_tiers)
|
|
431
|
+
else:
|
|
432
|
+
tier_count = random.randint(config.min_tiers, config.max_tiers)
|
|
433
|
+
|
|
434
|
+
tiers: list[RoleTier] = []
|
|
435
|
+
used: set[int] = set()
|
|
436
|
+
remaining = config.max_behaviors_per_role
|
|
437
|
+
|
|
438
|
+
for _ in range(tier_count):
|
|
439
|
+
if remaining <= 0:
|
|
440
|
+
break
|
|
441
|
+
|
|
442
|
+
# Sample tier size
|
|
443
|
+
if rng:
|
|
444
|
+
max_size = min(config.max_tier_size, remaining)
|
|
445
|
+
min_size = min(config.min_tier_size, max_size)
|
|
446
|
+
if min_size <= 0:
|
|
447
|
+
break
|
|
448
|
+
tier_size = rng.randint(min_size, max_size)
|
|
449
|
+
else:
|
|
450
|
+
max_size = min(config.max_tier_size, remaining)
|
|
451
|
+
min_size = min(config.min_tier_size, max_size)
|
|
452
|
+
if min_size <= 0:
|
|
453
|
+
break
|
|
454
|
+
tier_size = random.randint(min_size, max_size)
|
|
455
|
+
|
|
456
|
+
behavior_ids = _sample_unique_ids_weighted(catalog, tier_size, used, rng)
|
|
457
|
+
remaining -= len(behavior_ids)
|
|
458
|
+
|
|
459
|
+
# Random selection mode
|
|
460
|
+
if rng:
|
|
461
|
+
selection = TierSelection.SHUFFLE if rng.random() < 0.5 else TierSelection.FIXED
|
|
462
|
+
else:
|
|
463
|
+
selection = TierSelection.SHUFFLE if random.random() < 0.5 else TierSelection.FIXED
|
|
464
|
+
|
|
465
|
+
tiers.append(RoleTier(behavior_ids=behavior_ids, selection=selection))
|
|
466
|
+
|
|
467
|
+
name = catalog.generate_role_name(tiers)
|
|
468
|
+
return RoleDef(id=-1, name=name, tiers=tiers, origin="sampled")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def recombine_roles(
|
|
472
|
+
catalog: RoleCatalog,
|
|
473
|
+
left: RoleDef,
|
|
474
|
+
right: RoleDef,
|
|
475
|
+
rng: Optional[random.Random] = None,
|
|
476
|
+
) -> RoleDef:
|
|
477
|
+
"""Create a new role by crossover of two parent roles.
|
|
478
|
+
|
|
479
|
+
The crossover picks random cut points in each parent and combines
|
|
480
|
+
the left parent's early tiers with the right parent's later tiers.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
catalog: The role catalog (for name generation)
|
|
484
|
+
left: First parent role
|
|
485
|
+
right: Second parent role
|
|
486
|
+
rng: Random number generator
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
A new RoleDef combining aspects of both parents
|
|
490
|
+
"""
|
|
491
|
+
if not left.tiers and not right.tiers:
|
|
492
|
+
return RoleDef(id=-1, name="EmptyRole", origin="recombined")
|
|
493
|
+
if not left.tiers:
|
|
494
|
+
return RoleDef(
|
|
495
|
+
id=-1,
|
|
496
|
+
name=catalog.generate_role_name(right.tiers),
|
|
497
|
+
tiers=right.tiers.copy(),
|
|
498
|
+
origin="recombined",
|
|
499
|
+
)
|
|
500
|
+
if not right.tiers:
|
|
501
|
+
return RoleDef(
|
|
502
|
+
id=-1,
|
|
503
|
+
name=catalog.generate_role_name(left.tiers),
|
|
504
|
+
tiers=left.tiers.copy(),
|
|
505
|
+
origin="recombined",
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# Pick cut points
|
|
509
|
+
if rng:
|
|
510
|
+
cut_left = rng.randint(0, len(left.tiers))
|
|
511
|
+
cut_right = rng.randint(0, len(right.tiers))
|
|
512
|
+
else:
|
|
513
|
+
cut_left = random.randint(0, len(left.tiers))
|
|
514
|
+
cut_right = random.randint(0, len(right.tiers))
|
|
515
|
+
|
|
516
|
+
# Combine: left's tiers before cut_left + right's tiers from cut_right
|
|
517
|
+
tiers: list[RoleTier] = []
|
|
518
|
+
if cut_left > 0:
|
|
519
|
+
tiers.extend(left.tiers[:cut_left])
|
|
520
|
+
if cut_right < len(right.tiers):
|
|
521
|
+
tiers.extend(right.tiers[cut_right:])
|
|
522
|
+
|
|
523
|
+
# Ensure at least one tier
|
|
524
|
+
if not tiers:
|
|
525
|
+
tiers.append(left.tiers[0])
|
|
526
|
+
|
|
527
|
+
name = catalog.generate_role_name(tiers)
|
|
528
|
+
return RoleDef(id=-1, name=name, tiers=tiers, origin="recombined")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def mutate_role(
|
|
532
|
+
catalog: RoleCatalog,
|
|
533
|
+
role: RoleDef,
|
|
534
|
+
mutation_rate: float = 0.15,
|
|
535
|
+
rng: Optional[random.Random] = None,
|
|
536
|
+
) -> RoleDef:
|
|
537
|
+
"""Apply point mutations to a role.
|
|
538
|
+
|
|
539
|
+
Mutations include:
|
|
540
|
+
- Replacing random behaviors (at mutation_rate probability per tier)
|
|
541
|
+
- Flipping tier selection mode (at mutation_rate * 0.5 probability)
|
|
542
|
+
|
|
543
|
+
Args:
|
|
544
|
+
catalog: The role catalog (for behavior lookup)
|
|
545
|
+
role: The role to mutate (not modified in place)
|
|
546
|
+
mutation_rate: Base probability of mutation per tier
|
|
547
|
+
rng: Random number generator
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
A new RoleDef with mutations applied
|
|
551
|
+
"""
|
|
552
|
+
if not catalog.behaviors:
|
|
553
|
+
return role
|
|
554
|
+
|
|
555
|
+
# Deep copy tiers
|
|
556
|
+
new_tiers: list[RoleTier] = []
|
|
557
|
+
for tier in role.tiers:
|
|
558
|
+
new_tier = RoleTier(
|
|
559
|
+
behavior_ids=tier.behavior_ids.copy(),
|
|
560
|
+
weights=tier.weights.copy(),
|
|
561
|
+
selection=tier.selection,
|
|
562
|
+
)
|
|
563
|
+
new_tiers.append(new_tier)
|
|
564
|
+
|
|
565
|
+
for tier in new_tiers:
|
|
566
|
+
if not tier.behavior_ids:
|
|
567
|
+
continue
|
|
568
|
+
|
|
569
|
+
# Chance to mutate a behavior
|
|
570
|
+
roll = rng.random() if rng else random.random()
|
|
571
|
+
if roll < mutation_rate:
|
|
572
|
+
tier_max = len(tier.behavior_ids) - 1
|
|
573
|
+
idx = rng.randint(0, tier_max) if rng else random.randint(0, tier_max)
|
|
574
|
+
behav_max = len(catalog.behaviors) - 1
|
|
575
|
+
replacement = rng.randint(0, behav_max) if rng else random.randint(0, behav_max)
|
|
576
|
+
tier.behavior_ids[idx] = replacement
|
|
577
|
+
|
|
578
|
+
# Chance to flip selection mode
|
|
579
|
+
roll = rng.random() if rng else random.random()
|
|
580
|
+
if roll < mutation_rate * 0.5:
|
|
581
|
+
if tier.selection == TierSelection.FIXED:
|
|
582
|
+
tier.selection = TierSelection.SHUFFLE
|
|
583
|
+
else:
|
|
584
|
+
tier.selection = TierSelection.FIXED
|
|
585
|
+
|
|
586
|
+
return RoleDef(
|
|
587
|
+
id=-1,
|
|
588
|
+
name=role.name, # Keep name (unless renamed later)
|
|
589
|
+
tiers=new_tiers,
|
|
590
|
+
origin="mutated",
|
|
591
|
+
locked_name=role.locked_name,
|
|
592
|
+
fitness=role.fitness,
|
|
593
|
+
games=role.games,
|
|
594
|
+
wins=role.wins,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def pick_role_id_weighted(
|
|
599
|
+
catalog: RoleCatalog,
|
|
600
|
+
role_ids: list[int],
|
|
601
|
+
rng: Optional[random.Random] = None,
|
|
602
|
+
) -> int:
|
|
603
|
+
"""Select a role ID from a list, weighted by fitness.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
catalog: The role catalog
|
|
607
|
+
role_ids: List of role IDs to choose from
|
|
608
|
+
rng: Random number generator
|
|
609
|
+
|
|
610
|
+
Returns:
|
|
611
|
+
Selected role ID, or -1 if role_ids is empty
|
|
612
|
+
"""
|
|
613
|
+
if not role_ids:
|
|
614
|
+
return -1
|
|
615
|
+
|
|
616
|
+
weights: list[float] = []
|
|
617
|
+
for role_id in role_ids:
|
|
618
|
+
if 0 <= role_id < len(catalog.roles):
|
|
619
|
+
weights.append(role_selection_weight(catalog.roles[role_id]))
|
|
620
|
+
else:
|
|
621
|
+
weights.append(0.0)
|
|
622
|
+
|
|
623
|
+
idx = _weighted_pick_index(weights, rng)
|
|
624
|
+
return role_ids[idx]
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def resolve_tier_order(tier: RoleTier, rng: Optional[random.Random] = None) -> list[int]:
|
|
628
|
+
"""Resolve the behavior order for a tier based on its selection mode.
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
tier: The tier to resolve
|
|
632
|
+
rng: Random number generator
|
|
633
|
+
|
|
634
|
+
Returns:
|
|
635
|
+
List of behavior IDs in execution order
|
|
636
|
+
"""
|
|
637
|
+
if not tier.behavior_ids:
|
|
638
|
+
return []
|
|
639
|
+
|
|
640
|
+
if tier.selection == TierSelection.FIXED:
|
|
641
|
+
return tier.behavior_ids.copy()
|
|
642
|
+
|
|
643
|
+
if tier.selection == TierSelection.SHUFFLE:
|
|
644
|
+
result = tier.behavior_ids.copy()
|
|
645
|
+
if rng:
|
|
646
|
+
rng.shuffle(result)
|
|
647
|
+
else:
|
|
648
|
+
random.shuffle(result)
|
|
649
|
+
return result
|
|
650
|
+
|
|
651
|
+
# TierSelection.WEIGHTED
|
|
652
|
+
ids = tier.behavior_ids.copy()
|
|
653
|
+
weights = tier.weights.copy() if len(tier.weights) == len(ids) else [1.0] * len(ids)
|
|
654
|
+
|
|
655
|
+
result: list[int] = []
|
|
656
|
+
while ids:
|
|
657
|
+
idx = _weighted_pick_index(weights, rng)
|
|
658
|
+
result.append(ids[idx])
|
|
659
|
+
ids.pop(idx)
|
|
660
|
+
weights.pop(idx)
|
|
661
|
+
|
|
662
|
+
return result
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def materialize_role_behaviors(
|
|
666
|
+
catalog: RoleCatalog,
|
|
667
|
+
role: RoleDef,
|
|
668
|
+
rng: Optional[random.Random] = None,
|
|
669
|
+
max_behaviors: int = 0,
|
|
670
|
+
) -> list[BehaviorDef]:
|
|
671
|
+
"""Convert a role definition to an ordered list of behaviors.
|
|
672
|
+
|
|
673
|
+
This "materializes" the role by resolving tier orders and returning
|
|
674
|
+
the actual BehaviorDef objects ready for execution.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
catalog: The role catalog
|
|
678
|
+
role: The role to materialize
|
|
679
|
+
rng: Random number generator
|
|
680
|
+
max_behaviors: Maximum behaviors to return (0 = unlimited)
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
List of BehaviorDef in priority order
|
|
684
|
+
"""
|
|
685
|
+
result: list[BehaviorDef] = []
|
|
686
|
+
|
|
687
|
+
for tier in role.tiers:
|
|
688
|
+
ordered_ids = resolve_tier_order(tier, rng)
|
|
689
|
+
for behavior_id in ordered_ids:
|
|
690
|
+
if 0 <= behavior_id < len(catalog.behaviors):
|
|
691
|
+
result.append(catalog.behaviors[behavior_id])
|
|
692
|
+
if max_behaviors > 0 and len(result) >= max_behaviors:
|
|
693
|
+
return result
|
|
694
|
+
|
|
695
|
+
return result
|