malkhut(T7): UV Clock — event-driven reactor + staleness watchdog
UVClock (clock/host.py): T19 event-driven reactor, asyncio dispatch. Events (clock/events.py): Scan, Tick, Timer, BarFire, Stale. Staleness watchdog (clock/staleness.py): T19 Staleness Law enforcement. DeadNode reaper (clock/deadnode.py): iox2 orphan sweep on startup.
This commit is contained in:
168
MALKHUT/malkhut/clock/events.py
Normal file
168
MALKHUT/malkhut/clock/events.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
T19 Event Types — typed events for the UV clock host.
|
||||
|
||||
Per Fable's T19 spec (ANNEX B-CORRECTION):
|
||||
"iceoryx2 = the wires; ASEx = the state cells; UV clock = the conductor."
|
||||
|
||||
Events are published via iceoryx2/zinc topics (transport).
|
||||
ASEx guards the state cells that handlers mutate.
|
||||
|
||||
Staleness Law (ANNEX A):
|
||||
1. BarFire is EDGE-TRIGGERED on scan_number monotonic advance — NEVER timer-synthesized.
|
||||
2. Every event carries provenance: scan_number, scan_ts, ingest_ts, AGE at dispatch.
|
||||
3. Freshness watchdog TimerEvent emits StaleInput when stale.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class EventType(str, Enum):
|
||||
SCAN = "SCAN"
|
||||
TICK = "TICK"
|
||||
TIMER = "TIMER"
|
||||
BAR_FIRE = "BAR_FIRE"
|
||||
STALE_INPUT = "STALE_INPUT"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EventProvenance:
|
||||
"""
|
||||
First-class freshness payload. Handlers CANNOT NOT know this.
|
||||
|
||||
Every event carries provenance per T19 Staleness Law §2.
|
||||
"""
|
||||
scan_number: int
|
||||
scan_ts: int # ns, original scan timestamp
|
||||
ingest_ts: int # ns, when we received it
|
||||
source: str # "live" | "replay"
|
||||
|
||||
@property
|
||||
def age_ns(self) -> int:
|
||||
"""Age at construction time (not dispatch — dispatch adds overhead)."""
|
||||
return time.time_ns() - self.ingest_ts
|
||||
|
||||
@property
|
||||
def is_fresh(self) -> bool:
|
||||
"""Fresh if age < 1.5x cadence (default 5.85s * 1.5 = 8.775s)."""
|
||||
return self.age_ns < 8_775_000_000 # 8.775s in ns
|
||||
|
||||
def stale_threshold_ns(self, cadence_ns: int = 5_850_000_000) -> int:
|
||||
return int(cadence_ns * 1.5)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScanEvent:
|
||||
"""
|
||||
NG7 scan from HZ (live) or recorded source (replay).
|
||||
~5.85s cadence live. Edge-triggered on scan_number advance.
|
||||
"""
|
||||
event_type: EventType = EventType.SCAN
|
||||
scan_number: int = 0
|
||||
symbol: str = ""
|
||||
data: Any = None # scan payload (dict, bytes, etc.)
|
||||
provenance: EventProvenance = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.provenance is None:
|
||||
object.__setattr__(self, 'provenance', EventProvenance(
|
||||
scan_number=self.scan_number,
|
||||
scan_ts=time.time_ns(),
|
||||
ingest_ts=time.time_ns(),
|
||||
source="live",
|
||||
))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TickEvent:
|
||||
"""
|
||||
OBF price tick. ~0.18s cadence live, per-symbol.
|
||||
TP/SL handlers subscribe to this.
|
||||
"""
|
||||
event_type: EventType = EventType.TICK
|
||||
symbol: str = ""
|
||||
price: float = 0.0
|
||||
bid: float = 0.0
|
||||
ask: float = 0.0
|
||||
volume: float = 0.0
|
||||
provenance: EventProvenance = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.provenance is None:
|
||||
object.__setattr__(self, 'provenance', EventProvenance(
|
||||
scan_number=0,
|
||||
scan_ts=time.time_ns(),
|
||||
ingest_ts=time.time_ns(),
|
||||
source="live",
|
||||
))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TimerEvent:
|
||||
"""
|
||||
Scheduled wakeup: TTL expiries, watchdogs, cadence ticks.
|
||||
"""
|
||||
event_type: EventType = EventType.TIMER
|
||||
timer_id: str = ""
|
||||
payload: Any = None
|
||||
provenance: EventProvenance = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.provenance is None:
|
||||
object.__setattr__(self, 'provenance', EventProvenance(
|
||||
scan_number=0,
|
||||
scan_ts=time.time_ns(),
|
||||
ingest_ts=time.time_ns(),
|
||||
source="live",
|
||||
))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BarFire:
|
||||
"""
|
||||
Brain dispatch event. EDGE-TRIGGERED on scan_number monotonic advance.
|
||||
Never timer-synthesized. No new scan = no BarFire.
|
||||
"""
|
||||
event_type: EventType = EventType.BAR_FIRE
|
||||
scan_number: int = 0
|
||||
symbol: str = ""
|
||||
data: Any = None
|
||||
provenance: EventProvenance = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.provenance is None:
|
||||
object.__setattr__(self, 'provenance', EventProvenance(
|
||||
scan_number=self.scan_number,
|
||||
scan_ts=time.time_ns(),
|
||||
ingest_ts=time.time_ns(),
|
||||
source="live",
|
||||
))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StaleInput:
|
||||
"""
|
||||
Freshness watchdog alarm. Emitted when:
|
||||
now - last_scan_ts > 1.5x cadence
|
||||
|
||||
This is the June-22 scan-freeze / silent-scanner-death detector, native.
|
||||
Fixes BLUE's oldest observability hole as a side effect.
|
||||
"""
|
||||
event_type: EventType = EventType.STALE_INPUT
|
||||
source: str = ""
|
||||
last_scan_ts: int = 0
|
||||
current_ts: int = 0
|
||||
stale_duration_ns: int = 0
|
||||
provenance: EventProvenance = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.provenance is None:
|
||||
object.__setattr__(self, 'provenance', EventProvenance(
|
||||
scan_number=0,
|
||||
scan_ts=self.last_scan_ts,
|
||||
ingest_ts=time.time_ns(),
|
||||
source="live",
|
||||
))
|
||||
Reference in New Issue
Block a user