diff --git a/docs/superpowers/plans/2026-05-11-mutate-prompt-llm-phase-2-5.md b/docs/superpowers/plans/2026-05-11-mutate-prompt-llm-phase-2-5.md index 4c0bff0..18ba189 100644 --- a/docs/superpowers/plans/2026-05-11-mutate-prompt-llm-phase-2-5.md +++ b/docs/superpowers/plans/2026-05-11-mutate-prompt-llm-phase-2-5.md @@ -1,8 +1,15 @@ # `mutate_prompt_llm` — Phase 2.5 Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. -**Status:** Piano in tasca, **NON attivare** finché la condizione di trigger non è soddisfatta. Phase 2 (qwen3 + feature temporali) è la baseline. +**Status:** **IMPLEMENTATO 2026-05-11.** Task 1-5 completati e mergiati su main. Task 6 (cost attribution per call_kind) **deferito** — i cost mutator finiscono già in `cost_records` con l'`agent_id` del parent, quindi il totale è contabilizzato anche senza breakdown per call kind. + +**Trigger Phase 2.5 verificati con esito Phase 2 + run controllo:** +- ✅ Plateau max fitness ≥ 4 gen consecutive (Phase 2 qwen3-235b stuck 8 gen a 0.0238; run controllo qwen-2.5-72b stuck 9 gen a 0.0311). +- ✅ Diversità prompt collapsed: top genomi del run controllo hanno fitness/Sharpe/DD identici (mutazioni scalari non producono varianti significative). +- ✗ Top quasi-fit ≥ 0.10 non raggiunto, ma 2/3 trigger sufficienti. + +**Decisione collaterale:** rollback tier C a `qwen/qwen-2.5-72b-instruct` (run controllo l'ha dimostrato superiore a qwen3-235b: +30% fitness, 4× entropy, metà costo e tempo). **Goal:** Introdurre un quinto operatore di mutazione che usa un LLM tier B come "mutator" per riscrivere il `system_prompt` di un genoma, generando diversità reale dove oggi `random_mutate` tocca solo quattro scalari. La pipeline GA esistente resta intatta: `mutate_prompt_llm` è solo un nuovo membro di `MUTATION_OPS` con peso configurabile. @@ -49,7 +56,7 @@ Se Phase 2 raggiunge max fitness ≥ 0.30 senza plateau, **non attivare** (la di - New: `src/multi_swarm/genome/mutation_prompt_llm.py` - New: `tests/unit/test_mutation_prompt_llm.py` -- [ ] **Step 1.1: Write failing test — operator returns child con system_prompt diverso** +- [x] **Step 1.1: Write failing test — operator returns child con system_prompt diverso** Append a `tests/unit/test_mutation_prompt_llm.py`: @@ -63,7 +70,7 @@ def test_mutate_prompt_llm_produces_different_prompt(mock_llm: LLMClient) -> Non assert child.generation == parent.generation + 1 ``` -- [ ] **Step 1.2: Implement `MUTATION_INSTRUCTIONS` constant** +- [x] **Step 1.2: Implement `MUTATION_INSTRUCTIONS` constant** `mutation_prompt_llm.py`: @@ -78,7 +85,7 @@ MUTATION_INSTRUCTIONS: dict[str, str] = { } ``` -- [ ] **Step 1.3: Implement `mutate_prompt_llm`** +- [x] **Step 1.3: Implement `mutate_prompt_llm`** Firma: ```python @@ -98,7 +105,7 @@ Logica: 5. Estrai nuovo prompt da risposta (cerca blocco `...` o intero output). 6. Ritorna `_clone_with(g, system_prompt=new_prompt)` (riusa helper di `mutation.py`). -- [ ] **Step 1.4: Run test → green** +- [x] **Step 1.4: Run test → green** ```bash uv run pytest tests/unit/test_mutation_prompt_llm.py::test_mutate_prompt_llm_produces_different_prompt -xvs @@ -112,7 +119,7 @@ uv run pytest tests/unit/test_mutation_prompt_llm.py::test_mutate_prompt_llm_pro - Modify: `src/multi_swarm/genome/mutation_prompt_llm.py` - Append: `tests/unit/test_mutation_prompt_llm.py` -- [ ] **Step 2.1: Write failing test — fallback a random_mutate su prompt invalid** +- [x] **Step 2.1: Write failing test — fallback a random_mutate su prompt invalid** ```python def test_mutate_prompt_llm_falls_back_on_invalid_prompt(mock_llm: LLMClient) -> None: @@ -125,7 +132,7 @@ def test_mutate_prompt_llm_falls_back_on_invalid_prompt(mock_llm: LLMClient) -> assert child.parent_ids == [*parent.parent_ids, parent.id] ``` -- [ ] **Step 2.2: Implement validation step** +- [x] **Step 2.2: Implement validation step** Dopo aver estratto `new_prompt`, esegui `validate_prompt(new_prompt)`: - Lunghezza minima 50 caratteri. @@ -134,11 +141,11 @@ Dopo aver estratto `new_prompt`, esegui `validate_prompt(new_prompt)`: Su fail → log warning + ritorna `random_mutate(g, rng)`. -- [ ] **Step 2.3: Write failing test — diversity guard** +- [x] **Step 2.3: Write failing test — diversity guard** Mock LLM ritorna prompt identico al parent → `validate_prompt` rifiuta → fallback. -- [ ] **Step 2.4: Run test suite parziale** +- [x] **Step 2.4: Run test suite parziale** ```bash uv run pytest tests/unit/test_mutation_prompt_llm.py -xvs @@ -152,7 +159,7 @@ uv run pytest tests/unit/test_mutation_prompt_llm.py -xvs - Modify: `src/multi_swarm/genome/mutation.py` - New: `tests/unit/test_mutation_dispatcher.py` -- [ ] **Step 3.1: Write failing test — weighted_random_mutate rispetta pesi** +- [x] **Step 3.1: Write failing test — weighted_random_mutate rispetta pesi** ```python def test_weighted_random_mutate_picks_prompt_op_at_configured_rate() -> None: @@ -165,7 +172,7 @@ def test_weighted_random_mutate_picks_prompt_op_at_configured_rate() -> None: assert counter["prompt"] == 100 ``` -- [ ] **Step 3.2: Implement `weighted_random_mutate`** +- [x] **Step 3.2: Implement `weighted_random_mutate`** ```python def weighted_random_mutate( @@ -179,7 +186,7 @@ def weighted_random_mutate( return random_mutate(g, rng) ``` -- [ ] **Step 3.3: Test edge cases** +- [x] **Step 3.3: Test edge cases** - `llm=None` → sempre scalar mutation (backward compat). - `prompt_mutation_weight=0.0` → sempre scalar. @@ -194,7 +201,7 @@ def weighted_random_mutate( - Modify: `src/multi_swarm/orchestrator/run.py` - New: `tests/integration/test_ga_loop_with_prompt_mutator.py` -- [ ] **Step 4.1: Estendere `GAConfig`** +- [x] **Step 4.1: Estendere `GAConfig`** ```python @dataclass(frozen=True) @@ -206,7 +213,7 @@ class GAConfig: prompt_mutation_weight: float = 0.0 # default off → opt-in ``` -- [ ] **Step 4.2: Pass `LLMClient` in `next_generation`** +- [x] **Step 4.2: Pass `LLMClient` in `next_generation`** ```python def next_generation( @@ -220,11 +227,11 @@ def next_generation( child = weighted_random_mutate(parent, rng, llm, cfg.prompt_mutation_weight) ``` -- [ ] **Step 4.3: Wire in orchestrator** +- [x] **Step 4.3: Wire in orchestrator** `RunConfig.prompt_mutation_weight: float = 0.0` (default off). Quando attivo via CLI `--prompt-mutation-weight 0.30`, passare a `next_generation`. -- [ ] **Step 4.4: Integration test** +- [x] **Step 4.4: Integration test** Loop 2 gen × 5 genomi, mock LLM che ritorna prompt sempre diversi. Verifica che la popolazione finale abbia più diversità prompt della iniziale. @@ -236,18 +243,18 @@ Loop 2 gen × 5 genomi, mock LLM che ritorna prompt sempre diversi. Verifica che - New: `src/multi_swarm/metrics/diversity.py` - New: `tests/unit/test_diversity.py` -- [ ] **Step 5.1: Implement `population_prompt_diversity`** +- [x] **Step 5.1: Implement `population_prompt_diversity`** ```python def population_prompt_diversity(prompts: list[str]) -> float: """Levenshtein normalizzata media su tutte le coppie. 0.0 = identici, 1.0 = totalmente diversi.""" ``` -- [ ] **Step 5.2: Test** +- [x] **Step 5.2: Test** Tre prompt identici → 0.0. Tre prompt totalmente diversi → ~1.0. -- [ ] **Step 5.3: Logging** +- [x] **Step 5.3: Logging** Aggiungere `diversity_prompt` come campo per-generazione in `repository.save_generation` (richiede migration leggera). diff --git a/src/multi_swarm/metrics/diversity.py b/src/multi_swarm/metrics/diversity.py new file mode 100644 index 0000000..4556bdc --- /dev/null +++ b/src/multi_swarm/metrics/diversity.py @@ -0,0 +1,30 @@ +"""Metriche di diversità popolazione. + +``population_prompt_diversity`` calcola la diversità media fra i prompt di una +popolazione tramite la similarity di ``difflib.SequenceMatcher`` (proxy di +Levenshtein normalizzata): 0.0 = tutti i prompt identici, 1.0 = tutti diversi. + +Usata come telemetry Phase 2.5 per monitorare se ``mutate_prompt_llm`` sta +effettivamente introducendo diversità di prompt nel pool. +""" + +from __future__ import annotations + +from difflib import SequenceMatcher +from itertools import combinations + + +def population_prompt_diversity(prompts: list[str]) -> float: + """Diversità media (0.0 - 1.0) sui prompt della popolazione. + + Calcolo: media di ``1 - similarity(a, b)`` su tutte le coppie distinte. + Per N prompt il numero di coppie è ``N*(N-1)/2``. Con N=20 sono 190 coppie + — trascurabile a livello di compute. + """ + if len(prompts) < 2: + return 0.0 + diffs = [ + 1.0 - SequenceMatcher(None, a, b).ratio() + for a, b in combinations(prompts, 2) + ] + return sum(diffs) / len(diffs) diff --git a/tests/unit/test_diversity.py b/tests/unit/test_diversity.py new file mode 100644 index 0000000..fbcbfb3 --- /dev/null +++ b/tests/unit/test_diversity.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from multi_swarm.metrics.diversity import population_prompt_diversity + + +def test_empty_or_single_prompt_zero_diversity() -> None: + assert population_prompt_diversity([]) == 0.0 + assert population_prompt_diversity(["solo prompt"]) == 0.0 + + +def test_identical_prompts_zero_diversity() -> None: + prompts = ["Strategia RSI < 30 long"] * 5 + assert population_prompt_diversity(prompts) == 0.0 + + +def test_completely_different_prompts_high_diversity() -> None: + prompts = [ + "AAAAAA AAAA AAAAA", + "BBBBBB BBBB BBBBB", + "CCCCCC CCCC CCCCC", + "DDDDDD DDDD DDDDD", + ] + d = population_prompt_diversity(prompts) + # SequenceMatcher considera spazi e lunghezza simili → similarity > 0 + # anche su stringhe completamente "diverse". Soglia realistica: 0.8. + assert d > 0.8 + + +def test_partial_overlap_intermediate_diversity() -> None: + prompts = [ + "Strategia momentum 1h con RSI", + "Strategia momentum 1h con SMA", + "Strategia momentum 4h con RSI", + ] + d = population_prompt_diversity(prompts) + assert 0.05 < d < 0.5 + + +def test_diversity_symmetric() -> None: + prompts_a = ["x", "yy", "zzz"] + prompts_b = ["zzz", "x", "yy"] + assert ( + abs(population_prompt_diversity(prompts_a) + - population_prompt_diversity(prompts_b)) < 1e-9 + )