1use 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}