diff --git a/.env.example b/.env.example index d136a30..5fb0f59 100644 --- a/.env.example +++ b/.env.example @@ -26,8 +26,8 @@ DB_PATH=./runs.db # Docker / Traefik (usati SOLO da docker-compose.yml) # Dominio base: traefik espone la dashboard su swarm.${DOMAIN_NAME} DOMAIN_NAME=tielogic.xyz -# Porta interna della Streamlit dashboard (Traefik fa il TLS davanti) -SWARM_DASHBOARD_PORT=8501 +# Porta interna della NiceGUI dashboard (Traefik fa il TLS davanti) +SWARM_DASHBOARD_PORT=8080 # Paper-trading runner — override del command nel compose (opzionali) PAPER_RUN_NAME=phase3-papertrade-prod diff --git a/README.md b/README.md index bf7b037..208a58b 100644 --- a/README.md +++ b/README.md @@ -75,17 +75,11 @@ src/multi_swarm/ ├── orchestrator/ │ └── run.py End-to-end pipeline + persistence └── dashboard/ - ├── streamlit_app.py Hub multipage - ├── data.py Lettura runs.db per le pagine - ├── aquarium.py Helper canvas HTML5 (fish data + JS template) - └── pages/ - ├── 01_overview.py Run + metriche aggregate - ├── 02_ga_convergence.py Fitness convergence + entropy plot - ├── 03_genomes.py Top-10 + ispezione system_prompt - └── 04_aquarium.py Acquario 2D con click → info + lineage + ├── nicegui_app.py NiceGUI dashboard (overview / convergence / genomes) + └── data.py Lettura runs.db per le pagine ``` -Stack: Python 3.13, uv, pytest+pytest-mock+responses, openai SDK (verso OpenRouter), requests+tenacity, pandas+numpy+scipy, sqlmodel+sqlite, streamlit+plotly. +Stack: Python 3.13, uv, pytest+pytest-mock+responses, openai SDK (verso OpenRouter), requests+tenacity, pandas+numpy+scipy, sqlmodel+sqlite, nicegui+plotly. ## Setup @@ -150,17 +144,18 @@ uv run python scripts/run_phase1.py \ --population-size 20 --n-generations 10 # Dashboard -DB_PATH=./runs.db uv run streamlit run src/multi_swarm/dashboard/streamlit_app.py +DB_PATH=./runs.db uv run python -m multi_swarm.dashboard.nicegui_app ``` ## Dashboard -Streamlit multipage su `http://localhost:8501` (override con `--server.port`): +NiceGUI dashboard (dark/neon palette) su `http://localhost:8080` (override con env `SWARM_DASHBOARD_PORT`): -- **Overview**: lista runs, status, costo, metriche aggregate evaluations (parse success %, top fitness, median). -- **GA Convergence**: fitness median/max/p90 per generazione, entropy con hline a soglia gate (0.5). -- **Genomes**: top-10 ordinati per fitness, click su row per ispezione system_prompt + raw_text JSON strategy. -- **Aquarium**: visualizzazione 2D canvas HTML5 con un pesce per agente; dimensione ∝ fitness, colore per cognitive_style, halo sui top-3, click su pesce → panel info completo + lineage BFS (parents → grandparents → ...). +- **Overview** (`/`): lista runs, status, costo, metriche aggregate evaluations (parse success %, top fitness, median). +- **GA Convergence** (`/convergence`): fitness median/max/p90 per generazione, entropy con hline a soglia gate (0.5). +- **Genomes** (`/genomes`): top-K ordinati per fitness, click su riga per ispezione system_prompt + raw_text JSON strategy. + +In produzione gira dentro Docker dietro Traefik su `https://swarm.${DOMAIN_NAME}` — vedi `docker-compose.yml`. ## Costi tipici Phase 1 diff --git a/docker-compose.yml b/docker-compose.yml index 70cac31..7273350 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,20 +81,16 @@ services: - ./data:/app/data:ro - ./series:/app/series:ro entrypoint: - - streamlit - - run - - /app/src/multi_swarm/dashboard/streamlit_app.py - - --server.address=0.0.0.0 - - --server.port=${SWARM_DASHBOARD_PORT:-8501} - - --server.headless=true - - --browser.gatherUsageStats=false + - python + - -m + - multi_swarm.dashboard.nicegui_app command: [] healthcheck: test: - "CMD" - "python" - "-c" - - "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"SWARM_DASHBOARD_PORT\",\"8501\")}/_stcore/health', timeout=3).close()" + - "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"SWARM_DASHBOARD_PORT\",\"8080\")}/', timeout=3).close()" interval: 30s timeout: 5s retries: 3 @@ -106,5 +102,5 @@ services: - traefik.http.routers.multi-swarm-dashboard.tls=true - traefik.http.routers.multi-swarm-dashboard.entrypoints=websecure - traefik.http.routers.multi-swarm-dashboard.tls.certresolver=mytlschallenge - - "traefik.http.services.multi-swarm-dashboard.loadbalancer.server.port=${SWARM_DASHBOARD_PORT:-8501}" + - "traefik.http.services.multi-swarm-dashboard.loadbalancer.server.port=${SWARM_DASHBOARD_PORT:-8080}" - com.centurylinklabs.watchtower.enable=true diff --git a/pyproject.toml b/pyproject.toml index c220f79..1c4e07e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,6 @@ dependencies = [ "requests>=2.32", "tenacity>=9.0", "pyyaml>=6.0", - "streamlit>=1.40", "plotly>=5.24", "pyarrow>=18.0", "nicegui>=3.11.1", diff --git a/src/multi_swarm/dashboard/aquarium.py b/src/multi_swarm/dashboard/aquarium.py deleted file mode 100644 index 817f13e..0000000 --- a/src/multi_swarm/dashboard/aquarium.py +++ /dev/null @@ -1,590 +0,0 @@ -"""Aquarium 2D visualization helpers. - -Builds fish records (with full genome attributes + ancestor lineage) and -renders a self-contained HTML/JS canvas animation, embeddable in Streamlit -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 - -import json -import math -from typing import Any - -import pandas as pd # type: ignore[import-untyped] - -# Color palette per cognitive style. Default fallback for unknown styles is grey. -STYLE_COLORS: dict[str, str] = { - "physicist": "#4cc9f0", - "biologist": "#52b788", - "historian": "#e76f51", - "meteorologist": "#ffd166", - "ecologist": "#a78bfa", - "engineer": "#fb6f92", -} -DEFAULT_COLOR: str = "#94a3b8" - - -def _is_nan(v: Any) -> bool: - try: - return bool(pd.isna(v)) - except (TypeError, ValueError): - 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( - fish: list[dict[str, Any]], - canvas_w: int = 1000, - canvas_h: int = 600, -) -> str: - """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) - palette_json = json.dumps(STYLE_COLORS) - default_color = DEFAULT_COLOR - - # All braces inside