41e26cbe5b
Piano in tasca: NON attivare finché Phase 2 (qwen3 + temporal) non raggiunge una delle condizioni di trigger documentate: - plateau max fitness < 0.01 per >= 4 gen consecutive - diversità prompt Levenshtein <= 0.15 - top genome quasi-fit (>= 0.10) con adversarial HIGH >= 2 Sei tipi di mutazione (tighten_threshold, swap_comparator, add/remove_condition, change_timeframe, add_temporal_gate) via mutator tier B (deepseek-v4-flash), weighted_random_mutate dispatcher con default 0.0 (opt-in), diversity guard Levenshtein, fallback random_mutate su validation fail. Costo stimato +$0.006/run. A/B con baseline B = phase2-qwen3-001 vs trattamento T = phase2-qwen3-prompt-mut-001 per attribuzione effetto. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
312 lines
12 KiB
Markdown
312 lines
12 KiB
Markdown
# `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.
|
||
|
||
**Status:** Piano in tasca, **NON attivare** finché la condizione di trigger non è soddisfatta. Phase 2 (qwen3 + feature temporali) è la baseline.
|
||
|
||
**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`
|
||
|
||
- [ ] **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
|
||
```
|
||
|
||
- [ ] **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)...",
|
||
}
|
||
```
|
||
|
||
- [ ] **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`).
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **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]
|
||
```
|
||
|
||
- [ ] **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)`.
|
||
|
||
- [ ] **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**
|
||
|
||
```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`
|
||
|
||
- [ ] **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
|
||
```
|
||
|
||
- [ ] **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)
|
||
```
|
||
|
||
- [ ] **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`
|
||
|
||
- [ ] **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
|
||
```
|
||
|
||
- [ ] **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)
|
||
```
|
||
|
||
- [ ] **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**
|
||
|
||
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`
|
||
|
||
- [ ] **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**
|
||
|
||
Tre prompt identici → 0.0. Tre prompt totalmente diversi → ~1.0.
|
||
|
||
- [ ] **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).
|