ouroboros-ai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ouroboros-ai might be problematic. Click here for more details.

Files changed (81) hide show
  1. ouroboros/__init__.py +15 -0
  2. ouroboros/__main__.py +9 -0
  3. ouroboros/bigbang/__init__.py +39 -0
  4. ouroboros/bigbang/ambiguity.py +464 -0
  5. ouroboros/bigbang/interview.py +530 -0
  6. ouroboros/bigbang/seed_generator.py +610 -0
  7. ouroboros/cli/__init__.py +9 -0
  8. ouroboros/cli/commands/__init__.py +7 -0
  9. ouroboros/cli/commands/config.py +79 -0
  10. ouroboros/cli/commands/init.py +425 -0
  11. ouroboros/cli/commands/run.py +201 -0
  12. ouroboros/cli/commands/status.py +85 -0
  13. ouroboros/cli/formatters/__init__.py +31 -0
  14. ouroboros/cli/formatters/panels.py +157 -0
  15. ouroboros/cli/formatters/progress.py +112 -0
  16. ouroboros/cli/formatters/tables.py +166 -0
  17. ouroboros/cli/main.py +60 -0
  18. ouroboros/config/__init__.py +81 -0
  19. ouroboros/config/loader.py +292 -0
  20. ouroboros/config/models.py +332 -0
  21. ouroboros/core/__init__.py +62 -0
  22. ouroboros/core/ac_tree.py +401 -0
  23. ouroboros/core/context.py +472 -0
  24. ouroboros/core/errors.py +246 -0
  25. ouroboros/core/seed.py +212 -0
  26. ouroboros/core/types.py +205 -0
  27. ouroboros/evaluation/__init__.py +110 -0
  28. ouroboros/evaluation/consensus.py +350 -0
  29. ouroboros/evaluation/mechanical.py +351 -0
  30. ouroboros/evaluation/models.py +235 -0
  31. ouroboros/evaluation/pipeline.py +286 -0
  32. ouroboros/evaluation/semantic.py +302 -0
  33. ouroboros/evaluation/trigger.py +278 -0
  34. ouroboros/events/__init__.py +5 -0
  35. ouroboros/events/base.py +80 -0
  36. ouroboros/events/decomposition.py +153 -0
  37. ouroboros/events/evaluation.py +248 -0
  38. ouroboros/execution/__init__.py +44 -0
  39. ouroboros/execution/atomicity.py +451 -0
  40. ouroboros/execution/decomposition.py +481 -0
  41. ouroboros/execution/double_diamond.py +1386 -0
  42. ouroboros/execution/subagent.py +275 -0
  43. ouroboros/observability/__init__.py +63 -0
  44. ouroboros/observability/drift.py +383 -0
  45. ouroboros/observability/logging.py +504 -0
  46. ouroboros/observability/retrospective.py +338 -0
  47. ouroboros/orchestrator/__init__.py +78 -0
  48. ouroboros/orchestrator/adapter.py +391 -0
  49. ouroboros/orchestrator/events.py +278 -0
  50. ouroboros/orchestrator/runner.py +597 -0
  51. ouroboros/orchestrator/session.py +486 -0
  52. ouroboros/persistence/__init__.py +23 -0
  53. ouroboros/persistence/checkpoint.py +511 -0
  54. ouroboros/persistence/event_store.py +183 -0
  55. ouroboros/persistence/migrations/__init__.py +1 -0
  56. ouroboros/persistence/migrations/runner.py +100 -0
  57. ouroboros/persistence/migrations/scripts/001_initial.sql +20 -0
  58. ouroboros/persistence/schema.py +56 -0
  59. ouroboros/persistence/uow.py +230 -0
  60. ouroboros/providers/__init__.py +28 -0
  61. ouroboros/providers/base.py +133 -0
  62. ouroboros/providers/claude_code_adapter.py +212 -0
  63. ouroboros/providers/litellm_adapter.py +316 -0
  64. ouroboros/py.typed +0 -0
  65. ouroboros/resilience/__init__.py +67 -0
  66. ouroboros/resilience/lateral.py +595 -0
  67. ouroboros/resilience/stagnation.py +727 -0
  68. ouroboros/routing/__init__.py +60 -0
  69. ouroboros/routing/complexity.py +272 -0
  70. ouroboros/routing/downgrade.py +664 -0
  71. ouroboros/routing/escalation.py +340 -0
  72. ouroboros/routing/router.py +204 -0
  73. ouroboros/routing/tiers.py +247 -0
  74. ouroboros/secondary/__init__.py +40 -0
  75. ouroboros/secondary/scheduler.py +467 -0
  76. ouroboros/secondary/todo_registry.py +483 -0
  77. ouroboros_ai-0.1.0.dist-info/METADATA +607 -0
  78. ouroboros_ai-0.1.0.dist-info/RECORD +81 -0
  79. ouroboros_ai-0.1.0.dist-info/WHEEL +4 -0
  80. ouroboros_ai-0.1.0.dist-info/entry_points.txt +2 -0
  81. ouroboros_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,664 @@
