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:
2026-05-11 23:52:09 +02:00
parent c38311e5fa
commit ec80af9f26
3 changed files with 102 additions and 20 deletions
@@ -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 `<prompt>...</prompt>` 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).