kailash 0.6.0__py3-none-any.whl → 0.6.1__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.
kailash/__init__.py CHANGED
@@ -33,7 +33,7 @@ except ImportError:
33
33
  # For backward compatibility
34
34
  WorkflowGraph = Workflow
35
35
 
36
- __version__ = "0.6.0"
36
+ __version__ = "0.6.1"
37
37
 
38
38
  __all__ = [
39
39
  # Core workflow components
@@ -42,8 +42,8 @@ set_access_control_manager = _original_module.set_access_control_manager
42
42
 
43
43
  # Import new composition-based components
44
44
  from kailash.access_control.managers import AccessControlManager # noqa: E402
45
+ from kailash.access_control.rule_evaluators import ABACRuleEvaluator # noqa: E402
45
46
  from kailash.access_control.rule_evaluators import ( # noqa: E402
46
- ABACRuleEvaluator,
47
47
  HybridRuleEvaluator,
48
48
  RBACRuleEvaluator,
49
49
  RuleEvaluator,
@@ -0,0 +1,630 @@
1
+ """Adaptive pool sizing controller for dynamic connection management.
2
+
3
+ This module implements intelligent pool size adjustment based on workload
4
+ patterns and resource constraints.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import time
10
+ from collections import deque
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timedelta
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ import psutil
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class PoolMetrics:
22
+ """Metrics for pool sizing decisions."""
23
+
24
+ current_size: int
25
+ active_connections: int
26
+ idle_connections: int
27
+ queue_depth: int
28
+ avg_wait_time_ms: float
29
+ avg_query_time_ms: float
30
+ queries_per_second: float
31
+ utilization_rate: float # 0-1
32
+ health_score: float # 0-100
33
+
34
+
35
+ @dataclass
36
+ class ResourceConstraints:
37
+ """System resource constraints."""
38
+
39
+ max_database_connections: int
40
+ available_memory_mb: float
41
+ memory_per_connection_mb: float
42
+ cpu_usage_percent: float
43
+ network_bandwidth_mbps: float
44
+
45
+
46
+ @dataclass
47
+ class ScalingDecision:
48
+ """Result of scaling decision."""
49
+
50
+ action: str # "scale_up", "scale_down", "no_change"
51
+ current_size: int
52
+ target_size: int
53
+ reason: str
54
+ confidence: float # 0-1
55
+
56
+
57
+ class PoolSizeCalculator:
58
+ """Calculates optimal pool size using queueing theory and heuristics."""
59
+
60
+ def __init__(
61
+ self, target_utilization: float = 0.75, max_wait_time_ms: float = 100.0
62
+ ):
63
+ self.target_utilization = target_utilization
64
+ self.max_wait_time_ms = max_wait_time_ms
65
+
66
+ def calculate_optimal_size(
67
+ self,
68
+ metrics: PoolMetrics,
69
+ constraints: ResourceConstraints,
70
+ workload_forecast: Optional[Dict[str, Any]] = None,
71
+ ) -> int:
72
+ """Calculate optimal pool size based on multiple factors."""
73
+
74
+ # Method 1: Little's Law
75
+ littles_law_size = self._calculate_by_littles_law(metrics)
76
+
77
+ # Method 2: Utilization-based
78
+ utilization_size = self._calculate_by_utilization(metrics)
79
+
80
+ # Method 3: Queue depth based
81
+ queue_size = self._calculate_by_queue_depth(metrics)
82
+
83
+ # Method 4: Response time based
84
+ response_time_size = self._calculate_by_response_time(metrics)
85
+
86
+ # Method 5: Forecast-based (if available)
87
+ forecast_size = metrics.current_size
88
+ if workload_forecast:
89
+ forecast_size = self._calculate_by_forecast(workload_forecast)
90
+
91
+ # Combine methods with weights
92
+ weighted_size = (
93
+ littles_law_size * 0.25
94
+ + utilization_size * 0.25
95
+ + queue_size * 0.2
96
+ + response_time_size * 0.2
97
+ + forecast_size * 0.1
98
+ )
99
+
100
+ # Apply constraints
101
+ optimal_size = self._apply_constraints(
102
+ int(weighted_size), metrics.current_size, constraints
103
+ )
104
+
105
+ logger.debug(
106
+ f"Pool size calculation: Little's={littles_law_size}, "
107
+ f"Utilization={utilization_size}, Queue={queue_size}, "
108
+ f"Response={response_time_size}, Forecast={forecast_size}, "
109
+ f"Final={optimal_size}"
110
+ )
111
+
112
+ return optimal_size
113
+
114
+ def _calculate_by_littles_law(self, metrics: PoolMetrics) -> int:
115
+ """Use Little's Law: L = λW (connections = arrival_rate * service_time)."""
116
+ if metrics.queries_per_second == 0 or metrics.avg_query_time_ms == 0:
117
+ return metrics.current_size
118
+
119
+ arrival_rate = metrics.queries_per_second
120
+ service_time_seconds = metrics.avg_query_time_ms / 1000
121
+
122
+ # Add buffer for variability
123
+ required_connections = arrival_rate * service_time_seconds * 1.2
124
+
125
+ return max(2, int(required_connections))
126
+
127
+ def _calculate_by_utilization(self, metrics: PoolMetrics) -> int:
128
+ """Calculate based on target utilization."""
129
+ if metrics.utilization_rate == 0:
130
+ return metrics.current_size
131
+
132
+ # If utilization is too high, scale up
133
+ if metrics.utilization_rate > self.target_utilization + 0.1:
134
+ scale_factor = metrics.utilization_rate / self.target_utilization
135
+ return int(metrics.current_size * scale_factor)
136
+
137
+ # If utilization is too low, scale down
138
+ elif metrics.utilization_rate < self.target_utilization - 0.2:
139
+ scale_factor = metrics.utilization_rate / self.target_utilization
140
+ return max(2, int(metrics.current_size * scale_factor))
141
+
142
+ return metrics.current_size
143
+
144
+ def _calculate_by_queue_depth(self, metrics: PoolMetrics) -> int:
145
+ """Calculate based on queue depth."""
146
+ if metrics.queue_depth == 0:
147
+ return metrics.current_size
148
+
149
+ # If queue is building up, we need more connections
150
+ if metrics.queue_depth > metrics.current_size * 0.5:
151
+ # Add connections proportional to queue depth
152
+ additional_needed = int(metrics.queue_depth / 2)
153
+ return metrics.current_size + additional_needed
154
+
155
+ # If no queue and low utilization, we might have too many
156
+ elif metrics.queue_depth == 0 and metrics.utilization_rate < 0.5:
157
+ return max(2, int(metrics.current_size * 0.8))
158
+
159
+ return metrics.current_size
160
+
161
+ def _calculate_by_response_time(self, metrics: PoolMetrics) -> int:
162
+ """Calculate based on response time targets."""
163
+ if metrics.avg_wait_time_ms <= self.max_wait_time_ms:
164
+ # Meeting targets, check if we can reduce
165
+ if metrics.avg_wait_time_ms < self.max_wait_time_ms * 0.5:
166
+ return max(2, int(metrics.current_size * 0.9))
167
+ return metrics.current_size
168
+ else:
169
+ # Not meeting targets, scale up
170
+ scale_factor = metrics.avg_wait_time_ms / self.max_wait_time_ms
171
+ return int(metrics.current_size * scale_factor)
172
+
173
+ def _calculate_by_forecast(self, forecast: Dict[str, Any]) -> int:
174
+ """Calculate based on workload forecast."""
175
+ return forecast.get("recommended_pool_size", 10)
176
+
177
+ def _apply_constraints(
178
+ self, calculated_size: int, current_size: int, constraints: ResourceConstraints
179
+ ) -> int:
180
+ """Apply resource constraints to calculated size."""
181
+ # Database connection limit (use 80% to leave room for other apps)
182
+ max_by_db = int(constraints.max_database_connections * 0.8)
183
+
184
+ # Memory limit
185
+ max_by_memory = int(
186
+ constraints.available_memory_mb / constraints.memory_per_connection_mb
187
+ )
188
+
189
+ # CPU constraint (don't scale up if CPU is high)
190
+ if constraints.cpu_usage_percent > 80 and calculated_size > current_size:
191
+ calculated_size = current_size
192
+
193
+ # Apply all constraints
194
+ final_size = min(calculated_size, max_by_db, max_by_memory)
195
+
196
+ # Ensure minimum size
197
+ return max(2, final_size)
198
+
199
+
200
+ class ScalingDecisionEngine:
201
+ """Makes scaling decisions with hysteresis and dampening."""
202
+
203
+ def __init__(
204
+ self,
205
+ scale_up_threshold: float = 0.15,
206
+ scale_down_threshold: float = 0.20,
207
+ max_adjustment_step: int = 2,
208
+ cooldown_seconds: int = 60,
209
+ ):
210
+ self.scale_up_threshold = scale_up_threshold
211
+ self.scale_down_threshold = scale_down_threshold
212
+ self.max_adjustment_step = max_adjustment_step
213
+ self.cooldown_seconds = cooldown_seconds
214
+
215
+ # History tracking
216
+ self.decision_history: deque = deque(maxlen=100)
217
+ self.last_scaling_time = datetime.min
218
+ self.size_history: deque = deque(maxlen=20)
219
+
220
+ def should_scale(
221
+ self,
222
+ current_size: int,
223
+ optimal_size: int,
224
+ metrics: PoolMetrics,
225
+ emergency: bool = False,
226
+ ) -> ScalingDecision:
227
+ """Decide whether to scale with hysteresis."""
228
+
229
+ # Check cooldown period
230
+ if not emergency and not self._cooldown_expired():
231
+ return ScalingDecision(
232
+ action="no_change",
233
+ current_size=current_size,
234
+ target_size=current_size,
235
+ reason="In cooldown period",
236
+ confidence=1.0,
237
+ )
238
+
239
+ # Calculate size difference
240
+ size_diff = optimal_size - current_size
241
+ size_diff_ratio = abs(size_diff) / current_size if current_size > 0 else 1.0
242
+
243
+ # Check for flapping
244
+ if self._is_flapping():
245
+ return ScalingDecision(
246
+ action="no_change",
247
+ current_size=current_size,
248
+ target_size=current_size,
249
+ reason="Flapping detected - stabilizing",
250
+ confidence=0.9,
251
+ )
252
+
253
+ # Emergency scaling (bypass normal thresholds)
254
+ if emergency:
255
+ if metrics.queue_depth > current_size:
256
+ target = min(current_size + self.max_adjustment_step * 2, optimal_size)
257
+ return self._create_scaling_decision(
258
+ "scale_up",
259
+ current_size,
260
+ target,
261
+ "Emergency: High queue depth",
262
+ 0.95,
263
+ )
264
+
265
+ # Normal scaling logic
266
+ if size_diff > 0 and size_diff_ratio > self.scale_up_threshold:
267
+ # Scale up
268
+ target = self._calculate_gradual_target(current_size, optimal_size, "up")
269
+ reason = self._get_scale_up_reason(metrics)
270
+ confidence = self._calculate_confidence(metrics, size_diff_ratio)
271
+
272
+ return self._create_scaling_decision(
273
+ "scale_up", current_size, target, reason, confidence
274
+ )
275
+
276
+ elif size_diff < 0 and size_diff_ratio > self.scale_down_threshold:
277
+ # Scale down
278
+ target = self._calculate_gradual_target(current_size, optimal_size, "down")
279
+ reason = self._get_scale_down_reason(metrics)
280
+ confidence = self._calculate_confidence(metrics, size_diff_ratio)
281
+
282
+ return self._create_scaling_decision(
283
+ "scale_down", current_size, target, reason, confidence
284
+ )
285
+
286
+ else:
287
+ # No change needed
288
+ return ScalingDecision(
289
+ action="no_change",
290
+ current_size=current_size,
291
+ target_size=current_size,
292
+ reason="Within acceptable thresholds",
293
+ confidence=0.8,
294
+ )
295
+
296
+ def _cooldown_expired(self) -> bool:
297
+ """Check if cooldown period has expired."""
298
+ return (
299
+ datetime.now() - self.last_scaling_time
300
+ ).total_seconds() > self.cooldown_seconds
301
+
302
+ def _is_flapping(self) -> bool:
303
+ """Detect if pool size is flapping."""
304
+ if len(self.decision_history) < 4:
305
+ return False
306
+
307
+ # Check if we've been alternating between scale up/down
308
+ recent_actions = [d.action for d in list(self.decision_history)[-4:]]
309
+ alternating = all(
310
+ recent_actions[i] != recent_actions[i + 1]
311
+ for i in range(len(recent_actions) - 1)
312
+ if recent_actions[i] != "no_change"
313
+ )
314
+
315
+ return alternating
316
+
317
+ def _calculate_gradual_target(
318
+ self, current: int, optimal: int, direction: str
319
+ ) -> int:
320
+ """Calculate gradual scaling target."""
321
+ if direction == "up":
322
+ # Don't scale up more than max_adjustment_step at once
323
+ max_target = current + self.max_adjustment_step
324
+ return min(optimal, max_target)
325
+ else:
326
+ # Don't scale down more than max_adjustment_step at once
327
+ min_target = current - self.max_adjustment_step
328
+ return max(optimal, min_target, 2) # Never go below 2
329
+
330
+ def _get_scale_up_reason(self, metrics: PoolMetrics) -> str:
331
+ """Generate reason for scaling up."""
332
+ reasons = []
333
+
334
+ if metrics.utilization_rate > 0.85:
335
+ reasons.append(f"High utilization ({metrics.utilization_rate:.1%})")
336
+ if metrics.queue_depth > 0:
337
+ reasons.append(f"Queue depth: {metrics.queue_depth}")
338
+ if metrics.avg_wait_time_ms > 50:
339
+ reasons.append(f"Wait time: {metrics.avg_wait_time_ms:.0f}ms")
340
+
341
+ return " | ".join(reasons) if reasons else "Optimal size increased"
342
+
343
+ def _get_scale_down_reason(self, metrics: PoolMetrics) -> str:
344
+ """Generate reason for scaling down."""
345
+ reasons = []
346
+
347
+ if metrics.utilization_rate < 0.5:
348
+ reasons.append(f"Low utilization ({metrics.utilization_rate:.1%})")
349
+ if metrics.idle_connections > metrics.active_connections:
350
+ reasons.append(f"Idle connections: {metrics.idle_connections}")
351
+
352
+ return " | ".join(reasons) if reasons else "Optimal size decreased"
353
+
354
+ def _calculate_confidence(
355
+ self, metrics: PoolMetrics, size_diff_ratio: float
356
+ ) -> float:
357
+ """Calculate confidence in scaling decision."""
358
+ confidence = 0.5
359
+
360
+ # Higher confidence for extreme situations
361
+ if metrics.utilization_rate > 0.9 or metrics.utilization_rate < 0.3:
362
+ confidence += 0.2
363
+
364
+ if metrics.queue_depth > 5:
365
+ confidence += 0.15
366
+
367
+ if size_diff_ratio > 0.3:
368
+ confidence += 0.15
369
+
370
+ # Lower confidence if health score is low
371
+ if metrics.health_score < 70:
372
+ confidence *= 0.8
373
+
374
+ return min(confidence, 0.95)
375
+
376
+ def _create_scaling_decision(
377
+ self,
378
+ action: str,
379
+ current_size: int,
380
+ target_size: int,
381
+ reason: str,
382
+ confidence: float,
383
+ ) -> ScalingDecision:
384
+ """Create and record scaling decision."""
385
+ decision = ScalingDecision(
386
+ action=action,
387
+ current_size=current_size,
388
+ target_size=target_size,
389
+ reason=reason,
390
+ confidence=confidence,
391
+ )
392
+
393
+ # Record decision
394
+ self.decision_history.append(decision)
395
+ self.size_history.append(target_size)
396
+
397
+ if action != "no_change":
398
+ self.last_scaling_time = datetime.now()
399
+
400
+ return decision
401
+
402
+
403
+ class ResourceMonitor:
404
+ """Monitors system resources for constraint enforcement."""
405
+
406
+ def __init__(self):
407
+ self.process = psutil.Process()
408
+ self.last_check_time = datetime.min
409
+ self.check_interval = timedelta(seconds=10)
410
+ self.cached_constraints: Optional[ResourceConstraints] = None
411
+
412
+ async def get_resource_constraints(
413
+ self, db_connection_info: Dict[str, Any]
414
+ ) -> ResourceConstraints:
415
+ """Get current resource constraints."""
416
+ # Use cache if recent
417
+ if (
418
+ self.cached_constraints
419
+ and datetime.now() - self.last_check_time < self.check_interval
420
+ ):
421
+ return self.cached_constraints
422
+
423
+ # Get system memory
424
+ memory = psutil.virtual_memory()
425
+ available_memory_mb = memory.available / (1024 * 1024)
426
+
427
+ # Get CPU usage
428
+ cpu_percent = psutil.cpu_percent(interval=0.1)
429
+
430
+ # Estimate network bandwidth (simplified)
431
+ network_bandwidth_mbps = 100.0 # Default 100 Mbps
432
+
433
+ # Get database connection limit
434
+ max_db_connections = await self._get_database_limit(db_connection_info)
435
+
436
+ # Estimate memory per connection
437
+ memory_per_connection = self._estimate_connection_memory()
438
+
439
+ constraints = ResourceConstraints(
440
+ max_database_connections=max_db_connections,
441
+ available_memory_mb=available_memory_mb,
442
+ memory_per_connection_mb=memory_per_connection,
443
+ cpu_usage_percent=cpu_percent,
444
+ network_bandwidth_mbps=network_bandwidth_mbps,
445
+ )
446
+
447
+ self.cached_constraints = constraints
448
+ self.last_check_time = datetime.now()
449
+
450
+ return constraints
451
+
452
+ async def _get_database_limit(self, db_info: Dict[str, Any]) -> int:
453
+ """Get database connection limit."""
454
+ # This would query the database for max_connections
455
+ # For now, use reasonable defaults
456
+ db_type = db_info.get("type", "postgresql")
457
+
458
+ defaults = {"postgresql": 100, "mysql": 150, "sqlite": 10}
459
+
460
+ return defaults.get(db_type, 50)
461
+
462
+ def _estimate_connection_memory(self) -> float:
463
+ """Estimate memory usage per connection in MB."""
464
+ # This is a rough estimate
465
+ # Real implementation would measure actual usage
466
+ return 10.0 # 10 MB per connection
467
+
468
+
469
+ class AdaptivePoolController:
470
+ """Main controller for adaptive pool sizing."""
471
+
472
+ def __init__(
473
+ self,
474
+ min_size: int = 2,
475
+ max_size: int = 50,
476
+ target_utilization: float = 0.75,
477
+ adjustment_interval_seconds: int = 30,
478
+ ):
479
+ self.min_size = min_size
480
+ self.max_size = max_size
481
+ self.target_utilization = target_utilization
482
+ self.adjustment_interval_seconds = adjustment_interval_seconds
483
+
484
+ # Components
485
+ self.calculator = PoolSizeCalculator(target_utilization=target_utilization)
486
+ self.decision_engine = ScalingDecisionEngine()
487
+ self.resource_monitor = ResourceMonitor()
488
+
489
+ # State
490
+ self.running = False
491
+ self.adjustment_task: Optional[asyncio.Task] = None
492
+ self.metrics_history: deque = deque(maxlen=60) # 30 minutes of history
493
+
494
+ async def start(self, pool_ref: Any, pattern_tracker: Optional[Any] = None):
495
+ """Start the adaptive controller."""
496
+ self.pool_ref = pool_ref
497
+ self.pattern_tracker = pattern_tracker
498
+ self.running = True
499
+
500
+ # Start adjustment loop
501
+ self.adjustment_task = asyncio.create_task(self._adjustment_loop())
502
+
503
+ logger.info("Adaptive pool controller started")
504
+
505
+ async def stop(self):
506
+ """Stop the adaptive controller."""
507
+ self.running = False
508
+
509
+ if self.adjustment_task:
510
+ self.adjustment_task.cancel()
511
+ try:
512
+ await self.adjustment_task
513
+ except asyncio.CancelledError:
514
+ pass
515
+
516
+ logger.info("Adaptive pool controller stopped")
517
+
518
+ async def _adjustment_loop(self):
519
+ """Main loop for pool size adjustments."""
520
+ while self.running:
521
+ try:
522
+ # Collect metrics
523
+ metrics = await self._collect_metrics()
524
+ self.metrics_history.append((datetime.now(), metrics))
525
+
526
+ # Get resource constraints
527
+ constraints = await self.resource_monitor.get_resource_constraints(
528
+ self.pool_ref.db_config
529
+ )
530
+
531
+ # Get workload forecast if available
532
+ forecast = None
533
+ if self.pattern_tracker:
534
+ forecast = self.pattern_tracker.get_workload_forecast(
535
+ horizon_minutes=self.adjustment_interval_seconds // 60 + 5
536
+ )
537
+
538
+ # Calculate optimal size
539
+ optimal_size = self.calculator.calculate_optimal_size(
540
+ metrics, constraints, forecast
541
+ )
542
+
543
+ # Make scaling decision
544
+ decision = self.decision_engine.should_scale(
545
+ metrics.current_size,
546
+ optimal_size,
547
+ metrics,
548
+ emergency=self._is_emergency(metrics),
549
+ )
550
+
551
+ # Execute scaling if needed
552
+ if decision.action != "no_change":
553
+ await self._execute_scaling(decision)
554
+
555
+ # Log metrics
556
+ if decision.action != "no_change" or metrics.current_size % 10 == 0:
557
+ logger.info(
558
+ f"Pool metrics: size={metrics.current_size}, "
559
+ f"utilization={metrics.utilization_rate:.1%}, "
560
+ f"queue={metrics.queue_depth}, "
561
+ f"action={decision.action}, "
562
+ f"target={decision.target_size}"
563
+ )
564
+
565
+ except Exception as e:
566
+ logger.error(f"Error in adaptive pool adjustment: {e}")
567
+
568
+ # Wait for next adjustment
569
+ await asyncio.sleep(self.adjustment_interval_seconds)
570
+
571
+ async def _collect_metrics(self) -> PoolMetrics:
572
+ """Collect current pool metrics."""
573
+ pool_stats = await self.pool_ref.get_pool_statistics()
574
+
575
+ return PoolMetrics(
576
+ current_size=pool_stats["total_connections"],
577
+ active_connections=pool_stats["active_connections"],
578
+ idle_connections=pool_stats["idle_connections"],
579
+ queue_depth=pool_stats.get("queue_depth", 0),
580
+ avg_wait_time_ms=pool_stats.get("avg_acquisition_time_ms", 0),
581
+ avg_query_time_ms=pool_stats.get("avg_query_time_ms", 0),
582
+ queries_per_second=pool_stats.get("queries_per_second", 0),
583
+ utilization_rate=pool_stats.get("utilization_rate", 0),
584
+ health_score=pool_stats.get("avg_health_score", 100),
585
+ )
586
+
587
+ def _is_emergency(self, metrics: PoolMetrics) -> bool:
588
+ """Check if emergency scaling is needed."""
589
+ return (
590
+ metrics.queue_depth > metrics.current_size * 2
591
+ or metrics.avg_wait_time_ms > 1000
592
+ or metrics.utilization_rate > 0.95
593
+ )
594
+
595
+ async def _execute_scaling(self, decision: ScalingDecision):
596
+ """Execute the scaling decision."""
597
+ logger.info(
598
+ f"Executing pool scaling: {decision.action} from "
599
+ f"{decision.current_size} to {decision.target_size} - {decision.reason}"
600
+ )
601
+
602
+ try:
603
+ # Apply bounds
604
+ target_size = max(self.min_size, min(self.max_size, decision.target_size))
605
+
606
+ # Call pool's adjustment method
607
+ success = await self.pool_ref.adjust_pool_size(target_size)
608
+
609
+ if success:
610
+ logger.info(f"Pool size adjusted to {target_size}")
611
+ else:
612
+ logger.warning(f"Failed to adjust pool size to {target_size}")
613
+
614
+ except Exception as e:
615
+ logger.error(f"Error executing pool scaling: {e}")
616
+
617
+ def get_adjustment_history(self) -> List[Dict[str, Any]]:
618
+ """Get recent adjustment history."""
619
+ return [
620
+ {
621
+ "timestamp": self.decision_engine.last_scaling_time.isoformat(),
622
+ "action": decision.action,
623
+ "from_size": decision.current_size,
624
+ "to_size": decision.target_size,
625
+ "reason": decision.reason,
626
+ "confidence": decision.confidence,
627
+ }
628
+ for decision in self.decision_engine.decision_history
629
+ if decision.action != "no_change"
630
+ ]
@@ -0,0 +1 @@
1
+ """Machine learning components for intelligent SDK features."""