1
+ """Downgrade management for tier optimization in Ouroboros.
2
+
3
+ This module implements automatic tier downgrade after sustained success,
4
+ enabling continuous cost optimization while maintaining quality.
5
+
6
+ Key Components:
7
+ - SuccessTracker: Tracks consecutive successes per task pattern
8
+ - PatternMatcher: Identifies similar task patterns using Jaccard similarity
9
+ - DowngradeManager: Coordinates downgrade decisions based on success history
10
+
11
+ Downgrade Rules:
12
+ - 5 consecutive successes trigger a downgrade
13
+ - Frontier -> Standard -> Frugal
14
+ - Tasks at Frugal tier remain at Frugal
15
+ - Similar patterns (>=80% similarity) inherit tier preferences
16
+
17
+ Usage:
18
+ from ouroboros.routing.downgrade import DowngradeManager, PatternMatcher
19
+
20
+ # Create manager
21
+ manager = DowngradeManager()
22
+
23
+ # Record success and check for downgrade
24
+ result = manager.record_success("task_pattern_123", Tier.STANDARD)
25
+ if result.is_ok:
26
+ downgrade_result = result.value
27
+ if downgrade_result.should_downgrade:
28
+ new_tier = downgrade_result.recommended_tier
29
+ print(f"Downgrade to {new_tier.value}")
30
+
31
+ # Pattern matching for similar tasks
32
+ matcher = PatternMatcher()
33
+ similarity = matcher.calculate_similarity("fix typo in README", "fix typo in docs")
34
+ if similarity >= 0.8:
35
+ # Apply same tier preference
36
+ pass
37
+ """
38
+
39
+ from dataclasses import dataclass, field
40
+ from typing import TypeAlias
41
+
42
+ from ouroboros.core.types import Result
43
+ from ouroboros.observability.logging import get_logger
44
+ from ouroboros.routing.tiers import Tier
45
+
46
+ log = get_logger(__name__)
47
+
48
+
49
+ # Constants
50
+ DOWNGRADE_THRESHOLD = 5 # Consecutive successes needed for downgrade
51
+ SIMILARITY_THRESHOLD = 0.80 # Minimum similarity for pattern matching
52
+
53
+ # Type aliases
54
+ PatternId: TypeAlias = str
55
+
56
+
57
+ @dataclass
58
+ class SuccessTracker:
59
+ """Tracks consecutive successes per task pattern.
60
+
61
+ This class maintains state for tracking how many consecutive successful
62
+ completions have occurred for each unique task pattern. It supports
63
+ both recording successes and resetting on failures.
64
+
65
+ Attributes:
66
+ _success_counts: Internal dict mapping pattern IDs to consecutive success counts.
67
+ _tier_history: Internal dict mapping pattern IDs to their current tier.
68
+
69
+ Example:
70
+ tracker = SuccessTracker()
71
+
72
+ # Record successes
73
+ tracker.record_success("pattern_1", Tier.STANDARD)
74
+ tracker.record_success("pattern_1", Tier.STANDARD)
75
+
76
+ # Get current count
77
+ count = tracker.get_success_count("pattern_1") # Returns 2
78
+
79
+ # Reset on failure
80
+ tracker.reset_on_failure("pattern_1")
81
+ count = tracker.get_success_count("pattern_1") # Returns 0
82
+ """
83
+
84
+ _success_counts: dict[PatternId, int] = field(default_factory=dict)
85
+ _tier_history: dict[PatternId, Tier] = field(default_factory=dict)
86
+
87
+ def record_success(self, pattern_id: PatternId, tier: Tier) -> int:
88
+ """Record a successful completion for a pattern.
89
+
90
+ Increments the consecutive success counter for the given pattern
91
+ and updates the tier history.
92
+
93
+ Args:
94
+ pattern_id: Unique identifier for the task pattern.
95
+ tier: The tier at which the task was executed.
96
+
97
+ Returns:
98
+ The new consecutive success count for this pattern.
99
+
100
+ Example:
101
+ tracker = SuccessTracker()
102
+ count = tracker.record_success("pattern_1", Tier.STANDARD)
103
+ # count is now 1
104
+ """
105
+ current_count = self._success_counts.get(pattern_id, 0)
106
+ new_count = current_count + 1
107
+ self._success_counts[pattern_id] = new_count
108
+ self._tier_history[pattern_id] = tier
109
+
110
+ log.debug(
111
+ "success_tracker.recorded",
112
+ pattern_id=pattern_id,
113
+ tier=tier.value,
114
+ consecutive_count=new_count,
115
+ )
116
+
117
+ return new_count
118
+
119
+ def reset_on_failure(self, pattern_id: PatternId) -> None:
120
+ """Reset the success counter for a pattern after a failure.
121
+
122
+ Sets the consecutive success count to 0 while preserving tier history.
123
+
124
+ Args:
125
+ pattern_id: Unique identifier for the task pattern.
126
+
127
+ Example:
128
+ tracker = SuccessTracker()
129
+ tracker.record_success("pattern_1", Tier.STANDARD)
130
+ tracker.reset_on_failure("pattern_1")
131
+ # Success count is now 0
132
+ """
133
+ previous_count = self._success_counts.get(pattern_id, 0)
134
+ self._success_counts[pattern_id] = 0
135
+
136
+ log.debug(
137
+ "success_tracker.reset",
138
+ pattern_id=pattern_id,
139
+ previous_count=previous_count,
140
+ )
141
+
142
+ def get_success_count(self, pattern_id: PatternId) -> int:
143
+ """Get the current consecutive success count for a pattern.
144
+
145
+ Args:
146
+ pattern_id: Unique identifier for the task pattern.
147
+
148
+ Returns:
149
+ The consecutive success count, or 0 if pattern not tracked.
150
+ """
151
+ return self._success_counts.get(pattern_id, 0)
152
+
153
+ def get_tier(self, pattern_id: PatternId) -> Tier | None:
154
+ """Get the current tier for a pattern.
155
+
156
+ Args:
157
+ pattern_id: Unique identifier for the task pattern.
158
+
159
+ Returns:
160
+ The tier for this pattern, or None if not tracked.
161
+ """
162
+ return self._tier_history.get(pattern_id)
163
+
164
+ def get_all_patterns(self) -> list[PatternId]:
165
+ """Get all tracked pattern IDs.
166
+
167
+ Returns:
168
+ List of all pattern IDs being tracked.
169
+ """
170
+ return list(self._success_counts.keys())
171
+
172
+ def clear(self) -> None:
173
+ """Clear all tracking state.
174
+
175
+ Resets both success counts and tier history.
176
+ """
177
+ self._success_counts.clear()
178
+ self._tier_history.clear()
179
+ log.debug("success_tracker.cleared")
180
+
181
+
182
+ @dataclass(frozen=True, slots=True)
183
+ class DowngradeResult:
184
+ """Result of a downgrade evaluation.
185
+
186
+ Contains information about whether a downgrade should occur
187
+ and the recommended tier if so.
188
+
189
+ Attributes:
190
+ should_downgrade: Whether a downgrade is recommended.
191
+ current_tier: The tier the task was executed at.
192
+ recommended_tier: The recommended tier after evaluation.
193
+ consecutive_successes: Number of consecutive successes that led to this decision.
194
+ cost_savings_factor: Estimated cost savings if downgrade is applied (ratio).
195
+
196
+ Example:
197
+ result = DowngradeResult(
198
+ should_downgrade=True,
199
+ current_tier=Tier.STANDARD,
200
+ recommended_tier=Tier.FRUGAL,
201
+ consecutive_successes=5,
202
+ cost_savings_factor=10.0, # Standard (10x) to Frugal (1x)
203
+ )
204
+ """
205
+
206
+ should_downgrade: bool
207
+ current_tier: Tier
208
+ recommended_tier: Tier
209
+ consecutive_successes: int
210
+ cost_savings_factor: float
211
+
212
+
213
+ def _get_lower_tier(tier: Tier) -> Tier:
214
+ """Get the next lower tier.
215
+
216
+ Args:
217
+ tier: Current tier.
218
+
219
+ Returns:
220
+ The next lower tier, or the same tier if already at Frugal.
221
+ """
222
+ tier_order = [Tier.FRUGAL, Tier.STANDARD, Tier.FRONTIER]
223
+ current_index = tier_order.index(tier)
224
+
225
+ if current_index > 0:
226
+ return tier_order[current_index - 1]
227
+ return tier # Already at Frugal
228
+
229
+
230
+ def _calculate_cost_savings(from_tier: Tier, to_tier: Tier) -> float:
231
+ """Calculate the cost savings factor from downgrading.
232
+
233
+ Args:
234
+ from_tier: Original tier.
235
+ to_tier: Target tier.
236
+
237
+ Returns:
238
+ Cost savings factor (ratio of cost multipliers).
239
+ """
240
+ if from_tier == to_tier:
241
+ return 1.0
242
+
243
+ from_cost = from_tier.cost_multiplier
244
+ to_cost = to_tier.cost_multiplier
245
+
246
+ return from_cost / to_cost
247
+
248
+
249
+ class PatternMatcher:
250
+ """Matches similar task patterns using Jaccard similarity.
251
+
252
+ This class provides pattern similarity calculation using Jaccard similarity
253
+ on tokenized task descriptions. Similar patterns (>=80% similarity) can
254
+ inherit tier preferences from successful completions.
255
+
256
+ For MVP, we use simple word-based tokenization and Jaccard similarity
257
+ instead of embeddings. This provides a fast, interpretable similarity
258
+ measure suitable for initial implementation.
259
+
260
+ Jaccard Similarity:
261
+ J(A, B) = |A intersection B| / |A union B|
262
+
263
+ Example:
264
+ matcher = PatternMatcher()
265
+
266
+ # Check similarity between patterns
267
+ sim = matcher.calculate_similarity(
268
+ "fix typo in README",
269
+ "fix typo in documentation",
270
+ )
271
+ # sim ~= 0.4 (shares "fix", "typo", "in")
272
+
273
+ # Find similar patterns
274
+ patterns = ["fix typo", "add feature", "fix bug"]
275
+ similar = matcher.find_similar_patterns("fix issue", patterns)
276
+ # Returns patterns with similarity >= 0.8
277
+ """
278
+
279
+ def __init__(self, similarity_threshold: float = SIMILARITY_THRESHOLD) -> None:
280
+ """Initialize the pattern matcher.
281
+
282
+ Args:
283
+ similarity_threshold: Minimum similarity for patterns to be considered similar.
284
+ Defaults to 0.80 (80%).
285
+ """
286
+ self._similarity_threshold = similarity_threshold
287
+
288
+ @property
289
+ def similarity_threshold(self) -> float:
290
+ """Get the similarity threshold."""
291
+ return self._similarity_threshold
292
+
293
+ def _tokenize(self, text: str) -> set[str]:
294
+ """Tokenize a text string into a set of lowercase words.
295
+
296
+ Args:
297
+ text: The text to tokenize.
298
+
299
+ Returns:
300
+ Set of lowercase word tokens.
301
+ """
302
+ # Simple word tokenization: split on whitespace and punctuation
303
+ # Convert to lowercase for case-insensitive matching
304
+ words = text.lower().split()
305
+ # Remove common punctuation from tokens
306
+ cleaned_words = set()
307
+ for word in words:
308
+ # Strip punctuation from start and end
309
+ cleaned = word.strip(".,;:!?\"'()-[]{}/<>")
310
+ if cleaned: # Only add non-empty tokens
311
+ cleaned_words.add(cleaned)
312
+ return cleaned_words
313
+
314
+ def calculate_similarity(self, pattern_a: str, pattern_b: str) -> float:
315
+ """Calculate Jaccard similarity between two patterns.
316
+
317
+ Jaccard similarity is defined as:
318
+ J(A, B) = |A intersection B| / |A union B|
319
+
320
+ Args:
321
+ pattern_a: First pattern string.
322
+ pattern_b: Second pattern string.
323
+
324
+ Returns:
325
+ Similarity score between 0.0 and 1.0.
326
+
327
+ Example:
328
+ matcher = PatternMatcher()
329
+ sim = matcher.calculate_similarity("fix bug", "fix typo")
330
+ # sim = 1/3 (intersection={fix}, union={fix, bug, typo})
331
+ """
332
+ tokens_a = self._tokenize(pattern_a)
333
+ tokens_b = self._tokenize(pattern_b)
334
+
335
+ if not tokens_a and not tokens_b:
336
+ return 1.0 # Both empty = identical
337
+
338
+ if not tokens_a or not tokens_b:
339
+ return 0.0 # One empty, one not = no similarity
340
+
341
+ intersection = tokens_a & tokens_b
342
+ union = tokens_a | tokens_b
343
+
344
+ similarity = len(intersection) / len(union)
345
+
346
+ log.debug(
347
+ "pattern_matcher.similarity_calculated",
348
+ pattern_a=pattern_a[:50], # Truncate for logging
349
+ pattern_b=pattern_b[:50],
350
+ similarity=similarity,
351
+ intersection_size=len(intersection),
352
+ union_size=len(union),
353
+ )
354
+
355
+ return similarity
356
+
357
+ def is_similar(self, pattern_a: str, pattern_b: str) -> bool:
358
+ """Check if two patterns are similar based on threshold.
359
+
360
+ Args:
361
+ pattern_a: First pattern string.
362
+ pattern_b: Second pattern string.
363
+
364
+ Returns:
365
+ True if similarity >= threshold.
366
+ """
367
+ return self.calculate_similarity(pattern_a, pattern_b) >= self._similarity_threshold
368
+
369
+ def find_similar_patterns(
370
+ self,
371
+ target_pattern: str,
372
+ candidate_patterns: list[str],
373
+ ) -> list[tuple[str, float]]:
374
+ """Find all candidate patterns similar to the target.
375
+
376
+ Args:
377
+ target_pattern: The pattern to match against.
378
+ candidate_patterns: List of patterns to compare.
379
+
380
+ Returns:
381
+ List of (pattern, similarity) tuples for patterns meeting threshold,
382
+ sorted by similarity in descending order.
383
+ """
384
+ similar: list[tuple[str, float]] = []
385
+
386
+ for candidate in candidate_patterns:
387
+ similarity = self.calculate_similarity(target_pattern, candidate)
388
+ if similarity >= self._similarity_threshold:
389
+ similar.append((candidate, similarity))
390
+
391
+ # Sort by similarity descending
392
+ similar.sort(key=lambda x: x[1], reverse=True)
393
+
394
+ if similar:
395
+ log.debug(
396
+ "pattern_matcher.similar_patterns_found",
397
+ target_pattern=target_pattern[:50],
398
+ match_count=len(similar),
399
+ best_match_similarity=similar[0][1] if similar else 0.0,
400
+ )
401
+
402
+ return similar
403
+
404
+
405
+ @dataclass
406
+ class DowngradeManager:
407
+ """Manages tier downgrade decisions based on success history.
408
+
409
+ The DowngradeManager coordinates success tracking and pattern matching
410
+ to determine when tasks should be downgraded to a lower (cheaper) tier.
411
+
412
+ Downgrade Rules:
413
+ - 5 consecutive successes at a tier trigger a downgrade evaluation
414
+ - Tiers downgrade: Frontier -> Standard -> Frugal
415
+ - Frugal tier tasks cannot be downgraded further
416
+ - Similar patterns inherit tier preferences
417
+
418
+ Example:
419
+ manager = DowngradeManager()
420
+
421
+ # Record successes
422
+ for i in range(5):
423
+ result = manager.record_success("pattern_1", Tier.STANDARD)
424
+
425
+ # After 5 successes, should_downgrade will be True
426
+ assert result.is_ok
427
+ assert result.value.should_downgrade
428
+ assert result.value.recommended_tier == Tier.FRUGAL
429
+
430
+ # Record failure resets the counter
431
+ manager.record_failure("pattern_1")
432
+ """
433
+
434
+ _tracker: SuccessTracker = field(default_factory=SuccessTracker)
435
+ _pattern_matcher: PatternMatcher = field(default_factory=PatternMatcher)
436
+ _downgrade_threshold: int = DOWNGRADE_THRESHOLD
437
+
438
+ @property
439
+ def downgrade_threshold(self) -> int:
440
+ """Get the downgrade threshold (consecutive successes needed)."""
441
+ return self._downgrade_threshold
442
+
443
+ @property
444
+ def tracker(self) -> SuccessTracker:
445
+ """Get the internal success tracker."""
446
+ return self._tracker
447
+
448
+ @property
449
+ def pattern_matcher(self) -> PatternMatcher:
450
+ """Get the internal pattern matcher."""
451
+ return self._pattern_matcher
452
+
453
+ def record_success(
454
+ self,
455
+ pattern_id: PatternId,
456
+ tier: Tier,
457
+ ) -> Result[DowngradeResult, None]:
458
+ """Record a successful task completion and evaluate for downgrade.
459
+
460
+ Increments the success counter for the pattern and checks if the
461
+ threshold for downgrade has been met.
462
+
463
+ Args:
464
+ pattern_id: Unique identifier for the task pattern.
465
+ tier: The tier at which the task was executed.
466
+
467
+ Returns:
468
+ Result containing DowngradeResult with the evaluation.
469
+ Always succeeds (Result.ok) with downgrade information.
470
+
471
+ Example:
472
+ manager = DowngradeManager()
473
+
474
+ # Record first success - no downgrade
475
+ result = manager.record_success("pattern_1", Tier.FRONTIER)
476
+ assert not result.value.should_downgrade
477
+
478
+ # After 5 successes, downgrade recommended
479
+ for _ in range(4):
480
+ result = manager.record_success("pattern_1", Tier.FRONTIER)
481
+ assert result.value.should_downgrade
482
+ """
483
+ # Record the success
484
+ success_count = self._tracker.record_success(pattern_id, tier)
485
+
486
+ # Check if downgrade threshold met
487
+ should_downgrade = (
488
+ success_count >= self._downgrade_threshold and tier != Tier.FRUGAL
489
+ )
490
+
491
+ # Determine recommended tier
492
+ if should_downgrade:
493
+ recommended_tier = _get_lower_tier(tier)
494
+ cost_savings = _calculate_cost_savings(tier, recommended_tier)
495
+
496
+ log.info(
497
+ "downgrade.recommended",
498
+ pattern_id=pattern_id,
499
+ current_tier=tier.value,
500
+ recommended_tier=recommended_tier.value,
501
+ consecutive_successes=success_count,
502
+ cost_savings_factor=cost_savings,
503
+ )
504
+ else:
505
+ recommended_tier = tier
506
+ cost_savings = 1.0
507
+
508
+ log.debug(
509
+ "downgrade.not_ready",
510
+ pattern_id=pattern_id,
511
+ tier=tier.value,
512
+ consecutive_successes=success_count,
513
+ threshold=self._downgrade_threshold,
514
+ )
515
+
516
+ downgrade_result = DowngradeResult(
517
+ should_downgrade=should_downgrade,
518
+ current_tier=tier,
519
+ recommended_tier=recommended_tier,
520
+ consecutive_successes=success_count,
521
+ cost_savings_factor=cost_savings,
522
+ )
523
+
524
+ return Result.ok(downgrade_result)
525
+
526
+ def record_failure(self, pattern_id: PatternId) -> None:
527
+ """Record a failed task completion.
528
+
529
+ Resets the consecutive success counter for the pattern.
530
+
531
+ Args:
532
+ pattern_id: Unique identifier for the task pattern.
533
+
534
+ Example:
535
+ manager = DowngradeManager()
536
+
537
+ # Record some successes
538
+ manager.record_success("pattern_1", Tier.STANDARD)
539
+ manager.record_success("pattern_1", Tier.STANDARD)
540
+
541
+ # Failure resets the counter
542
+ manager.record_failure("pattern_1")
543
+ # Success count is now 0
544
+ """
545
+ self._tracker.reset_on_failure(pattern_id)
546
+
547
+ log.info(
548
+ "downgrade.failure_recorded",
549
+ pattern_id=pattern_id,
550
+ )
551
+
552
+ def get_recommended_tier_for_pattern(
553
+ self,
554
+ pattern_description: str,
555
+ default_tier: Tier = Tier.FRUGAL,
556
+ ) -> Tier:
557
+ """Get the recommended tier for a pattern based on similar patterns.
558
+
559
+ Looks for similar tracked patterns and returns the tier of the most
560
+ similar successful pattern, allowing new tasks to benefit from
561
+ learned tier preferences.
562
+
563
+ Args:
564
+ pattern_description: Description of the task pattern.
565
+ default_tier: Tier to return if no similar patterns found.
566
+ Defaults to FRUGAL (optimistic for cost savings).
567
+
568
+ Returns:
569
+ The recommended tier based on similar patterns.
570
+
571
+ Example:
572
+ manager = DowngradeManager()
573
+
574
+ # After tracking "fix typo in README" at Frugal tier
575
+ manager.record_success("fix typo in README", Tier.FRUGAL)
576
+
577
+ # Similar pattern gets same tier recommendation
578
+ tier = manager.get_recommended_tier_for_pattern("fix typo in docs")
579
+ # tier == Tier.FRUGAL (if similarity >= 80%)
580
+ """
581
+ tracked_patterns = self._tracker.get_all_patterns()
582
+
583
+ if not tracked_patterns:
584
+ log.debug(
585
+ "downgrade.no_patterns_tracked",
586
+ pattern_description=pattern_description[:50],
587
+ default_tier=default_tier.value,
588
+ )
589
+ return default_tier
590
+
591
+ # Find similar patterns
592
+ similar_patterns = self._pattern_matcher.find_similar_patterns(
593
+ pattern_description,
594
+ tracked_patterns,
595
+ )
596
+
597
+ if not similar_patterns:
598
+ log.debug(
599
+ "downgrade.no_similar_patterns",
600
+ pattern_description=pattern_description[:50],
601
+ default_tier=default_tier.value,
602
+ )
603
+ return default_tier
604
+
605
+ # Get the tier of the most similar pattern
606
+ best_match, best_similarity = similar_patterns[0]
607
+ matched_tier = self._tracker.get_tier(best_match)
608
+
609
+ if matched_tier is None:
610
+ return default_tier
611
+
612
+ log.info(
613
+ "downgrade.tier_inherited",
614
+ pattern_description=pattern_description[:50],
615
+ matched_pattern=best_match[:50],
616
+ similarity=best_similarity,
617
+ inherited_tier=matched_tier.value,
618
+ )
619
+
620
+ return matched_tier
621
+
622
+ def apply_downgrade(self, pattern_id: PatternId) -> None:
623
+ """Apply a downgrade by resetting the success counter.
624
+
625
+ After a downgrade is applied, the success counter is reset
626
+ to allow the pattern to be evaluated again at the new tier.
627
+
628
+ Args:
629
+ pattern_id: Unique identifier for the task pattern.
630
+ """
631
+ current_tier = self._tracker.get_tier(pattern_id)
632
+ self._tracker._success_counts[pattern_id] = 0
633
+
634
+ if current_tier:
635
+ new_tier = _get_lower_tier(current_tier)
636
+ self._tracker._tier_history[pattern_id] = new_tier
637
+
638
+ log.info(
639
+ "downgrade.applied",
640
+ pattern_id=pattern_id,
641
+ from_tier=current_tier.value,
642
+ to_tier=new_tier.value,
643
+ )
644
+
645
+ def get_cost_savings_estimate(self, pattern_id: PatternId) -> float:
646
+ """Estimate cost savings if downgrade is applied.
647
+
648
+ Args:
649
+ pattern_id: Unique identifier for the task pattern.
650
+
651
+ Returns:
652
+ Cost savings factor (>1.0 means savings).
653
+ """
654
+ current_tier = self._tracker.get_tier(pattern_id)
655
+ if current_tier is None or current_tier == Tier.FRUGAL:
656
+ return 1.0
657
+
658
+ new_tier = _get_lower_tier(current_tier)
659
+ return _calculate_cost_savings(current_tier, new_tier)
660
+
661
+ def clear(self) -> None:
662
+ """Clear all tracking state."""
663
+ self._tracker.clear()
664
+ log.info("downgrade.manager_cleared")