# `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 10–20%...", "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 `...` 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).