Files
Multi_Swarm_Coevolutive/docs/superpowers/plans/2026-05-11-mutate-prompt-llm-phase-2-5.md
T
Adriano ec80af9f26 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>
2026-05-11 23:52:09 +02:00

319 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `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 (`- [x]`) syntax for tracking.
**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.
**Architecture:** Operatore puro come gli altri quattro (`mutate_temperature`, `mutate_lookback`, `mutate_feature_access`, `mutate_cognitive_style`). Riceve `parent_genome`, `llm_client`, `rng` e restituisce un child genome con `system_prompt` modificato. Il mutator LLM (tier B = `deepseek/deepseek-v4-flash`) riceve una mutation-instruction casuale tra sei tipi predefiniti (`tighten_threshold`, `swap_comparator`, `add_condition`, `remove_condition`, `change_timeframe`, `add_temporal_gate`) e produce un nuovo prompt vincolato a una mutazione "atomica". Il child viene validato (parser + adversarial dry-run); su fallimento si effettua fallback a `random_mutate`. Selezione probabilistica nel `random_mutate` dispatcher con peso configurabile (default 0.30) — i quattro operator scalari mantengono il 70% complessivo.
**Tech Stack:** Python 3.13, `LLMClient` esistente (OpenAI SDK via OpenRouter), pytest + `pytest-mock`. Niente nuove dipendenze.
**Spec di riferimento:** sezione "Meccanismo di mutazione" della conversazione `2026-05-11`, valutazione `mutate_prompt_llm` (questa pagina contiene la sintesi).
---
## Trigger condition (quando attivare)
Implementare e mergiare **solo se** uno dei seguenti è vero al termine di Phase 2:
1. **Plateau evolutivo**: max fitness stagnante (Δ < 0.01) per ≥ 4 generazioni consecutive su `phase2-qwen3-001` o successori.
2. **Diversità prompt collassa**: media Levenshtein normalizzata fra i prompt della popolazione finale ≤ 0.15 (= popolazione clonata).
3. **Top genome problematico ma quasi-fit**: max fitness ≥ 0.10 ma adversarial finding HIGH ≥ 2 per il top, suggerendo che una mutazione mirata del prompt potrebbe "ripararlo".
Se Phase 2 raggiunge max fitness ≥ 0.30 senza plateau, **non attivare** (la diversità random basta).
---
## File map
| File | Tipo | Responsabilità |
|------|------|----------------|
| `src/multi_swarm/genome/mutation_prompt_llm.py` | New | Operatore `mutate_prompt_llm` + helper `MUTATION_INSTRUCTIONS` + retry/fallback wrapper |
| `src/multi_swarm/genome/mutation.py` | Modify | Estendere `MUTATION_OPS` + introdurre dispatcher pesato `weighted_random_mutate` |
| `src/multi_swarm/ga/loop.py` | Modify | Sostituire `random_mutate(parent, rng)` con `weighted_random_mutate(parent, rng, llm_client, weights)` |
| `src/multi_swarm/orchestrator/run.py` | Modify | Aggiungere `mutator_tier: ModelTier = ModelTier.B` e `prompt_mutation_weight: float = 0.30` a `RunConfig`, passare `LLMClient` al loop GA |
| `src/multi_swarm/llm/cost_tracker.py` | Modify (minimo) | Loggare `mutation_call` separatamente da `hypothesis_call` per attribuzione costo |
| `src/multi_swarm/metrics/diversity.py` | New | Funzione `population_prompt_diversity` (Levenshtein normalizzata) — usata in trigger check + telemetry |
| `tests/unit/test_mutation_prompt_llm.py` | New | Test operator con mock `LLMClient` (success + validation fail + retry/fallback) |
| `tests/unit/test_mutation_dispatcher.py` | New | Test `weighted_random_mutate` rispetta i pesi |
| `tests/unit/test_diversity.py` | New | Test `population_prompt_diversity` su prompt identici/diversi |
| `tests/integration/test_ga_loop_with_prompt_mutator.py` | New | Loop end-to-end di 2 gen × 5 genomi con mock LLM, verifica diversità prompt cresce |
---
## Task 1: Mutator instructions + operator stub
**Files:**
- New: `src/multi_swarm/genome/mutation_prompt_llm.py`
- New: `tests/unit/test_mutation_prompt_llm.py`
- [x] **Step 1.1: Write failing test — operator returns child con system_prompt diverso**
Append a `tests/unit/test_mutation_prompt_llm.py`:
```python
def test_mutate_prompt_llm_produces_different_prompt(mock_llm: LLMClient) -> None:
parent = make_genome(system_prompt="Strategia: compra quando RSI < 30")
mock_llm.respond_with("Strategia: compra quando RSI < 25 e ora >= 14")
child = mutate_prompt_llm(parent, mock_llm, rng=random.Random(0))
assert child.system_prompt != parent.system_prompt
assert child.parent_ids == [*parent.parent_ids, parent.id]
assert child.generation == parent.generation + 1
```
- [x] **Step 1.2: Implement `MUTATION_INSTRUCTIONS` constant**
`mutation_prompt_llm.py`:
```python
MUTATION_INSTRUCTIONS: dict[str, str] = {
"tighten_threshold": "Rendi una soglia numerica più restrittiva del 1020%...",
"swap_comparator": "Inverti un comparator (gt ↔ lt, gte ↔ lte) mantenendo intent...",
"add_condition": "Aggiungi una condizione AND/OR alla rule più specifica...",
"remove_condition": "Rimuovi una condizione ridondante o debole...",
"change_timeframe": "Modifica una finestra rolling (lookback) di ±30%...",
"add_temporal_gate": "Aggiungi un gate temporale (hour, dow, is_weekend)...",
}
```
- [x] **Step 1.3: Implement `mutate_prompt_llm`**
Firma:
```python
def mutate_prompt_llm(
g: HypothesisAgentGenome,
llm: LLMClient,
rng: random.Random,
mutator_tier: ModelTier = ModelTier.B,
) -> HypothesisAgentGenome:
```
Logica:
1. Scegli `instruction_key = rng.choice(list(MUTATION_INSTRUCTIONS))`.
2. Costruisci messaggio system + user con `MUTATION_INSTRUCTIONS[instruction_key]` + `g.system_prompt`.
3. Crea genoma temporaneo `mutator_genome` con `model_tier=mutator_tier`.
4. Chiama `llm.complete(mutator_genome, system, user, max_tokens=2000)`.
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`).
- [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
```
---
## Task 2: Validation + fallback
**Files:**
- Modify: `src/multi_swarm/genome/mutation_prompt_llm.py`
- Append: `tests/unit/test_mutation_prompt_llm.py`
- [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:
parent = make_genome()
mock_llm.respond_with("garbage that does not parse")
child = mutate_prompt_llm(parent, mock_llm, rng=random.Random(0))
# Garbage prompt deve fallback: child è prodotto da random_mutate, quindi
# system_prompt == parent.system_prompt (random_mutate tocca solo scalari)
assert child.system_prompt == parent.system_prompt
assert child.parent_ids == [*parent.parent_ids, parent.id]
```
- [x] **Step 2.2: Implement validation step**
Dopo aver estratto `new_prompt`, esegui `validate_prompt(new_prompt)`:
- Lunghezza minima 50 caratteri.
- Contiene almeno una keyword fra `{rsi, sma, ema, atr, momentum, breakout, mean reversion, gt, lt, ...}`.
- Non identico a `parent.system_prompt` (Levenshtein > 0.05 normalizzata).
Su fail → log warning + ritorna `random_mutate(g, rng)`.
- [x] **Step 2.3: Write failing test — diversity guard**
Mock LLM ritorna prompt identico al parent → `validate_prompt` rifiuta → fallback.
- [x] **Step 2.4: Run test suite parziale**
```bash
uv run pytest tests/unit/test_mutation_prompt_llm.py -xvs
```
---
## Task 3: Weighted dispatcher
**Files:**
- Modify: `src/multi_swarm/genome/mutation.py`
- New: `tests/unit/test_mutation_dispatcher.py`
- [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:
rng = random.Random(0)
weights = {"prompt": 1.0, "scalar": 0.0} # 100% prompt
counter = Counter()
for _ in range(100):
op_name = _pick_op_name(weights, rng)
counter[op_name] += 1
assert counter["prompt"] == 100
```
- [x] **Step 3.2: Implement `weighted_random_mutate`**
```python
def weighted_random_mutate(
g: HypothesisAgentGenome,
rng: random.Random,
llm: LLMClient | None = None,
prompt_mutation_weight: float = 0.30,
) -> HypothesisAgentGenome:
if llm is not None and rng.random() < prompt_mutation_weight:
return mutate_prompt_llm(g, llm, rng)
return random_mutate(g, rng)
```
- [x] **Step 3.3: Test edge cases**
- `llm=None` → sempre scalar mutation (backward compat).
- `prompt_mutation_weight=0.0` → sempre scalar.
- `prompt_mutation_weight=1.0` → sempre prompt (se llm presente).
---
## Task 4: Integrazione GA loop
**Files:**
- Modify: `src/multi_swarm/ga/loop.py`
- Modify: `src/multi_swarm/orchestrator/run.py`
- New: `tests/integration/test_ga_loop_with_prompt_mutator.py`
- [x] **Step 4.1: Estendere `GAConfig`**
```python
@dataclass(frozen=True)
class GAConfig:
population_size: int
elite_k: int
tournament_k: int
p_crossover: float
prompt_mutation_weight: float = 0.0 # default off → opt-in
```
- [x] **Step 4.2: Pass `LLMClient` in `next_generation`**
```python
def next_generation(
population: list[HypothesisAgentGenome],
fitnesses: dict[str, float],
cfg: GAConfig,
rng: random.Random,
llm: LLMClient | None = None,
) -> list[HypothesisAgentGenome]:
...
child = weighted_random_mutate(parent, rng, llm, cfg.prompt_mutation_weight)
```
- [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`.
- [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.
---
## Task 5: Diversity metric
**Files:**
- New: `src/multi_swarm/metrics/diversity.py`
- New: `tests/unit/test_diversity.py`
- [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."""
```
- [x] **Step 5.2: Test**
Tre prompt identici → 0.0. Tre prompt totalmente diversi → ~1.0.
- [x] **Step 5.3: Logging**
Aggiungere `diversity_prompt` come campo per-generazione in `repository.save_generation` (richiede migration leggera).
---
## Task 6: Cost attribution
**Files:**
- Modify: `src/multi_swarm/llm/cost_tracker.py`
- Modify: tests esistenti
- [ ] **Step 6.1: Aggiungere `call_kind` a `CostRecord`**
```python
@dataclass
class CostRecord:
...
call_kind: str = "hypothesis" # "hypothesis" | "mutation"
```
- [ ] **Step 6.2: Loggare separatamente in summary**
`summary()["by_call_kind"]` con breakdown.
- [ ] **Step 6.3: Test compatibilità con record esistenti**
Backward compat: record senza `call_kind` interpretati come `"hypothesis"`.
---
## Verification end-to-end
- [ ] `uv run pytest -q` → 100% passa (157 + nuovi test).
- [ ] `uv run python scripts/smoke_run.py` → completa con mock LLM.
- [ ] **Run baseline B**: ripetere `phase2-qwen3-001` con `--prompt-mutation-weight 0.0` per controllo.
- [ ] **Run trattamento T**: `phase2-qwen3-prompt-mut-001` con `--prompt-mutation-weight 0.30`.
- [ ] Confronto: max fitness T > B + 20%, diversity_prompt(T) > diversity_prompt(B) + 30%.
- [ ] Costo aggiuntivo run T ≤ $0.10 (sanity check).
---
## Risks & mitigations
| Rischio | Mitigazione |
|---------|-------------|
| Mode collapse mutator LLM | `mutation_instruction` scelta random + diversity guard Levenshtein |
| Prompt LLM-output non parsabile dal compiler | Validation step + fallback `random_mutate` |
| Costo runaway (loop infinito retry) | `max_tokens=2000`, no retry su validation fail |
| Bias condiviso con generator tier C | Mutator tier B = `deepseek-v4-flash`, famiglia diversa da Qwen3 |
| Variabili confuse con Phase 2 | Attivare **solo** dopo Phase 2 baseline; A/B isolato |
---
## Cost estimate
Pop = 20, gen = 10, mutation rate ~75% (5 elite + 15 children), prompt_mutation_weight = 0.30:
- ~45 chiamate LLM tier B aggiuntive per run.
- ~500 tok input + 200 tok output per call → 22.5k in + 9k out totali.
- 22.5k × $0.14/1M + 9k × $0.28/1M ≈ **$0.0057/run**.
Trascurabile rispetto al budget run base (~$0.10).