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.
- fragility_engine/__init__.py +3 -0
- fragility_engine/adversary/__init__.py +27 -0
- fragility_engine/adversary/encoding.py +79 -0
- fragility_engine/adversary/fitness.py +31 -0
- fragility_engine/adversary/pareto.py +74 -0
- fragility_engine/adversary/search.py +299 -0
- fragility_engine/agents/__init__.py +3 -0
- fragility_engine/agents/base.py +14 -0
- fragility_engine/agents/stablecoin_agents.py +80 -0
- fragility_engine/benchmarks/__init__.py +91 -0
- fragility_engine/benchmarks/bundled_artifacts.py +82 -0
- fragility_engine/benchmarks/certificate.py +120 -0
- fragility_engine/benchmarks/coupled_fork.py +50 -0
- fragility_engine/benchmarks/ensemble.py +1105 -0
- fragility_engine/benchmarks/flagship.py +178 -0
- fragility_engine/benchmarks/hypervolume.py +119 -0
- fragility_engine/benchmarks/institutional_composite.py +350 -0
- fragility_engine/benchmarks/manifest.py +267 -0
- fragility_engine/benchmarks/manifest_inventory.py +26 -0
- fragility_engine/benchmarks/stretch_presets.py +45 -0
- fragility_engine/benchmarks/suite.py +429 -0
- fragility_engine/byow/__init__.py +15 -0
- fragility_engine/byow/check.py +40 -0
- fragility_engine/byow/examples/__init__.py +51 -0
- fragility_engine/byow/examples/capacity_pool.py +113 -0
- fragility_engine/byow/examples/token_bucket.py +97 -0
- fragility_engine/byow/rollout.py +68 -0
- fragility_engine/cli/__init__.py +5 -0
- fragility_engine/cli/main.py +267 -0
- fragility_engine/coevolution/__init__.py +73 -0
- fragility_engine/coevolution/alternating.py +444 -0
- fragility_engine/coevolution/coupled_institution.py +149 -0
- fragility_engine/coevolution/defender.py +332 -0
- fragility_engine/coevolution/pareto_export.py +69 -0
- fragility_engine/coevolution/thread_safe_template.py +54 -0
- fragility_engine/explain/__init__.py +146 -0
- fragility_engine/explain/counterfactual.py +608 -0
- fragility_engine/explain/counterfactual_chain.py +260 -0
- fragility_engine/explain/counterfactual_chain_aggregate.py +155 -0
- fragility_engine/explain/counterfactual_chain_inventory_buffer.py +168 -0
- fragility_engine/explain/counterfactual_chain_liquidity_ladder.py +168 -0
- fragility_engine/explain/counterfactual_chain_resource_cascade.py +178 -0
- fragility_engine/explain/counterfactual_chain_service_backlog.py +177 -0
- fragility_engine/explain/explanation_dag.py +159 -0
- fragility_engine/explain/interaction_summary.py +63 -0
- fragility_engine/explain/merge_attribution.py +119 -0
- fragility_engine/explain/minimal_collapse.py +101 -0
- fragility_engine/explain/narration.py +259 -0
- fragility_engine/explain/sweep.py +535 -0
- fragility_engine/explain/trace.py +495 -0
- fragility_engine/falsify/__init__.py +16 -0
- fragility_engine/falsify/examples/__init__.py +44 -0
- fragility_engine/falsify/examples/ranked_store.py +102 -0
- fragility_engine/falsify/protocol.py +45 -0
- fragility_engine/falsify/rollout.py +66 -0
- fragility_engine/fel/__init__.py +27 -0
- fragility_engine/fel/conventions.py +150 -0
- fragility_engine/network/__init__.py +24 -0
- fragility_engine/network/contagion.py +90 -0
- fragility_engine/network/contagion_graph.py +77 -0
- fragility_engine/network/graph_cli.py +36 -0
- fragility_engine/network/neighbor_io.py +39 -0
- fragility_engine/network/network_world_cli.py +77 -0
- fragility_engine/network/topology.py +22 -0
- fragility_engine/parallel_rollouts.py +66 -0
- fragility_engine/runner.py +592 -0
- fragility_engine/runner_resource_cascade_numba.py +411 -0
- fragility_engine/types.py +52 -0
- fragility_engine/world/__init__.py +22 -0
- fragility_engine/world/base.py +35 -0
- fragility_engine/world/inventory_buffer.py +106 -0
- fragility_engine/world/liquidity_ladder.py +109 -0
- fragility_engine/world/resource_cascade.py +111 -0
- fragility_engine/world/service_backlog.py +104 -0
- fragility_engine/world/stablecoin_network.py +231 -0
- fragility_engine/world/stablecoin_peg.py +116 -0
- fragility_engine-0.6.0.dist-info/METADATA +283 -0
- fragility_engine-0.6.0.dist-info/RECORD +82 -0
- fragility_engine-0.6.0.dist-info/WHEEL +5 -0
- fragility_engine-0.6.0.dist-info/entry_points.txt +2 -0
- fragility_engine-0.6.0.dist-info/licenses/LICENSE +202 -0
- fragility_engine-0.6.0.dist-info/top_level.txt +1 -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,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
|
+
)
|