arbiter-engine 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.
- arbiter_engine/__init__.py +31 -0
- arbiter_engine/api.py +473 -0
- arbiter_engine/axiom_thresholds.py +118 -0
- arbiter_engine/envelope.py +270 -0
- arbiter_engine/examples/water_tank.yaml +112 -0
- arbiter_engine/fire_frequency.py +252 -0
- arbiter_engine/history/__init__.py +12 -0
- arbiter_engine/history/observation.py +596 -0
- arbiter_engine/history/observation_production.py +335 -0
- arbiter_engine/history/observation_source_wiring.py +120 -0
- arbiter_engine/history/readiness.py +288 -0
- arbiter_engine/interfaces.py +1353 -0
- arbiter_engine/mcp/__init__.py +1 -0
- arbiter_engine/mcp/server.py +221 -0
- arbiter_engine/ontology/__init__.py +18 -0
- arbiter_engine/ontology/axiom_verdicts_production.py +332 -0
- arbiter_engine/ontology/axioms/__init__.py +26 -0
- arbiter_engine/ontology/axioms/boundedness.py +340 -0
- arbiter_engine/ontology/axioms/connectivity.py +396 -0
- arbiter_engine/ontology/axioms/conservation.py +266 -0
- arbiter_engine/ontology/axioms/consistency.py +422 -0
- arbiter_engine/ontology/axioms/extensions.py +106 -0
- arbiter_engine/ontology/axioms/homeostasis.py +783 -0
- arbiter_engine/ontology/axioms/monotonicity.py +444 -0
- arbiter_engine/ontology/axioms/responsiveness.py +752 -0
- arbiter_engine/ontology/axioms/roles.py +207 -0
- arbiter_engine/ontology/axioms/stability.py +327 -0
- arbiter_engine/ontology/domain_loader.py +485 -0
- arbiter_engine/ontology/loader.py +802 -0
- arbiter_engine/ontology/reasoner.py +1069 -0
- arbiter_engine/propagation/__init__.py +1 -0
- arbiter_engine/propagation/impact_estimator.py +305 -0
- arbiter_engine/propagation/lp_confidence.py +123 -0
- arbiter_engine/propagation/mcts_root_cause.py +266 -0
- arbiter_engine/propagation/root_cause.py +559 -0
- arbiter_engine/propagation/weight_learner.py +216 -0
- arbiter_engine/rca/__init__.py +4 -0
- arbiter_engine/rca/greedy_set_cover.py +291 -0
- arbiter_engine/residual/__init__.py +1 -0
- arbiter_engine/residual/predict_vs_mirror.py +542 -0
- arbiter_engine/temporal/__init__.py +1 -0
- arbiter_engine/temporal/temporal_edge.py +584 -0
- arbiter_engine/temporal/trend_projection.py +430 -0
- arbiter_engine/twin/__init__.py +23 -0
- arbiter_engine/twin/action_clears_problem.py +217 -0
- arbiter_engine/twin/builder.py +479 -0
- arbiter_engine/twin/gap.py +132 -0
- arbiter_engine/twin/hypothesis_generator.py +530 -0
- arbiter_engine/twin/hypothesis_production.py +236 -0
- arbiter_engine/twin/kernel_pipeline_executor.py +571 -0
- arbiter_engine/twin/monte_carlo_predictor.py +1001 -0
- arbiter_engine/twin/optimization_production.py +233 -0
- arbiter_engine/twin/pipeline_production.py +176 -0
- arbiter_engine/twin/topology.py +450 -0
- arbiter_engine/twin/topology_optimizer.py +412 -0
- arbiter_engine/twin/traverser.py +1260 -0
- arbiter_engine/twin/traverser_production.py +283 -0
- arbiter_engine/types.py +427 -0
- arbiter_engine-0.1.0.dist-info/METADATA +278 -0
- arbiter_engine-0.1.0.dist-info/RECORD +63 -0
- arbiter_engine-0.1.0.dist-info/WHEEL +4 -0
- arbiter_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
- arbiter_engine-0.1.0.dist-info/licenses/NOTICE +18 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Observation History Storage.
|
|
3
|
+
|
|
4
|
+
Provides storage and retrieval of historical observations for
|
|
5
|
+
axiom reasoning and statistical detection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
from ..interfaces import Observation, ObservationHistory
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InMemoryObservationHistory(ObservationHistory):
|
|
20
|
+
"""
|
|
21
|
+
In-memory implementation of ObservationHistory.
|
|
22
|
+
|
|
23
|
+
Suitable for development and single-instance deployments.
|
|
24
|
+
For production, use TimescaleDB or similar time-series database.
|
|
25
|
+
|
|
26
|
+
Thread-safe with read-write locks.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
max_observations_per_key: int = 10000,
|
|
32
|
+
retention_period: timedelta = timedelta(days=7),
|
|
33
|
+
max_total_observations: int = 5_000_000, # Global memory bound
|
|
34
|
+
cluster_id: Optional[str] = None, # (Bucket A)
|
|
35
|
+
):
|
|
36
|
+
# this history is stored per-cluster in
|
|
37
|
+
# core._observation_histories[cluster_id], so it inherently belongs
|
|
38
|
+
# to one cluster — stamp its production-observation emissions with it
|
|
39
|
+
# (Layer 3). Cross-cluster merge / transient histories construct with
|
|
40
|
+
# cluster_id=None (correctly unstamped).
|
|
41
|
+
self._cluster_id = cluster_id
|
|
42
|
+
self.max_observations = max_observations_per_key
|
|
43
|
+
self.retention_period = retention_period
|
|
44
|
+
self._max_total_observations = max_total_observations #
|
|
45
|
+
self._total_observation_count = 0 # Running total
|
|
46
|
+
|
|
47
|
+
# Storage: (entity_id, property_name) -> [(timestamp, value)]
|
|
48
|
+
self._history: Dict[Tuple[str, str], List[Tuple[datetime, Any]]] = defaultdict(list)
|
|
49
|
+
|
|
50
|
+
# Observation counts for readiness tracking
|
|
51
|
+
self._observation_counts: Dict[Tuple[str, str], int] = defaultdict(int)
|
|
52
|
+
|
|
53
|
+
# Entity metadata
|
|
54
|
+
self._entity_metadata: Dict[str, Dict[str, Any]] = {}
|
|
55
|
+
|
|
56
|
+
# Thread lock
|
|
57
|
+
self._lock = threading.RLock()
|
|
58
|
+
|
|
59
|
+
# silent-destructive-eviction archetype family **7th member**
|
|
60
|
+
# (ScenarioStore + PendingRecommendations +
|
|
61
|
+
# EventBuffer + _rollback_history + _snapshots/_change_history
|
|
62
|
+
# + TopologySnapshotStore + _evict_oldest_keys). previously
|
|
63
|
+
# the eviction in `_evict_oldest_keys` dropped (entity_id,
|
|
64
|
+
# property_name) keys silently when the global max was hit; operators
|
|
65
|
+
# diagnosing "axiom readiness dropped suddenly" / "indicator history
|
|
66
|
+
# vanished" had no signal pointing at memory-bound eviction.
|
|
67
|
+
# Heartbeat-paced cadence (add() is
|
|
68
|
+
# called on every metric flush — high frequency).
|
|
69
|
+
self._eviction_wave_count: int = 0
|
|
70
|
+
self._total_observations_evicted: int = 0
|
|
71
|
+
self._total_keys_evicted: int = 0
|
|
72
|
+
|
|
73
|
+
# per-key trim visibility. previously the per-key
|
|
74
|
+
# trim at the ``add()`` boundary was silent — operators
|
|
75
|
+
# debugging "why does this property's history look truncated"
|
|
76
|
+
# couldn't tell whether the trim was firing or whether the
|
|
77
|
+
# history simply hadn't accumulated yet. Pre-audit refined
|
|
78
|
+
# the scout's "race" framing: there is no actual race
|
|
79
|
+
# (per-key trim DECREMENTS _total_observation_count so
|
|
80
|
+
# single-key floods are absorbed in-place; global eviction
|
|
81
|
+
# correctly stays silent). The real residual is just
|
|
82
|
+
# visibility — surface the per-key trim counts via lifetime
|
|
83
|
+
# counters + periodic INFO log every 10k trims.
|
|
84
|
+
self._total_per_key_trim_waves: int = 0
|
|
85
|
+
self._total_per_key_observations_trimmed: int = 0
|
|
86
|
+
|
|
87
|
+
def add(
|
|
88
|
+
self,
|
|
89
|
+
entity_id: str,
|
|
90
|
+
property_name: str,
|
|
91
|
+
value: Any,
|
|
92
|
+
timestamp: Optional[datetime] = None
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Add an observation to history."""
|
|
95
|
+
timestamp = timestamp or datetime.utcnow()
|
|
96
|
+
key = (entity_id, property_name)
|
|
97
|
+
|
|
98
|
+
with self._lock:
|
|
99
|
+
self._history[key].append((timestamp, value))
|
|
100
|
+
self._observation_counts[key] += 1
|
|
101
|
+
self._total_observation_count += 1
|
|
102
|
+
|
|
103
|
+
# Enforce per-key max observations
|
|
104
|
+
if len(self._history[key]) > self.max_observations:
|
|
105
|
+
trimmed = len(self._history[key]) - self.max_observations
|
|
106
|
+
self._history[key] = self._history[key][-self.max_observations:]
|
|
107
|
+
self._total_observation_count -= trimmed
|
|
108
|
+
# surface per-key trim activity via lifetime
|
|
109
|
+
# counters + first-occurrence INFO log + heartbeat every
|
|
110
|
+
# 10000 waves (per-key trim is high-frequency on
|
|
111
|
+
# flood-heavy keys; 10k cadence balances visibility vs
|
|
112
|
+
# log volume).
|
|
113
|
+
self._total_per_key_trim_waves += 1
|
|
114
|
+
self._total_per_key_observations_trimmed += trimmed
|
|
115
|
+
first_wave = self._total_per_key_trim_waves == 1
|
|
116
|
+
every_10k = self._total_per_key_trim_waves % 10000 == 0
|
|
117
|
+
if first_wave or every_10k:
|
|
118
|
+
logger.info(
|
|
119
|
+
"InMemoryObservationHistory per-key trim "
|
|
120
|
+
"for %r: trimmed %d observations this wave "
|
|
121
|
+
"(wave=%d, total_observations_trimmed=%d, "
|
|
122
|
+
"key now at %d observations). Repeated trims on "
|
|
123
|
+
"the same key indicate a flood — single-key "
|
|
124
|
+
"floods are absorbed in-place by per-key trim "
|
|
125
|
+
"and do NOT trigger global eviction.",
|
|
126
|
+
key,
|
|
127
|
+
trimmed,
|
|
128
|
+
self._total_per_key_trim_waves,
|
|
129
|
+
self._total_per_key_observations_trimmed,
|
|
130
|
+
len(self._history[key]),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Enforce global memory bound by evicting oldest keys
|
|
134
|
+
if self._total_observation_count > self._max_total_observations:
|
|
135
|
+
self._evict_oldest_keys()
|
|
136
|
+
|
|
137
|
+
# (callsite) — emit per-observation production record
|
|
138
|
+
# for source-health monitoring. `record_observation` internally checks
|
|
139
|
+
# DT_OBSERVATION_PRODUCTION_ENABLED gate (no-op when off) + hybrid
|
|
140
|
+
# emit-policy fires on health-transition OR stale-freshness. Outside
|
|
141
|
+
# _lock to avoid lock-nesting; substrate has its own RLock.
|
|
142
|
+
try:
|
|
143
|
+
from arbiter_engine.history.observation_production import (
|
|
144
|
+
record_observation,
|
|
145
|
+
)
|
|
146
|
+
freshness_age = max(0.0, (datetime.utcnow() - timestamp).total_seconds())
|
|
147
|
+
observation_id = f"{entity_id}:{property_name}:{timestamp.isoformat()}"
|
|
148
|
+
record_observation(
|
|
149
|
+
observation_id=observation_id,
|
|
150
|
+
source_id=entity_id,
|
|
151
|
+
freshness_age_seconds=freshness_age,
|
|
152
|
+
observed_at=timestamp,
|
|
153
|
+
cluster_id=self._cluster_id, # Layer 3 (per-cluster history)
|
|
154
|
+
)
|
|
155
|
+
except Exception: # noqa: BLE001 — defensive; substrate-unavailable
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
def add_observation(self, observation: Observation) -> None:
|
|
159
|
+
"""Add an Observation object."""
|
|
160
|
+
self.add(
|
|
161
|
+
observation.entity_id,
|
|
162
|
+
observation.property_name,
|
|
163
|
+
observation.value,
|
|
164
|
+
observation.timestamp
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def add_entity_observations(
|
|
168
|
+
self,
|
|
169
|
+
entity_id: str,
|
|
170
|
+
entity_type: str,
|
|
171
|
+
properties: Dict[str, Any],
|
|
172
|
+
timestamp: Optional[datetime] = None
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Add observations for all properties of an entity.
|
|
175
|
+
|
|
176
|
+
Filters non-scalar properties (lists, dicts, long strings)
|
|
177
|
+
to prevent false stability oscillation and memory waste.
|
|
178
|
+
"""
|
|
179
|
+
timestamp = timestamp or datetime.utcnow()
|
|
180
|
+
|
|
181
|
+
def add_props(props: dict, prefix: str = ''):
|
|
182
|
+
for key, value in props.items():
|
|
183
|
+
path = f"{prefix}.{key}" if prefix else key
|
|
184
|
+
if isinstance(value, (int, float)):
|
|
185
|
+
self.add(entity_id, path, value, timestamp)
|
|
186
|
+
elif isinstance(value, bool):
|
|
187
|
+
self.add(entity_id, path, str(value), timestamp)
|
|
188
|
+
elif isinstance(value, str) and len(value) < 500:
|
|
189
|
+
# Skip long strings (serialized dicts/lists)
|
|
190
|
+
# Increased from 100 to 500 chars to preserve
|
|
191
|
+
# status descriptions and error messages from unknown domains.
|
|
192
|
+
self.add(entity_id, path, value, timestamp)
|
|
193
|
+
elif isinstance(value, dict):
|
|
194
|
+
add_props(value, path)
|
|
195
|
+
elif isinstance(value, list) and len(value) <= 10:
|
|
196
|
+
# Expand short numeric lists into indexed scalar properties.
|
|
197
|
+
# Unknown domains with array-valued properties (fan_speeds, multi-sensor
|
|
198
|
+
# readings) would otherwise lose all observability.
|
|
199
|
+
numeric_items = [v for v in value if isinstance(v, (int, float))]
|
|
200
|
+
if len(numeric_items) == len(value) and numeric_items:
|
|
201
|
+
for idx, item in enumerate(numeric_items):
|
|
202
|
+
self.add(entity_id, f"{path}[{idx}]", item, timestamp)
|
|
203
|
+
# Also record aggregates for axiom detection
|
|
204
|
+
self.add(entity_id, f"{path}._mean", sum(numeric_items) / len(numeric_items), timestamp)
|
|
205
|
+
self.add(entity_id, f"{path}._min", min(numeric_items), timestamp)
|
|
206
|
+
self.add(entity_id, f"{path}._max", max(numeric_items), timestamp)
|
|
207
|
+
# Skip: long lists, None, long strings — no detection value
|
|
208
|
+
|
|
209
|
+
add_props(properties)
|
|
210
|
+
|
|
211
|
+
# Track structured property groups for group-level anomaly detection.
|
|
212
|
+
# When a dict property is flattened (e.g., sensors.temp, sensors.fan),
|
|
213
|
+
# store group membership so axiom checkers can detect "all sensors degrading".
|
|
214
|
+
groups = {}
|
|
215
|
+
for key, value in properties.items():
|
|
216
|
+
if isinstance(value, dict):
|
|
217
|
+
group_keys = []
|
|
218
|
+
for sub_key in value:
|
|
219
|
+
full_key = f"{key}.{sub_key}"
|
|
220
|
+
group_keys.append(full_key)
|
|
221
|
+
if group_keys:
|
|
222
|
+
groups[key] = group_keys
|
|
223
|
+
|
|
224
|
+
# Update entity metadata
|
|
225
|
+
with self._lock:
|
|
226
|
+
prev_meta = self._entity_metadata.get(entity_id)
|
|
227
|
+
# (softened): Log type mismatch but preserve observations.
|
|
228
|
+
# Entity IDs are now type-qualified (e.g. "deployment/ns/name"),
|
|
229
|
+
# so cross-type collisions no longer reach here. If they do,
|
|
230
|
+
# observations under different property names don't interfere
|
|
231
|
+
# because axiom checkers only inspect type-specific indicators.
|
|
232
|
+
# Clearing aggressively prevented axiom thresholds from ever
|
|
233
|
+
# accumulating enough samples.
|
|
234
|
+
if prev_meta and prev_meta.get('type') and prev_meta['type'] != entity_type:
|
|
235
|
+
logger.warning(
|
|
236
|
+
f"Entity {entity_id} type mismatch: "
|
|
237
|
+
f"recorded={prev_meta['type']} incoming={entity_type}. "
|
|
238
|
+
f"Preserving observations (property names are type-specific)."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
self._entity_metadata[entity_id] = {
|
|
242
|
+
'type': entity_type,
|
|
243
|
+
'last_observed': timestamp,
|
|
244
|
+
}
|
|
245
|
+
if groups:
|
|
246
|
+
self._entity_metadata[entity_id]['property_groups'] = groups
|
|
247
|
+
|
|
248
|
+
def get_values(
|
|
249
|
+
self,
|
|
250
|
+
entity_id: str,
|
|
251
|
+
property_name: str,
|
|
252
|
+
window: timedelta
|
|
253
|
+
) -> List[Tuple[datetime, float]]:
|
|
254
|
+
"""Get numeric values within time window.
|
|
255
|
+
|
|
256
|
+
Results sorted by timestamp (not insertion order) to handle
|
|
257
|
+
out-of-order arrivals from batch processing or agent reconnection.
|
|
258
|
+
"""
|
|
259
|
+
key = (entity_id, property_name)
|
|
260
|
+
cutoff = datetime.utcnow() - window
|
|
261
|
+
|
|
262
|
+
with self._lock:
|
|
263
|
+
observations = self._history.get(key, [])
|
|
264
|
+
result = []
|
|
265
|
+
for ts, value in observations:
|
|
266
|
+
if ts > cutoff:
|
|
267
|
+
try:
|
|
268
|
+
result.append((ts, float(value)))
|
|
269
|
+
except (TypeError, ValueError):
|
|
270
|
+
pass
|
|
271
|
+
result.sort(key=lambda x: x[0])
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
def get_states(
|
|
275
|
+
self,
|
|
276
|
+
entity_id: str,
|
|
277
|
+
property_name: str,
|
|
278
|
+
window: timedelta
|
|
279
|
+
) -> List[Tuple[datetime, str]]:
|
|
280
|
+
"""Get state values within time window.
|
|
281
|
+
|
|
282
|
+
Results sorted by timestamp to handle out-of-order arrivals.
|
|
283
|
+
StabilityChecker checks consecutive values by list index, so correct
|
|
284
|
+
temporal ordering prevents false oscillation detections.
|
|
285
|
+
"""
|
|
286
|
+
key = (entity_id, property_name)
|
|
287
|
+
cutoff = datetime.utcnow() - window
|
|
288
|
+
|
|
289
|
+
with self._lock:
|
|
290
|
+
observations = self._history.get(key, [])
|
|
291
|
+
result = [
|
|
292
|
+
(ts, str(value))
|
|
293
|
+
for ts, value in observations
|
|
294
|
+
if ts > cutoff
|
|
295
|
+
]
|
|
296
|
+
result.sort(key=lambda x: x[0])
|
|
297
|
+
return result
|
|
298
|
+
|
|
299
|
+
def get_observations(
|
|
300
|
+
self,
|
|
301
|
+
entity_id: str,
|
|
302
|
+
start: datetime,
|
|
303
|
+
end: datetime
|
|
304
|
+
) -> List[Observation]:
|
|
305
|
+
"""Get all observations for entity in time range."""
|
|
306
|
+
observations = []
|
|
307
|
+
|
|
308
|
+
with self._lock:
|
|
309
|
+
for (eid, prop), values in self._history.items():
|
|
310
|
+
if eid != entity_id:
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
for ts, value in values:
|
|
314
|
+
if start <= ts <= end:
|
|
315
|
+
# Determine property type
|
|
316
|
+
if isinstance(value, (int, float)):
|
|
317
|
+
prop_type = 'numeric'
|
|
318
|
+
elif isinstance(value, str):
|
|
319
|
+
prop_type = 'state'
|
|
320
|
+
else:
|
|
321
|
+
prop_type = 'unknown'
|
|
322
|
+
|
|
323
|
+
entity_type = self._entity_metadata.get(entity_id, {}).get('type', 'unknown')
|
|
324
|
+
|
|
325
|
+
observations.append(Observation(
|
|
326
|
+
entity_id=entity_id,
|
|
327
|
+
entity_type=entity_type,
|
|
328
|
+
property_name=prop,
|
|
329
|
+
property_type=prop_type,
|
|
330
|
+
value=value,
|
|
331
|
+
timestamp=ts,
|
|
332
|
+
))
|
|
333
|
+
|
|
334
|
+
return sorted(observations, key=lambda o: o.timestamp)
|
|
335
|
+
|
|
336
|
+
def get_all_observations(
|
|
337
|
+
self,
|
|
338
|
+
start: datetime,
|
|
339
|
+
end: datetime
|
|
340
|
+
) -> List[Observation]:
|
|
341
|
+
"""Get all observations in time range."""
|
|
342
|
+
observations = []
|
|
343
|
+
|
|
344
|
+
with self._lock:
|
|
345
|
+
for (entity_id, prop), values in self._history.items():
|
|
346
|
+
for ts, value in values:
|
|
347
|
+
if start <= ts <= end:
|
|
348
|
+
if isinstance(value, (int, float)):
|
|
349
|
+
prop_type = 'numeric'
|
|
350
|
+
elif isinstance(value, str):
|
|
351
|
+
prop_type = 'state'
|
|
352
|
+
else:
|
|
353
|
+
prop_type = 'unknown'
|
|
354
|
+
|
|
355
|
+
entity_type = self._entity_metadata.get(entity_id, {}).get('type', 'unknown')
|
|
356
|
+
|
|
357
|
+
observations.append(Observation(
|
|
358
|
+
entity_id=entity_id,
|
|
359
|
+
entity_type=entity_type,
|
|
360
|
+
property_name=prop,
|
|
361
|
+
property_type=prop_type,
|
|
362
|
+
value=value,
|
|
363
|
+
timestamp=ts,
|
|
364
|
+
))
|
|
365
|
+
|
|
366
|
+
return sorted(observations, key=lambda o: o.timestamp)
|
|
367
|
+
|
|
368
|
+
def get_observation_count(
|
|
369
|
+
self,
|
|
370
|
+
entity_id: str,
|
|
371
|
+
property_name: str
|
|
372
|
+
) -> int:
|
|
373
|
+
"""Get count of observations for a property."""
|
|
374
|
+
key = (entity_id, property_name)
|
|
375
|
+
with self._lock:
|
|
376
|
+
return self._observation_counts.get(key, 0)
|
|
377
|
+
|
|
378
|
+
def get_all_numeric_series(
|
|
379
|
+
self,
|
|
380
|
+
window: timedelta = timedelta(days=7),
|
|
381
|
+
entity_filter: Optional[str] = None,
|
|
382
|
+
) -> Dict[str, List[Tuple[datetime, float]]]:
|
|
383
|
+
"""Get all numeric time series.
|
|
384
|
+
|
|
385
|
+
``entity_filter`` (default ``None``) restricts results to a
|
|
386
|
+
single entity. previously a module held from this package
|
|
387
|
+
passed ``entity_filter=entity.id`` to this method, but the signature
|
|
388
|
+
had no such parameter — Python raised ``TypeError`` per call,
|
|
389
|
+
caught upstream as ``Hybrid discovery error:.
|
|
390
|
+
unexpected keyword argument 'entity_filter'``. Correlation
|
|
391
|
+
mining was silently broken for ~6 weeks; wires the
|
|
392
|
+
parameter through so the per-entity-series filter actually works.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
window: time window for observations (default 7 days).
|
|
396
|
+
entity_filter: if provided, only return series for this
|
|
397
|
+
entity_id. ``None`` preserves the previously all-entities
|
|
398
|
+
behavior for backward compat.
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
Dict mapping ``"{entity_id}.{prop}"`` → list of (ts, value)
|
|
402
|
+
tuples, filtered to entries within ``window`` and (if
|
|
403
|
+
``entity_filter`` provided) only that entity.
|
|
404
|
+
"""
|
|
405
|
+
result = {}
|
|
406
|
+
cutoff = datetime.utcnow() - window
|
|
407
|
+
|
|
408
|
+
with self._lock:
|
|
409
|
+
for (entity_id, prop), values in self._history.items():
|
|
410
|
+
# filter by entity_id when caller wants a single-
|
|
411
|
+
# entity slice (relationship_mining.py:222 use case).
|
|
412
|
+
if entity_filter is not None and entity_id != entity_filter:
|
|
413
|
+
continue
|
|
414
|
+
series = []
|
|
415
|
+
for ts, value in values:
|
|
416
|
+
if ts > cutoff:
|
|
417
|
+
try:
|
|
418
|
+
series.append((ts, float(value)))
|
|
419
|
+
except (TypeError, ValueError):
|
|
420
|
+
pass
|
|
421
|
+
|
|
422
|
+
if series:
|
|
423
|
+
key = f"{entity_id}.{prop}"
|
|
424
|
+
result[key] = series
|
|
425
|
+
|
|
426
|
+
return result
|
|
427
|
+
|
|
428
|
+
def get_entity_metadata(self) -> Dict[str, Dict]:
|
|
429
|
+
"""Get metadata for all entities."""
|
|
430
|
+
with self._lock:
|
|
431
|
+
return dict(self._entity_metadata)
|
|
432
|
+
|
|
433
|
+
def prune_old_observations(self) -> int:
|
|
434
|
+
"""Remove observations older than retention period."""
|
|
435
|
+
cutoff = datetime.utcnow() - self.retention_period
|
|
436
|
+
pruned = 0
|
|
437
|
+
|
|
438
|
+
with self._lock:
|
|
439
|
+
for key in list(self._history.keys()):
|
|
440
|
+
original_len = len(self._history[key])
|
|
441
|
+
self._history[key] = [
|
|
442
|
+
(ts, v) for ts, v in self._history[key]
|
|
443
|
+
if ts > cutoff
|
|
444
|
+
]
|
|
445
|
+
removed = original_len - len(self._history[key])
|
|
446
|
+
pruned += removed
|
|
447
|
+
self._total_observation_count -= removed
|
|
448
|
+
|
|
449
|
+
# Remove empty entries
|
|
450
|
+
if not self._history[key]:
|
|
451
|
+
del self._history[key]
|
|
452
|
+
|
|
453
|
+
return pruned
|
|
454
|
+
|
|
455
|
+
def _evict_oldest_keys(self) -> None:
|
|
456
|
+
"""Evict oldest observation keys when total exceeds global limit.
|
|
457
|
+
|
|
458
|
+
Removes keys with the oldest last-observation timestamp until total
|
|
459
|
+
drops below 90% of max. Called within existing lock context.
|
|
460
|
+
|
|
461
|
+
surfaces previously-silent destructive eviction via WARNING +
|
|
462
|
+
lifetime counters (silent-destructive-eviction archetype family **7th
|
|
463
|
+
member**). FIRST wave + every 100th wave fires a WARNING naming the
|
|
464
|
+
keys dropped this wave + the lifetime counters; subsequent waves stay
|
|
465
|
+
silent. Cadence matches (heartbeat-paced —
|
|
466
|
+
add() is called on every metric flush).
|
|
467
|
+
"""
|
|
468
|
+
target = int(self._max_total_observations * 0.9)
|
|
469
|
+
# Sort keys by last timestamp (oldest first)
|
|
470
|
+
key_last_ts = []
|
|
471
|
+
for key, obs_list in self._history.items():
|
|
472
|
+
last_ts = obs_list[-1][0] if obs_list else datetime.min
|
|
473
|
+
key_last_ts.append((last_ts, key, len(obs_list)))
|
|
474
|
+
key_last_ts.sort()
|
|
475
|
+
|
|
476
|
+
# track wave-level metrics for the operator-visible WARN.
|
|
477
|
+
wave_keys_dropped = 0
|
|
478
|
+
wave_observations_dropped = 0
|
|
479
|
+
# Sample of affected (entity_id, property_name) keys included in the
|
|
480
|
+
# WARN message — bounded so the log line stays readable on
|
|
481
|
+
# mass-eviction waves.
|
|
482
|
+
_SAMPLE_CAP = 5
|
|
483
|
+
sample_keys: List[Tuple[str, str]] = []
|
|
484
|
+
|
|
485
|
+
for _, key, count in key_last_ts:
|
|
486
|
+
if self._total_observation_count <= target:
|
|
487
|
+
break
|
|
488
|
+
del self._history[key]
|
|
489
|
+
self._observation_counts.pop(key, None)
|
|
490
|
+
self._total_observation_count -= count
|
|
491
|
+
wave_keys_dropped += 1
|
|
492
|
+
wave_observations_dropped += count
|
|
493
|
+
if len(sample_keys) < _SAMPLE_CAP:
|
|
494
|
+
sample_keys.append(key)
|
|
495
|
+
|
|
496
|
+
# Clean up _entity_metadata for entities with no remaining observations.
|
|
497
|
+
# Without this, entities whose all observations were evicted retain stale metadata
|
|
498
|
+
# entries indefinitely, causing get_entity_types() to return phantom types.
|
|
499
|
+
remaining_entities = set(k[0] for k in self._history.keys())
|
|
500
|
+
stale_meta = [eid for eid in self._entity_metadata if eid not in remaining_entities]
|
|
501
|
+
for eid in stale_meta:
|
|
502
|
+
del self._entity_metadata[eid]
|
|
503
|
+
|
|
504
|
+
# update lifetime counters + emit WARNING on first wave +
|
|
505
|
+
# every 100th wave thereafter. Stay silent when no keys were dropped
|
|
506
|
+
# (e.g. count already below target before loop ran — defensive case).
|
|
507
|
+
if wave_keys_dropped > 0:
|
|
508
|
+
self._eviction_wave_count += 1
|
|
509
|
+
self._total_keys_evicted += wave_keys_dropped
|
|
510
|
+
self._total_observations_evicted += wave_observations_dropped
|
|
511
|
+
if self._eviction_wave_count == 1 or self._eviction_wave_count % 100 == 0:
|
|
512
|
+
sample_str = ", ".join(
|
|
513
|
+
f"{eid}/{prop}" for eid, prop in sample_keys
|
|
514
|
+
)
|
|
515
|
+
if wave_keys_dropped > _SAMPLE_CAP:
|
|
516
|
+
sample_str += f" (+{wave_keys_dropped - _SAMPLE_CAP} more)"
|
|
517
|
+
logger.warning(
|
|
518
|
+
"InMemoryObservationHistory evicted %d oldest key(s) "
|
|
519
|
+
"(dropped %d observation(s)) to enforce global cap of %d "
|
|
520
|
+
"(target after eviction: %d). Affected: %s. "
|
|
521
|
+
"Lifetime: %d wave(s), %d key(s), %d observation(s) evicted; "
|
|
522
|
+
"%d observation(s) remain in store. Operators seeing axiom "
|
|
523
|
+
"readiness drops should inspect this surface. Bump "
|
|
524
|
+
"`max_total_observations` constructor arg if eviction is "
|
|
525
|
+
"premature for the active workload.",
|
|
526
|
+
wave_keys_dropped,
|
|
527
|
+
wave_observations_dropped,
|
|
528
|
+
self._max_total_observations,
|
|
529
|
+
target,
|
|
530
|
+
sample_str or "(none)",
|
|
531
|
+
self._eviction_wave_count,
|
|
532
|
+
self._total_keys_evicted,
|
|
533
|
+
self._total_observations_evicted,
|
|
534
|
+
self._total_observation_count,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def get_statistics(self) -> Dict:
|
|
538
|
+
"""Get history statistics.
|
|
539
|
+
|
|
540
|
+
lifetime eviction counters added so operators querying stats
|
|
541
|
+
see whether `_evict_oldest_keys` has fired and how much history
|
|
542
|
+
was lost. Sibling of the response-channel surface introduced for
|
|
543
|
+
PlaceholderAuditLogger in PlaceholderConfigManager in
|
|
544
|
+
the WARN log channel surface is fixed by the eviction
|
|
545
|
+
WARNING; this dict-channel surface lets operators query the
|
|
546
|
+
truncation magnitude programmatically.
|
|
547
|
+
"""
|
|
548
|
+
with self._lock:
|
|
549
|
+
total_observations = sum(len(v) for v in self._history.values())
|
|
550
|
+
return {
|
|
551
|
+
'property_keys': len(self._history),
|
|
552
|
+
'total_observations': total_observations,
|
|
553
|
+
'entity_count': len(self._entity_metadata),
|
|
554
|
+
'max_observations_per_key': self.max_observations,
|
|
555
|
+
# eviction surface counters
|
|
556
|
+
'eviction_wave_count': self._eviction_wave_count,
|
|
557
|
+
'total_observations_evicted': self._total_observations_evicted,
|
|
558
|
+
'total_keys_evicted': self._total_keys_evicted,
|
|
559
|
+
'max_total_observations': self._max_total_observations,
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
def get_memory_estimate(self) -> Dict[str, int]:
|
|
563
|
+
"""Estimate memory usage."""
|
|
564
|
+
with self._lock:
|
|
565
|
+
total_obs = sum(len(v) for v in self._history.values())
|
|
566
|
+
# Rough estimate: ~100 bytes per observation (timestamp + value + overhead)
|
|
567
|
+
estimated_bytes = total_obs * 100
|
|
568
|
+
return {
|
|
569
|
+
'total_observations': total_obs,
|
|
570
|
+
'property_keys': len(self._history),
|
|
571
|
+
'estimated_bytes': estimated_bytes,
|
|
572
|
+
'estimated_mb': estimated_bytes // (1024 * 1024),
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
def clear_entity(self, entity_id: str) -> int:
|
|
576
|
+
"""Clear all observation history for a specific entity.
|
|
577
|
+
|
|
578
|
+
Called on entity eviction to prevent chimeric history when the entity
|
|
579
|
+
reappears with different properties (e.g., pod recreated with new config).
|
|
580
|
+
"""
|
|
581
|
+
removed = 0
|
|
582
|
+
with self._lock:
|
|
583
|
+
keys_to_remove = [k for k in self._history if k[0] == entity_id]
|
|
584
|
+
for k in keys_to_remove:
|
|
585
|
+
removed += len(self._history[k])
|
|
586
|
+
del self._history[k]
|
|
587
|
+
self._observation_counts.pop(k, None)
|
|
588
|
+
self._entity_metadata.pop(entity_id, None)
|
|
589
|
+
return removed
|
|
590
|
+
|
|
591
|
+
def clear(self) -> None:
|
|
592
|
+
"""Clear all history."""
|
|
593
|
+
with self._lock:
|
|
594
|
+
self._history.clear()
|
|
595
|
+
self._observation_counts.clear()
|
|
596
|
+
self._entity_metadata.clear()
|