fragility-engine 0.6.0__py3-none-any.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 (82) hide show
  1. fragility_engine/__init__.py +3 -0
  2. fragility_engine/adversary/__init__.py +27 -0
  3. fragility_engine/adversary/encoding.py +79 -0
  4. fragility_engine/adversary/fitness.py +31 -0
  5. fragility_engine/adversary/pareto.py +74 -0
  6. fragility_engine/adversary/search.py +299 -0
  7. fragility_engine/agents/__init__.py +3 -0
  8. fragility_engine/agents/base.py +14 -0
  9. fragility_engine/agents/stablecoin_agents.py +80 -0
  10. fragility_engine/benchmarks/__init__.py +91 -0
  11. fragility_engine/benchmarks/bundled_artifacts.py +82 -0
  12. fragility_engine/benchmarks/certificate.py +120 -0
  13. fragility_engine/benchmarks/coupled_fork.py +50 -0
  14. fragility_engine/benchmarks/ensemble.py +1105 -0
  15. fragility_engine/benchmarks/flagship.py +178 -0
  16. fragility_engine/benchmarks/hypervolume.py +119 -0
  17. fragility_engine/benchmarks/institutional_composite.py +350 -0
  18. fragility_engine/benchmarks/manifest.py +267 -0
  19. fragility_engine/benchmarks/manifest_inventory.py +26 -0
  20. fragility_engine/benchmarks/stretch_presets.py +45 -0
  21. fragility_engine/benchmarks/suite.py +429 -0
  22. fragility_engine/byow/__init__.py +15 -0
  23. fragility_engine/byow/check.py +40 -0
  24. fragility_engine/byow/examples/__init__.py +51 -0
  25. fragility_engine/byow/examples/capacity_pool.py +113 -0
  26. fragility_engine/byow/examples/token_bucket.py +97 -0
  27. fragility_engine/byow/rollout.py +68 -0
  28. fragility_engine/cli/__init__.py +5 -0
  29. fragility_engine/cli/main.py +267 -0
  30. fragility_engine/coevolution/__init__.py +73 -0
  31. fragility_engine/coevolution/alternating.py +444 -0
  32. fragility_engine/coevolution/coupled_institution.py +149 -0
  33. fragility_engine/coevolution/defender.py +332 -0
  34. fragility_engine/coevolution/pareto_export.py +69 -0
  35. fragility_engine/coevolution/thread_safe_template.py +54 -0
  36. fragility_engine/explain/__init__.py +146 -0
  37. fragility_engine/explain/counterfactual.py +608 -0
  38. fragility_engine/explain/counterfactual_chain.py +260 -0
  39. fragility_engine/explain/counterfactual_chain_aggregate.py +155 -0
  40. fragility_engine/explain/counterfactual_chain_inventory_buffer.py +168 -0
  41. fragility_engine/explain/counterfactual_chain_liquidity_ladder.py +168 -0
  42. fragility_engine/explain/counterfactual_chain_resource_cascade.py +178 -0
  43. fragility_engine/explain/counterfactual_chain_service_backlog.py +177 -0
  44. fragility_engine/explain/explanation_dag.py +159 -0
  45. fragility_engine/explain/interaction_summary.py +63 -0
  46. fragility_engine/explain/merge_attribution.py +119 -0
  47. fragility_engine/explain/minimal_collapse.py +101 -0
  48. fragility_engine/explain/narration.py +259 -0
  49. fragility_engine/explain/sweep.py +535 -0
  50. fragility_engine/explain/trace.py +495 -0
  51. fragility_engine/falsify/__init__.py +16 -0
  52. fragility_engine/falsify/examples/__init__.py +44 -0
  53. fragility_engine/falsify/examples/ranked_store.py +102 -0
  54. fragility_engine/falsify/protocol.py +45 -0
  55. fragility_engine/falsify/rollout.py +66 -0
  56. fragility_engine/fel/__init__.py +27 -0
  57. fragility_engine/fel/conventions.py +150 -0
  58. fragility_engine/network/__init__.py +24 -0
  59. fragility_engine/network/contagion.py +90 -0
  60. fragility_engine/network/contagion_graph.py +77 -0
  61. fragility_engine/network/graph_cli.py +36 -0
  62. fragility_engine/network/neighbor_io.py +39 -0
  63. fragility_engine/network/network_world_cli.py +77 -0
  64. fragility_engine/network/topology.py +22 -0
  65. fragility_engine/parallel_rollouts.py +66 -0
  66. fragility_engine/runner.py +592 -0
  67. fragility_engine/runner_resource_cascade_numba.py +411 -0
  68. fragility_engine/types.py +52 -0
  69. fragility_engine/world/__init__.py +22 -0
  70. fragility_engine/world/base.py +35 -0
  71. fragility_engine/world/inventory_buffer.py +106 -0
  72. fragility_engine/world/liquidity_ladder.py +109 -0
  73. fragility_engine/world/resource_cascade.py +111 -0
  74. fragility_engine/world/service_backlog.py +104 -0
  75. fragility_engine/world/stablecoin_network.py +231 -0
  76. fragility_engine/world/stablecoin_peg.py +116 -0
  77. fragility_engine-0.6.0.dist-info/METADATA +283 -0
  78. fragility_engine-0.6.0.dist-info/RECORD +82 -0
  79. fragility_engine-0.6.0.dist-info/WHEEL +5 -0
  80. fragility_engine-0.6.0.dist-info/entry_points.txt +2 -0
  81. fragility_engine-0.6.0.dist-info/licenses/LICENSE +202 -0
  82. fragility_engine-0.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """Fragility Discovery Engine — deterministic adversarial simulation search."""
