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.
Files changed (63) hide show
  1. arbiter_engine/__init__.py +31 -0
  2. arbiter_engine/api.py +473 -0
  3. arbiter_engine/axiom_thresholds.py +118 -0
  4. arbiter_engine/envelope.py +270 -0
  5. arbiter_engine/examples/water_tank.yaml +112 -0
  6. arbiter_engine/fire_frequency.py +252 -0
  7. arbiter_engine/history/__init__.py +12 -0
  8. arbiter_engine/history/observation.py +596 -0
  9. arbiter_engine/history/observation_production.py +335 -0
  10. arbiter_engine/history/observation_source_wiring.py +120 -0
  11. arbiter_engine/history/readiness.py +288 -0
  12. arbiter_engine/interfaces.py +1353 -0
  13. arbiter_engine/mcp/__init__.py +1 -0
  14. arbiter_engine/mcp/server.py +221 -0
  15. arbiter_engine/ontology/__init__.py +18 -0
  16. arbiter_engine/ontology/axiom_verdicts_production.py +332 -0
  17. arbiter_engine/ontology/axioms/__init__.py +26 -0
  18. arbiter_engine/ontology/axioms/boundedness.py +340 -0
  19. arbiter_engine/ontology/axioms/connectivity.py +396 -0
  20. arbiter_engine/ontology/axioms/conservation.py +266 -0
  21. arbiter_engine/ontology/axioms/consistency.py +422 -0
  22. arbiter_engine/ontology/axioms/extensions.py +106 -0
  23. arbiter_engine/ontology/axioms/homeostasis.py +783 -0
  24. arbiter_engine/ontology/axioms/monotonicity.py +444 -0
  25. arbiter_engine/ontology/axioms/responsiveness.py +752 -0
  26. arbiter_engine/ontology/axioms/roles.py +207 -0
  27. arbiter_engine/ontology/axioms/stability.py +327 -0
  28. arbiter_engine/ontology/domain_loader.py +485 -0
  29. arbiter_engine/ontology/loader.py +802 -0
  30. arbiter_engine/ontology/reasoner.py +1069 -0
  31. arbiter_engine/propagation/__init__.py +1 -0
  32. arbiter_engine/propagation/impact_estimator.py +305 -0
  33. arbiter_engine/propagation/lp_confidence.py +123 -0
  34. arbiter_engine/propagation/mcts_root_cause.py +266 -0
  35. arbiter_engine/propagation/root_cause.py +559 -0
  36. arbiter_engine/propagation/weight_learner.py +216 -0
  37. arbiter_engine/rca/__init__.py +4 -0
  38. arbiter_engine/rca/greedy_set_cover.py +291 -0
  39. arbiter_engine/residual/__init__.py +1 -0
  40. arbiter_engine/residual/predict_vs_mirror.py +542 -0
  41. arbiter_engine/temporal/__init__.py +1 -0
  42. arbiter_engine/temporal/temporal_edge.py +584 -0
  43. arbiter_engine/temporal/trend_projection.py +430 -0
  44. arbiter_engine/twin/__init__.py +23 -0
  45. arbiter_engine/twin/action_clears_problem.py +217 -0
  46. arbiter_engine/twin/builder.py +479 -0
  47. arbiter_engine/twin/gap.py +132 -0
  48. arbiter_engine/twin/hypothesis_generator.py +530 -0
  49. arbiter_engine/twin/hypothesis_production.py +236 -0
  50. arbiter_engine/twin/kernel_pipeline_executor.py +571 -0
  51. arbiter_engine/twin/monte_carlo_predictor.py +1001 -0
  52. arbiter_engine/twin/optimization_production.py +233 -0
  53. arbiter_engine/twin/pipeline_production.py +176 -0
  54. arbiter_engine/twin/topology.py +450 -0
  55. arbiter_engine/twin/topology_optimizer.py +412 -0
  56. arbiter_engine/twin/traverser.py +1260 -0
  57. arbiter_engine/twin/traverser_production.py +283 -0
  58. arbiter_engine/types.py +427 -0
  59. arbiter_engine-0.1.0.dist-info/METADATA +278 -0
  60. arbiter_engine-0.1.0.dist-info/RECORD +63 -0
  61. arbiter_engine-0.1.0.dist-info/WHEEL +4 -0
  62. arbiter_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
  63. 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()