feat(dashboard): aquarium click handler with info panel + ancestor lineage
Rimuove sidebar acquario (slider max-pesci, toggle label): la dimensione popolazione è già definita dal GA, le label sono ridondanti col pannello di ispezione. Mostra tutti i pesci della generazione selezionata. Aggiunge `build_lineage_index` (mappa ogni genome_id della run ai suoi attributi) e `trace_ancestors` (BFS sui parent_ids fino a max_levels, guardia su cicli). `build_fish_dataset` accetta ora il lineage_index e allega il campo `ancestors` ad ogni pesce; conserva la firma legacy per compat con i fixture di test esistenti. `build_aquarium_html` perde `show_labels`. Embedda click handler con hit-test in canvas pixel space (account per CSS scaling) + pannello info top-right con stile, fitness/DSR/Sharpe/maxDD/trades, prompt e albero discendenza colorato per cognitive_style. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
"""Aquarium 2D visualization helpers.
|
"""Aquarium 2D visualization helpers.
|
||||||
|
|
||||||
Builds a list of fish records from a merged DataFrame (evaluations + genomes)
|
Builds fish records (with full genome attributes + ancestor lineage) and
|
||||||
and renders a self-contained HTML/JS canvas animation, embeddable in Streamlit
|
renders a self-contained HTML/JS canvas animation, embeddable in Streamlit
|
||||||
via ``streamlit.components.v1.html``.
|
via ``streamlit.components.v1.html``. Includes a click handler that opens
|
||||||
|
an info panel showing genome details and BFS ancestor levels.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -25,50 +26,6 @@ STYLE_COLORS: dict[str, str] = {
|
|||||||
DEFAULT_COLOR: str = "#94a3b8"
|
DEFAULT_COLOR: str = "#94a3b8"
|
||||||
|
|
||||||
|
|
||||||
def build_fish_dataset(merged: pd.DataFrame, max_fish: int = 30) -> list[dict[str, Any]]:
|
|
||||||
"""Build a list of fish records from a merged evaluations+genomes DataFrame.
|
|
||||||
|
|
||||||
Expects columns: ``genome_id``, ``fitness``, ``cognitive_style``, ``n_trades``,
|
|
||||||
``dsr``. Rows with NaN ``fitness`` are dropped. Result is sorted by fitness
|
|
||||||
descending and capped at ``max_fish`` entries.
|
|
||||||
"""
|
|
||||||
if merged.empty:
|
|
||||||
return []
|
|
||||||
|
|
||||||
cols_needed = ["genome_id", "fitness", "cognitive_style", "n_trades", "dsr"]
|
|
||||||
available = [c for c in cols_needed if c in merged.columns]
|
|
||||||
df = merged[available].copy()
|
|
||||||
|
|
||||||
if "fitness" not in df.columns:
|
|
||||||
return []
|
|
||||||
|
|
||||||
df = df.dropna(subset=["fitness"])
|
|
||||||
df = df.sort_values("fitness", ascending=False).head(max_fish)
|
|
||||||
|
|
||||||
fish: list[dict[str, Any]] = []
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
gid = str(row.get("genome_id", ""))
|
|
||||||
fitness_val = float(row.get("fitness", 0.0))
|
|
||||||
if math.isnan(fitness_val):
|
|
||||||
fitness_val = 0.0
|
|
||||||
style_raw = row.get("cognitive_style", None)
|
|
||||||
style = str(style_raw) if style_raw is not None and not _is_nan(style_raw) else "unknown"
|
|
||||||
n_trades_raw = row.get("n_trades", 0)
|
|
||||||
n_trades = int(n_trades_raw) if not _is_nan(n_trades_raw) else 0
|
|
||||||
dsr_raw = row.get("dsr", 0.0)
|
|
||||||
dsr = float(dsr_raw) if not _is_nan(dsr_raw) else 0.0
|
|
||||||
fish.append(
|
|
||||||
{
|
|
||||||
"id": gid,
|
|
||||||
"fitness": fitness_val,
|
|
||||||
"cognitive_style": style,
|
|
||||||
"n_trades": n_trades,
|
|
||||||
"dsr": dsr,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return fish
|
|
||||||
|
|
||||||
|
|
||||||
def _is_nan(v: Any) -> bool:
|
def _is_nan(v: Any) -> bool:
|
||||||
try:
|
try:
|
||||||
return bool(pd.isna(v))
|
return bool(pd.isna(v))
|
||||||
@@ -76,17 +33,236 @@ def _is_nan(v: Any) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(v: Any, default: float = 0.0) -> float:
|
||||||
|
if v is None or _is_nan(v):
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(v: Any, default: int = 0) -> int:
|
||||||
|
if v is None or _is_nan(v):
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_str(v: Any, default: str = "") -> str:
|
||||||
|
if v is None or _is_nan(v):
|
||||||
|
return default
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_list(v: Any) -> list[Any]:
|
||||||
|
if v is None:
|
||||||
|
return []
|
||||||
|
if isinstance(v, list):
|
||||||
|
return list(v)
|
||||||
|
# pandas may store python lists in object cells; if it's e.g. a numpy array,
|
||||||
|
# falling back to list() is fine. NaN scalar is excluded by _is_nan.
|
||||||
|
if _is_nan(v):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(v)
|
||||||
|
except TypeError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def build_lineage_index(
|
||||||
|
genomes_df: pd.DataFrame, evals_df: pd.DataFrame
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Build ``{genome_id: attrs}`` for every genome in the run.
|
||||||
|
|
||||||
|
``genomes_df`` must come from ``genomes_df(repo, run_id)`` (no gen filter):
|
||||||
|
columns include ``id``, ``generation_idx``, ``system_prompt``,
|
||||||
|
``feature_access``, ``temperature``, ``top_p``, ``model_tier``,
|
||||||
|
``lookback_window``, ``cognitive_style``, ``parent_ids``, ``generation``.
|
||||||
|
|
||||||
|
``evals_df`` must come from ``evaluations_df(repo, run_id)``: columns
|
||||||
|
include ``genome_id``, ``fitness``, ``dsr``, ``sharpe``, ``max_dd``,
|
||||||
|
``n_trades``.
|
||||||
|
"""
|
||||||
|
if genomes_df.empty:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if evals_df is None or evals_df.empty:
|
||||||
|
merged = genomes_df.copy()
|
||||||
|
for col in ("fitness", "dsr", "sharpe", "max_dd", "n_trades"):
|
||||||
|
if col not in merged.columns:
|
||||||
|
merged[col] = 0.0 if col != "n_trades" else 0
|
||||||
|
else:
|
||||||
|
merged = genomes_df.merge(
|
||||||
|
evals_df,
|
||||||
|
left_on="id",
|
||||||
|
right_on="genome_id",
|
||||||
|
how="left",
|
||||||
|
suffixes=("", "_eval"),
|
||||||
|
)
|
||||||
|
|
||||||
|
index: dict[str, dict[str, Any]] = {}
|
||||||
|
for _, row in merged.iterrows():
|
||||||
|
gid = _safe_str(row.get("id"))
|
||||||
|
if not gid:
|
||||||
|
continue
|
||||||
|
# ``generation`` is the genome's evolutionary generation (from payload).
|
||||||
|
# If absent, fall back to ``generation_idx`` (column added by the
|
||||||
|
# repository). Defensive: both may be missing in edge cases.
|
||||||
|
gen_val: Any = row.get("generation")
|
||||||
|
if gen_val is None or _is_nan(gen_val):
|
||||||
|
gen_val = row.get("generation_idx", 0)
|
||||||
|
index[gid] = {
|
||||||
|
"id": gid,
|
||||||
|
"generation": _safe_int(gen_val, 0),
|
||||||
|
"fitness": _safe_float(row.get("fitness"), 0.0),
|
||||||
|
"dsr": _safe_float(row.get("dsr"), 0.0),
|
||||||
|
"sharpe": _safe_float(row.get("sharpe"), 0.0),
|
||||||
|
"max_dd": _safe_float(row.get("max_dd"), 0.0),
|
||||||
|
"n_trades": _safe_int(row.get("n_trades"), 0),
|
||||||
|
"cognitive_style": _safe_str(row.get("cognitive_style"), ""),
|
||||||
|
"system_prompt": _safe_str(row.get("system_prompt"), ""),
|
||||||
|
"temperature": _safe_float(row.get("temperature"), 0.0),
|
||||||
|
"lookback_window": _safe_int(row.get("lookback_window"), 0),
|
||||||
|
"feature_access": _safe_list(row.get("feature_access")),
|
||||||
|
"model_tier": _safe_str(row.get("model_tier"), ""),
|
||||||
|
"parent_ids": _safe_list(row.get("parent_ids")),
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def trace_ancestors(
|
||||||
|
genome_id: str,
|
||||||
|
lineage_index: dict[str, dict[str, Any]],
|
||||||
|
max_levels: int = 5,
|
||||||
|
) -> list[list[dict[str, Any]]]:
|
||||||
|
"""BFS over ``parent_ids`` returning levels of ancestors.
|
||||||
|
|
||||||
|
``levels[0]`` = direct parents, ``levels[1]`` = grandparents, etc. Each
|
||||||
|
entry is a small dict (no ``system_prompt``, to keep JSON payload light):
|
||||||
|
``{id, generation, fitness, cognitive_style}``. Cycles are guarded via a
|
||||||
|
``seen`` set; missing parents (not in this run) are stubbed with sentinel
|
||||||
|
values so the lineage display still renders the relationship.
|
||||||
|
"""
|
||||||
|
levels: list[list[dict[str, Any]]] = []
|
||||||
|
root = lineage_index.get(genome_id, {})
|
||||||
|
current_ids: list[str] = list(root.get("parent_ids", []))
|
||||||
|
seen: set[str] = {genome_id}
|
||||||
|
for _ in range(max_levels):
|
||||||
|
if not current_ids:
|
||||||
|
break
|
||||||
|
level_entries: list[dict[str, Any]] = []
|
||||||
|
next_ids: list[str] = []
|
||||||
|
for pid in current_ids:
|
||||||
|
if pid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(pid)
|
||||||
|
entry = lineage_index.get(pid)
|
||||||
|
if entry is None:
|
||||||
|
level_entries.append(
|
||||||
|
{
|
||||||
|
"id": pid,
|
||||||
|
"generation": -1,
|
||||||
|
"fitness": 0.0,
|
||||||
|
"cognitive_style": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
level_entries.append(
|
||||||
|
{
|
||||||
|
"id": entry["id"],
|
||||||
|
"generation": entry["generation"],
|
||||||
|
"fitness": entry["fitness"],
|
||||||
|
"cognitive_style": entry["cognitive_style"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
next_ids.extend(entry.get("parent_ids", []))
|
||||||
|
if not level_entries:
|
||||||
|
break
|
||||||
|
levels.append(level_entries)
|
||||||
|
current_ids = next_ids
|
||||||
|
return levels
|
||||||
|
|
||||||
|
|
||||||
|
def build_fish_dataset(
|
||||||
|
active_df: pd.DataFrame,
|
||||||
|
lineage_index: dict[str, dict[str, Any]] | None = None,
|
||||||
|
max_lineage_levels: int = 5,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Build full fish records for each active genome.
|
||||||
|
|
||||||
|
For every row in ``active_df`` the matching entry in ``lineage_index`` is
|
||||||
|
looked up by ``genome_id`` (or ``id``) and attached together with the BFS
|
||||||
|
ancestor levels. Rows whose id is not in the index are skipped.
|
||||||
|
|
||||||
|
Backward-compat: if ``lineage_index`` is ``None`` (legacy call site, e.g.
|
||||||
|
test fixtures with simple merged DataFrames) we synthesize a minimal
|
||||||
|
lineage from ``active_df`` itself so the function still returns useful
|
||||||
|
fish records.
|
||||||
|
"""
|
||||||
|
if active_df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if lineage_index is None:
|
||||||
|
# Legacy path: build a tiny index from the active df only.
|
||||||
|
synth: dict[str, dict[str, Any]] = {}
|
||||||
|
for _, row in active_df.iterrows():
|
||||||
|
gid = _safe_str(row.get("genome_id") or row.get("id"))
|
||||||
|
if not gid:
|
||||||
|
continue
|
||||||
|
fitness_val = _safe_float(row.get("fitness"), float("nan"))
|
||||||
|
if math.isnan(fitness_val):
|
||||||
|
continue
|
||||||
|
synth[gid] = {
|
||||||
|
"id": gid,
|
||||||
|
"generation": _safe_int(row.get("generation"), 0),
|
||||||
|
"fitness": fitness_val,
|
||||||
|
"dsr": _safe_float(row.get("dsr"), 0.0),
|
||||||
|
"sharpe": _safe_float(row.get("sharpe"), 0.0),
|
||||||
|
"max_dd": _safe_float(row.get("max_dd"), 0.0),
|
||||||
|
"n_trades": _safe_int(row.get("n_trades"), 0),
|
||||||
|
"cognitive_style": _safe_str(row.get("cognitive_style"), "unknown"),
|
||||||
|
"system_prompt": _safe_str(row.get("system_prompt"), ""),
|
||||||
|
"temperature": _safe_float(row.get("temperature"), 0.0),
|
||||||
|
"lookback_window": _safe_int(row.get("lookback_window"), 0),
|
||||||
|
"feature_access": _safe_list(row.get("feature_access")),
|
||||||
|
"model_tier": _safe_str(row.get("model_tier"), ""),
|
||||||
|
"parent_ids": _safe_list(row.get("parent_ids")),
|
||||||
|
}
|
||||||
|
lineage_index = synth
|
||||||
|
|
||||||
|
fish: list[dict[str, Any]] = []
|
||||||
|
for _, row in active_df.iterrows():
|
||||||
|
gid = _safe_str(row.get("genome_id") or row.get("id"))
|
||||||
|
if not gid:
|
||||||
|
continue
|
||||||
|
attrs = lineage_index.get(gid)
|
||||||
|
if attrs is None:
|
||||||
|
continue
|
||||||
|
if math.isnan(attrs.get("fitness", 0.0)):
|
||||||
|
continue
|
||||||
|
ancestors = trace_ancestors(gid, lineage_index, max_lineage_levels)
|
||||||
|
record = {**attrs, "ancestors": ancestors}
|
||||||
|
fish.append(record)
|
||||||
|
return fish
|
||||||
|
|
||||||
|
|
||||||
def build_aquarium_html(
|
def build_aquarium_html(
|
||||||
fish: list[dict[str, Any]],
|
fish: list[dict[str, Any]],
|
||||||
canvas_w: int = 1000,
|
canvas_w: int = 1000,
|
||||||
canvas_h: int = 600,
|
canvas_h: int = 600,
|
||||||
show_labels: bool = False,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build the self-contained HTML/JS string for the aquarium canvas."""
|
"""Build the self-contained HTML/JS string for the aquarium canvas.
|
||||||
|
|
||||||
|
The output embeds a click handler: tapping a fish opens an info panel
|
||||||
|
(top-right of the canvas) showing its genome attributes and BFS ancestor
|
||||||
|
levels. Labels are no longer rendered on the canvas itself.
|
||||||
|
"""
|
||||||
fish_json = json.dumps(fish)
|
fish_json = json.dumps(fish)
|
||||||
palette_json = json.dumps(STYLE_COLORS)
|
palette_json = json.dumps(STYLE_COLORS)
|
||||||
default_color = DEFAULT_COLOR
|
default_color = DEFAULT_COLOR
|
||||||
show_labels_js = "true" if show_labels else "false"
|
|
||||||
|
|
||||||
# All braces inside <style>/<script> are escaped to literals using {{ }}
|
# All braces inside <style>/<script> are escaped to literals using {{ }}
|
||||||
# so we can use Python f-string substitution for the few JSON payloads.
|
# so we can use Python f-string substitution for the few JSON payloads.
|
||||||
@@ -95,14 +271,40 @@ def build_aquarium_html(
|
|||||||
<canvas id="aquarium" width="{canvas_w}" height="{canvas_h}"
|
<canvas id="aquarium" width="{canvas_w}" height="{canvas_h}"
|
||||||
style="width:100%;height:{canvas_h}px;border-radius:12px;
|
style="width:100%;height:{canvas_h}px;border-radius:12px;
|
||||||
background:linear-gradient(180deg,#0a2540 0%,#1a4d80 100%);
|
background:linear-gradient(180deg,#0a2540 0%,#1a4d80 100%);
|
||||||
display:block;"></canvas>
|
display:block;cursor:pointer;"></canvas>
|
||||||
|
<div id="fish-info-panel" style="
|
||||||
|
position:absolute;
|
||||||
|
top:12px;
|
||||||
|
right:12px;
|
||||||
|
width:340px;
|
||||||
|
max-height:580px;
|
||||||
|
overflow-y:auto;
|
||||||
|
background:rgba(8,16,32,0.92);
|
||||||
|
color:#e2e8f0;
|
||||||
|
border-radius:10px;
|
||||||
|
padding:14px 16px;
|
||||||
|
font-family:system-ui,-apple-system,sans-serif;
|
||||||
|
font-size:12px;
|
||||||
|
line-height:1.5;
|
||||||
|
border:1px solid rgba(255,255,255,0.1);
|
||||||
|
backdrop-filter:blur(6px);
|
||||||
|
-webkit-backdrop-filter:blur(6px);
|
||||||
|
display:none;
|
||||||
|
z-index:10;
|
||||||
|
">
|
||||||
|
<div id="fish-info-content"></div>
|
||||||
|
<button id="fish-info-close" style="
|
||||||
|
position:absolute;top:8px;right:10px;
|
||||||
|
background:transparent;color:#94a3b8;border:none;
|
||||||
|
cursor:pointer;font-size:16px;
|
||||||
|
">×</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function() {{
|
(function() {{
|
||||||
const FISH_DATA = {fish_json};
|
const FISH_DATA = {fish_json};
|
||||||
const STYLE_COLORS = {palette_json};
|
const STYLE_COLORS = {palette_json};
|
||||||
const DEFAULT_COLOR = {json.dumps(default_color)};
|
const DEFAULT_COLOR = {json.dumps(default_color)};
|
||||||
const SHOW_LABELS = {show_labels_js};
|
|
||||||
|
|
||||||
const canvas = document.getElementById('aquarium');
|
const canvas = document.getElementById('aquarium');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
@@ -110,6 +312,15 @@ def build_aquarium_html(
|
|||||||
const W = canvas.width;
|
const W = canvas.width;
|
||||||
const H = canvas.height;
|
const H = canvas.height;
|
||||||
|
|
||||||
|
const panel = document.getElementById('fish-info-panel');
|
||||||
|
const panelContent = document.getElementById('fish-info-content');
|
||||||
|
const closeBtn = document.getElementById('fish-info-close');
|
||||||
|
if (closeBtn) {{
|
||||||
|
closeBtn.addEventListener('click', function() {{
|
||||||
|
panel.style.display = 'none';
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
// Normalize fitness for sizing.
|
// Normalize fitness for sizing.
|
||||||
let maxFit = 0.0;
|
let maxFit = 0.0;
|
||||||
for (const f of FISH_DATA) {{
|
for (const f of FISH_DATA) {{
|
||||||
@@ -128,15 +339,14 @@ def build_aquarium_html(
|
|||||||
return STYLE_COLORS[style] || DEFAULT_COLOR;
|
return STYLE_COLORS[style] || DEFAULT_COLOR;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Init fish state.
|
// Init fish state. Each entry keeps a reference to the original data dict
|
||||||
const fish = FISH_DATA.map((f, idx) => {{
|
// so the click handler can show full attributes + ancestors.
|
||||||
|
const fishState = FISH_DATA.map(function(f, idx) {{
|
||||||
const r = radiusFor(f.fitness);
|
const r = radiusFor(f.fitness);
|
||||||
return {{
|
return {{
|
||||||
id: f.id,
|
data: f,
|
||||||
fitness: f.fitness,
|
|
||||||
style: f.cognitive_style,
|
|
||||||
color: colorFor(f.cognitive_style),
|
color: colorFor(f.cognitive_style),
|
||||||
r: r,
|
radius: r,
|
||||||
x: Math.random() * (W - 2 * r) + r,
|
x: Math.random() * (W - 2 * r) + r,
|
||||||
y: Math.random() * (H - 2 * r) + r,
|
y: Math.random() * (H - 2 * r) + r,
|
||||||
vx: (Math.random() - 0.5) * 1.5,
|
vx: (Math.random() - 0.5) * 1.5,
|
||||||
@@ -147,12 +357,12 @@ def build_aquarium_html(
|
|||||||
|
|
||||||
// Bubbles for ambience.
|
// Bubbles for ambience.
|
||||||
const N_BUBBLES = 25;
|
const N_BUBBLES = 25;
|
||||||
const bubbles = Array.from({{length: N_BUBBLES}}, () => ({{
|
const bubbles = Array.from({{length: N_BUBBLES}}, function() {{ return {{
|
||||||
x: Math.random() * W,
|
x: Math.random() * W,
|
||||||
y: Math.random() * H,
|
y: Math.random() * H,
|
||||||
r: 1 + Math.random() * 3,
|
r: 1 + Math.random() * 3,
|
||||||
vy: 0.3 + Math.random() * 0.7,
|
vy: 0.3 + Math.random() * 0.7,
|
||||||
}}));
|
}}; }});
|
||||||
|
|
||||||
function drawBubble(b) {{
|
function drawBubble(b) {{
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -177,55 +387,46 @@ def build_aquarium_html(
|
|||||||
|
|
||||||
// Halo for top-3 fish.
|
// Halo for top-3 fish.
|
||||||
if (f.rank < 3) {{
|
if (f.rank < 3) {{
|
||||||
const grad = ctx.createRadialGradient(0, 0, f.r * 0.5, 0, 0, f.r * 2.0);
|
const grad = ctx.createRadialGradient(0, 0, f.radius * 0.5, 0, 0, f.radius * 2.0);
|
||||||
grad.addColorStop(0, f.color + 'aa');
|
grad.addColorStop(0, f.color + 'aa');
|
||||||
grad.addColorStop(1, f.color + '00');
|
grad.addColorStop(1, f.color + '00');
|
||||||
ctx.fillStyle = grad;
|
ctx.fillStyle = grad;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(0, 0, f.r * 2.0, 0, Math.PI * 2);
|
ctx.arc(0, 0, f.radius * 2.0, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Body (ellipse).
|
// Body (ellipse).
|
||||||
ctx.fillStyle = f.color;
|
ctx.fillStyle = f.color;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.ellipse(0, 0, f.r, f.r * 0.6, 0, 0, Math.PI * 2);
|
ctx.ellipse(0, 0, f.radius, f.radius * 0.6, 0, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Tail (triangle pointing left, body extends right).
|
// Tail.
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(-f.r, 0);
|
ctx.moveTo(-f.radius, 0);
|
||||||
ctx.lineTo(-f.r * 1.6, -f.r * 0.5);
|
ctx.lineTo(-f.radius * 1.6, -f.radius * 0.5);
|
||||||
ctx.lineTo(-f.r * 1.6, f.r * 0.5);
|
ctx.lineTo(-f.radius * 1.6, f.radius * 0.5);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Eye.
|
// Eye.
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(f.r * 0.45, -f.r * 0.15, Math.max(1.5, f.r * 0.12), 0, Math.PI * 2);
|
ctx.arc(f.radius * 0.45, -f.radius * 0.15, Math.max(1.5, f.radius * 0.12), 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.fillStyle = '#1a1a1a';
|
ctx.fillStyle = '#1a1a1a';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(f.r * 0.50, -f.r * 0.15, Math.max(0.8, f.r * 0.06), 0, Math.PI * 2);
|
ctx.arc(f.radius * 0.50, -f.radius * 0.15, Math.max(0.8, f.radius * 0.06), 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
if (SHOW_LABELS && f.id) {{
|
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
|
||||||
ctx.font = '10px sans-serif';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.fillText(String(f.id).slice(0, 6), f.x, f.y - f.r - 4);
|
|
||||||
}}
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
function updateFish(f) {{
|
function updateFish(f) {{
|
||||||
// Random perturbation (organic motion).
|
|
||||||
f.vx += (Math.random() - 0.5) * 0.05;
|
f.vx += (Math.random() - 0.5) * 0.05;
|
||||||
f.vy += (Math.random() - 0.5) * 0.05;
|
f.vy += (Math.random() - 0.5) * 0.05;
|
||||||
|
|
||||||
// Clamp speed.
|
|
||||||
const speed = Math.hypot(f.vx, f.vy);
|
const speed = Math.hypot(f.vx, f.vy);
|
||||||
const maxSpeed = 1.5;
|
const maxSpeed = 1.5;
|
||||||
if (speed > maxSpeed) {{
|
if (speed > maxSpeed) {{
|
||||||
@@ -236,17 +437,15 @@ def build_aquarium_html(
|
|||||||
f.x += f.vx;
|
f.x += f.vx;
|
||||||
f.y += f.vy;
|
f.y += f.vy;
|
||||||
|
|
||||||
// Bounce on edges.
|
if (f.x < f.radius) {{ f.x = f.radius; f.vx = -f.vx; }}
|
||||||
if (f.x < f.r) {{ f.x = f.r; f.vx = -f.vx; }}
|
if (f.x > W - f.radius) {{ f.x = W - f.radius; f.vx = -f.vx; }}
|
||||||
if (f.x > W - f.r) {{ f.x = W - f.r; f.vx = -f.vx; }}
|
if (f.y < f.radius) {{ f.y = f.radius; f.vy = -f.vy; }}
|
||||||
if (f.y < f.r) {{ f.y = f.r; f.vy = -f.vy; }}
|
if (f.y > H - f.radius) {{ f.y = H - f.radius; f.vy = -f.vy; }}
|
||||||
if (f.y > H - f.r) {{ f.y = H - f.r; f.vy = -f.vy; }}
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
function frame() {{
|
function frame() {{
|
||||||
ctx.clearRect(0, 0, W, H);
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
// Subtle current lines.
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
for (let i = 0; i < 6; i++) {{
|
for (let i = 0; i < 6; i++) {{
|
||||||
@@ -261,14 +460,124 @@ def build_aquarium_html(
|
|||||||
updateBubble(b);
|
updateBubble(b);
|
||||||
drawBubble(b);
|
drawBubble(b);
|
||||||
}}
|
}}
|
||||||
for (const f of fish) {{
|
for (const f of fishState) {{
|
||||||
updateFish(f);
|
updateFish(f);
|
||||||
drawFish(f);
|
drawFish(f);
|
||||||
}}
|
}}
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
if (fish.length === 0) {{
|
// CLICK HANDLER: hit-test in canvas pixel space (account for CSS scaling).
|
||||||
|
canvas.addEventListener('click', function(e) {{
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const scaleX = canvas.width / rect.width;
|
||||||
|
const scaleY = canvas.height / rect.height;
|
||||||
|
const cx = (e.clientX - rect.left) * scaleX;
|
||||||
|
const cy = (e.clientY - rect.top) * scaleY;
|
||||||
|
let best = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (const f of fishState) {{
|
||||||
|
const dx = cx - f.x;
|
||||||
|
const dy = cy - f.y;
|
||||||
|
const d = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const hit = Math.max(f.radius + 6, 14);
|
||||||
|
if (d < hit && d < bestDist) {{
|
||||||
|
best = f;
|
||||||
|
bestDist = d;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
if (best) showFishInfo(best);
|
||||||
|
}});
|
||||||
|
|
||||||
|
const ROW_STYLE = 'display:flex;justify-content:space-between;'
|
||||||
|
+ 'padding:2px 0;border-bottom:1px solid rgba(255,255,255,0.05);';
|
||||||
|
const PROMPT_STYLE = 'margin-top:10px;padding:8px;'
|
||||||
|
+ 'background:rgba(255,255,255,0.04);border-radius:6px;'
|
||||||
|
+ 'font-size:11px;font-style:italic;color:#cbd5e1;';
|
||||||
|
const ANC_HEAD_STYLE = 'margin:14px 0 6px 0;color:#94a3b8;'
|
||||||
|
+ 'text-transform:uppercase;font-size:10px;letter-spacing:1px;';
|
||||||
|
const ANC_ROW_STYLE = 'display:flex;align-items:center;padding:4px 6px;'
|
||||||
|
+ 'margin-bottom:2px;background:rgba(255,255,255,0.03);'
|
||||||
|
+ 'border-radius:4px;border-left:3px solid ';
|
||||||
|
const NO_ANC_STYLE = 'margin-top:10px;font-size:10px;color:#64748b;';
|
||||||
|
const DASH = '\\u2014';
|
||||||
|
|
||||||
|
function metricRow(label, value) {{
|
||||||
|
return '<div style="' + ROW_STYLE + '">'
|
||||||
|
+ '<span style="color:#94a3b8;">' + label + '</span>'
|
||||||
|
+ '<span style="color:#e2e8f0;">' + value + '</span></div>';
|
||||||
|
}}
|
||||||
|
|
||||||
|
function escapeHtml(s) {{
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.appendChild(document.createTextNode(String(s)));
|
||||||
|
return div.innerHTML;
|
||||||
|
}}
|
||||||
|
|
||||||
|
function fmt(v, dp) {{
|
||||||
|
if (v === null || v === undefined || typeof v !== 'number' || isNaN(v)) {{
|
||||||
|
return DASH;
|
||||||
|
}}
|
||||||
|
return v.toFixed(dp);
|
||||||
|
}}
|
||||||
|
|
||||||
|
function showFishInfo(fish) {{
|
||||||
|
const data = fish.data;
|
||||||
|
const styleColor = STYLE_COLORS[data.cognitive_style] || DEFAULT_COLOR;
|
||||||
|
let html = '';
|
||||||
|
const idShort = String(data.id || '').slice(0, 8);
|
||||||
|
html += '<h4 style="margin:0 0 10px 0;color:' + styleColor + ';">';
|
||||||
|
html += escapeHtml(idShort)
|
||||||
|
+ ' <span style="color:#94a3b8;font-weight:normal;font-size:11px;">'
|
||||||
|
+ 'gen ' + escapeHtml(data.generation) + '</span>';
|
||||||
|
html += '</h4>';
|
||||||
|
html += metricRow('fitness', fmt(data.fitness, 3));
|
||||||
|
html += metricRow('DSR', fmt(data.dsr, 3));
|
||||||
|
html += metricRow('Sharpe', fmt(data.sharpe, 3));
|
||||||
|
html += metricRow('max DD', fmt(data.max_dd, 3));
|
||||||
|
const trades = data.n_trades == null ? 0 : data.n_trades;
|
||||||
|
html += metricRow('trades', escapeHtml(trades));
|
||||||
|
html += metricRow('style', escapeHtml(data.cognitive_style || DASH));
|
||||||
|
html += metricRow('tier', escapeHtml(data.model_tier || DASH));
|
||||||
|
html += metricRow('temp', fmt(data.temperature, 2));
|
||||||
|
const lookback = data.lookback_window == null ? DASH : data.lookback_window;
|
||||||
|
html += metricRow('lookback', escapeHtml(lookback));
|
||||||
|
const feats = (data.feature_access || []).join(', ');
|
||||||
|
html += metricRow('features', escapeHtml(feats || DASH));
|
||||||
|
if (data.system_prompt) {{
|
||||||
|
html += '<div style="' + PROMPT_STYLE + '">'
|
||||||
|
+ escapeHtml(data.system_prompt) + '</div>';
|
||||||
|
}}
|
||||||
|
if (data.ancestors && data.ancestors.length > 0) {{
|
||||||
|
html += '<h5 style="' + ANC_HEAD_STYLE + '">Discendenza</h5>';
|
||||||
|
data.ancestors.forEach(function(level, idx) {{
|
||||||
|
html += '<div style="margin-bottom:8px;">';
|
||||||
|
html += '<div style="font-size:10px;color:#64748b;margin-bottom:4px;">'
|
||||||
|
+ 'Gen N\\u2212' + (idx + 1) + '</div>';
|
||||||
|
level.forEach(function(ancestor) {{
|
||||||
|
const c = STYLE_COLORS[ancestor.cognitive_style] || DEFAULT_COLOR;
|
||||||
|
const aShort = String(ancestor.id || '').slice(0, 8);
|
||||||
|
html += '<div style="' + ANC_ROW_STYLE + c + ';">';
|
||||||
|
html += '<code style="color:' + c + ';font-size:10px;">'
|
||||||
|
+ escapeHtml(aShort) + '</code>';
|
||||||
|
const af = ancestor.fitness;
|
||||||
|
const fitTxt = (typeof af === 'number' && !isNaN(af))
|
||||||
|
? af.toFixed(2) : DASH;
|
||||||
|
html += '<span style="margin-left:auto;color:#94a3b8;font-size:10px;">'
|
||||||
|
+ 'fit ' + fitTxt + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
}});
|
||||||
|
html += '</div>';
|
||||||
|
}});
|
||||||
|
}} else {{
|
||||||
|
html += '<div style="' + NO_ANC_STYLE + '">'
|
||||||
|
+ 'Genoma di prima generazione (no ancestors)</div>';
|
||||||
|
}}
|
||||||
|
panelContent.innerHTML = html;
|
||||||
|
panel.style.display = 'block';
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (fishState.length === 0) {{
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||||||
ctx.font = '16px sans-serif';
|
ctx.font = '16px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ from multi_swarm.dashboard.aquarium import (
|
|||||||
STYLE_COLORS,
|
STYLE_COLORS,
|
||||||
build_aquarium_html,
|
build_aquarium_html,
|
||||||
build_fish_dataset,
|
build_fish_dataset,
|
||||||
|
build_lineage_index,
|
||||||
)
|
)
|
||||||
from multi_swarm.dashboard.data import (
|
from multi_swarm.dashboard.data import (
|
||||||
evaluations_df,
|
evaluations_df,
|
||||||
generations_df,
|
|
||||||
genomes_df,
|
genomes_df,
|
||||||
get_repo,
|
get_repo,
|
||||||
list_runs_df,
|
list_runs_df,
|
||||||
@@ -18,8 +18,8 @@ from multi_swarm.dashboard.data import (
|
|||||||
|
|
||||||
st.title("Aquarium 2D")
|
st.title("Aquarium 2D")
|
||||||
st.caption(
|
st.caption(
|
||||||
"Ogni genoma è un pesce: dimensione proporzionale alla fitness, "
|
"Pesci colorati per stile cognitivo, dimensione proporzionale a fitness. "
|
||||||
"colore per cognitive_style."
|
"Click su un pesce per dettaglio + discendenza."
|
||||||
)
|
)
|
||||||
|
|
||||||
db_path = st.session_state.get("db_path", "./runs.db")
|
db_path = st.session_state.get("db_path", "./runs.db")
|
||||||
@@ -27,52 +27,55 @@ repo = get_repo(db_path)
|
|||||||
|
|
||||||
runs = list_runs_df(repo)
|
runs = list_runs_df(repo)
|
||||||
if runs.empty:
|
if runs.empty:
|
||||||
st.info("Nessuna run.")
|
st.info("Nessuna run nel database.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
selected = st.selectbox("Run", runs["id"].tolist())
|
selected_run = st.selectbox("Run", runs["id"].tolist())
|
||||||
|
|
||||||
gens = generations_df(repo, selected)
|
# Fetch ALL genomes of the run (no gen filter): needed to build the lineage
|
||||||
gen_options: list[int | None] = [None]
|
# index across generations. The active set is filtered afterwards.
|
||||||
if not gens.empty:
|
all_genomes = genomes_df(repo, selected_run)
|
||||||
gen_options.extend(sorted(gens["generation_idx"].unique().tolist()))
|
all_evals = evaluations_df(repo, selected_run)
|
||||||
|
|
||||||
def _fmt_gen(v: int | None) -> str:
|
if all_genomes.empty:
|
||||||
return "tutte le generazioni" if v is None else f"gen {v}"
|
st.warning("Nessun genoma per questa run.")
|
||||||
|
|
||||||
# Default to latest gen if available.
|
|
||||||
default_idx = len(gen_options) - 1 if len(gen_options) > 1 else 0
|
|
||||||
gen_choice = st.selectbox(
|
|
||||||
"Generazione", gen_options, index=default_idx, format_func=_fmt_gen
|
|
||||||
)
|
|
||||||
|
|
||||||
with st.sidebar:
|
|
||||||
st.subheader("Aquarium controls")
|
|
||||||
max_fish = st.slider("Max pesci", min_value=5, max_value=100, value=30, step=1)
|
|
||||||
show_labels = st.toggle("Mostra ID genoma", value=False)
|
|
||||||
if st.button("Refresh"):
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
evals = evaluations_df(repo, selected)
|
|
||||||
genomes = genomes_df(repo, selected, generation_idx=gen_choice)
|
|
||||||
|
|
||||||
if evals.empty or genomes.empty:
|
|
||||||
st.warning("Nessun dato disponibile per questa run/generazione.")
|
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
merged = evals.merge(
|
available_gens = sorted(all_genomes["generation_idx"].unique().tolist())
|
||||||
genomes, left_on="genome_id", right_on="id", how="inner", suffixes=("", "_g")
|
selected_gen = st.selectbox(
|
||||||
|
"Generazione",
|
||||||
|
available_gens,
|
||||||
|
index=len(available_gens) - 1, # default ultima
|
||||||
)
|
)
|
||||||
if merged.empty:
|
|
||||||
st.warning("Nessuna evaluation associata ai genomi della generazione scelta.")
|
active_genomes = all_genomes[all_genomes["generation_idx"] == selected_gen]
|
||||||
|
active_evals = (
|
||||||
|
all_evals[all_evals["genome_id"].isin(active_genomes["id"])]
|
||||||
|
if not all_evals.empty
|
||||||
|
else all_evals
|
||||||
|
)
|
||||||
|
if not active_evals.empty:
|
||||||
|
active_merged = active_genomes.merge(
|
||||||
|
active_evals,
|
||||||
|
left_on="id",
|
||||||
|
right_on="genome_id",
|
||||||
|
how="left",
|
||||||
|
suffixes=("", "_eval"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
active_merged = active_genomes.copy()
|
||||||
|
active_merged["genome_id"] = active_merged["id"]
|
||||||
|
|
||||||
|
lineage = build_lineage_index(all_genomes, all_evals)
|
||||||
|
fish = build_fish_dataset(active_merged, lineage, max_lineage_levels=5)
|
||||||
|
|
||||||
|
if not fish:
|
||||||
|
st.warning("Nessun agente attivo in questa generazione.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
fish = build_fish_dataset(merged, max_fish=max_fish)
|
st.caption(f"{len(fish)} agenti in generazione {selected_gen}")
|
||||||
st.write(f"Visualizzati {len(fish)} pesci (top per fitness).")
|
|
||||||
|
|
||||||
html_str = build_aquarium_html(
|
html_str = build_aquarium_html(fish, canvas_w=1000, canvas_h=600)
|
||||||
fish, canvas_w=1000, canvas_h=600, show_labels=show_labels
|
|
||||||
)
|
|
||||||
components.html(html_str, height=620, scrolling=False)
|
components.html(html_str, height=620, scrolling=False)
|
||||||
|
|
||||||
with st.expander("Legenda colori"):
|
with st.expander("Legenda colori"):
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ def test_dashboard_data_helpers_signatures():
|
|||||||
assert hasattr(data, "genomes_df")
|
assert hasattr(data, "genomes_df")
|
||||||
|
|
||||||
|
|
||||||
def test_aquarium_helper_builds_html():
|
def test_aquarium_helper_builds_html_with_click_handler():
|
||||||
from multi_swarm.dashboard.aquarium import build_aquarium_html
|
from multi_swarm.dashboard.aquarium import build_aquarium_html
|
||||||
|
|
||||||
fish = [
|
fish = [
|
||||||
@@ -26,40 +26,55 @@ def test_aquarium_helper_builds_html():
|
|||||||
"cognitive_style": "physicist",
|
"cognitive_style": "physicist",
|
||||||
"n_trades": 30,
|
"n_trades": 30,
|
||||||
"dsr": 0.7,
|
"dsr": 0.7,
|
||||||
},
|
"sharpe": 1.2,
|
||||||
{
|
"max_dd": 0.1,
|
||||||
"id": "def456",
|
"system_prompt": "test",
|
||||||
"fitness": 0.0,
|
"temperature": 0.9,
|
||||||
"cognitive_style": "biologist",
|
"lookback_window": 200,
|
||||||
"n_trades": 0,
|
"feature_access": ["close"],
|
||||||
"dsr": 0.0,
|
"model_tier": "C",
|
||||||
},
|
"generation": 1,
|
||||||
|
"parent_ids": [],
|
||||||
|
"ancestors": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
html = build_aquarium_html(fish, canvas_w=800, canvas_h=400, show_labels=True)
|
html = build_aquarium_html(fish, canvas_w=800, canvas_h=400)
|
||||||
assert "canvas" in html
|
assert "canvas" in html
|
||||||
assert "abc123" in html
|
assert "abc123" in html # fish id present in JSON payload
|
||||||
assert "physicist" in html or "4cc9f0" in html
|
assert "addEventListener('click'" in html
|
||||||
|
assert "fish-info-panel" in html
|
||||||
|
assert "showFishInfo" in html
|
||||||
|
assert "Discendenza" in html
|
||||||
assert "requestAnimationFrame" in html
|
assert "requestAnimationFrame" in html
|
||||||
|
|
||||||
|
|
||||||
def test_aquarium_build_fish_dataset_sorts_and_caps():
|
def test_aquarium_build_fish_dataset_legacy_path():
|
||||||
from multi_swarm.dashboard.aquarium import build_fish_dataset
|
from multi_swarm.dashboard.aquarium import build_fish_dataset
|
||||||
|
|
||||||
df = pd.DataFrame(
|
df = pd.DataFrame(
|
||||||
[
|
[
|
||||||
{"genome_id": "low", "fitness": 0.1, "cognitive_style": "physicist",
|
{
|
||||||
"n_trades": 1, "dsr": 0.0},
|
"genome_id": "low",
|
||||||
{"genome_id": "high", "fitness": 0.9, "cognitive_style": "biologist",
|
"fitness": 0.1,
|
||||||
"n_trades": 10, "dsr": 0.5},
|
"cognitive_style": "physicist",
|
||||||
{"genome_id": "mid", "fitness": 0.5, "cognitive_style": "engineer",
|
"n_trades": 1,
|
||||||
"n_trades": 5, "dsr": 0.2},
|
"dsr": 0.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"genome_id": "high",
|
||||||
|
"fitness": 0.9,
|
||||||
|
"cognitive_style": "biologist",
|
||||||
|
"n_trades": 10,
|
||||||
|
"dsr": 0.5,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
out = build_fish_dataset(df, max_fish=2)
|
out = build_fish_dataset(df)
|
||||||
assert len(out) == 2
|
ids = {f["id"] for f in out}
|
||||||
assert out[0]["id"] == "high"
|
assert ids == {"low", "high"}
|
||||||
assert out[1]["id"] == "mid"
|
high = next(f for f in out if f["id"] == "high")
|
||||||
assert out[0]["cognitive_style"] == "biologist"
|
assert high["cognitive_style"] == "biologist"
|
||||||
|
assert high["ancestors"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_aquarium_build_fish_dataset_drops_nan_fitness():
|
def test_aquarium_build_fish_dataset_drops_nan_fitness():
|
||||||
@@ -67,10 +82,20 @@ def test_aquarium_build_fish_dataset_drops_nan_fitness():
|
|||||||
|
|
||||||
df = pd.DataFrame(
|
df = pd.DataFrame(
|
||||||
[
|
[
|
||||||
{"genome_id": "ok", "fitness": 0.4, "cognitive_style": "historian",
|
{
|
||||||
"n_trades": 2, "dsr": 0.1},
|
"genome_id": "ok",
|
||||||
{"genome_id": "bad", "fitness": float("nan"), "cognitive_style": "ecologist",
|
"fitness": 0.4,
|
||||||
"n_trades": 0, "dsr": 0.0},
|
"cognitive_style": "historian",
|
||||||
|
"n_trades": 2,
|
||||||
|
"dsr": 0.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"genome_id": "bad",
|
||||||
|
"fitness": float("nan"),
|
||||||
|
"cognitive_style": "ecologist",
|
||||||
|
"n_trades": 0,
|
||||||
|
"dsr": 0.0,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
out = build_fish_dataset(df)
|
out = build_fish_dataset(df)
|
||||||
@@ -85,3 +110,201 @@ def test_aquarium_empty_input_returns_empty():
|
|||||||
html = build_aquarium_html([], canvas_w=400, canvas_h=200)
|
html = build_aquarium_html([], canvas_w=400, canvas_h=200)
|
||||||
assert "canvas" in html
|
assert "canvas" in html
|
||||||
assert "Acquario vuoto" in html
|
assert "Acquario vuoto" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_lineage_index_returns_dict_keyed_by_id():
|
||||||
|
from multi_swarm.dashboard.aquarium import build_lineage_index
|
||||||
|
|
||||||
|
genomes = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "g1",
|
||||||
|
"generation_idx": 0,
|
||||||
|
"generation": 0,
|
||||||
|
"system_prompt": "x",
|
||||||
|
"feature_access": ["close"],
|
||||||
|
"temperature": 0.9,
|
||||||
|
"top_p": 0.95,
|
||||||
|
"model_tier": "C",
|
||||||
|
"lookback_window": 100,
|
||||||
|
"cognitive_style": "physicist",
|
||||||
|
"parent_ids": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g2",
|
||||||
|
"generation_idx": 1,
|
||||||
|
"generation": 1,
|
||||||
|
"system_prompt": "y",
|
||||||
|
"feature_access": ["close", "volume"],
|
||||||
|
"temperature": 1.0,
|
||||||
|
"top_p": 0.95,
|
||||||
|
"model_tier": "C",
|
||||||
|
"lookback_window": 200,
|
||||||
|
"cognitive_style": "biologist",
|
||||||
|
"parent_ids": ["g1"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
evals = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"genome_id": "g1",
|
||||||
|
"fitness": 0.5,
|
||||||
|
"dsr": 0.6,
|
||||||
|
"sharpe": 1.2,
|
||||||
|
"max_dd": 0.1,
|
||||||
|
"n_trades": 30,
|
||||||
|
"parse_error": None,
|
||||||
|
"raw_text": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"genome_id": "g2",
|
||||||
|
"fitness": 0.7,
|
||||||
|
"dsr": 0.8,
|
||||||
|
"sharpe": 1.5,
|
||||||
|
"max_dd": 0.05,
|
||||||
|
"n_trades": 40,
|
||||||
|
"parse_error": None,
|
||||||
|
"raw_text": "",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
idx = build_lineage_index(genomes, evals)
|
||||||
|
assert "g1" in idx and "g2" in idx
|
||||||
|
assert idx["g2"]["parent_ids"] == ["g1"]
|
||||||
|
assert idx["g2"]["fitness"] == 0.7
|
||||||
|
assert idx["g1"]["cognitive_style"] == "physicist"
|
||||||
|
assert idx["g2"]["feature_access"] == ["close", "volume"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_ancestors_walks_levels():
|
||||||
|
from multi_swarm.dashboard.aquarium import trace_ancestors
|
||||||
|
|
||||||
|
idx = {
|
||||||
|
"child": {
|
||||||
|
"id": "child",
|
||||||
|
"parent_ids": ["p1", "p2"],
|
||||||
|
"fitness": 0.8,
|
||||||
|
"generation": 2,
|
||||||
|
"cognitive_style": "physicist",
|
||||||
|
},
|
||||||
|
"p1": {
|
||||||
|
"id": "p1",
|
||||||
|
"parent_ids": ["gp1"],
|
||||||
|
"fitness": 0.5,
|
||||||
|
"generation": 1,
|
||||||
|
"cognitive_style": "biologist",
|
||||||
|
},
|
||||||
|
"p2": {
|
||||||
|
"id": "p2",
|
||||||
|
"parent_ids": [],
|
||||||
|
"fitness": 0.3,
|
||||||
|
"generation": 1,
|
||||||
|
"cognitive_style": "engineer",
|
||||||
|
},
|
||||||
|
"gp1": {
|
||||||
|
"id": "gp1",
|
||||||
|
"parent_ids": [],
|
||||||
|
"fitness": 0.2,
|
||||||
|
"generation": 0,
|
||||||
|
"cognitive_style": "historian",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
levels = trace_ancestors("child", idx, max_levels=5)
|
||||||
|
assert len(levels) == 2
|
||||||
|
assert {a["id"] for a in levels[0]} == {"p1", "p2"}
|
||||||
|
assert {a["id"] for a in levels[1]} == {"gp1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_ancestors_handles_cycles():
|
||||||
|
from multi_swarm.dashboard.aquarium import trace_ancestors
|
||||||
|
|
||||||
|
# Pathological cycle: a <-> b. Should terminate cleanly.
|
||||||
|
idx = {
|
||||||
|
"a": {
|
||||||
|
"id": "a",
|
||||||
|
"parent_ids": ["b"],
|
||||||
|
"fitness": 0.1,
|
||||||
|
"generation": 1,
|
||||||
|
"cognitive_style": "physicist",
|
||||||
|
},
|
||||||
|
"b": {
|
||||||
|
"id": "b",
|
||||||
|
"parent_ids": ["a"],
|
||||||
|
"fitness": 0.2,
|
||||||
|
"generation": 0,
|
||||||
|
"cognitive_style": "biologist",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
levels = trace_ancestors("a", idx, max_levels=5)
|
||||||
|
# a -> b at level 0; b's only parent is a, already seen -> stop.
|
||||||
|
assert len(levels) == 1
|
||||||
|
assert levels[0][0]["id"] == "b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_ancestors_no_parents_returns_empty():
|
||||||
|
from multi_swarm.dashboard.aquarium import trace_ancestors
|
||||||
|
|
||||||
|
idx = {
|
||||||
|
"solo": {
|
||||||
|
"id": "solo",
|
||||||
|
"parent_ids": [],
|
||||||
|
"fitness": 0.4,
|
||||||
|
"generation": 0,
|
||||||
|
"cognitive_style": "engineer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert trace_ancestors("solo", idx) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_fish_dataset_attaches_ancestors():
|
||||||
|
from multi_swarm.dashboard.aquarium import build_fish_dataset, build_lineage_index
|
||||||
|
|
||||||
|
genomes = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "p",
|
||||||
|
"generation_idx": 0,
|
||||||
|
"generation": 0,
|
||||||
|
"system_prompt": "p",
|
||||||
|
"feature_access": ["close"],
|
||||||
|
"temperature": 0.8,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"model_tier": "C",
|
||||||
|
"lookback_window": 100,
|
||||||
|
"cognitive_style": "physicist",
|
||||||
|
"parent_ids": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c",
|
||||||
|
"generation_idx": 1,
|
||||||
|
"generation": 1,
|
||||||
|
"system_prompt": "c",
|
||||||
|
"feature_access": ["close"],
|
||||||
|
"temperature": 0.8,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"model_tier": "C",
|
||||||
|
"lookback_window": 120,
|
||||||
|
"cognitive_style": "biologist",
|
||||||
|
"parent_ids": ["p"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
evals = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{"genome_id": "p", "fitness": 0.3, "dsr": 0.0, "sharpe": 0.0,
|
||||||
|
"max_dd": 0.0, "n_trades": 0},
|
||||||
|
{"genome_id": "c", "fitness": 0.6, "dsr": 0.0, "sharpe": 0.0,
|
||||||
|
"max_dd": 0.0, "n_trades": 0},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lineage = build_lineage_index(genomes, evals)
|
||||||
|
|
||||||
|
active = genomes[genomes["generation_idx"] == 1].merge(
|
||||||
|
evals, left_on="id", right_on="genome_id", how="left"
|
||||||
|
)
|
||||||
|
fish = build_fish_dataset(active, lineage)
|
||||||
|
assert len(fish) == 1
|
||||||
|
assert fish[0]["id"] == "c"
|
||||||
|
assert len(fish[0]["ancestors"]) == 1
|
||||||
|
assert fish[0]["ancestors"][0][0]["id"] == "p"
|
||||||
|
|||||||
Reference in New Issue
Block a user