feat(phase-2.5): population_prompt_diversity metric + piano aggiornato
Task 5 del piano Phase 2.5: nuovo modulo src/multi_swarm/metrics/diversity.py con population_prompt_diversity(prompts) che ritorna la diversità media 1 - SequenceMatcher.ratio() su tutte le coppie distinte. 0.0 identici, fino a ~0.9 totalmente diversi (SequenceMatcher considera spazi/lunghezza). 5 test: edge case empty/single, identici, diversi, intermediate, simmetria. Piano aggiornato a stato "IMPLEMENTATO 2026-05-11": checkbox task 1-5 spuntate, task 6 (cost attribution per call_kind) deferito con motivazione. Header preambolo aggiornato con trigger verificati e decisione collaterale rollback tier C. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,15 @@
|
|||||||
# `mutate_prompt_llm` — Phase 2.5 Implementation Plan
|
# `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.
|
**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: `src/multi_swarm/genome/mutation_prompt_llm.py`
|
||||||
- New: `tests/unit/test_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`:
|
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
|
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`:
|
`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:
|
Firma:
|
||||||
```python
|
```python
|
||||||
@@ -98,7 +105,7 @@ Logica:
|
|||||||
5. Estrai nuovo prompt da risposta (cerca blocco `<prompt>...</prompt>` o intero output).
|
5. Estrai nuovo prompt da risposta (cerca blocco `<prompt>...</prompt>` o intero output).
|
||||||
6. Ritorna `_clone_with(g, system_prompt=new_prompt)` (riusa helper di `mutation.py`).
|
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
|
```bash
|
||||||
uv run pytest tests/unit/test_mutation_prompt_llm.py::test_mutate_prompt_llm_produces_different_prompt -xvs
|
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`
|
- Modify: `src/multi_swarm/genome/mutation_prompt_llm.py`
|
||||||
- Append: `tests/unit/test_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
|
```python
|
||||||
def test_mutate_prompt_llm_falls_back_on_invalid_prompt(mock_llm: LLMClient) -> None:
|
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]
|
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)`:
|
Dopo aver estratto `new_prompt`, esegui `validate_prompt(new_prompt)`:
|
||||||
- Lunghezza minima 50 caratteri.
|
- 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)`.
|
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.
|
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
|
```bash
|
||||||
uv run pytest tests/unit/test_mutation_prompt_llm.py -xvs
|
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`
|
- Modify: `src/multi_swarm/genome/mutation.py`
|
||||||
- New: `tests/unit/test_mutation_dispatcher.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
|
```python
|
||||||
def test_weighted_random_mutate_picks_prompt_op_at_configured_rate() -> None:
|
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
|
assert counter["prompt"] == 100
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3.2: Implement `weighted_random_mutate`**
|
- [x] **Step 3.2: Implement `weighted_random_mutate`**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def weighted_random_mutate(
|
def weighted_random_mutate(
|
||||||
@@ -179,7 +186,7 @@ def weighted_random_mutate(
|
|||||||
return random_mutate(g, rng)
|
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).
|
- `llm=None` → sempre scalar mutation (backward compat).
|
||||||
- `prompt_mutation_weight=0.0` → sempre scalar.
|
- `prompt_mutation_weight=0.0` → sempre scalar.
|
||||||
@@ -194,7 +201,7 @@ def weighted_random_mutate(
|
|||||||
- Modify: `src/multi_swarm/orchestrator/run.py`
|
- Modify: `src/multi_swarm/orchestrator/run.py`
|
||||||
- New: `tests/integration/test_ga_loop_with_prompt_mutator.py`
|
- New: `tests/integration/test_ga_loop_with_prompt_mutator.py`
|
||||||
|
|
||||||
- [ ] **Step 4.1: Estendere `GAConfig`**
|
- [x] **Step 4.1: Estendere `GAConfig`**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -206,7 +213,7 @@ class GAConfig:
|
|||||||
prompt_mutation_weight: float = 0.0 # default off → opt-in
|
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
|
```python
|
||||||
def next_generation(
|
def next_generation(
|
||||||
@@ -220,11 +227,11 @@ def next_generation(
|
|||||||
child = weighted_random_mutate(parent, rng, llm, cfg.prompt_mutation_weight)
|
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`.
|
`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.
|
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: `src/multi_swarm/metrics/diversity.py`
|
||||||
- New: `tests/unit/test_diversity.py`
|
- New: `tests/unit/test_diversity.py`
|
||||||
|
|
||||||
- [ ] **Step 5.1: Implement `population_prompt_diversity`**
|
- [x] **Step 5.1: Implement `population_prompt_diversity`**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def population_prompt_diversity(prompts: list[str]) -> float:
|
def population_prompt_diversity(prompts: list[str]) -> float:
|
||||||
"""Levenshtein normalizzata media su tutte le coppie. 0.0 = identici, 1.0 = totalmente diversi."""
|
"""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.
|
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).
|
Aggiungere `diversity_prompt` come campo per-generazione in `repository.save_generation` (richiede migration leggera).
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user