Skip to main content

stabilizer_core/
lib.rs

1//! # stabilizer-core
2//!
3//! Attempt-phase and delta-policy primitives for `forge-engine`.
4//!
5//! This crate owns the bounded policy objects that step an attempt through
6//! innovate/stabilize/clamp phases. It does not execute patches, run checks, or
7//! decide archive/promotion outcomes.
8
9//! # stabilizer-core
10//!
11//! Attempt-phase and novelty helpers for iterative patch generation.
12//!
13//! This Tier 3 crate models retry/stabilization phases and derives patch-level
14//! strategy signals. It does not own execution, scoring, or persistence.
15
16use std::collections::BTreeSet;
17
18use typed_patch::{EditOp, FileMode, StructuredPatch};
19
20pub type StrategyTag = String;
21
22#[derive(Debug, thiserror::Error)]
23pub enum StabilizerError {
24    #[error("stabilizer: all attempts exhausted")]
25    Exhausted,
26}
27
28#[derive(Debug, Clone)]
29pub struct StabilizerConfig {
30    pub force_family: String,
31    pub force_minimal_diff: bool,
32    pub stabilize_weight_factor: f64,
33}
34
35#[derive(Debug, Clone)]
36pub struct DeltaPolicy {
37    pub delta_amp_default: f64,
38    pub delta_amp_stabilize1: f64,
39    pub delta_amp_stabilize2: f64,
40    pub delta_amp_clamp: f64,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
44pub enum AttemptPhase {
45    Innovative = 0,
46    Stabilize1 = 1,
47    Stabilize2 = 2,
48    Clamp = 3,
49}
50
51#[derive(Debug, Clone)]
52pub struct AttemptOverrides {
53    pub phase: AttemptPhase,
54    pub delta_amplitude: f64,
55    pub force_family: Option<String>,
56    pub force_minimal_diff: bool,
57    pub weight_factor: f64,
58}
59
60#[derive(Debug, Clone)]
61pub struct Stabilizer {
62    delta_policy: DeltaPolicy,
63    current_phase_index: usize,
64    config: StabilizerConfig,
65}
66
67impl DeltaPolicy {
68    pub fn new(
69        delta_amp_default: f64,
70        delta_amp_stabilize1: f64,
71        delta_amp_stabilize2: f64,
72        delta_amp_clamp: f64,
73    ) -> Self {
74        Self {
75            delta_amp_default,
76            delta_amp_stabilize1,
77            delta_amp_stabilize2,
78            delta_amp_clamp,
79        }
80    }
81
82    pub fn amplitude_for_phase(&self, phase: AttemptPhase) -> f64 {
83        match phase {
84            AttemptPhase::Innovative => self.delta_amp_default,
85            AttemptPhase::Stabilize1 => self.delta_amp_stabilize1,
86            AttemptPhase::Stabilize2 => self.delta_amp_stabilize2,
87            AttemptPhase::Clamp => self.delta_amp_clamp,
88        }
89    }
90}
91
92impl AttemptPhase {
93    pub fn all() -> &'static [AttemptPhase] {
94        &[
95            AttemptPhase::Innovative,
96            AttemptPhase::Stabilize1,
97            AttemptPhase::Stabilize2,
98            AttemptPhase::Clamp,
99        ]
100    }
101}
102
103impl Stabilizer {
104    pub fn new(delta_policy: DeltaPolicy, config: StabilizerConfig) -> Self {
105        Self {
106            delta_policy,
107            current_phase_index: 0,
108            config,
109        }
110    }
111
112    pub fn next_attempt(&mut self) -> Result<AttemptOverrides, StabilizerError> {
113        let phases = AttemptPhase::all();
114        if self.current_phase_index >= phases.len() {
115            return Err(StabilizerError::Exhausted);
116        }
117
118        let phase = phases[self.current_phase_index];
119        let delta_amplitude = self.delta_policy.amplitude_for_phase(phase);
120        let force_family = match phase {
121            AttemptPhase::Stabilize1 | AttemptPhase::Stabilize2 => {
122                Some(self.config.force_family.clone())
123            }
124            _ => None,
125        };
126        let force_minimal_diff = matches!(phase, AttemptPhase::Stabilize2 | AttemptPhase::Clamp)
127            && self.config.force_minimal_diff;
128        let weight_factor = match phase {
129            AttemptPhase::Stabilize1 | AttemptPhase::Stabilize2 => {
130                self.config.stabilize_weight_factor
131            }
132            _ => 1.0,
133        };
134
135        self.current_phase_index += 1;
136
137        Ok(AttemptOverrides {
138            phase,
139            delta_amplitude,
140            force_family,
141            force_minimal_diff,
142            weight_factor,
143        })
144    }
145
146    pub fn has_next(&self) -> bool {
147        self.current_phase_index < AttemptPhase::all().len()
148    }
149
150    pub fn reset(&mut self) {
151        self.current_phase_index = 0;
152    }
153
154    pub fn current_index(&self) -> usize {
155        self.current_phase_index
156    }
157}
158
159pub fn extract_strategy_tags(patch: &StructuredPatch) -> Vec<StrategyTag> {
160    let mut tags = BTreeSet::new();
161
162    let file_count = patch.edits.len();
163    if file_count == 1 {
164        tags.insert("single_file".to_string());
165    }
166    if file_count > 3 {
167        tags.insert("multi_file".to_string());
168    }
169
170    let mut insert_count = 0_usize;
171    let mut replace_count = 0_usize;
172    let mut delete_count = 0_usize;
173
174    for edit in &patch.edits {
175        for op in &edit.ops {
176            match op {
177                EditOp::Insert { .. } => insert_count += 1,
178                EditOp::Replace { .. } => replace_count += 1,
179                EditOp::Delete { .. } => delete_count += 1,
180            }
181        }
182    }
183
184    if replace_count > insert_count + delete_count {
185        tags.insert("replace_heavy".to_string());
186    }
187    if insert_count > replace_count * 2 {
188        tags.insert("insert_heavy".to_string());
189    }
190    if delete_count > 0 && insert_count == 0 {
191        tags.insert("deletion_only".to_string());
192    }
193
194    for edit in &patch.edits {
195        if edit.mode == Some(FileMode::Create) {
196            tags.insert("new_file".to_string());
197        }
198        if edit.mode == Some(FileMode::Delete) {
199            tags.insert("file_deletion".to_string());
200        }
201
202        for op in &edit.ops {
203            let context = op.context_lines().join(" ");
204            if context.contains("fn ") {
205                tags.insert("fn_level_edit".to_string());
206            }
207            if context.contains("impl ") {
208                tags.insert("impl_level_edit".to_string());
209            }
210            if context.contains("trait ") {
211                tags.insert("trait_level_edit".to_string());
212            }
213            if context.contains("mod ") {
214                tags.insert("mod_level_edit".to_string());
215            }
216            if context.contains("macro_rules!") {
217                tags.insert("macro_level_edit".to_string());
218            }
219            if context.contains("async ") {
220                tags.insert("async_boundary".to_string());
221            }
222            if context.contains("-> Result") {
223                tags.insert("error_type_edit".to_string());
224            }
225        }
226    }
227
228    if tags.contains("new_file")
229        && (tags.contains("fn_level_edit") || tags.contains("impl_level_edit"))
230    {
231        tags.insert("module_split".to_string());
232    }
233
234    tags.into_iter().collect()
235}
236
237pub fn compute_tag_novelty(current_tags: &[String], recent_tags_union: &BTreeSet<String>) -> f64 {
238    if recent_tags_union.is_empty() {
239        return 1.0;
240    }
241
242    let current_set: BTreeSet<&str> = current_tags.iter().map(|tag| tag.as_str()).collect();
243    let recent_set: BTreeSet<&str> = recent_tags_union.iter().map(|tag| tag.as_str()).collect();
244
245    let intersection = current_set.intersection(&recent_set).count();
246    let union = current_set.union(&recent_set).count();
247    if union == 0 {
248        return 1.0;
249    }
250
251    1.0 - (intersection as f64 / union as f64)
252}
253
254pub fn determine_approach_family(tags: &[String]) -> &'static str {
255    let has = |tag: &str| tags.iter().any(|value| value == tag);
256
257    if (has("replace_heavy") || (has("fn_level_edit") && tags.len() <= 2) || has("single_file"))
258        && (has("replace_heavy") || has("fn_level_edit"))
259    {
260        return "mechanical";
261    }
262
263    if has("extract_function") || has("introduce_trait") || has("module_split") {
264        return "pattern_refactor";
265    }
266
267    if has("new_file") || has("trait_level_edit") || has("multi_file") {
268        return "architectural";
269    }
270
271    if tags.iter().any(|tag| tag.contains("perf")) || (has("async_boundary") && has("multi_file")) {
272        return "perf";
273    }
274
275    if has("error_type_edit") || has("macro_level_edit") {
276        return "safety";
277    }
278
279    "mechanical"
280}
281
282#[cfg(test)]
283mod wave1_tests {
284    use super::*;
285    use typed_patch::{Anchor, EditOp, FileEdit, FileMode, StructuredPatch};
286
287    fn sample_patch() -> StructuredPatch {
288        StructuredPatch {
289            patch_id: uuid::Uuid::new_v4(),
290            summary: "add helper".into(),
291            edits: vec![FileEdit {
292                path: "src/helper.rs".into(),
293                mode: Some(FileMode::Create),
294                ops: vec![EditOp::Insert {
295                    anchor: Anchor::AfterLine {
296                        line: 0,
297                        context_before: vec!["fn helper() {".into()],
298                        context_after: vec!["}".into()],
299                    },
300                    lines: vec!["    println!(\"ok\");".into()],
301                }],
302            }],
303            notes: vec![],
304        }
305    }
306
307    #[test]
308    fn next_attempt_advances_through_all_phases_then_exhausts() {
309        let mut stabilizer = Stabilizer::new(
310            DeltaPolicy::new(1.0, 0.5, 0.25, 0.1),
311            StabilizerConfig {
312                force_family: "mechanical".into(),
313                force_minimal_diff: true,
314                stabilize_weight_factor: 0.8,
315            },
316        );
317
318        let innovative = stabilizer.next_attempt().unwrap();
319        assert_eq!(innovative.phase, AttemptPhase::Innovative);
320        assert_eq!(innovative.delta_amplitude, 1.0);
321        assert!(innovative.force_family.is_none());
322
323        let stabilize1 = stabilizer.next_attempt().unwrap();
324        assert_eq!(stabilize1.phase, AttemptPhase::Stabilize1);
325        assert_eq!(stabilize1.force_family.as_deref(), Some("mechanical"));
326        assert!(!stabilize1.force_minimal_diff);
327
328        let stabilize2 = stabilizer.next_attempt().unwrap();
329        assert_eq!(stabilize2.phase, AttemptPhase::Stabilize2);
330        assert!(stabilize2.force_minimal_diff);
331
332        let clamp = stabilizer.next_attempt().unwrap();
333        assert_eq!(clamp.phase, AttemptPhase::Clamp);
334        assert!(clamp.force_minimal_diff);
335
336        assert!(matches!(
337            stabilizer.next_attempt(),
338            Err(StabilizerError::Exhausted)
339        ));
340    }
341
342    #[test]
343    fn strategy_tags_capture_patch_shape_and_family() {
344        let patch = sample_patch();
345        let tags = extract_strategy_tags(&patch);
346
347        assert!(tags.contains(&"single_file".to_string()));
348        assert!(tags.contains(&"new_file".to_string()));
349        assert!(tags.contains(&"fn_level_edit".to_string()));
350        assert!(tags.contains(&"module_split".to_string()));
351        assert_eq!(determine_approach_family(&tags), "mechanical");
352
353        let recent = BTreeSet::from(["new_file".to_string(), "single_file".to_string()]);
354        let novelty = compute_tag_novelty(&tags, &recent);
355        assert!(novelty > 0.0);
356        assert!(novelty < 1.0);
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use typed_patch::{Anchor, FileEdit};
364
365    fn sample_patch() -> StructuredPatch {
366        StructuredPatch {
367            patch_id: uuid::Uuid::nil(),
368            summary: "add helper".into(),
369            edits: vec![FileEdit {
370                path: "src/helper.rs".into(),
371                mode: Some(FileMode::Create),
372                ops: vec![EditOp::Insert {
373                    anchor: Anchor::AfterLine {
374                        line: 1,
375                        context_before: vec!["fn helper() {".into()],
376                        context_after: vec!["}".into()],
377                    },
378                    lines: vec!["    println!(\"hi\");".into()],
379                }],
380            }],
381            notes: vec![],
382        }
383    }
384
385    fn sample_stabilizer() -> Stabilizer {
386        Stabilizer::new(
387            DeltaPolicy::new(1.0, 0.5, 0.25, 0.1),
388            StabilizerConfig {
389                force_family: "mechanical".into(),
390                force_minimal_diff: true,
391                stabilize_weight_factor: 0.7,
392            },
393        )
394    }
395
396    #[test]
397    fn next_attempt_progresses_through_all_phases_then_exhausts() {
398        let mut stabilizer = sample_stabilizer();
399
400        let innovative = stabilizer.next_attempt().unwrap();
401        assert_eq!(innovative.phase, AttemptPhase::Innovative);
402        assert_eq!(innovative.delta_amplitude, 1.0);
403        assert_eq!(innovative.force_family, None);
404        assert!(!innovative.force_minimal_diff);
405        assert_eq!(innovative.weight_factor, 1.0);
406
407        let stabilize1 = stabilizer.next_attempt().unwrap();
408        assert_eq!(stabilize1.phase, AttemptPhase::Stabilize1);
409        assert_eq!(stabilize1.delta_amplitude, 0.5);
410        assert_eq!(stabilize1.force_family.as_deref(), Some("mechanical"));
411        assert!(!stabilize1.force_minimal_diff);
412        assert_eq!(stabilize1.weight_factor, 0.7);
413
414        let stabilize2 = stabilizer.next_attempt().unwrap();
415        assert_eq!(stabilize2.phase, AttemptPhase::Stabilize2);
416        assert_eq!(stabilize2.delta_amplitude, 0.25);
417        assert_eq!(stabilize2.force_family.as_deref(), Some("mechanical"));
418        assert!(stabilize2.force_minimal_diff);
419        assert_eq!(stabilize2.weight_factor, 0.7);
420
421        let clamp = stabilizer.next_attempt().unwrap();
422        assert_eq!(clamp.phase, AttemptPhase::Clamp);
423        assert_eq!(clamp.delta_amplitude, 0.1);
424        assert_eq!(clamp.force_family, None);
425        assert!(clamp.force_minimal_diff);
426        assert_eq!(clamp.weight_factor, 1.0);
427
428        assert!(!stabilizer.has_next());
429        assert!(matches!(
430            stabilizer.next_attempt(),
431            Err(StabilizerError::Exhausted)
432        ));
433    }
434
435    #[test]
436    fn reset_rewinds_phase_sequence() {
437        let mut stabilizer = sample_stabilizer();
438        let _ = stabilizer.next_attempt().unwrap();
439        let _ = stabilizer.next_attempt().unwrap();
440        assert_eq!(stabilizer.current_index(), 2);
441
442        stabilizer.reset();
443
444        assert_eq!(stabilizer.current_index(), 0);
445        assert_eq!(
446            stabilizer.next_attempt().unwrap().phase,
447            AttemptPhase::Innovative
448        );
449    }
450
451    #[test]
452    fn extract_strategy_tags_detects_patch_shape_and_context() {
453        let tags = extract_strategy_tags(&sample_patch());
454
455        assert!(tags.iter().any(|tag| tag == "single_file"));
456        assert!(tags.iter().any(|tag| tag == "new_file"));
457        assert!(tags.iter().any(|tag| tag == "fn_level_edit"));
458        assert!(tags.iter().any(|tag| tag == "module_split"));
459    }
460
461    #[test]
462    fn novelty_and_approach_family_follow_tag_overlap() {
463        let novelty = compute_tag_novelty(
464            &["replace_heavy".into(), "single_file".into()],
465            &BTreeSet::from(["single_file".into(), "async_boundary".into()]),
466        );
467        assert!(novelty > 0.0 && novelty < 1.0);
468
469        assert_eq!(
470            determine_approach_family(&["replace_heavy".into(), "fn_level_edit".into()]),
471            "mechanical"
472        );
473        assert_eq!(
474            determine_approach_family(&["extract_function".into(), "multi_file".into()]),
475            "pattern_refactor"
476        );
477        assert_eq!(
478            determine_approach_family(&["trait_level_edit".into(), "new_file".into()]),
479            "architectural"
480        );
481        assert_eq!(
482            determine_approach_family(&["error_type_edit".into()]),
483            "safety"
484        );
485    }
486}