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,727 @@
1
+ """Stagnation detection for Ouroboros execution cycles.
2
+
3
+ This module implements Story 4.1: Stagnation Detection (4 Patterns).
4
+
5
+ Detects 4 stagnation patterns:
6
+ 1. Spinning: Same output repeated (e.g., same error 3+ times)
7
+ 2. Oscillation: A→B→A→B alternating pattern
8
+ 3. No Drift: No progress toward goal (drift score unchanging)
9
+ 4. Diminishing Returns: Progress rate decreasing
10
+
11
+ Design:
12
+ - Stateless detector: All state passed via ExecutionHistory
13
+ - Hash-based comparison: Fast, O(1) for most patterns
14
+ - Event emission: Each pattern emits its own event type
15
+
16
+ Usage:
17
+ from ouroboros.resilience.stagnation import (
18
+ StagnationDetector,
19
+ ExecutionHistory,
20
+ )
21
+
22
+ # Build history from execution
23
+ history = ExecutionHistory(
24
+ phase_outputs=["output1", "output2", "output1", "output2"],
25
+ error_signatures=["error_A", "error_A"],
26
+ drift_scores=[0.5, 0.5, 0.5],
27
+ iteration=3,
28
+ )
29
+
30
+ # Detect patterns
31
+ detector = StagnationDetector()
32
+ result = detector.detect(history)
33
+
34
+ for detection in result.value:
35
+ if detection.detected:
36
+ print(f"Stagnation: {detection.pattern.value}")
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import hashlib
42
+ from dataclasses import dataclass, field
43
+ from datetime import UTC, datetime
44
+ from enum import Enum
45
+ from typing import Any
46
+
47
+ from ouroboros.core.types import Result
48
+ from ouroboros.events.base import BaseEvent
49
+ from ouroboros.observability.logging import get_logger
50
+
51
+ log = get_logger(__name__)
52
+
53
+
54
+ # =============================================================================
55
+ # Enums and Data Models
56
+ # =============================================================================
57
+
58
+
59
+ class StagnationPattern(str, Enum):
60
+ """Four stagnation patterns detected in execution loops.
61
+
62
+ Attributes:
63
+ SPINNING: Identical outputs repeated (same error, same result)
64
+ OSCILLATION: Alternating A→B→A→B pattern (flip-flopping)
65
+ NO_DRIFT: Output generated but no progress toward goal
66
+ DIMINISHING_RETURNS: Progress rate consistently decreasing
67
+ """
68
+
69
+ SPINNING = "spinning"
70
+ OSCILLATION = "oscillation"
71
+ NO_DRIFT = "no_drift"
72
+ DIMINISHING_RETURNS = "diminishing_returns"
73
+
74
+ @property
75
+ def default_threshold(self) -> int:
76
+ """Return default threshold for this pattern."""
77
+ thresholds = {
78
+ StagnationPattern.SPINNING: 3,
79
+ StagnationPattern.OSCILLATION: 2,
80
+ StagnationPattern.NO_DRIFT: 3,
81
+ StagnationPattern.DIMINISHING_RETURNS: 3,
82
+ }
83
+ return thresholds[self]
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class StagnationDetection:
88
+ """Result of stagnation pattern detection.
89
+
90
+ Attributes:
91
+ pattern: The type of stagnation pattern checked.
92
+ detected: Whether stagnation was detected.
93
+ confidence: Confidence score (0.0-1.0).
94
+ evidence: Pattern-specific evidence supporting detection.
95
+ """
96
+
97
+ pattern: StagnationPattern
98
+ detected: bool
99
+ confidence: float
100
+ evidence: dict[str, Any] = field(default_factory=dict)
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class ExecutionHistory:
105
+ """Historical execution data for stagnation detection.
106
+
107
+ Contains recent outputs and metrics needed for pattern analysis.
108
+ All collections are tuples to ensure immutability.
109
+
110
+ Attributes:
111
+ phase_outputs: Recent phase outputs (strings, for hash comparison).
112
+ error_signatures: Recent error messages (for spinning detection).
113
+ drift_scores: Recent drift measurements (for no_drift/diminishing).
114
+ iteration: Current iteration number.
115
+ """
116
+
117
+ phase_outputs: tuple[str, ...] = field(default_factory=tuple)
118
+ error_signatures: tuple[str, ...] = field(default_factory=tuple)
119
+ drift_scores: tuple[float, ...] = field(default_factory=tuple)
120
+ iteration: int = 0
121
+
122
+ @classmethod
123
+ def from_lists(
124
+ cls,
125
+ phase_outputs: list[str],
126
+ error_signatures: list[str],
127
+ drift_scores: list[float],
128
+ iteration: int,
129
+ ) -> ExecutionHistory:
130
+ """Create ExecutionHistory from mutable lists.
131
+
132
+ Args:
133
+ phase_outputs: Recent phase output strings.
134
+ error_signatures: Recent error messages.
135
+ drift_scores: Recent drift score values.
136
+ iteration: Current iteration number.
137
+
138
+ Returns:
139
+ Immutable ExecutionHistory instance.
140
+ """
141
+ return cls(
142
+ phase_outputs=tuple(phase_outputs),
143
+ error_signatures=tuple(error_signatures),
144
+ drift_scores=tuple(drift_scores),
145
+ iteration=iteration,
146
+ )
147
+
148
+
149
+ # =============================================================================
150
+ # Stagnation Detector
151
+ # =============================================================================
152
+
153
+
154
+ class StagnationDetector:
155
+ """Stateless detector for stagnation patterns.
156
+
157
+ Analyzes ExecutionHistory to detect 4 stagnation patterns.
158
+ All detection methods are pure functions operating on history.
159
+
160
+ Attributes:
161
+ spinning_threshold: Repetitions needed for spinning detection.
162
+ oscillation_cycles: Complete A→B cycles needed.
163
+ no_drift_epsilon: Maximum drift change to consider "no progress".
164
+ no_drift_iterations: Iterations with no drift to trigger detection.
165
+ diminishing_threshold: Improvement rate below this triggers detection.
166
+ """
167
+
168
+ # Default thresholds (can be overridden via __init__)
169
+ DEFAULT_SPINNING_THRESHOLD = 3
170
+ DEFAULT_OSCILLATION_CYCLES = 2
171
+ DEFAULT_NO_DRIFT_EPSILON = 0.01
172
+ DEFAULT_NO_DRIFT_ITERATIONS = 3
173
+ DEFAULT_DIMINISHING_THRESHOLD = 0.01
174
+
175
+ def __init__(
176
+ self,
177
+ *,
178
+ spinning_threshold: int | None = None,
179
+ oscillation_cycles: int | None = None,
180
+ no_drift_epsilon: float | None = None,
181
+ no_drift_iterations: int | None = None,
182
+ diminishing_threshold: float | None = None,
183
+ ) -> None:
184
+ """Initialize StagnationDetector with configurable thresholds.
185
+
186
+ Args:
187
+ spinning_threshold: Repetitions for spinning (default: 3).
188
+ oscillation_cycles: A→B cycles for oscillation (default: 2).
189
+ no_drift_epsilon: Max drift delta for no_drift (default: 0.01).
190
+ no_drift_iterations: Iterations for no_drift (default: 3).
191
+ diminishing_threshold: Min improvement rate (default: 0.01).
192
+ """
193
+ self._spinning_threshold = spinning_threshold or self.DEFAULT_SPINNING_THRESHOLD
194
+ self._oscillation_cycles = oscillation_cycles or self.DEFAULT_OSCILLATION_CYCLES
195
+ self._no_drift_epsilon = no_drift_epsilon or self.DEFAULT_NO_DRIFT_EPSILON
196
+ self._no_drift_iterations = no_drift_iterations or self.DEFAULT_NO_DRIFT_ITERATIONS
197
+ self._diminishing_threshold = diminishing_threshold or self.DEFAULT_DIMINISHING_THRESHOLD
198
+
199
+ def _compute_hash(self, text: str) -> str:
200
+ """Compute SHA-256 hash of text for fast comparison.
201
+
202
+ Args:
203
+ text: Text to hash.
204
+
205
+ Returns:
206
+ First 16 characters of hex digest (enough for collision avoidance).
207
+ """
208
+ return hashlib.sha256(text.encode()).hexdigest()[:16]
209
+
210
+ def detect(self, history: ExecutionHistory) -> Result[list[StagnationDetection], None]:
211
+ """Detect all stagnation patterns from execution history.
212
+
213
+ Runs all 4 pattern detectors and returns results.
214
+ Each detector is independent and non-blocking.
215
+
216
+ Args:
217
+ history: ExecutionHistory with recent outputs and metrics.
218
+
219
+ Returns:
220
+ Result containing list of StagnationDetection for each pattern.
221
+ Always returns Ok (detection never fails, just detects or not).
222
+ """
223
+ log.debug(
224
+ "resilience.stagnation.detection_started",
225
+ iteration=history.iteration,
226
+ outputs_count=len(history.phase_outputs),
227
+ errors_count=len(history.error_signatures),
228
+ drifts_count=len(history.drift_scores),
229
+ )
230
+
231
+ detections = [
232
+ self._detect_spinning(history),
233
+ self._detect_oscillation(history),
234
+ self._detect_no_drift(history),
235
+ self._detect_diminishing_returns(history),
236
+ ]
237
+
238
+ detected_count = sum(1 for d in detections if d.detected)
239
+ if detected_count > 0:
240
+ log.info(
241
+ "resilience.stagnation.patterns_detected",
242
+ count=detected_count,
243
+ patterns=[d.pattern.value for d in detections if d.detected],
244
+ )
245
+
246
+ return Result.ok(detections)
247
+
248
+ def _detect_spinning(self, history: ExecutionHistory) -> StagnationDetection:
249
+ """Detect spinning pattern: same output repeated N times.
250
+
251
+ Checks both phase_outputs and error_signatures for repetition.
252
+
253
+ Args:
254
+ history: ExecutionHistory to analyze.
255
+
256
+ Returns:
257
+ StagnationDetection with detected=True if spinning found.
258
+ """
259
+ # Check phase outputs
260
+ outputs = history.phase_outputs
261
+ if len(outputs) >= self._spinning_threshold:
262
+ recent = outputs[-self._spinning_threshold :]
263
+ hashes = [self._compute_hash(o) for o in recent]
264
+
265
+ if len(set(hashes)) == 1:
266
+ return StagnationDetection(
267
+ pattern=StagnationPattern.SPINNING,
268
+ detected=True,
269
+ confidence=1.0,
270
+ evidence={
271
+ "repeated_output_sample": recent[-1][:200],
272
+ "repeat_count": len(recent),
273
+ "source": "phase_outputs",
274
+ },
275
+ )
276
+
277
+ # Check error signatures
278
+ errors = history.error_signatures
279
+ if len(errors) >= self._spinning_threshold:
280
+ recent_errors = errors[-self._spinning_threshold :]
281
+ error_hashes = [self._compute_hash(e) for e in recent_errors]
282
+
283
+ if len(set(error_hashes)) == 1:
284
+ return StagnationDetection(
285
+ pattern=StagnationPattern.SPINNING,
286
+ detected=True,
287
+ confidence=1.0,
288
+ evidence={
289
+ "repeated_error": recent_errors[-1][:200],
290
+ "repeat_count": len(recent_errors),
291
+ "source": "error_signatures",
292
+ },
293
+ )
294
+
295
+ return StagnationDetection(
296
+ pattern=StagnationPattern.SPINNING,
297
+ detected=False,
298
+ confidence=0.0,
299
+ )
300
+
301
+ def _detect_oscillation(self, history: ExecutionHistory) -> StagnationDetection:
302
+ """Detect oscillation pattern: A→B→A→B alternating states.
303
+
304
+ Looks for alternating pattern where even indices match each other
305
+ and odd indices match each other, but even ≠ odd.
306
+
307
+ Args:
308
+ history: ExecutionHistory to analyze.
309
+
310
+ Returns:
311
+ StagnationDetection with detected=True if oscillation found.
312
+ """
313
+ min_length = self._oscillation_cycles * 2
314
+ outputs = history.phase_outputs
315
+
316
+ if len(outputs) < min_length:
317
+ return StagnationDetection(
318
+ pattern=StagnationPattern.OSCILLATION,
319
+ detected=False,
320
+ confidence=0.0,
321
+ )
322
+
323
+ recent = outputs[-min_length:]
324
+ hashes = [self._compute_hash(o) for o in recent]
325
+
326
+ # Split into even and odd indices
327
+ even_hashes = hashes[::2] # [0, 2, 4, ...]
328
+ odd_hashes = hashes[1::2] # [1, 3, 5, ...]
329
+
330
+ # Check: all evens same, all odds same, but even ≠ odd
331
+ evens_same = len(set(even_hashes)) == 1
332
+ odds_same = len(set(odd_hashes)) == 1
333
+ different_states = even_hashes[0] != odd_hashes[0]
334
+
335
+ if evens_same and odds_same and different_states:
336
+ return StagnationDetection(
337
+ pattern=StagnationPattern.OSCILLATION,
338
+ detected=True,
339
+ confidence=0.9,
340
+ evidence={
341
+ "state_a_sample": recent[0][:100],
342
+ "state_b_sample": recent[1][:100],
343
+ "cycles_detected": self._oscillation_cycles,
344
+ },
345
+ )
346
+
347
+ return StagnationDetection(
348
+ pattern=StagnationPattern.OSCILLATION,
349
+ detected=False,
350
+ confidence=0.0,
351
+ )
352
+
353
+ def _detect_no_drift(self, history: ExecutionHistory) -> StagnationDetection:
354
+ """Detect no drift pattern: drift score not improving.
355
+
356
+ Checks if drift scores have changed by less than epsilon
357
+ over the required number of iterations.
358
+
359
+ Args:
360
+ history: ExecutionHistory with drift_scores.
361
+
362
+ Returns:
363
+ StagnationDetection with detected=True if no drift found.
364
+ """
365
+ scores = history.drift_scores
366
+
367
+ if len(scores) < self._no_drift_iterations:
368
+ return StagnationDetection(
369
+ pattern=StagnationPattern.NO_DRIFT,
370
+ detected=False,
371
+ confidence=0.0,
372
+ )
373
+
374
+ recent = scores[-self._no_drift_iterations :]
375
+
376
+ # Calculate deltas between consecutive scores
377
+ deltas = [abs(recent[i] - recent[i - 1]) for i in range(1, len(recent))]
378
+
379
+ # Check if all deltas are below epsilon
380
+ if all(delta < self._no_drift_epsilon for delta in deltas):
381
+ avg_score = sum(recent) / len(recent)
382
+ return StagnationDetection(
383
+ pattern=StagnationPattern.NO_DRIFT,
384
+ detected=True,
385
+ confidence=1.0 - (sum(deltas) / len(deltas) / self._no_drift_epsilon),
386
+ evidence={
387
+ "drift_scores": list(recent),
388
+ "deltas": deltas,
389
+ "epsilon_threshold": self._no_drift_epsilon,
390
+ "average_drift": avg_score,
391
+ "stagnant_iterations": len(recent),
392
+ },
393
+ )
394
+
395
+ return StagnationDetection(
396
+ pattern=StagnationPattern.NO_DRIFT,
397
+ detected=False,
398
+ confidence=0.0,
399
+ )
400
+
401
+ def _detect_diminishing_returns(self, history: ExecutionHistory) -> StagnationDetection:
402
+ """Detect diminishing returns: improvement rate decreasing.
403
+
404
+ Analyzes drift scores to check if improvements are getting smaller.
405
+
406
+ Args:
407
+ history: ExecutionHistory with drift_scores.
408
+
409
+ Returns:
410
+ StagnationDetection with detected=True if diminishing returns found.
411
+ """
412
+ scores = history.drift_scores
413
+
414
+ if len(scores) < self._no_drift_iterations + 1:
415
+ return StagnationDetection(
416
+ pattern=StagnationPattern.DIMINISHING_RETURNS,
417
+ detected=False,
418
+ confidence=0.0,
419
+ )
420
+
421
+ recent = scores[-(self._no_drift_iterations + 1) :]
422
+
423
+ # Calculate improvement rates (positive = improving toward goal)
424
+ # Assuming lower drift = better (closer to goal)
425
+ improvements = [recent[i - 1] - recent[i] for i in range(1, len(recent))]
426
+
427
+ # Check if all improvements are below threshold
428
+ if all(imp < self._diminishing_threshold for imp in improvements):
429
+ # Additional check: are improvements decreasing?
430
+ is_decreasing = all(
431
+ improvements[i] >= improvements[i + 1] for i in range(len(improvements) - 1)
432
+ )
433
+
434
+ confidence = 0.8 if is_decreasing else 0.6
435
+
436
+ return StagnationDetection(
437
+ pattern=StagnationPattern.DIMINISHING_RETURNS,
438
+ detected=True,
439
+ confidence=confidence,
440
+ evidence={
441
+ "improvement_rates": improvements,
442
+ "threshold": self._diminishing_threshold,
443
+ "monotonically_decreasing": is_decreasing,
444
+ },
445
+ )
446
+
447
+ return StagnationDetection(
448
+ pattern=StagnationPattern.DIMINISHING_RETURNS,
449
+ detected=False,
450
+ confidence=0.0,
451
+ )
452
+
453
+
454
+ # =============================================================================
455
+ # Event Classes
456
+ # =============================================================================
457
+
458
+
459
+ class SpinningDetectedEvent(BaseEvent):
460
+ """Event emitted when spinning pattern detected.
461
+
462
+ Spinning occurs when the same output is repeated multiple times,
463
+ indicating the system is stuck in a loop.
464
+ """
465
+
466
+ def __init__(
467
+ self,
468
+ execution_id: str,
469
+ repeated_output_sample: str,
470
+ repeat_count: int,
471
+ source: str,
472
+ *,
473
+ seed_id: str | None = None,
474
+ confidence: float = 1.0,
475
+ iteration: int = 0,
476
+ ) -> None:
477
+ """Create SpinningDetectedEvent.
478
+
479
+ Args:
480
+ execution_id: Execution identifier.
481
+ repeated_output_sample: Sample of repeated output (truncated).
482
+ repeat_count: Number of repetitions detected.
483
+ source: Where repetition was found ("phase_outputs" or "error_signatures").
484
+ seed_id: Optional seed identifier.
485
+ confidence: Confidence score (0.0-1.0).
486
+ iteration: Current iteration number.
487
+ """
488
+ super().__init__(
489
+ type="resilience.stagnation.spinning.detected",
490
+ aggregate_type="execution",
491
+ aggregate_id=execution_id,
492
+ data={
493
+ "pattern": StagnationPattern.SPINNING.value,
494
+ "repeated_output_sample": repeated_output_sample[:200],
495
+ "repeat_count": repeat_count,
496
+ "source": source,
497
+ "seed_id": seed_id,
498
+ "confidence": confidence,
499
+ "iteration": iteration,
500
+ },
501
+ )
502
+
503
+
504
+ class OscillationDetectedEvent(BaseEvent):
505
+ """Event emitted when oscillation pattern detected.
506
+
507
+ Oscillation occurs when execution alternates between two states
508
+ in an A→B→A→B pattern.
509
+ """
510
+
511
+ def __init__(
512
+ self,
513
+ execution_id: str,
514
+ state_a_sample: str,
515
+ state_b_sample: str,
516
+ cycles_detected: int,
517
+ *,
518
+ seed_id: str | None = None,
519
+ confidence: float = 0.9,
520
+ iteration: int = 0,
521
+ ) -> None:
522
+ """Create OscillationDetectedEvent.
523
+
524
+ Args:
525
+ execution_id: Execution identifier.
526
+ state_a_sample: Sample of state A (truncated).
527
+ state_b_sample: Sample of state B (truncated).
528
+ cycles_detected: Number of A→B cycles detected.
529
+ seed_id: Optional seed identifier.
530
+ confidence: Confidence score (0.0-1.0).
531
+ iteration: Current iteration number.
532
+ """
533
+ super().__init__(
534
+ type="resilience.stagnation.oscillation.detected",
535
+ aggregate_type="execution",
536
+ aggregate_id=execution_id,
537
+ data={
538
+ "pattern": StagnationPattern.OSCILLATION.value,
539
+ "state_a_sample": state_a_sample[:100],
540
+ "state_b_sample": state_b_sample[:100],
541
+ "cycles_detected": cycles_detected,
542
+ "seed_id": seed_id,
543
+ "confidence": confidence,
544
+ "iteration": iteration,
545
+ },
546
+ )
547
+
548
+
549
+ class NoDriftDetectedEvent(BaseEvent):
550
+ """Event emitted when no drift pattern detected.
551
+
552
+ No drift occurs when the drift score (distance from goal) is not
553
+ improving over multiple iterations.
554
+ """
555
+
556
+ def __init__(
557
+ self,
558
+ execution_id: str,
559
+ drift_scores: list[float],
560
+ average_drift: float,
561
+ stagnant_iterations: int,
562
+ *,
563
+ seed_id: str | None = None,
564
+ confidence: float = 1.0,
565
+ iteration: int = 0,
566
+ ) -> None:
567
+ """Create NoDriftDetectedEvent.
568
+
569
+ Args:
570
+ execution_id: Execution identifier.
571
+ drift_scores: Recent drift score values.
572
+ average_drift: Average drift score over period.
573
+ stagnant_iterations: Number of iterations with no progress.
574
+ seed_id: Optional seed identifier.
575
+ confidence: Confidence score (0.0-1.0).
576
+ iteration: Current iteration number.
577
+ """
578
+ super().__init__(
579
+ type="resilience.stagnation.no_drift.detected",
580
+ aggregate_type="execution",
581
+ aggregate_id=execution_id,
582
+ data={
583
+ "pattern": StagnationPattern.NO_DRIFT.value,
584
+ "drift_scores": drift_scores,
585
+ "average_drift": average_drift,
586
+ "stagnant_iterations": stagnant_iterations,
587
+ "seed_id": seed_id,
588
+ "confidence": confidence,
589
+ "iteration": iteration,
590
+ },
591
+ )
592
+
593
+
594
+ class DiminishingReturnsDetectedEvent(BaseEvent):
595
+ """Event emitted when diminishing returns pattern detected.
596
+
597
+ Diminishing returns occurs when the improvement rate is consistently
598
+ decreasing, indicating progress is slowing.
599
+ """
600
+
601
+ def __init__(
602
+ self,
603
+ execution_id: str,
604
+ improvement_rates: list[float],
605
+ monotonically_decreasing: bool,
606
+ *,
607
+ seed_id: str | None = None,
608
+ confidence: float = 0.8,
609
+ iteration: int = 0,
610
+ ) -> None:
611
+ """Create DiminishingReturnsDetectedEvent.
612
+
613
+ Args:
614
+ execution_id: Execution identifier.
615
+ improvement_rates: Recent improvement rate values.
616
+ monotonically_decreasing: Whether rates are strictly decreasing.
617
+ seed_id: Optional seed identifier.
618
+ confidence: Confidence score (0.0-1.0).
619
+ iteration: Current iteration number.
620
+ """
621
+ super().__init__(
622
+ type="resilience.stagnation.diminishing_returns.detected",
623
+ aggregate_type="execution",
624
+ aggregate_id=execution_id,
625
+ data={
626
+ "pattern": StagnationPattern.DIMINISHING_RETURNS.value,
627
+ "improvement_rates": improvement_rates,
628
+ "monotonically_decreasing": monotonically_decreasing,
629
+ "seed_id": seed_id,
630
+ "confidence": confidence,
631
+ "iteration": iteration,
632
+ },
633
+ )
634
+
635
+
636
+ # =============================================================================
637
+ # Helper Functions
638
+ # =============================================================================
639
+
640
+
641
+ def create_stagnation_event(
642
+ detection: StagnationDetection,
643
+ execution_id: str,
644
+ *,
645
+ seed_id: str | None = None,
646
+ iteration: int = 0,
647
+ ) -> BaseEvent:
648
+ """Create appropriate event for a stagnation detection.
649
+
650
+ Factory function that creates the correct event type based on
651
+ the detected pattern.
652
+
653
+ Args:
654
+ detection: StagnationDetection result.
655
+ execution_id: Execution identifier.
656
+ seed_id: Optional seed identifier.
657
+ iteration: Current iteration number.
658
+
659
+ Returns:
660
+ Appropriate event type for the detected pattern.
661
+
662
+ Raises:
663
+ ValueError: If detection.detected is False.
664
+ """
665
+ if not detection.detected:
666
+ raise ValueError("Cannot create event for non-detected stagnation")
667
+
668
+ evidence = detection.evidence
669
+
670
+ match detection.pattern:
671
+ case StagnationPattern.SPINNING:
672
+ return SpinningDetectedEvent(
673
+ execution_id=execution_id,
674
+ repeated_output_sample=str(evidence.get("repeated_output_sample", evidence.get("repeated_error", ""))),
675
+ repeat_count=int(evidence.get("repeat_count", 0)),
676
+ source=str(evidence.get("source", "unknown")),
677
+ seed_id=seed_id,
678
+ confidence=detection.confidence,
679
+ iteration=iteration,
680
+ )
681
+
682
+ case StagnationPattern.OSCILLATION:
683
+ return OscillationDetectedEvent(
684
+ execution_id=execution_id,
685
+ state_a_sample=str(evidence.get("state_a_sample", "")),
686
+ state_b_sample=str(evidence.get("state_b_sample", "")),
687
+ cycles_detected=int(evidence.get("cycles_detected", 0)),
688
+ seed_id=seed_id,
689
+ confidence=detection.confidence,
690
+ iteration=iteration,
691
+ )
692
+
693
+ case StagnationPattern.NO_DRIFT:
694
+ return NoDriftDetectedEvent(
695
+ execution_id=execution_id,
696
+ drift_scores=list(evidence.get("drift_scores", [])),
697
+ average_drift=float(evidence.get("average_drift", 0.0)),
698
+ stagnant_iterations=int(evidence.get("stagnant_iterations", 0)),
699
+ seed_id=seed_id,
700
+ confidence=detection.confidence,
701
+ iteration=iteration,
702
+ )
703
+
704
+ case StagnationPattern.DIMINISHING_RETURNS:
705
+ return DiminishingReturnsDetectedEvent(
706
+ execution_id=execution_id,
707
+ improvement_rates=list(evidence.get("improvement_rates", [])),
708
+ monotonically_decreasing=bool(evidence.get("monotonically_decreasing", False)),
709
+ seed_id=seed_id,
710
+ confidence=detection.confidence,
711
+ iteration=iteration,
712
+ )
713
+
714
+ case _:
715
+ # Fallback - should never reach here
716
+ return BaseEvent(
717
+ type=f"resilience.stagnation.{detection.pattern.value}.detected",
718
+ aggregate_type="execution",
719
+ aggregate_id=execution_id,
720
+ data={
721
+ "pattern": detection.pattern.value,
722
+ "evidence": evidence,
723
+ "confidence": detection.confidence,
724
+ "seed_id": seed_id,
725
+ "iteration": iteration,
726
+ },
727
+ )