2
+
3
+ __version__ = "0.6.0"
@@ -0,0 +1,27 @@
1
+ from fragility_engine.adversary.encoding import decode_schedule, random_genome, schedule_attack_cost
2
+ from fragility_engine.adversary.fitness import fitness_phase_a, fitness_severity_minus_cost, severity_score
3
+ from fragility_engine.adversary.pareto import (
4
+ ParetoPoint,
5
+ merge_pareto_points,
6
+ pareto_indices,
7
+ pareto_point_from_rollout,
8
+ rollout_cloud_to_pareto,
9
+ )
10
+ from fragility_engine.adversary.search import genetic_search, genetic_vector_search, monte_carlo_search
11
+
12
+ __all__ = [
13
+ "decode_schedule",
14
+ "random_genome",
15
+ "schedule_attack_cost",
16
+ "genetic_search",
17
+ "genetic_vector_search",
18
+ "monte_carlo_search",
19
+ "fitness_phase_a",
20
+ "fitness_severity_minus_cost",
21
+ "severity_score",
22
+ "pareto_indices",
23
+ "merge_pareto_points",
24
+ "pareto_point_from_rollout",
25
+ "rollout_cloud_to_pareto",
26
+ "ParetoPoint",
27
+ ]
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from fragility_engine.types import ExogenousEvent, ShockKind
6
+
7
+ SHOCK_KINDS: tuple[ShockKind, ...] = ("none", "reserve_loss", "rumor")
8
+
9
+ # Abstract attacker budget units (not calibrated currency); tunable for search behavior.
10
+ DEFAULT_ATTACK_COST_WEIGHTS: dict[ShockKind, float] = {
11
+ "none": 0.0,
12
+ "reserve_loss": 2.5,
13
+ "rumor": 1.0,
14
+ }
15
+
16
+
17
+ def decode_kind(index: int) -> ShockKind:
18
+ return SHOCK_KINDS[int(index) % len(SHOCK_KINDS)]
19
+
20
+
21
+ def decode_schedule(genome: np.ndarray) -> dict[int, tuple[ExogenousEvent, ...]]:
22
+ """
23
+ Each row is one timestep: [kind_selector, magnitude].
24
+
25
+ kind_selector in [0,1) maps evenly to ShockKind via bucketization.
26
+ """
27
+
28
+ if genome.ndim != 2 or genome.shape[1] != 2:
29
+ raise ValueError("Genome must have shape (horizon, 2).")
30
+ horizon = genome.shape[0]
31
+ events: dict[int, tuple[ExogenousEvent, ...]] = {}
32
+ for t in range(horizon):
33
+ sel = float(np.clip(genome[t, 0], 0.0, 1.0 - 1e-9))
34
+ bucket = int(sel * len(SHOCK_KINDS))
35
+ if bucket >= len(SHOCK_KINDS):
36
+ bucket = len(SHOCK_KINDS) - 1
37
+ kind = SHOCK_KINDS[bucket]
38
+ mag = float(np.clip(genome[t, 1], 0.0, 1.0))
39
+ if kind == "none" or mag <= 1e-6:
40
+ continue
41
+ events[t] = (ExogenousEvent(kind=kind, magnitude=mag),)
42
+ return events
43
+
44
+
45
+ def schedule_attack_cost(
46
+ schedule: dict[int, tuple[ExogenousEvent, ...]],
47
+ *,
48
+ weights: dict[ShockKind, float] | None = None,
49
+ ) -> float:
50
+ """Sum marginal attack costs for decoded shocks (magnitude scales linearly)."""
51
+
52
+ wmap = weights or DEFAULT_ATTACK_COST_WEIGHTS
53
+ total = 0.0
54
+ for events in schedule.values():
55
+ for e in events:
56
+ total += float(wmap[e.kind]) * float(np.clip(e.magnitude, 0.0, 1.0))
57
+ return float(total)
58
+
59
+
60
+ def random_genome(horizon: int, rng: np.random.Generator) -> np.ndarray:
61
+ return rng.uniform(size=(horizon, 2))
62
+
63
+
64
+ def mutate_genome(genome: np.ndarray, rng: np.random.Generator, rate: float = 0.15, sigma: float = 0.12) -> np.ndarray:
65
+ child = genome.copy()
66
+ mask = rng.random(size=genome.shape) < rate
67
+ noise = rng.normal(scale=sigma, size=genome.shape)
68
+ child = np.clip(child + mask * noise, 0.0, 1.0)
69
+ return child
70
+
71
+
72
+ def crossover_genome(a: np.ndarray, b: np.ndarray, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
73
+ h = a.shape[0]
74
+ if h <= 1:
75
+ return a.copy(), b.copy()
76
+ cut = int(rng.integers(1, h))
77
+ c1 = np.vstack([a[:cut], b[cut:]])
78
+ c2 = np.vstack([b[:cut], a[cut:]])
79
+ return c1, c2
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+
5
+ from fragility_engine.types import RolloutResult
6
+
7
+
8
+ def severity_score(r: RolloutResult) -> float:
9
+ """Instability peak plus early-collapse bonus (matches legacy Phase A scalar without attack economics)."""
10
+
11
+ speed_bonus = 0.0
12
+ if r.collapsed and r.collapse_timestep is not None:
13
+ speed_bonus = 10.0 / (1 + r.collapse_timestep)
14
+ return float(r.final_instability + speed_bonus)
15
+
16
+
17
+ def fitness_phase_a(r: RolloutResult) -> float:
18
+ """Backward-compatible scalar: severity only (attack_cost ignored)."""
19
+
20
+ return severity_score(r)
21
+
22
+
23
+ def fitness_severity_minus_cost(*, attack_cost_weight: float) -> Callable[[RolloutResult], float]:
24
+ """Phase C hook: penalize expensive schedules."""
25
+
26
+ w = float(attack_cost_weight)
27
+
28
+ def _score(r: RolloutResult) -> float:
29
+ return severity_score(r) - w * r.attack_cost
30
+
31
+ return _score
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+ from fragility_engine.adversary.fitness import severity_score
8
+ from fragility_engine.types import RolloutResult
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ParetoPoint:
13
+ genome: np.ndarray
14
+ severity: float
15
+ attack_cost: float
16
+ collapsed: bool
17
+ integral_instability: float = 0.0
18
+
19
+
20
+ def pareto_indices(severity: np.ndarray, attack_cost: np.ndarray) -> list[int]:
21
+ """Maximize severity, minimize attack_cost (pairwise dominance in 2D)."""
22
+
23
+ n = severity.shape[0]
24
+ keep: list[int] = []
25
+ for i in range(n):
26
+ dominated = False
27
+ for j in range(n):
28
+ if i == j:
29
+ continue
30
+ better_or_equal = severity[j] >= severity[i] and attack_cost[j] <= attack_cost[i]
31
+ strictly_better = severity[j] > severity[i] or attack_cost[j] < attack_cost[i]
32
+ if better_or_equal and strictly_better:
33
+ dominated = True
34
+ break
35
+ if not dominated:
36
+ keep.append(i)
37
+ return keep
38
+
39
+
40
+ def pareto_point_from_rollout(genome: np.ndarray, rollout: RolloutResult) -> ParetoPoint:
41
+ return ParetoPoint(
42
+ genome=genome.copy(),
43
+ severity=float(severity_score(rollout)),
44
+ attack_cost=float(rollout.attack_cost),
45
+ collapsed=bool(rollout.collapsed),
46
+ integral_instability=float(rollout.integral_instability),
47
+ )
48
+
49
+
50
+ def merge_pareto_points(points: list[ParetoPoint]) -> list[ParetoPoint]:
51
+ if not points:
52
+ return []
53
+ sev = np.array([p.severity for p in points], dtype=np.float64)
54
+ cost = np.array([p.attack_cost for p in points], dtype=np.float64)
55
+ idx = pareto_indices(sev, cost)
56
+ return [points[i] for i in idx]
57
+
58
+
59
+ def rollout_cloud_to_pareto(genomes: list[np.ndarray], rollouts: list[RolloutResult]) -> list[ParetoPoint]:
60
+ sev = np.array([severity_score(r) for r in rollouts], dtype=np.float64)
61
+ cost = np.array([r.attack_cost for r in rollouts], dtype=np.float64)
62
+ idx = pareto_indices(sev, cost)
63
+ out: list[ParetoPoint] = []
64
+ for i in idx:
65
+ out.append(
66
+ ParetoPoint(
67
+ genome=genomes[i].copy(),
68
+ severity=float(sev[i]),
69
+ attack_cost=float(cost[i]),
70
+ collapsed=rollouts[i].collapsed,
71
+ integral_instability=float(rollouts[i].integral_instability),
72
+ )
73
+ )
74
+ return out
@@ -0,0 +1,299 @@
1
+ from __future__ import annotations
2
+
3
+ import pickle
4
+ from collections.abc import Callable
5
+ from functools import partial
6
+ from typing import Any, Literal
7
+
8
+ import numpy as np
9
+
10
+ from fragility_engine.adversary.encoding import crossover_genome, mutate_genome, random_genome
11
+ from fragility_engine.adversary.fitness import fitness_phase_a
12
+ from fragility_engine.adversary.pareto import ParetoPoint, merge_pareto_points, pareto_point_from_rollout
13
+ from fragility_engine.parallel_rollouts import process_pool_map_ordered, thread_pool_map_ordered
14
+ from fragility_engine.types import RolloutResult, SearchResult
15
+
16
+ EvalPool = Literal["threads", "processes"]
17
+
18
+
19
+ def _fitness_rollout_pair(
20
+ pair: tuple[np.ndarray, int],
21
+ *,
22
+ rollout_fn: Callable[[np.ndarray, int], RolloutResult],
23
+ score: Callable[[RolloutResult], float],
24
+ ) -> tuple[float, RolloutResult]:
25
+ ind, s = pair
26
+ rr = rollout_fn(ind, s)
27
+ return float(score(rr)), rr
28
+
29
+
30
+ def _ordered_map(
31
+ pool: EvalPool,
32
+ fn: Callable[[tuple[np.ndarray, int]], tuple[float, RolloutResult]],
33
+ pairs: list[tuple[np.ndarray, int]],
34
+ *,
35
+ max_workers: int,
36
+ ) -> list[tuple[float, RolloutResult]]:
37
+ if max_workers <= 1:
38
+ return [fn(p) for p in pairs]
39
+ if pool == "processes":
40
+ try:
41
+ pickle.dumps(fn)
42
+ except Exception as e:
43
+ raise ValueError(
44
+ "eval_pool='processes' requires a picklable rollout_fn bound for workers "
45
+ "(e.g. functools.partial(fragility_engine.benchmarks.suite.rollout_bundle_with_genome, "
46
+ "<bundle_id>, isolate=True))."
47
+ ) from e
48
+ return list(process_pool_map_ordered(fn, pairs, max_workers=max_workers))
49
+ return list(thread_pool_map_ordered(fn, pairs, max_workers=max_workers))
50
+
51
+
52
+ def monte_carlo_search(
53
+ rollout_fn: Callable[[np.ndarray, int], RolloutResult],
54
+ *,
55
+ horizon: int,
56
+ samples: int,
57
+ seed: int,
58
+ fitness_fn: Callable[[RolloutResult], float] | None = None,
59
+ collect_pareto: bool = False,
60
+ eval_workers: int = 1,
61
+ eval_pool: EvalPool = "threads",
62
+ ) -> SearchResult:
63
+ score = fitness_fn or fitness_phase_a
64
+ ew = int(eval_workers)
65
+ if ew < 1:
66
+ raise ValueError("eval_workers must be >= 1")
67
+ if eval_pool not in ("threads", "processes"):
68
+ raise ValueError("eval_pool must be 'threads' or 'processes'")
69
+ pool: EvalPool = eval_pool
70
+ rng = np.random.default_rng(seed)
71
+ best_genome = random_genome(horizon, rng)
72
+ best_rollout = rollout_fn(best_genome, seed + 1)
73
+ best_fitness = float(score(best_rollout))
74
+
75
+ history: list[dict[str, Any]] = []
76
+ archive: list[ParetoPoint] = []
77
+ if collect_pareto:
78
+ archive.append(pareto_point_from_rollout(best_genome, best_rollout))
79
+
80
+ trial_genomes = [random_genome(horizon, rng) for _ in range(samples)]
81
+ pairs: list[tuple[np.ndarray, int]] = [(trial_genomes[i], seed + 2 + i) for i in range(samples)]
82
+
83
+ _fn = partial(_fitness_rollout_pair, rollout_fn=rollout_fn, score=score)
84
+ mc_results = _ordered_map(pool, _fn, pairs, max_workers=ew)
85
+ for i in range(samples):
86
+ fitness, r = mc_results[i]
87
+ g = trial_genomes[i]
88
+ history.append({"sample": i, "fitness": fitness, "collapsed": r.collapsed})
89
+ if collect_pareto:
90
+ archive.append(pareto_point_from_rollout(g, r))
91
+ if fitness > best_fitness:
92
+ best_fitness = fitness
93
+ best_genome = g
94
+ best_rollout = r
95
+
96
+ pareto_archive = merge_pareto_points(archive) if collect_pareto else []
97
+ return SearchResult(
98
+ best_genome=best_genome,
99
+ best_fitness=best_fitness,
100
+ best_rollout=best_rollout,
101
+ history=history,
102
+ pareto_archive=pareto_archive,
103
+ )
104
+
105
+
106
+ def genetic_search(
107
+ rollout_fn: Callable[[np.ndarray, int], RolloutResult],
108
+ *,
109
+ horizon: int,
110
+ generations: int,
111
+ population_size: int,
112
+ seed: int,
113
+ elite_frac: float = 0.2,
114
+ mutation_rate: float = 0.18,
115
+ mutation_sigma: float = 0.14,
116
+ fitness_fn: Callable[[RolloutResult], float] | None = None,
117
+ collect_pareto: bool = False,
118
+ eval_workers: int = 1,
119
+ eval_pool: EvalPool = "threads",
120
+ ) -> SearchResult:
121
+ score = fitness_fn or fitness_phase_a
122
+ ew = int(eval_workers)
123
+ if ew < 1:
124
+ raise ValueError("eval_workers must be >= 1")
125
+ if eval_pool not in ("threads", "processes"):
126
+ raise ValueError("eval_pool must be 'threads' or 'processes'")
127
+ pool: EvalPool = eval_pool
128
+ rng = np.random.default_rng(seed)
129
+ population = [random_genome(horizon, rng) for _ in range(population_size)]
130
+
131
+ best_rollout = rollout_fn(population[0], seed + 7)
132
+ best_fitness = float(score(best_rollout))
133
+ best_genome = population[0].copy()
134
+ history: list[dict[str, Any]] = []
135
+ archive: list[ParetoPoint] = []
136
+ if collect_pareto:
137
+ archive.append(pareto_point_from_rollout(population[0], best_rollout))
138
+
139
+ for gen in range(generations):
140
+ seed_base = seed + 1000 + gen * population_size
141
+ pairs: list[tuple[np.ndarray, int]] = [(population[idx], seed_base + idx) for idx in range(population_size)]
142
+
143
+ _fn = partial(_fitness_rollout_pair, rollout_fn=rollout_fn, score=score)
144
+ evaluated = _ordered_map(pool, _fn, pairs, max_workers=ew)
145
+ fitnesses = [e[0] for e in evaluated]
146
+ for idx, (fit, rr) in enumerate(evaluated):
147
+ individual = population[idx]
148
+ if collect_pareto:
149
+ archive.append(pareto_point_from_rollout(individual, rr))
150
+ if fit > best_fitness:
151
+ best_fitness = fit
152
+ best_genome = individual.copy()
153
+ best_rollout = rr
154
+
155
+ history.append(
156
+ {
157
+ "generation": gen,
158
+ "mean_fitness": float(np.mean(fitnesses)),
159
+ "max_fitness": float(np.max(fitnesses)),
160
+ }
161
+ )
162
+
163
+ elite_n = max(1, int(population_size * elite_frac))
164
+ ranked = sorted(range(population_size), key=lambda i: fitnesses[i], reverse=True)
165
+ elites = [population[i].copy() for i in ranked[:elite_n]]
166
+
167
+ next_pop: list[np.ndarray] = elites[:]
168
+ while len(next_pop) < population_size:
169
+ if elite_n >= 2:
170
+ i1, i2 = rng.choice(elite_n, size=2, replace=False)
171
+ parent_a, parent_b = elites[i1], elites[i2]
172
+ else:
173
+ parent_a = parent_b = elites[0]
174
+ c1, c2 = crossover_genome(parent_a, parent_b, rng)
175
+ c1 = mutate_genome(c1, rng, rate=mutation_rate, sigma=mutation_sigma)
176
+ c2 = mutate_genome(c2, rng, rate=mutation_rate, sigma=mutation_sigma)
177
+ next_pop.append(c1)
178
+ if len(next_pop) < population_size:
179
+ next_pop.append(c2)
180
+
181
+ population = next_pop[:population_size]
182
+
183
+ pareto_archive = merge_pareto_points(archive) if collect_pareto else []
184
+ return SearchResult(
185
+ best_genome=best_genome,
186
+ best_fitness=best_fitness,
187
+ best_rollout=best_rollout,
188
+ history=history,
189
+ pareto_archive=pareto_archive,
190
+ )
191
+
192
+
193
+ def _random_vector(dim: int, rng: np.random.Generator) -> np.ndarray:
194
+ return rng.uniform(size=(dim,))
195
+
196
+
197
+ def _mutate_vector(vec: np.ndarray, rng: np.random.Generator, *, rate: float, sigma: float) -> np.ndarray:
198
+ mask = rng.random(size=vec.shape) < rate
199
+ noise = rng.normal(scale=sigma, size=vec.shape)
200
+ return np.clip(vec + mask * noise, 0.0, 1.0)
201
+
202
+
203
+ def _crossover_vector(a: np.ndarray, b: np.ndarray, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
204
+ dim = a.shape[0]
205
+ if dim <= 1:
206
+ return a.copy(), b.copy()
207
+ cut = int(rng.integers(1, dim))
208
+ c1 = np.concatenate([a[:cut], b[cut:]])
209
+ c2 = np.concatenate([b[:cut], a[cut:]])
210
+ return c1, c2
211
+
212
+
213
+ def genetic_vector_search(
214
+ rollout_fn: Callable[[np.ndarray, int], RolloutResult],
215
+ *,
216
+ dim: int,
217
+ generations: int,
218
+ population_size: int,
219
+ seed: int,
220
+ elite_frac: float = 0.2,
221
+ mutation_rate: float = 0.28,
222
+ mutation_sigma: float = 0.18,
223
+ fitness_fn: Callable[[RolloutResult], float] | None = None,
224
+ collect_pareto: bool = False,
225
+ eval_workers: int = 1,
226
+ eval_pool: EvalPool = "threads",
227
+ ) -> SearchResult:
228
+ """Evolutionary search over a bounded ``[0, 1]^{dim}`` defender/policy vector."""
229
+
230
+ score = fitness_fn or fitness_phase_a
231
+ ew = int(eval_workers)
232
+ if ew < 1:
233
+ raise ValueError("eval_workers must be >= 1")
234
+ if eval_pool not in ("threads", "processes"):
235
+ raise ValueError("eval_pool must be 'threads' or 'processes'")
236
+ pool: EvalPool = eval_pool
237
+ rng = np.random.default_rng(seed)
238
+ population = [_random_vector(dim, rng) for _ in range(population_size)]
239
+
240
+ best_rollout = rollout_fn(population[0], seed + 7)
241
+ best_fitness = float(score(best_rollout))
242
+ best_genome = population[0].copy()
243
+ history: list[dict[str, Any]] = []
244
+ archive: list[ParetoPoint] = []
245
+ if collect_pareto:
246
+ archive.append(pareto_point_from_rollout(population[0], best_rollout))
247
+
248
+ for gen in range(generations):
249
+ seed_base = seed + 2000 + gen * population_size
250
+ pairs = [(population[idx], seed_base + idx) for idx in range(population_size)]
251
+
252
+ _fn = partial(_fitness_rollout_pair, rollout_fn=rollout_fn, score=score)
253
+ evaluated = _ordered_map(pool, _fn, pairs, max_workers=ew)
254
+ fitnesses = [e[0] for e in evaluated]
255
+ for idx, (fit, rr) in enumerate(evaluated):
256
+ individual = population[idx]
257
+ if collect_pareto:
258
+ archive.append(pareto_point_from_rollout(individual, rr))
259
+ if fit > best_fitness:
260
+ best_fitness = fit
261
+ best_genome = individual.copy()
262
+ best_rollout = rr
263
+
264
+ history.append(
265
+ {
266
+ "generation": gen,
267
+ "mean_fitness": float(np.mean(fitnesses)),
268
+ "max_fitness": float(np.max(fitnesses)),
269
+ }
270
+ )
271
+
272
+ elite_n = max(1, int(population_size * elite_frac))
273
+ ranked = sorted(range(population_size), key=lambda i: fitnesses[i], reverse=True)
274
+ elites = [population[i].copy() for i in ranked[:elite_n]]
275
+
276
+ next_pop: list[np.ndarray] = elites[:]
277
+ while len(next_pop) < population_size:
278
+ if elite_n >= 2:
279
+ i1, i2 = rng.choice(elite_n, size=2, replace=False)
280
+ pa, pb = elites[i1], elites[i2]
281
+ else:
282
+ pa = pb = elites[0]
283
+ c1, c2 = _crossover_vector(pa, pb, rng)
284
+ c1 = _mutate_vector(c1, rng, rate=mutation_rate, sigma=mutation_sigma)
285
+ c2 = _mutate_vector(c2, rng, rate=mutation_rate, sigma=mutation_sigma)
286
+ next_pop.append(c1)
287
+ if len(next_pop) < population_size:
288
+ next_pop.append(c2)
289
+
290
+ population = next_pop[:population_size]
291
+
292
+ pareto_archive = merge_pareto_points(archive) if collect_pareto else []
293
+ return SearchResult(
294
+ best_genome=best_genome,
295
+ best_fitness=best_fitness,
296
+ best_rollout=best_rollout,
297
+ history=history,
298
+ pareto_archive=pareto_archive,
299
+ )
@@ -0,0 +1,3 @@
1
+ from fragility_engine.agents.stablecoin_agents import AgentPopulation
2
+
3
+ __all__ = ["AgentPopulation"]
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+ import numpy as np
6
+
7
+
8
+ class BehaviorArchetype(Protocol):
9
+ """Small, fast response rules so search can run many rollouts."""
10
+
11
+ name: str
12
+
13
+ def redeem_fraction(self, observation: dict[str, float], rng: np.random.Generator) -> float:
14
+ """Return desired aggregate redemption pressure in [0, 1]."""
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+
8
+ @dataclass
9
+ class RationalArchetype:
10
+ name: str = "rational"
11
+ backing_trigger: float = 0.97
12
+
13
+ def redeem_fraction(self, observation: dict[str, float], rng: np.random.Generator) -> float:
14
+ price = observation["price"]
15
+ if price < self.backing_trigger:
16
+ # Stronger response as backing deteriorates.
17
+ urgency = float(np.clip((self.backing_trigger - price) / 0.08, 0.0, 1.0))
18
+ return float(np.clip(0.15 + 0.85 * urgency, 0.0, 1.0))
19
+ return 0.03
20
+
21
+
22
+ @dataclass
23
+ class PanicArchetype:
24
+ name: str = "panic"
25
+ panic_slope: float = 1.4
26
+
27
+ def redeem_fraction(self, observation: dict[str, float], rng: np.random.Generator) -> float:
28
+ panic = observation["panic"]
29
+ price = observation["price"]
30
+ base = float(np.clip(self.panic_slope * panic, 0.0, 1.0))
31
+ if price < 0.99:
32
+ base = float(np.clip(base + 0.25, 0.0, 1.0))
33
+ return float(np.clip(base, 0.0, 1.0))
34
+
35
+
36
+ @dataclass
37
+ class WhaleArchetype:
38
+ name: str = "whale"
39
+ supply_fraction: float = 0.22
40
+ flee_backing: float = 0.985
41
+
42
+ def redeem_fraction(self, observation: dict[str, float], rng: np.random.Generator) -> float:
43
+ price = observation["price"]
44
+ panic = observation["panic"]
45
+ if price < self.flee_backing or panic > 0.55:
46
+ return float(np.clip(self.supply_fraction * 3.6, 0.0, 1.0))
47
+ return 0.02
48
+
49
+
50
+ @dataclass
51
+ class AgentPopulation:
52
+ """
53
+ Weighted mixture of archetypes producing aggregate redemption pressure.
54
+
55
+ This stays deterministic given `rng` — no LLM policies.
56
+ """
57
+
58
+ archetypes: list[tuple[float, RationalArchetype | PanicArchetype | WhaleArchetype]]
59
+ weights: np.ndarray | None = None
60
+
61
+ def reset(self, initial_supply: float, rng: np.random.Generator) -> None:
62
+ del initial_supply, rng # hook for future heterogeneity
63
+ total = sum(w for w, _ in self.archetypes)
64
+ self.weights = np.array([w / total for w, _ in self.archetypes], dtype=np.float64)
65
+
66
+ def aggregate_redeem_fraction(self, observation: dict[str, float], rng: np.random.Generator) -> float:
67
+ if self.weights is None:
68
+ raise RuntimeError("Population not initialized; call reset().")
69
+ demands = np.array([a.redeem_fraction(observation, rng) for _, a in self.archetypes], dtype=np.float64)
70
+ return float(np.dot(self.weights, demands))
71
+
72
+
73
+ def default_stablecoin_population() -> AgentPopulation:
74
+ return AgentPopulation(
75
+ archetypes=[
76
+ (0.62, RationalArchetype()),
77
+ (0.28, PanicArchetype()),
78
+ (0.10, WhaleArchetype()),
79
+ ]
80
+ )