mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
fix(fire): cold start seeds ALL current fires silently (no 48h dump)
Drop the fresh-ignition age window from the native cold-start seed — a fresh deploy with an empty fires table must not broadcast fires discovered in the last 48h. Now every fire present at boot is seeded silently; a fire only broadcasts New if it appears on a later poll (a genuine ignition since startup). Growth/containment updates unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9ab5ad58aa
commit
a7de1873b1
3 changed files with 102 additions and 56 deletions
|
|
@ -330,23 +330,17 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
||||||
"type": "float",
|
"type": "float",
|
||||||
"description": "Default attribution radius for FIRMS hotspot -> fire matching, miles. Per-fire override in fires.spread_radius_mi.",
|
"description": "Default attribution radius for FIRMS hotspot -> fire matching, miles. Per-fire override in fires.spread_radius_mi.",
|
||||||
},
|
},
|
||||||
# Native WFIGS cold-start silent-seed knobs (env/store.py::_ingest_fires).
|
# Native WFIGS cold-start silent-seed (env/store.py::_ingest_fires).
|
||||||
# new_ignition_max_age_seconds: a FIRST-SIGHT fire older than this at boot is
|
# The cold-start seed is now gated on a per-process FIRST-POLL flag, not a
|
||||||
# treated as a pre-existing active fire and silent-seeded (no broadcast); a
|
# wall-clock window: the FIRST fires poll after boot silent-seeds EVERY fire
|
||||||
# first-sight fire younger than this is a genuine fresh ignition and the
|
# present (regardless of age, no broadcast); a fire only broadcasts "New" if
|
||||||
# decider announces it (New). Default 48h.
|
# it first appears on a LATER poll. cold_start_grace_seconds is retained
|
||||||
("fires", "new_ignition_max_age_seconds"): {
|
# (GUI-visible / reserved) but no longer gates the fire seed; the old
|
||||||
"default": 172800,
|
# new_ignition_max_age_seconds knob was removed with the 48h age window.
|
||||||
"type": "int",
|
|
||||||
"description": "Max discovery age (seconds) for a first-sight native fire to count as a fresh ignition (announced New). Older/undated first-sights within the boot-grace window are silent-seeded instead. Default 48h.",
|
|
||||||
},
|
|
||||||
# cold_start_grace_seconds: how long after process boot the silent-seed
|
|
||||||
# pre-pass stays active. After this window, first-sights fall through to
|
|
||||||
# normal decider behavior. Default 120s (covers the first fire poll).
|
|
||||||
("fires", "cold_start_grace_seconds"): {
|
("fires", "cold_start_grace_seconds"): {
|
||||||
"default": 120,
|
"default": 120,
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"description": "Seconds after boot during which first-sight old/known native fires are silent-seeded (no backlog dump). After this window first-sights follow normal decider behavior. Default 120s.",
|
"description": "Reserved boot-grace window (seconds). The native fire cold-start silent-seed is now gated on the first fires poll rather than this window, so it no longer affects fire seeding; retained for GUI/back-compat. Default 120s.",
|
||||||
},
|
},
|
||||||
# v0.7-fire-2 -- growth + halt detection thresholds.
|
# v0.7-fire-2 -- growth + halt detection thresholds.
|
||||||
# growth_drift_threshold_mi: a per-pass centroid drift of at least
|
# growth_drift_threshold_mi: a per-pass centroid drift of at least
|
||||||
|
|
|
||||||
74
work/meshai/env/store.py
vendored
74
work/meshai/env/store.py
vendored
|
|
@ -44,11 +44,6 @@ class EnvironmentalStore:
|
||||||
self._adapters = {} # name -> adapter instance
|
self._adapters = {} # name -> adapter instance
|
||||||
self._failed_adapters = {} # name -> last_error string
|
self._failed_adapters = {} # name -> last_error string
|
||||||
self._events = {} # (source, event_id) -> event dict
|
self._events = {} # (source, event_id) -> event dict
|
||||||
# Process boot epoch — the native fire cold-start silent-seed window is
|
|
||||||
# measured from here (see _ingest_fires). clock.now() is the pipeline's
|
|
||||||
# monkeypatchable time seam so tests can freeze/override it.
|
|
||||||
from meshai.notifications import clock as _clock
|
|
||||||
self._boot_at = _clock.now()
|
|
||||||
self._event_bus = event_bus # Pipeline EventBus for emission
|
self._event_bus = event_bus # Pipeline EventBus for emission
|
||||||
self._swpc_status = {} # Kp/SFI/scales snapshot
|
self._swpc_status = {} # Kp/SFI/scales snapshot
|
||||||
self._ducting_status = {} # tropo ducting assessment
|
self._ducting_status = {} # tropo ducting assessment
|
||||||
|
|
@ -84,6 +79,17 @@ class EnvironmentalStore:
|
||||||
self._seen: dict[str, set] = {} # source -> set of item keys
|
self._seen: dict[str, set] = {} # source -> set of item keys
|
||||||
self._seeded: set[str] = set() # sources past their first non-empty poll
|
self._seeded: set[str] = set() # sources past their first non-empty poll
|
||||||
|
|
||||||
|
# Native WFIGS cold-start silent-seed gate (see _ingest_fires). The
|
||||||
|
# FIRST fires poll after boot treats every fire present as already-known
|
||||||
|
# (seed silently, no broadcast) so a fresh deploy never dumps the active-
|
||||||
|
# fire backlog to the mesh. Gated on this per-process flag rather than a
|
||||||
|
# wall-clock boot grace so it holds no matter how late the first
|
||||||
|
# successful WFIGS fetch lands (e.g. a failed first fetch pushes the
|
||||||
|
# first real poll ~10min out, well past any grace window). Flag flips
|
||||||
|
# only after a non-empty fires ingest; a fire that first appears on a
|
||||||
|
# LATER poll is a genuine ignition and broadcasts "New".
|
||||||
|
self._fires_seeded: bool = False
|
||||||
|
|
||||||
# Create adapter instances with error isolation
|
# Create adapter instances with error isolation
|
||||||
self._register_adapter("nws", config.nws, ".nws", "NWSAlertsAdapter",
|
self._register_adapter("nws", config.nws, ".nws", "NWSAlertsAdapter",
|
||||||
lambda cfg: (cfg, self._coverage_for("nws")))
|
lambda cfg: (cfg, self._coverage_for("nws")))
|
||||||
|
|
@ -286,8 +292,8 @@ class EnvironmentalStore:
|
||||||
# gate: a fire's growth updates are repeat sightings of the SAME
|
# gate: a fire's growth updates are repeat sightings of the SAME
|
||||||
# event_id that `_delta_emit` would wrongly suppress. The DECIDER
|
# event_id that `_delta_emit` would wrongly suppress. The DECIDER
|
||||||
# (gating.fire.decide, backed by the fires table + 8h cooldown) is
|
# (gating.fire.decide, backed by the fires table + 8h cooldown) is
|
||||||
# the gate instead. `_ingest_fires` also silent-seeds old/known
|
# the gate instead. `_ingest_fires` also silent-seeds EVERY current
|
||||||
# fires at cold start so boot never dumps a backlog.
|
# fire on the first poll (cold start) so boot never dumps a backlog.
|
||||||
for evt in adapter.get_events():
|
for evt in adapter.get_events():
|
||||||
key = (evt["source"], evt["event_id"])
|
key = (evt["source"], evt["event_id"])
|
||||||
self._events[key] = evt
|
self._events[key] = evt
|
||||||
|
|
@ -325,13 +331,19 @@ class EnvironmentalStore:
|
||||||
For each polled fire (already recorded in ``self._events`` by the
|
For each polled fire (already recorded in ``self._events`` by the
|
||||||
caller):
|
caller):
|
||||||
|
|
||||||
1. COLD-START silent-seed. A FIRST-SIGHT fire (no ``fires`` row) that
|
1. COLD-START silent-seed. On the FIRST fires poll after boot
|
||||||
is old/undated AND seen inside the boot-grace window is a
|
(``self._fires_seeded`` is False), EVERY first-sight fire (no
|
||||||
pre-existing active fire, not a fresh ignition. We INSERT a
|
``fires`` row) is treated as a pre-existing active fire —
|
||||||
``fires`` row stamped as ALREADY broadcast (``last_broadcast_*`` =
|
regardless of age. We INSERT a ``fires`` row stamped as ALREADY
|
||||||
current) and emit NOTHING, so the decider treats later polls as
|
broadcast (``last_broadcast_*`` = current) and emit NOTHING, so the
|
||||||
Update and only fires on real growth — no boot backlog dump. The
|
decider treats later polls as Update and only fires on real growth
|
||||||
shared ``gating.fire.decide`` is untouched.
|
— no boot backlog dump (a fresh deploy with an empty ``fires`` table
|
||||||
|
must NOT broadcast every fire discovered in the last 48h). A fire
|
||||||
|
only broadcasts "New" if it FIRST appears on a LATER poll (a
|
||||||
|
genuine ignition since startup). The shared ``gating.fire.decide``
|
||||||
|
is untouched. This is gated on the per-process first-poll flag, not
|
||||||
|
a wall-clock boot grace, so it holds no matter how late the first
|
||||||
|
successful WFIGS fetch lands.
|
||||||
|
|
||||||
2. Unconditional current-state write (mirrors the Central
|
2. Unconditional current-state write (mirrors the Central
|
||||||
``wfigs_handler``): INSERT (``last_broadcast_*`` NULL) on first
|
``wfigs_handler``): INSERT (``last_broadcast_*`` NULL) on first
|
||||||
|
|
@ -345,7 +357,6 @@ class EnvironmentalStore:
|
||||||
/ render hints) and arms ``gate.commit`` onto the emitted Event.
|
/ render hints) and arms ``gate.commit`` onto the emitted Event.
|
||||||
"""
|
"""
|
||||||
from meshai.notifications import clock as _clock
|
from meshai.notifications import clock as _clock
|
||||||
from meshai.adapter_config import adapter_config
|
|
||||||
|
|
||||||
now = _clock.now()
|
now = _clock.now()
|
||||||
try:
|
try:
|
||||||
|
|
@ -355,17 +366,13 @@ class EnvironmentalStore:
|
||||||
logger.warning("nifc fire ingest skipped (DB unavailable): %s", e)
|
logger.warning("nifc fire ingest skipped (DB unavailable): %s", e)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
# Cold-start gate: is this the FIRST fires poll since boot? Captured
|
||||||
grace = int(adapter_config.fires.cold_start_grace_seconds)
|
# once for the whole batch, before the flag is flipped below, so every
|
||||||
except Exception:
|
# fire in the initial full-state sweep is silent-seeded together.
|
||||||
grace = 120
|
cold_start = not self._fires_seeded
|
||||||
try:
|
|
||||||
max_age = int(adapter_config.fires.new_ignition_max_age_seconds)
|
|
||||||
except Exception:
|
|
||||||
max_age = 172800
|
|
||||||
within_boot_grace = (now - self._boot_at) < grace
|
|
||||||
|
|
||||||
for evt in adapter.get_events():
|
events = adapter.get_events()
|
||||||
|
for evt in events:
|
||||||
try:
|
try:
|
||||||
irwin_id = evt.get("irwin_id")
|
irwin_id = evt.get("irwin_id")
|
||||||
if not irwin_id:
|
if not irwin_id:
|
||||||
|
|
@ -379,11 +386,10 @@ class EnvironmentalStore:
|
||||||
(irwin_id,)).fetchone()
|
(irwin_id,)).fetchone()
|
||||||
first_sight = row is None
|
first_sight = row is None
|
||||||
|
|
||||||
old_or_undated = (
|
# (1) Cold-start silent-seed — EVERY first-sight fire on the
|
||||||
declared is None or (now - int(declared)) >= max_age)
|
# first poll, regardless of age; seed as already-broadcast, no
|
||||||
|
# emit.
|
||||||
# (1) Cold-start silent-seed — seed as already-broadcast, no emit.
|
if first_sight and cold_start:
|
||||||
if first_sight and within_boot_grace and old_or_undated:
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO fires(irwin_id, incident_name, "
|
"INSERT INTO fires(irwin_id, incident_name, "
|
||||||
"current_acres, current_contained_pct, lat, lon, "
|
"current_acres, current_contained_pct, lat, lon, "
|
||||||
|
|
@ -432,6 +438,14 @@ class EnvironmentalStore:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"nifc fire ingest failed for %s", evt.get("event_id", "?"))
|
"nifc fire ingest failed for %s", evt.get("event_id", "?"))
|
||||||
|
|
||||||
|
# First fires poll complete: later polls broadcast genuine ignitions.
|
||||||
|
# `_ingest_fires` is only reached when the adapter's tick() reported a
|
||||||
|
# change, which for the atomic WFIGS fetch means a non-empty batch on
|
||||||
|
# the first successful poll (a 0-fire fetch is `changed=False` and never
|
||||||
|
# ingests) — so this flip only ever happens on a real full-state sweep.
|
||||||
|
if events:
|
||||||
|
self._fires_seeded = True
|
||||||
|
|
||||||
def _seed_from_persistent(self) -> None:
|
def _seed_from_persistent(self) -> None:
|
||||||
"""Pre-seed ``self._seen`` from the durable hazard tables at startup.
|
"""Pre-seed ``self._seen`` from the durable hazard tables at startup.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,16 @@ composer renders them via the shared fire formatter. The shared decider and
|
||||||
formatter are REUSED unchanged.
|
formatter are REUSED unchanged.
|
||||||
|
|
||||||
Scenarios (mirror the gate-sequence intent of test_fire_refactor.py):
|
Scenarios (mirror the gate-sequence intent of test_fire_refactor.py):
|
||||||
1. Cold-start silent-seed: old/undated first-sight within boot grace -> NO
|
1. Cold-start silent-seed: ANY first-sight fire on the FIRST poll (undated OR
|
||||||
broadcast, `fires` row seeded already-broadcast.
|
recently declared) -> NO broadcast, `fires` row seeded already-broadcast.
|
||||||
|
The first full-state sweep after boot must produce ZERO broadcasts.
|
||||||
2. Growth: seeded MORA (last_bcast 2410 @ now-9h), poll 3000 -> Update; after
|
2. Growth: seeded MORA (last_bcast 2410 @ now-9h), poll 3000 -> Update; after
|
||||||
the deferred commit runs, last_broadcast_acres == 3000.
|
the deferred commit runs, last_broadcast_acres == 3000.
|
||||||
3. Containment rise past cooldown -> Update.
|
3. Containment rise past cooldown -> Update.
|
||||||
4. Unchanged OR within cooldown -> suppress (no emit).
|
4. Unchanged OR within cooldown -> suppress (no emit).
|
||||||
5. Fresh ignition (first-sight, declared within 48h) -> New; does NOT re-spam
|
5. Later-poll ignition: a fire absent at boot that FIRST appears on a later
|
||||||
on the next unchanged poll (the state-write latched last_broadcast_*).
|
poll (source already seeded) -> New; does NOT re-spam on the next unchanged
|
||||||
|
poll (the state-write latched last_broadcast_*).
|
||||||
6. Native event carries _dedup_suffix + _severity_override +
|
6. Native event carries _dedup_suffix + _severity_override +
|
||||||
_on_broadcast_committed onto the emitted Event; to_event stamps canonical
|
_on_broadcast_committed onto the emitted Event; to_event stamps canonical
|
||||||
`data`; the composer renders it via the fire formatter with no env var.
|
`data`; the composer renders it via the fire formatter with no env var.
|
||||||
|
|
@ -134,11 +136,12 @@ def _seed_row(conn, *, acres, contained, last_bcast_at):
|
||||||
def test_cold_start_silent_seed_no_broadcast(env):
|
def test_cold_start_silent_seed_no_broadcast(env):
|
||||||
conn, _clk = env
|
conn, _clk = env
|
||||||
store, adapter, captured = _make_store()
|
store, adapter, captured = _make_store()
|
||||||
# First-sight, undated, within boot grace (_boot_at == now).
|
# First-sight, undated, on the FIRST fires poll (source not yet seeded).
|
||||||
adapter.set_batch([_raw_fire(acres=2410, contained=10, declared=None)])
|
adapter.set_batch([_raw_fire(acres=2410, contained=10, declared=None)])
|
||||||
store._ingest("nifc", adapter)
|
store._ingest("nifc", adapter)
|
||||||
|
|
||||||
assert captured == [], "cold-start must broadcast NOTHING"
|
assert captured == [], "cold-start must broadcast NOTHING"
|
||||||
|
assert store._fires_seeded is True, "first non-empty poll marks fires seeded"
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT last_broadcast_acres, last_broadcast_at, "
|
"SELECT last_broadcast_acres, last_broadcast_at, "
|
||||||
"last_broadcast_contained FROM fires WHERE irwin_id=?",
|
"last_broadcast_contained FROM fires WHERE irwin_id=?",
|
||||||
|
|
@ -149,6 +152,27 @@ def test_cold_start_silent_seed_no_broadcast(env):
|
||||||
assert row["last_broadcast_at"] is not None
|
assert row["last_broadcast_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1b. cold-start seeds a RECENTLY-declared fire silently too (no 48h dump) ──
|
||||||
|
def test_cold_start_recent_fire_still_silent(env):
|
||||||
|
conn, _clk = env
|
||||||
|
store, adapter, captured = _make_store()
|
||||||
|
# Declared just 1h ago — under the OLD 48h window this first-sight fire
|
||||||
|
# would have broadcast New. On cold start it must now seed SILENT anyway:
|
||||||
|
# a fresh deploy must never dump fires discovered in the last 48h.
|
||||||
|
adapter.set_batch([_raw_fire(name="FRESH", irwin="IRWIN-FRESH-9",
|
||||||
|
acres=120, contained=0,
|
||||||
|
declared=_NOW - 3600)])
|
||||||
|
store._ingest("nifc", adapter)
|
||||||
|
|
||||||
|
assert captured == [], "cold-start must seed EVERY fire silent, even recent"
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT last_broadcast_acres, last_broadcast_at FROM fires "
|
||||||
|
"WHERE irwin_id=?", ("IRWIN-FRESH-9",)).fetchone()
|
||||||
|
assert row is not None and row["last_broadcast_acres"] == 120
|
||||||
|
assert row["last_broadcast_at"] is not None
|
||||||
|
assert store._fires_seeded is True
|
||||||
|
|
||||||
|
|
||||||
# ── 2. growth after cooldown -> Update + commit latches ─────────────────────
|
# ── 2. growth after cooldown -> Update + commit latches ─────────────────────
|
||||||
def test_growth_update_and_commit(env):
|
def test_growth_update_and_commit(env):
|
||||||
conn, clk = env
|
conn, clk = env
|
||||||
|
|
@ -209,17 +233,30 @@ def test_no_change_suppresses(env):
|
||||||
assert captured == [], "no forward change must be suppressed"
|
assert captured == [], "no forward change must be suppressed"
|
||||||
|
|
||||||
|
|
||||||
# ── 5. fresh ignition -> New, and no re-spam on the next unchanged poll ──────
|
# ── 5. later-poll ignition -> New, and no re-spam on the next unchanged poll ──
|
||||||
def test_fresh_ignition_new_then_no_respam(env):
|
def test_later_poll_ignition_new_then_no_respam(env):
|
||||||
conn, clk = env
|
conn, clk = env
|
||||||
store, adapter, captured = _make_store()
|
store, adapter, captured = _make_store()
|
||||||
fresh = dict(name="FRESH", irwin="IRWIN-FRESH-9", acres=120, contained=0,
|
|
||||||
declared=_NOW - 3600) # 1h old -> genuine fresh ignition
|
|
||||||
|
|
||||||
adapter.set_batch([_raw_fire(**fresh)])
|
# First (cold-start) poll: an existing fire present at boot -> silent-seed.
|
||||||
|
# This marks the fires source seeded; NOTHING broadcasts.
|
||||||
|
adapter.set_batch([_raw_fire(acres=2410, contained=10)])
|
||||||
store._ingest("nifc", adapter)
|
store._ingest("nifc", adapter)
|
||||||
assert len(captured) == 1, "fresh ignition must broadcast New"
|
assert captured == [], "cold-start poll must broadcast nothing"
|
||||||
|
assert store._fires_seeded is True
|
||||||
|
|
||||||
|
# Later poll (well after any grace window): a NEW fire that was NOT present
|
||||||
|
# at boot -> genuine ignition -> New broadcast.
|
||||||
|
clk.t = _NOW + 20 * 60
|
||||||
|
captured.clear()
|
||||||
|
fresh = dict(name="FRESH", irwin="IRWIN-FRESH-9", acres=120, contained=0,
|
||||||
|
declared=_NOW - 3600)
|
||||||
|
adapter.set_batch([_raw_fire(acres=2410, contained=10),
|
||||||
|
_raw_fire(**fresh)])
|
||||||
|
store._ingest("nifc", adapter)
|
||||||
|
assert len(captured) == 1, "new fire on a later poll must broadcast New"
|
||||||
ev = captured[0]
|
ev = captured[0]
|
||||||
|
assert ev.data.get("irwin_id") == "IRWIN-FRESH-9"
|
||||||
assert ev.data.get("category") == "wildfire_declared"
|
assert ev.data.get("category") == "wildfire_declared"
|
||||||
assert ev.data.get("is_update") is False
|
assert ev.data.get("is_update") is False
|
||||||
# Latch the New broadcast.
|
# Latch the New broadcast.
|
||||||
|
|
@ -228,7 +265,8 @@ def test_fresh_ignition_new_then_no_respam(env):
|
||||||
# Next poll, unchanged, even well past cooldown -> must NOT re-broadcast.
|
# Next poll, unchanged, even well past cooldown -> must NOT re-broadcast.
|
||||||
clk.t = _NOW + 10 * 3600
|
clk.t = _NOW + 10 * 3600
|
||||||
captured.clear()
|
captured.clear()
|
||||||
adapter.set_batch([_raw_fire(**fresh)])
|
adapter.set_batch([_raw_fire(acres=2410, contained=10),
|
||||||
|
_raw_fire(**fresh)])
|
||||||
store._ingest("nifc", adapter)
|
store._ingest("nifc", adapter)
|
||||||
assert captured == [], "latched New must not re-spam on unchanged re-poll"
|
assert captured == [], "latched New must not re-spam on unchanged re-poll"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue