triage4 1.0.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.
triage4/__init__.py ADDED
@@ -0,0 +1,70 @@
1
+ """
2
+ Tiered Resource Allocation for IoT Alarm and Geographic-priority Emergency (TRIAGE/4).
3
+
4
+ Four-band hierarchical scheduler that resolves priority inversion by
5
+ separating semantic urgency (alarms) from geographic priority (zones).
6
+
7
+ Main components:
8
+ - TRIAGE4Scheduler: Main scheduler with discrete-event simulation
9
+ - TRIAGE4Config: Configuration dataclass with band thresholds and token parameters
10
+ - DeviceFairQueue: Per-device round-robin queue for fairness
11
+ - BandClassifier: Message-to-band classification logic
12
+
13
+ Band hierarchy:
14
+ 0. ALARM: Emergency messages, strict priority
15
+ 1. HIGH: High-priority zones, token-constrained
16
+ 2. STANDARD: Standard zones, token-constrained
17
+ 3. BACKGROUND: Low-priority zones, token-constrained
18
+
19
+ Example:
20
+ >>> from triage4 import TRIAGE4Scheduler, TRIAGE4Config
21
+ >>> config = TRIAGE4Config(
22
+ ... high_zone_max=1,
23
+ ... standard_zone_max=3,
24
+ ... high_token_budget=10,
25
+ ... service_rate=20.0
26
+ ... )
27
+ >>> scheduler = TRIAGE4Scheduler(config)
28
+ >>> result = scheduler.schedule(
29
+ ... arrival_times=[0.0, 0.1, 0.2],
30
+ ... device_ids=["sensor_1", "sensor_2", "sensor_1"],
31
+ ... zone_priorities=[0, 5, 2],
32
+ ... is_alarm=[False, True, False]
33
+ ... )
34
+ """
35
+
36
+ from .band_classifier import (
37
+ BAND_ALARM,
38
+ BAND_BACKGROUND,
39
+ BAND_HIGH,
40
+ BAND_STANDARD,
41
+ BandClassifier,
42
+ )
43
+ from .adaptive_token_bucket import AdaptiveTokenBucket
44
+ from .alarm_rate_monitor import AlarmRateMonitor
45
+ from .device_fair_queue import DeviceFairQueue
46
+ from .triage4_config import TRIAGE4Config, create_triage4_custom, create_triage4_default
47
+ from .triage4_scheduler import TRIAGE4Scheduler
48
+ from .source_aware_queue import SourceAwareQueue
49
+
50
+ __all__ = [
51
+ # Main scheduler
52
+ "TRIAGE4Scheduler",
53
+ # Configuration
54
+ "TRIAGE4Config",
55
+ "create_triage4_default",
56
+ "create_triage4_custom",
57
+ # Components
58
+ "BandClassifier",
59
+ "DeviceFairQueue",
60
+ "AdaptiveTokenBucket",
61
+ "AlarmRateMonitor",
62
+ "SourceAwareQueue",
63
+ # Band constants
64
+ "BAND_ALARM",
65
+ "BAND_HIGH",
66
+ "BAND_STANDARD",
67
+ "BAND_BACKGROUND",
68
+ ]
69
+
70
+ __version__ = "1.0.0"
@@ -0,0 +1,61 @@
1
+ """
2
+ Adaptive token bucket that activates only when alarm protection is enabled.
3
+
4
+ Wraps the standard TokenBucket and exposes activation/deactivation hooks with
5
+ hysteresis controlled by the caller (e.g., AlarmRateMonitor).
6
+ """
7
+
8
+ from .token_bucket import TIME_TOLERANCE, TokenBucket
9
+
10
+
11
+ class AdaptiveTokenBucket:
12
+ """Token bucket that can be activated/deactivated at runtime."""
13
+
14
+ def __init__(self, budget: int, period: float, burst_capacity: int | None = None):
15
+ if budget <= 0:
16
+ raise ValueError("budget must be positive")
17
+ if period <= 0:
18
+ raise ValueError("period must be positive")
19
+ if burst_capacity is not None and burst_capacity < budget:
20
+ raise ValueError("burst_capacity must be >= budget")
21
+
22
+ self.bucket = TokenBucket(
23
+ budget=budget, period=period, burst_capacity=burst_capacity
24
+ )
25
+ self.active = False
26
+
27
+ def activate(self, current_time: float) -> None:
28
+ """Enable rate limiting and realign refill schedule."""
29
+ if not self.active:
30
+ self.active = True
31
+ # Align next refill to current_time to avoid retroactive refills.
32
+ self.bucket.next_refill = current_time + self.bucket.period
33
+ self.bucket.tokens = self.bucket.max_capacity
34
+
35
+ def deactivate(self) -> None:
36
+ """Disable rate limiting (bucket becomes pass-through)."""
37
+ self.active = False
38
+
39
+ def consume(self, current_time: float, amount: int = 1) -> bool:
40
+ """
41
+ Consume tokens if active. Returns True if the request is allowed.
42
+ When inactive, always returns True (no limiting).
43
+ """
44
+ if not self.active:
45
+ return True
46
+
47
+ self.bucket.refill(current_time)
48
+ return self.bucket.consume(amount)
49
+
50
+ def get_next_refill_time(self) -> float:
51
+ """Expose next refill time for event scheduling."""
52
+ if not self.active:
53
+ # If inactive, never schedule on bucket events
54
+ return float("inf")
55
+ return self.bucket.get_next_refill_time()
56
+
57
+ @property
58
+ def tokens(self) -> int:
59
+ """Current token count (0 when inactive implies unlimited)."""
60
+ return self.bucket.tokens if self.active else -1
61
+
@@ -0,0 +1,96 @@
1
+ """
2
+ Sliding-window alarm rate monitor for adaptive protection.
3
+
4
+ Detects abnormal alarm rates using a simple windowed counter with hysteresis.
5
+ """
6
+
7
+ from collections import deque
8
+ from typing import Deque, Tuple
9
+
10
+ from .token_bucket import TIME_TOLERANCE
11
+
12
+
13
+ class AlarmRateMonitor:
14
+ """Track alarm arrivals and detect abnormal rates."""
15
+
16
+ def __init__(
17
+ self,
18
+ window_duration: float,
19
+ abnormal_threshold: float,
20
+ deactivation_threshold: float,
21
+ min_observations: int = 1,
22
+ ):
23
+ """
24
+ Args:
25
+ window_duration: Sliding window size in seconds
26
+ abnormal_threshold: Alarms/sec that triggers protection
27
+ deactivation_threshold: Alarms/sec below which protection deactivates
28
+ min_observations: Minimum alarms before detection can trigger
29
+ """
30
+ if window_duration <= 0:
31
+ raise ValueError("window_duration must be positive")
32
+ if abnormal_threshold <= 0:
33
+ raise ValueError("abnormal_threshold must be positive")
34
+ if deactivation_threshold < 0:
35
+ raise ValueError("deactivation_threshold must be non-negative")
36
+ if deactivation_threshold > abnormal_threshold:
37
+ raise ValueError(
38
+ "deactivation_threshold must be <= abnormal_threshold for hysteresis"
39
+ )
40
+ if min_observations <= 0:
41
+ raise ValueError("min_observations must be positive")
42
+
43
+ self.window_duration = float(window_duration)
44
+ self.abnormal_threshold = float(abnormal_threshold)
45
+ self.deactivation_threshold = float(deactivation_threshold)
46
+ self.min_observations = int(min_observations)
47
+
48
+ self._arrivals: Deque[Tuple[float, str]] = deque()
49
+
50
+ def record_arrival(self, timestamp: float, alarm_source: str) -> None:
51
+ """Add an alarm arrival to the window."""
52
+ self._arrivals.append((timestamp, alarm_source))
53
+ self._prune(timestamp)
54
+
55
+ def get_rate(self, now: float | None = None) -> float:
56
+ """Return alarms/sec over the current window."""
57
+ if now is None and self._arrivals:
58
+ now = self._arrivals[-1][0]
59
+ elif now is None:
60
+ return 0.0
61
+
62
+ self._prune(now)
63
+ if not self._arrivals:
64
+ return 0.0
65
+
66
+ window_span = max(self.window_duration, TIME_TOLERANCE)
67
+ return len(self._arrivals) / window_span
68
+
69
+ def is_abnormal(self, now: float | None = None) -> bool:
70
+ """True if rate exceeds abnormal threshold with enough observations."""
71
+ if now is None and self._arrivals:
72
+ now = self._arrivals[-1][0]
73
+ elif now is None:
74
+ return False
75
+
76
+ self._prune(now)
77
+ if len(self._arrivals) < self.min_observations:
78
+ return False
79
+ return self.get_rate(now) >= self.abnormal_threshold - TIME_TOLERANCE
80
+
81
+ def is_recovered(self, now: float | None = None) -> bool:
82
+ """True if rate has fallen below deactivation threshold."""
83
+ if now is None and self._arrivals:
84
+ now = self._arrivals[-1][0]
85
+ elif now is None:
86
+ return True
87
+
88
+ self._prune(now)
89
+ return self.get_rate(now) <= self.deactivation_threshold + TIME_TOLERANCE
90
+
91
+ def _prune(self, now: float) -> None:
92
+ """Drop arrivals outside the sliding window."""
93
+ cutoff = now - self.window_duration - TIME_TOLERANCE
94
+ while self._arrivals and self._arrivals[0][0] < cutoff:
95
+ self._arrivals.popleft()
96
+
@@ -0,0 +1,110 @@
1
+ """
2
+ Band classification logic for TRIAGE/4.
3
+
4
+ Maps messages to one of four bands based on semantic urgency (is_alarm)
5
+ and geographic priority (zone_priority).
6
+ """
7
+
8
+ # Band constants
9
+ BAND_ALARM = 0 # Emergency messages, always served first
10
+ BAND_HIGH = 1 # High-priority zone telemetry, token-constrained
11
+ BAND_STANDARD = 2 # Standard zone telemetry, token-constrained
12
+ BAND_BACKGROUND = 3 # Low-priority zone data, best-effort
13
+
14
+
15
+ class BandClassifier:
16
+ """
17
+ Classifies messages into TRIAGE/4 bands.
18
+
19
+ Classification hierarchy:
20
+ 1. Semantic urgency (is_alarm) overrides geographic priority
21
+ 2. Geographic priority (zone_priority) determines band for non-alarms
22
+
23
+ Band assignment rules:
24
+ - is_alarm=True → ALARM (0) - regardless of zone priority
25
+ - zone_priority <= high_zone_max → HIGH (1)
26
+ - zone_priority <= standard_zone_max → STANDARD (2)
27
+ - zone_priority > standard_zone_max → BACKGROUND (3)
28
+
29
+ This separation resolves priority inversion where routine telemetry
30
+ from high-priority zones delays critical alarms from low-priority zones.
31
+ """
32
+
33
+ def __init__(self, high_zone_max: int, standard_zone_max: int):
34
+ """
35
+ Initialize band classifier with zone thresholds.
36
+
37
+ Args:
38
+ high_zone_max: Maximum zone priority for HIGH band (inclusive)
39
+ standard_zone_max: Maximum zone priority for STANDARD band (inclusive)
40
+
41
+ Example:
42
+ >>> classifier = BandClassifier(high_zone_max=1, standard_zone_max=3)
43
+ >>> classifier.classify(zone_priority=0, is_alarm=False)
44
+ 1 # HIGH band
45
+ >>> classifier.classify(zone_priority=5, is_alarm=True)
46
+ 0 # ALARM band (semantic override)
47
+ """
48
+ if high_zone_max < 0:
49
+ raise ValueError(f"high_zone_max must be non-negative, got {high_zone_max}")
50
+ if standard_zone_max < high_zone_max:
51
+ raise ValueError(
52
+ f"standard_zone_max ({standard_zone_max}) must be >= "
53
+ f"high_zone_max ({high_zone_max})"
54
+ )
55
+
56
+ self.high_zone_max = high_zone_max
57
+ self.standard_zone_max = standard_zone_max
58
+
59
+ def classify(self, zone_priority: int, is_alarm: bool) -> int:
60
+ """
61
+ Classify message into TRIAGE/4 band.
62
+
63
+ Args:
64
+ zone_priority: Geographic zone priority (0=highest priority zone)
65
+ is_alarm: Semantic urgency flag (True for emergency messages)
66
+
67
+ Returns:
68
+ Band number: 0=ALARM, 1=HIGH, 2=STANDARD, 3=BACKGROUND
69
+
70
+ Raises:
71
+ ValueError: If zone_priority is negative
72
+ """
73
+ if zone_priority < 0:
74
+ raise ValueError(f"zone_priority must be non-negative, got {zone_priority}")
75
+
76
+ # Semantic urgency overrides geographic priority
77
+ if is_alarm:
78
+ return BAND_ALARM
79
+
80
+ # Geographic priority determines band for non-alarms
81
+ if zone_priority <= self.high_zone_max:
82
+ return BAND_HIGH
83
+ elif zone_priority <= self.standard_zone_max:
84
+ return BAND_STANDARD
85
+ else:
86
+ return BAND_BACKGROUND
87
+
88
+ def get_band_name(self, band: int) -> str:
89
+ """
90
+ Get human-readable band name.
91
+
92
+ Args:
93
+ band: Band number (0-3)
94
+
95
+ Returns:
96
+ Band name string
97
+ """
98
+ names = {
99
+ BAND_ALARM: "ALARM",
100
+ BAND_HIGH: "HIGH",
101
+ BAND_STANDARD: "STANDARD",
102
+ BAND_BACKGROUND: "BACKGROUND",
103
+ }
104
+ return names.get(band, f"UNKNOWN({band})")
105
+
106
+ def __repr__(self) -> str:
107
+ return (
108
+ f"BandClassifier(high_zone_max={self.high_zone_max}, "
109
+ f"standard_zone_max={self.standard_zone_max})"
110
+ )
@@ -0,0 +1,184 @@
1
+ """
2
+ Per-device fair queue with round-robin device selection.
3
+
4
+ Prevents device monopolization by cycling through devices and serving
5
+ one message per device per round, maintaining FIFO within each device.
6
+ """
7
+
8
+ from collections import deque
9
+ from typing import Dict, Optional
10
+
11
+
12
+ class DeviceFairQueue:
13
+ """
14
+ Round-robin per-device queue within a band.
15
+
16
+ Ensures fairness by cycling through active devices and serving one message
17
+ from each device per round. Messages within each device are served FIFO.
18
+
19
+ Key properties:
20
+ - Fair device selection: Each device gets equal turns regardless of load
21
+ - FIFO within device: Messages from same device served in arrival order
22
+ - Monopolization prevention: High-rate device cannot starve low-rate device
23
+ - Dynamic device set: Devices added/removed as queues fill/empty
24
+
25
+ Example:
26
+ >>> queue = DeviceFairQueue()
27
+ >>> queue.enqueue(job_idx=0, device_id="A")
28
+ >>> queue.enqueue(job_idx=1, device_id="B")
29
+ >>> queue.enqueue(job_idx=2, device_id="A")
30
+ >>> queue.dequeue() # Returns 0 from device A
31
+ 0
32
+ >>> queue.dequeue() # Returns 1 from device B (round-robin)
33
+ 1
34
+ >>> queue.dequeue() # Returns 2 from device A (back to A)
35
+ 2
36
+ """
37
+
38
+ def __init__(self):
39
+ """Initialize empty per-device fair queue."""
40
+ self.device_queues: Dict[str, deque[int]] = {}
41
+ self.last_served_device: Optional[str] = None
42
+
43
+ def enqueue(self, job_idx: int, device_id: str) -> None:
44
+ """
45
+ Add message to device's queue.
46
+
47
+ Args:
48
+ job_idx: Index of job in arrival_times array
49
+ device_id: Device identifier (e.g., "sensor_42", "EDU_5")
50
+ """
51
+ if device_id not in self.device_queues:
52
+ self.device_queues[device_id] = deque()
53
+ self.device_queues[device_id].append(job_idx)
54
+
55
+ def dequeue(self) -> Optional[int]:
56
+ """
57
+ Remove and return next message using round-robin device selection.
58
+
59
+ Round-robin algorithm:
60
+ 1. Get list of devices with messages
61
+ 2. Find position of last_served_device (or start at 0)
62
+ 3. Iterate circularly starting from next device
63
+ 4. Return first message from first non-empty device
64
+ 5. Update last_served_device pointer
65
+ 6. Clean up empty device queues
66
+
67
+ Returns:
68
+ Job index of next message, or None if all queues empty
69
+
70
+ Example:
71
+ With devices ["A", "B", "C"] and last_served="A":
72
+ - Next check: B (if non-empty, serve from B)
73
+ - Then: C, then A, then B, ... (circular)
74
+ """
75
+ if not self.device_queues:
76
+ return None
77
+
78
+ # Get sorted device list for deterministic round-robin
79
+ devices = sorted(self.device_queues.keys())
80
+
81
+ # Find starting position (next device after last served)
82
+ # Maintain circular order even when devices are removed
83
+ start_idx = 0
84
+ if self.last_served_device is not None:
85
+ # Find first device that comes after last_served in sorted order
86
+ for i, dev in enumerate(devices):
87
+ if dev > self.last_served_device:
88
+ start_idx = i
89
+ break
90
+ # If no device comes after (last_served was alphabetically last),
91
+ # wrap around to start
92
+
93
+ # Search circularly for next non-empty device
94
+ for i in range(len(devices)):
95
+ idx = (start_idx + i) % len(devices)
96
+ device_id = devices[idx]
97
+
98
+ if self.device_queues[device_id]:
99
+ # Serve from this device
100
+ self.last_served_device = device_id
101
+ job_idx = self.device_queues[device_id].popleft()
102
+
103
+ # Clean up empty queues
104
+ if not self.device_queues[device_id]:
105
+ del self.device_queues[device_id]
106
+
107
+ return job_idx
108
+
109
+ # All queues empty (shouldn't reach here if self.device_queues non-empty)
110
+ return None
111
+
112
+ def peek(self) -> Optional[int]:
113
+ """
114
+ View next message without removing it.
115
+
116
+ Uses same round-robin logic as dequeue() but doesn't modify state.
117
+
118
+ Returns:
119
+ Job index of next message, or None if all queues empty
120
+ """
121
+ if not self.device_queues:
122
+ return None
123
+
124
+ devices = sorted(self.device_queues.keys())
125
+ start_idx = 0
126
+ if self.last_served_device is not None:
127
+ # Find first device that comes after last_served in sorted order
128
+ for i, dev in enumerate(devices):
129
+ if dev > self.last_served_device:
130
+ start_idx = i
131
+ break
132
+
133
+ for i in range(len(devices)):
134
+ idx = (start_idx + i) % len(devices)
135
+ device_id = devices[idx]
136
+
137
+ if self.device_queues[device_id]:
138
+ return self.device_queues[device_id][0]
139
+
140
+ return None
141
+
142
+ def is_empty(self) -> bool:
143
+ """
144
+ Check if all device queues are empty.
145
+
146
+ Returns:
147
+ True if no messages queued, False otherwise
148
+ """
149
+ return len(self.device_queues) == 0
150
+
151
+ def queue_length(self) -> int:
152
+ """
153
+ Get total number of messages across all devices.
154
+
155
+ Returns:
156
+ Total queued messages
157
+ """
158
+ return sum(len(q) for q in self.device_queues.values())
159
+
160
+ def device_count(self) -> int:
161
+ """
162
+ Get number of active devices (devices with queued messages).
163
+
164
+ Returns:
165
+ Number of devices with non-empty queues
166
+ """
167
+ return len(self.device_queues)
168
+
169
+ def get_device_queue_lengths(self) -> Dict[str, int]:
170
+ """
171
+ Get per-device queue lengths.
172
+
173
+ Returns:
174
+ Dictionary mapping device_id to queue length
175
+ """
176
+ return {device_id: len(queue) for device_id, queue in self.device_queues.items()}
177
+
178
+ def __repr__(self) -> str:
179
+ total = self.queue_length()
180
+ devices = self.device_count()
181
+ return (
182
+ f"DeviceFairQueue(total_messages={total}, "
183
+ f"active_devices={devices}, last_served={self.last_served_device})"
184
+ )