Skip to main content

dodecet_encoder/
seed_discovery.rs

1//! Seed Discovery Engine — Rapid Iteration with Tiny Models to Discover Response Tiles
2//!
3//! The architecture:
4//!
5//! ```text
6//! ┌─────────────────────────────────────────────────────────┐
7//! │                  SEED DISCOVERY ENGINE                   │
8//! │                                                          │
9//! │  1. Define a ROLE (e.g., "constraint-checker")          │
10//! │  2. Run N iterations with SEED model (cheap, fast)       │
11//! │  3. Score each response against criteria                 │
12//! │  4. Extract the PATTERN from high-scoring responses      │
13//! │  5. Crystallize pattern as a TILE (structured fragment)  │
14//! │  6. Propagate tile UP (larger models) and DOWN (seeds)   │
15//! │                                                          │
16//! │  The tile IS the inner logic.                            │
17//! │  The discovery IS the fine-tuning.                       │
18//! └─────────────────────────────────────────────────────────┘
19//! ```
20//!
21//! ## How It Works
22//!
23//! A "seed run" is:
24//! - A role definition (what the agent should do)
25//! - An evaluation function (how to score the response)
26//! - N iterations with a cheap model
27//! - Pattern extraction from the winners
28//!
29//! The output is a **Tile** — a compressed, self-contained fragment that captures
30//! the discovered inner logic. Future calls to ANY model include this tile as
31//! conditioning context.
32//!
33//! ## The Meta-Insight
34//!
35//! The seed doesn't need to produce the BEST response. It needs to produce
36//! enough variation that the PATTERN of good responses becomes visible.
37//! The tile captures that pattern, not any individual response.
38
39use crate::eisenstein::{EisensteinConstraint, SnapResult, COVERING_RADIUS};
40use crate::temporal::{TemporalAgent, AgentAction, FunnelPhase, ChiralityState};
41use std::collections::HashMap;
42
43/// Maximum iterations per seed run
44const MAX_ITERATIONS: usize = 64;
45
46/// A discovered tile — the crystallized inner logic from seed experimentation.
47#[derive(Debug, Clone)]
48pub struct DiscoveryTile {
49    /// What role this tile is for
50    pub role: String,
51    /// The discovered pattern (structured prompt fragment)
52    pub pattern: String,
53    /// The constraint parameters that produced high-scoring responses
54    pub optimal_params: TileParams,
55    /// How many seed iterations produced this tile
56    pub iterations: usize,
57    /// The score that crystallized this tile
58    pub crystallization_score: f64,
59    /// Entropy of the discovery process (how much variation was explored)
60    pub discovery_entropy: f64,
61    /// Which actions were most common in high-scoring runs
62    pub dominant_actions: Vec<(AgentAction, f64)>,
63    /// The funnel phase distribution in winning responses
64    pub phase_distribution: HashMap<String, f64>,
65    /// Generation: how many times this tile has been refined
66    pub generation: u32,
67}
68
69/// Parameters discovered by seed experimentation
70#[derive(Debug, Clone, Copy)]
71pub struct TileParams {
72    pub decay_rate: f64,
73    pub prediction_horizon: usize,
74    pub anomaly_sigma: f64,
75    pub learning_rate: f64,
76    pub chirality_lock_threshold: u16,
77    pub merge_trust: f64,
78}
79
80impl Default for TileParams {
81    fn default() -> Self {
82        TileParams {
83            decay_rate: 1.0,
84            prediction_horizon: 4,
85            anomaly_sigma: 2.0,
86            learning_rate: 0.1,
87            chirality_lock_threshold: 500,
88            merge_trust: 0.5,
89        }
90    }
91}
92
93/// Score for a single seed iteration
94#[derive(Debug, Clone)]
95pub struct IterationScore {
96    /// Which iteration (0..N)
97    pub iteration: usize,
98    /// The parameters used
99    pub params: TileParams,
100    /// Final error achieved
101    pub final_error: f64,
102    /// How many steps to converge (lower is better)
103    pub convergence_steps: usize,
104    /// How many anomalies detected (too many = bad)
105    pub anomaly_count: usize,
106    /// Whether chirality locked (good for deterministic systems)
107    pub chirality_locked: bool,
108    /// Total precision energy spent (lower is better)
109    pub precision_energy: f64,
110    /// The dominant action in the trajectory
111    pub dominant_action: AgentAction,
112    /// Composite score (higher is better)
113    pub score: f64,
114}
115
116/// A seed discovery run — rapid iteration to find optimal parameters
117pub struct SeedDiscovery {
118    /// The constraint checker
119    constraint: EisensteinConstraint,
120    /// The role being discovered
121    role: String,
122    /// Iteration results
123    iterations: Vec<IterationScore>,
124    /// Current best score
125    best_score: f64,
126    /// Current best params
127    best_params: TileParams,
128    /// Generation counter
129    generation: u32,
130}
131
132impl SeedDiscovery {
133    pub fn new(role: &str) -> Self {
134        SeedDiscovery {
135            constraint: EisensteinConstraint::new(),
136            role: role.to_string(),
137            iterations: Vec::with_capacity(MAX_ITERATIONS),
138            best_score: f64::NEG_INFINITY,
139            best_params: TileParams::default(),
140            generation: 0,
141        }
142    }
143
144    /// Run a single seed iteration with given parameters on a trajectory.
145    ///
146    /// The trajectory is a series of (x, y) sensor readings.
147    /// The evaluation scores how well the agent handles the trajectory.
148    pub fn run_iteration(
149        &mut self,
150        params: TileParams,
151        trajectory: &[(f64, f64)],
152    ) -> IterationScore {
153        let mut agent = TemporalAgent::new();
154        agent.decay_rate = params.decay_rate;
155        agent.prediction_horizon = params.prediction_horizon;
156        agent.anomaly_sigma = params.anomaly_sigma;
157        agent.learning_rate = params.learning_rate;
158        agent.chirality_lock_threshold = params.chirality_lock_threshold;
159        agent.merge_trust = params.merge_trust;
160
161        let mut anomaly_count = 0;
162        let mut convergence_step = trajectory.len();
163        let mut final_error = COVERING_RADIUS;
164        let mut action_counts: HashMap<AgentAction, usize> = HashMap::new();
165
166        for (step, &(x, y)) in trajectory.iter().enumerate() {
167            let update = agent.observe(x, y);
168
169            if update.is_anomaly {
170                anomaly_count += 1;
171            }
172
173            if update.snap.error < 0.05 * COVERING_RADIUS && convergence_step == trajectory.len() {
174                convergence_step = step;
175            }
176
177            final_error = update.snap.error;
178            *action_counts.entry(update.action).or_insert(0) += 1;
179        }
180
181        let dominant_action = action_counts
182            .iter()
183            .max_by_key(|(_, &c)| c)
184            .map(|(&a, _)| a)
185            .unwrap_or(AgentAction::Continue);
186
187        let chirality_locked = matches!(agent.summary().chirality, ChiralityState::Locked { .. });
188
189        // Composite score: balance convergence speed, error, anomalies, and energy
190        let convergence_bonus = 1.0 - (convergence_step as f64 / trajectory.len() as f64).min(1.0);
191        let error_score = 1.0 - (final_error / COVERING_RADIUS).min(1.0);
192        let anomaly_penalty = (anomaly_count as f64 * 0.1).min(1.0);
193        let chirality_bonus = if chirality_locked { 0.1 } else { 0.0 };
194        let energy_penalty = (agent.summary().precision_energy * 0.001).min(0.5);
195
196        let score = convergence_bonus * 0.3
197            + error_score * 0.3
198            + (1.0 - anomaly_penalty) * 0.2
199            + chirality_bonus * 0.1
200            + (1.0 - energy_penalty) * 0.1;
201
202        let iter_score = IterationScore {
203            iteration: self.iterations.len(),
204            params,
205            final_error,
206            convergence_steps: convergence_step,
207            anomaly_count,
208            chirality_locked,
209            precision_energy: agent.summary().precision_energy,
210            dominant_action,
211            score,
212        };
213
214        if score > self.best_score {
215            self.best_score = score;
216            self.best_params = params;
217        }
218
219        self.iterations.push(iter_score.clone());
220        iter_score
221    }
222
223    /// Run a sweep of parameter variations (the seed experimentation).
224    ///
225    /// Generates parameter variations around the current best and evaluates them.
226    pub fn run_sweep(&mut self, trajectory: &[(f64, f64)], n_variations: usize) {
227        for i in 0..n_variations {
228            let params = self.generate_variation(i, n_variations);
229            self.run_iteration(params, trajectory);
230        }
231    }
232
233    /// Generate a parameter variation.
234    ///
235    /// Strategy: Latin hypercube sampling around current best.
236    fn generate_variation(&self, index: usize, total: usize) -> TileParams {
237        let t = index as f64 / total as f64;
238        let base = self.best_params;
239
240        // Vary each parameter along a different dimension
241        let phase = t * std::f64::consts::PI * 2.0;
242        let r = 0.5; // variation radius
243
244        TileParams {
245            decay_rate: (base.decay_rate + r * (phase * 1.0).sin()).max(0.1).min(10.0),
246            prediction_horizon: (base.prediction_horizon as f64 + 4.0 * (phase * 2.0).sin())
247                .round()
248                .max(1.0)
249                .min(16.0) as usize,
250            anomaly_sigma: (base.anomaly_sigma + r * 2.0 * (phase * 3.0).sin()).max(0.5).min(5.0),
251            learning_rate: (base.learning_rate + 0.3 * (phase * 5.0).sin())
252                .max(0.01)
253                .min(1.0),
254            chirality_lock_threshold: ((base.chirality_lock_threshold as f64
255                + 200.0 * (phase * 7.0).sin())
256                .round()
257                .max(100.0)
258                .min(900.0)) as u16,
259            merge_trust: (base.merge_trust + 0.3 * (phase * 11.0).sin())
260                .max(0.0)
261                .min(1.0),
262        }
263    }
264
265    /// Crystallize the discovered pattern into a tile.
266    ///
267    /// This is the key operation: extract the inner logic from the iteration history
268    /// and compress it into a self-contained tile that can condition future models.
269    pub fn crystallize(&self) -> DiscoveryTile {
270        let top_scores: Vec<&IterationScore> = {
271            let mut sorted: Vec<&IterationScore> = self.iterations.iter().collect();
272            sorted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
273            sorted.into_iter().take(10).collect()
274        };
275
276        // Extract dominant actions from top scores
277        let mut action_counts: HashMap<AgentAction, f64> = HashMap::new();
278        for iter in &top_scores {
279            *action_counts.entry(iter.dominant_action).or_insert(0.0) += iter.score;
280        }
281        let total_action_weight: f64 = action_counts.values().sum();
282        let mut dominant_actions: Vec<(AgentAction, f64)> = action_counts
283            .into_iter()
284            .map(|(a, w)| (a, w / total_action_weight))
285            .collect();
286        dominant_actions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
287
288        // Compute entropy of the discovery process
289        let scores: Vec<f64> = self.iterations.iter().map(|i| i.score).collect();
290        let mean_score = scores.iter().sum::<f64>() / scores.len() as f64;
291        let variance = scores.iter().map(|s| (s - mean_score).powi(2)).sum::<f64>() / scores.len() as f64;
292        let discovery_entropy = (variance.sqrt() / mean_score).min(1.0);
293
294        // Build the pattern string (the inner logic)
295        let pattern = self.build_pattern(&top_scores);
296
297        // Phase distribution
298        let mut phase_dist: HashMap<String, f64> = HashMap::new();
299        // Re-run best params to get phase distribution
300        // (simplified: use the score distribution as proxy)
301        phase_dist.insert("convergent".to_string(), self.best_score);
302        phase_dist.insert("exploratory".to_string(), 1.0 - self.best_score);
303
304        DiscoveryTile {
305            role: self.role.clone(),
306            pattern,
307            optimal_params: self.best_params,
308            iterations: self.iterations.len(),
309            crystallization_score: self.best_score,
310            discovery_entropy,
311            dominant_actions,
312            phase_distribution: phase_dist,
313            generation: self.generation,
314        }
315    }
316
317    /// Build the pattern string from top-scoring iterations.
318    fn build_pattern(&self, top: &[&IterationScore]) -> String {
319        let avg_convergence: f64 =
320            top.iter().map(|i| i.convergence_steps as f64).sum::<f64>() / top.len() as f64;
321        let avg_anomaly: f64 =
322            top.iter().map(|i| i.anomaly_count as f64).sum::<f64>() / top.len() as f64;
323        let locked_ratio: f64 =
324            top.iter().filter(|i| i.chirality_locked).count() as f64 / top.len() as f64;
325
326        format!(
327            "Role: {}\n\
328             Optimal decay_rate: {:.3} (funnel speed)\n\
329             Optimal horizon: {} (prediction depth)\n\
330             Optimal anomaly_sigma: {:.2} (surprise sensitivity)\n\
331             Optimal learning_rate: {:.3} (memory plasticity)\n\
332             Optimal chirality_lock: {} (commitment threshold)\n\
333             Convergence: ~{:.0} steps average\n\
334             Anomaly rate: ~{:.1} per trajectory\n\
335             Chirality lock: {:.0}% of top runs\n\
336             Score: {:.3}\n\
337             Discovery entropy: {:.3}\n\
338             Generation: {}",
339            self.role,
340            self.best_params.decay_rate,
341            self.best_params.prediction_horizon,
342            self.best_params.anomaly_sigma,
343            self.best_params.learning_rate,
344            self.best_params.chirality_lock_threshold,
345            avg_convergence,
346            avg_anomaly,
347            locked_ratio * 100.0,
348            self.best_score,
349            self.discovery_entropy(),
350            self.generation,
351        )
352    }
353
354    fn discovery_entropy(&self) -> f64 {
355        if self.iterations.is_empty() {
356            return 0.0;
357        }
358        let scores: Vec<f64> = self.iterations.iter().map(|i| i.score).collect();
359        let mean = scores.iter().sum::<f64>() / scores.len() as f64;
360        let var = scores.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / scores.len() as f64;
361        (var.sqrt() / mean).min(1.0)
362    }
363
364    /// Refine: run another sweep centered on the current best.
365    /// Each refinement tightens the search around the optimum.
366    pub fn refine(&mut self, trajectory: &[(f64, f64)], n_variations: usize) {
367        self.generation += 1;
368        self.run_sweep(trajectory, n_variations);
369    }
370}
371
372/// Tile registry — stores discovered tiles for use by larger models.
373#[derive(Debug, Default)]
374pub struct TileRegistry {
375    tiles: HashMap<String, DiscoveryTile>,
376}
377
378impl TileRegistry {
379    pub fn new() -> Self {
380        TileRegistry {
381            tiles: HashMap::new(),
382        }
383    }
384
385    /// Register a discovered tile.
386    pub fn register(&mut self, tile: DiscoveryTile) {
387        self.tiles.insert(tile.role.clone(), tile);
388    }
389
390    /// Get a tile for a role.
391    pub fn get(&self, role: &str) -> Option<&DiscoveryTile> {
392        self.tiles.get(role)
393    }
394
395    /// Get the optimal parameters for a role (for conditioning larger models).
396    pub fn get_params(&self, role: &str) -> TileParams {
397        self.tiles
398            .get(role)
399            .map(|t| t.optimal_params)
400            .unwrap_or_default()
401    }
402
403    /// Generate a conditioning prompt for a larger model.
404    /// This is how the seed's discovered inner logic propagates upward.
405    pub fn conditioning_prompt(&self, role: &str) -> String {
406        match self.tiles.get(role) {
407            Some(tile) => format!(
408                "# Discovered Inner Logic for: {}\n\
409                 # (crystallized from {} seed iterations, generation {})\n\
410                 # Score: {:.3}, Entropy: {:.3}\n\
411                 \n\
412                 {}\n\
413                 \n\
414                 # Optimal Parameters:\n\
415                 decay_rate = {:.3}  # funnel speed (square-root base)\n\
416                 prediction_horizon = {}  # steps ahead\n\
417                 anomaly_sigma = {:.2}  # surprise threshold (sigma)\n\
418                 learning_rate = {:.3}  # memory plasticity\n\
419                 chirality_lock = {}  # commitment threshold (milli)\n\
420                 merge_trust = {:.2}  # fleet vs local\n\
421                 \n\
422                 # Dominant actions: {}\n\
423                 \n\
424                 Use these parameters when performing this role. \
425                 The seed experimentation has proven these are optimal \
426                 for the constraint geometry of this domain.",
427                tile.role,
428                tile.iterations,
429                tile.generation,
430                tile.crystallization_score,
431                tile.discovery_entropy,
432                tile.pattern,
433                tile.optimal_params.decay_rate,
434                tile.optimal_params.prediction_horizon,
435                tile.optimal_params.anomaly_sigma,
436                tile.optimal_params.learning_rate,
437                tile.optimal_params.chirality_lock_threshold,
438                tile.optimal_params.merge_trust,
439                tile.dominant_actions
440                    .iter()
441                    .take(3)
442                    .map(|(a, w)| format!("{:?} ({:.0}%)", a, w * 100.0))
443                    .collect::<Vec<_>>()
444                    .join(", "),
445            ),
446            None => "# No seed tile found for this role. Use defaults.".to_string(),
447        }
448    }
449
450    /// List all registered tiles.
451    pub fn list(&self) -> Vec<&DiscoveryTile> {
452        self.tiles.values().collect()
453    }
454}
455
456/// Generate a test trajectory (converging spiral toward origin)
457pub fn converging_spiral(steps: usize, radius: f64, turns: f64) -> Vec<(f64, f64)> {
458    (0..steps)
459        .map(|i| {
460            let t = i as f64 / steps as f64;
461            let r = radius * (1.0 - t);
462            let angle = turns * 2.0 * std::f64::consts::PI * t;
463            (r * angle.cos(), r * angle.sin())
464        })
465        .collect()
466}
467
468/// Generate a test trajectory (noisy sensor reading around a point)
469pub fn noisy_sensor(steps: usize, center: (f64, f64), noise: f64) -> Vec<(f64, f64)> {
470    (0..steps)
471        .map(|i| {
472            let t = i as f64 / steps as f64;
473            let angle = t * 7.0 * std::f64::consts::PI;
474            let r = noise * (angle.sin() * 0.7 + angle.cos() * 0.3);
475            (center.0 + r * angle.cos(), center.1 + r * angle.sin())
476        })
477        .collect()
478}
479
480/// Generate a test trajectory (step function — sudden jump)
481pub fn step_trajectory(steps: usize, jump_at: usize) -> Vec<(f64, f64)> {
482    (0..steps)
483        .map(|i| {
484            if i < jump_at {
485                (0.1, 0.1)
486            } else {
487                (2.0, 2.0)
488            }
489        })
490        .collect()
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn test_seed_discovery_converging() {
499        let trajectory = converging_spiral(50, COVERING_RADIUS * 2.0, 2.0);
500        let mut discovery = SeedDiscovery::new("converging-tracker");
501        discovery.run_sweep(&trajectory, 20);
502
503        let tile = discovery.crystallize();
504        assert!(tile.crystallization_score > 0.0);
505        assert_eq!(tile.role, "converging-tracker");
506        assert_eq!(tile.iterations, 20);
507    }
508
509    #[test]
510    fn test_seed_discovery_noisy() {
511        let trajectory = noisy_sensor(50, (0.0, 0.0), 0.1);
512        let mut discovery = SeedDiscovery::new("noisy-sensor");
513        discovery.run_sweep(&trajectory, 20);
514
515        let tile = discovery.crystallize();
516        assert!(tile.crystallization_score > 0.0);
517        // Noisy sensor should prefer higher anomaly sigma (less jumpy)
518        // and lower learning rate (more stable)
519    }
520
521    #[test]
522    fn test_seed_discovery_step() {
523        let trajectory = step_trajectory(50, 25);
524        let mut discovery = SeedDiscovery::new("step-detector");
525        discovery.run_sweep(&trajectory, 20);
526
527        let tile = discovery.crystallize();
528        assert!(tile.crystallization_score > 0.0);
529        // Step detector should prefer lower anomaly sigma (more sensitive)
530    }
531
532    #[test]
533    fn test_tile_registry() {
534        let trajectory = converging_spiral(50, COVERING_RADIUS * 2.0, 2.0);
535        let mut discovery = SeedDiscovery::new("test-role");
536        discovery.run_sweep(&trajectory, 10);
537        let tile = discovery.crystallize();
538
539        let mut registry = TileRegistry::new();
540        registry.register(tile);
541
542        assert!(registry.get("test-role").is_some());
543        assert!(registry.get("nonexistent").is_none());
544
545        let prompt = registry.conditioning_prompt("test-role");
546        assert!(prompt.contains("test-role"));
547        assert!(prompt.contains("decay_rate"));
548    }
549
550    #[test]
551    fn test_refinement_improves() {
552        let trajectory = converging_spiral(50, COVERING_RADIUS * 2.0, 2.0);
553        let mut discovery = SeedDiscovery::new("refinement-test");
554
555        // First sweep
556        discovery.run_sweep(&trajectory, 10);
557        let _score_gen0 = discovery.crystallize().crystallization_score;
558
559        // Refine
560        discovery.refine(&trajectory, 10);
561        let score_gen1 = discovery.crystallize().crystallization_score;
562
563        // Refinement should not significantly degrade (may not improve due to randomness)
564        assert!(score_gen1 > 0.0);
565        assert_eq!(discovery.crystallize().generation, 1);
566    }
567
568    #[test]
569    fn test_trajectory_generators() {
570        let spiral = converging_spiral(20, 1.0, 1.0);
571        assert_eq!(spiral.len(), 20);
572        assert!(spiral[0].0.abs() > spiral[19].0.abs()); // converging
573
574        let noisy = noisy_sensor(20, (1.0, 1.0), 0.5);
575        assert_eq!(noisy.len(), 20);
576
577        let step = step_trajectory(20, 10);
578        assert_eq!(step.len(), 20);
579        assert!((step[5].0 - 0.1).abs() < 0.01);
580        assert!((step[15].0 - 2.0).abs() < 0.01);
581    }
582
583    #[test]
584    fn test_conditioning_prompt_structure() {
585        let trajectory = converging_spiral(30, COVERING_RADIUS, 1.5);
586        let mut discovery = SeedDiscovery::new("structured-role");
587        discovery.run_sweep(&trajectory, 15);
588        let tile = discovery.crystallize();
589
590        let mut registry = TileRegistry::new();
591        registry.register(tile);
592
593        let prompt = registry.conditioning_prompt("structured-role");
594        assert!(prompt.contains("Discovered Inner Logic"));
595        assert!(prompt.contains("seed iterations"));
596        assert!(prompt.contains("decay_rate"));
597        assert!(prompt.contains("prediction_horizon"));
598        assert!(prompt.contains("anomaly_sigma"));
599        assert!(prompt.contains("learning_rate"));
600        assert!(prompt.contains("chirality_lock"));
601        assert!(prompt.contains("merge_trust"));
602    }
603}