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.
Files changed (128) hide show
  1. cogames_agents/__init__.py +0 -0
  2. cogames_agents/evals/__init__.py +5 -0
  3. cogames_agents/evals/planky_evals.py +415 -0
  4. cogames_agents/policy/__init__.py +0 -0
  5. cogames_agents/policy/evolution/__init__.py +0 -0
  6. cogames_agents/policy/evolution/cogsguard/__init__.py +0 -0
  7. cogames_agents/policy/evolution/cogsguard/evolution.py +695 -0
  8. cogames_agents/policy/evolution/cogsguard/evolutionary_coordinator.py +540 -0
  9. cogames_agents/policy/nim_agents/__init__.py +20 -0
  10. cogames_agents/policy/nim_agents/agents.py +98 -0
  11. cogames_agents/policy/nim_agents/bindings/generated/libnim_agents.dylib +0 -0
  12. cogames_agents/policy/nim_agents/bindings/generated/nim_agents.py +215 -0
  13. cogames_agents/policy/nim_agents/cogsguard_agents.nim +555 -0
  14. cogames_agents/policy/nim_agents/cogsguard_align_all_agents.nim +569 -0
  15. cogames_agents/policy/nim_agents/common.nim +1054 -0
  16. cogames_agents/policy/nim_agents/install.sh +1 -0
  17. cogames_agents/policy/nim_agents/ladybug_agent.nim +954 -0
  18. cogames_agents/policy/nim_agents/nim_agents.nim +68 -0
  19. cogames_agents/policy/nim_agents/nim_agents.nims +14 -0
  20. cogames_agents/policy/nim_agents/nimby.lock +3 -0
  21. cogames_agents/policy/nim_agents/racecar_agents.nim +844 -0
  22. cogames_agents/policy/nim_agents/random_agents.nim +68 -0
  23. cogames_agents/policy/nim_agents/test_agents.py +53 -0
  24. cogames_agents/policy/nim_agents/thinky_agents.nim +677 -0
  25. cogames_agents/policy/nim_agents/thinky_eval.py +230 -0
  26. cogames_agents/policy/scripted_agent/README.md +360 -0
  27. cogames_agents/policy/scripted_agent/__init__.py +0 -0
  28. cogames_agents/policy/scripted_agent/baseline_agent.py +1031 -0
  29. cogames_agents/policy/scripted_agent/cogas/__init__.py +5 -0
  30. cogames_agents/policy/scripted_agent/cogas/context.py +68 -0
  31. cogames_agents/policy/scripted_agent/cogas/entity_map.py +152 -0
  32. cogames_agents/policy/scripted_agent/cogas/goal.py +115 -0
  33. cogames_agents/policy/scripted_agent/cogas/goals/__init__.py +27 -0
  34. cogames_agents/policy/scripted_agent/cogas/goals/aligner.py +160 -0
  35. cogames_agents/policy/scripted_agent/cogas/goals/gear.py +197 -0
  36. cogames_agents/policy/scripted_agent/cogas/goals/miner.py +441 -0
  37. cogames_agents/policy/scripted_agent/cogas/goals/scout.py +40 -0
  38. cogames_agents/policy/scripted_agent/cogas/goals/scrambler.py +174 -0
  39. cogames_agents/policy/scripted_agent/cogas/goals/shared.py +160 -0
  40. cogames_agents/policy/scripted_agent/cogas/goals/stem.py +60 -0
  41. cogames_agents/policy/scripted_agent/cogas/goals/survive.py +100 -0
  42. cogames_agents/policy/scripted_agent/cogas/navigator.py +401 -0
  43. cogames_agents/policy/scripted_agent/cogas/obs_parser.py +238 -0
  44. cogames_agents/policy/scripted_agent/cogas/policy.py +525 -0
  45. cogames_agents/policy/scripted_agent/cogas/trace.py +69 -0
  46. cogames_agents/policy/scripted_agent/cogsguard/CLAUDE.md +517 -0
  47. cogames_agents/policy/scripted_agent/cogsguard/README.md +252 -0
  48. cogames_agents/policy/scripted_agent/cogsguard/__init__.py +74 -0
  49. cogames_agents/policy/scripted_agent/cogsguard/aligned_junction_held_investigation.md +152 -0
  50. cogames_agents/policy/scripted_agent/cogsguard/aligner.py +333 -0
  51. cogames_agents/policy/scripted_agent/cogsguard/behavior_hooks.py +44 -0
  52. cogames_agents/policy/scripted_agent/cogsguard/control_agent.py +323 -0
  53. cogames_agents/policy/scripted_agent/cogsguard/debug_agent.py +533 -0
  54. cogames_agents/policy/scripted_agent/cogsguard/miner.py +589 -0
  55. cogames_agents/policy/scripted_agent/cogsguard/options.py +67 -0
  56. cogames_agents/policy/scripted_agent/cogsguard/parity_metrics.py +36 -0
  57. cogames_agents/policy/scripted_agent/cogsguard/policy.py +1967 -0
  58. cogames_agents/policy/scripted_agent/cogsguard/prereq_trace.py +33 -0
  59. cogames_agents/policy/scripted_agent/cogsguard/role_trace.py +50 -0
  60. cogames_agents/policy/scripted_agent/cogsguard/roles.py +31 -0
  61. cogames_agents/policy/scripted_agent/cogsguard/rollout_trace.py +40 -0
  62. cogames_agents/policy/scripted_agent/cogsguard/scout.py +69 -0
  63. cogames_agents/policy/scripted_agent/cogsguard/scrambler.py +350 -0
  64. cogames_agents/policy/scripted_agent/cogsguard/targeted_agent.py +418 -0
  65. cogames_agents/policy/scripted_agent/cogsguard/teacher.py +224 -0
  66. cogames_agents/policy/scripted_agent/cogsguard/types.py +381 -0
  67. cogames_agents/policy/scripted_agent/cogsguard/v2_agent.py +49 -0
  68. cogames_agents/policy/scripted_agent/common/__init__.py +0 -0
  69. cogames_agents/policy/scripted_agent/common/geometry.py +24 -0
  70. cogames_agents/policy/scripted_agent/common/roles.py +34 -0
  71. cogames_agents/policy/scripted_agent/common/tag_utils.py +48 -0
  72. cogames_agents/policy/scripted_agent/demo_policy.py +242 -0
  73. cogames_agents/policy/scripted_agent/pathfinding.py +126 -0
  74. cogames_agents/policy/scripted_agent/pinky/DESIGN.md +317 -0
  75. cogames_agents/policy/scripted_agent/pinky/__init__.py +5 -0
  76. cogames_agents/policy/scripted_agent/pinky/behaviors/__init__.py +17 -0
  77. cogames_agents/policy/scripted_agent/pinky/behaviors/aligner.py +400 -0
  78. cogames_agents/policy/scripted_agent/pinky/behaviors/base.py +119 -0
  79. cogames_agents/policy/scripted_agent/pinky/behaviors/miner.py +632 -0
  80. cogames_agents/policy/scripted_agent/pinky/behaviors/scout.py +138 -0
  81. cogames_agents/policy/scripted_agent/pinky/behaviors/scrambler.py +433 -0
  82. cogames_agents/policy/scripted_agent/pinky/policy.py +570 -0
  83. cogames_agents/policy/scripted_agent/pinky/services/__init__.py +7 -0
  84. cogames_agents/policy/scripted_agent/pinky/services/map_tracker.py +808 -0
  85. cogames_agents/policy/scripted_agent/pinky/services/navigator.py +864 -0
  86. cogames_agents/policy/scripted_agent/pinky/services/safety.py +189 -0
  87. cogames_agents/policy/scripted_agent/pinky/state.py +299 -0
  88. cogames_agents/policy/scripted_agent/pinky/types.py +138 -0
  89. cogames_agents/policy/scripted_agent/planky/CLAUDE.md +124 -0
  90. cogames_agents/policy/scripted_agent/planky/IMPROVEMENTS.md +160 -0
  91. cogames_agents/policy/scripted_agent/planky/NOTES.md +153 -0
  92. cogames_agents/policy/scripted_agent/planky/PLAN.md +254 -0
  93. cogames_agents/policy/scripted_agent/planky/README.md +214 -0
  94. cogames_agents/policy/scripted_agent/planky/STRATEGY.md +100 -0
  95. cogames_agents/policy/scripted_agent/planky/__init__.py +5 -0
  96. cogames_agents/policy/scripted_agent/planky/context.py +68 -0
  97. cogames_agents/policy/scripted_agent/planky/entity_map.py +152 -0
  98. cogames_agents/policy/scripted_agent/planky/goal.py +107 -0
  99. cogames_agents/policy/scripted_agent/planky/goals/__init__.py +27 -0
  100. cogames_agents/policy/scripted_agent/planky/goals/aligner.py +168 -0
  101. cogames_agents/policy/scripted_agent/planky/goals/gear.py +179 -0
  102. cogames_agents/policy/scripted_agent/planky/goals/miner.py +416 -0
  103. cogames_agents/policy/scripted_agent/planky/goals/scout.py +40 -0
  104. cogames_agents/policy/scripted_agent/planky/goals/scrambler.py +174 -0
  105. cogames_agents/policy/scripted_agent/planky/goals/shared.py +160 -0
  106. cogames_agents/policy/scripted_agent/planky/goals/stem.py +49 -0
  107. cogames_agents/policy/scripted_agent/planky/goals/survive.py +96 -0
  108. cogames_agents/policy/scripted_agent/planky/navigator.py +388 -0
  109. cogames_agents/policy/scripted_agent/planky/obs_parser.py +238 -0
  110. cogames_agents/policy/scripted_agent/planky/policy.py +485 -0
  111. cogames_agents/policy/scripted_agent/planky/tests/__init__.py +0 -0
  112. cogames_agents/policy/scripted_agent/planky/tests/conftest.py +66 -0
  113. cogames_agents/policy/scripted_agent/planky/tests/helpers.py +152 -0
  114. cogames_agents/policy/scripted_agent/planky/tests/test_aligner.py +24 -0
  115. cogames_agents/policy/scripted_agent/planky/tests/test_miner.py +30 -0
  116. cogames_agents/policy/scripted_agent/planky/tests/test_scout.py +15 -0
  117. cogames_agents/policy/scripted_agent/planky/tests/test_scrambler.py +29 -0
  118. cogames_agents/policy/scripted_agent/planky/tests/test_stem.py +36 -0
  119. cogames_agents/policy/scripted_agent/planky/trace.py +69 -0
  120. cogames_agents/policy/scripted_agent/types.py +239 -0
  121. cogames_agents/policy/scripted_agent/unclipping_agent.py +461 -0
  122. cogames_agents/policy/scripted_agent/utils.py +381 -0
  123. cogames_agents/policy/scripted_registry.py +80 -0
  124. cogames_agents/py.typed +0 -0
  125. cogames_agents-0.0.0.7.dist-info/METADATA +98 -0
  126. cogames_agents-0.0.0.7.dist-info/RECORD +128 -0
  127. cogames_agents-0.0.0.7.dist-info/WHEEL +6 -0
  128. 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