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",
|
||||
"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).
|
||||
# new_ignition_max_age_seconds: a FIRST-SIGHT fire older than this at boot is
|
||||
# treated as a pre-existing active fire and silent-seeded (no broadcast); a
|
||||
# first-sight fire younger than this is a genuine fresh ignition and the
|
||||
# decider announces it (New). Default 48h.
|
||||
("fires", "new_ignition_max_age_seconds"): {
|
||||
"default": 172800,
|
||||
"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).
|
||||
# Native WFIGS cold-start silent-seed (env/store.py::_ingest_fires).
|
||||
# The cold-start seed is now gated on a per-process FIRST-POLL flag, not a
|
||||
# wall-clock window: the FIRST fires poll after boot silent-seeds EVERY fire
|
||||
# present (regardless of age, no broadcast); a fire only broadcasts "New" if
|
||||
# it first appears on a LATER poll. cold_start_grace_seconds is retained
|
||||
# (GUI-visible / reserved) but no longer gates the fire seed; the old
|
||||
# new_ignition_max_age_seconds knob was removed with the 48h age window.
|
||||
("fires", "cold_start_grace_seconds"): {
|
||||
"default": 120,
|
||||
"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.
|
||||
# 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._failed_adapters = {} # name -> last_error string
|
||||
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._swpc_status = {} # Kp/SFI/scales snapshot
|
||||
self._ducting_status = {} # tropo ducting assessment
|
||||
|
|
@ -84,6 +79,17 @@ class EnvironmentalStore:
|
|||
self._seen: dict[str, set] = {} # source -> set of item keys
|
||||
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
|
||||
self._register_adapter("nws", config.nws, ".nws", "NWSAlertsAdapter",
|
||||
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
|
||||
# event_id that `_delta_emit` would wrongly suppress. The DECIDER
|
||||
# (gating.fire.decide, backed by the fires table + 8h cooldown) is
|
||||
# the gate instead. `_ingest_fires` also silent-seeds old/known
|
||||
# fires at cold start so boot never dumps a backlog.
|
||||
# the gate instead. `_ingest_fires` also silent-seeds EVERY current
|
||||
# fire on the first poll (cold start) so boot never dumps a backlog.
|
||||
for evt in adapter.get_events():
|
||||
key = (evt["source"], evt["event_id"])
|
||||
self._events[key] = evt
|
||||
|
|
@ -325,13 +331,19 @@ class EnvironmentalStore:
|
|||
For each polled fire (already recorded in ``self._events`` by the
|
||||
caller):
|
||||
|
||||
1. COLD-START silent-seed. A FIRST-SIGHT fire (no ``fires`` row) that
|
||||
is old/undated AND seen inside the boot-grace window is a
|
||||
pre-existing active fire, not a fresh ignition. We INSERT a
|
||||
``fires`` row stamped as ALREADY broadcast (``last_broadcast_*`` =
|
||||
current) and emit NOTHING, so the decider treats later polls as
|
||||
Update and only fires on real growth — no boot backlog dump. The
|
||||
shared ``gating.fire.decide`` is untouched.
|
||||
1. COLD-START silent-seed. On the FIRST fires poll after boot
|
||||
(``self._fires_seeded`` is False), EVERY first-sight fire (no
|
||||
``fires`` row) is treated as a pre-existing active fire —
|
||||
regardless of age. We INSERT a ``fires`` row stamped as ALREADY
|
||||
broadcast (``last_broadcast_*`` = current) and emit NOTHING, so the
|
||||
decider treats later polls as Update and only fires on real growth
|
||||
— 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
|
||||
``wfigs_handler``): INSERT (``last_broadcast_*`` NULL) on first
|
||||
|
|
@ -345,7 +357,6 @@ class EnvironmentalStore:
|
|||
/ render hints) and arms ``gate.commit`` onto the emitted Event.
|
||||
"""
|
||||
from meshai.notifications import clock as _clock
|
||||
from meshai.adapter_config import adapter_config
|
||||
|
||||
now = _clock.now()
|
||||
try:
|
||||
|
|
@ -355,17 +366,13 @@ class EnvironmentalStore:
|
|||
logger.warning("nifc fire ingest skipped (DB unavailable): %s", e)
|
||||
return
|
||||
|
||||
try:
|
||||
grace = int(adapter_config.fires.cold_start_grace_seconds)
|
||||
except Exception:
|
||||
grace = 120
|
||||
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
|
||||
# Cold-start gate: is this the FIRST fires poll since boot? Captured
|
||||
# once for the whole batch, before the flag is flipped below, so every
|
||||
# fire in the initial full-state sweep is silent-seeded together.
|
||||
cold_start = not self._fires_seeded
|
||||
|
||||
for evt in adapter.get_events():
|
||||
events = adapter.get_events()
|
||||
for evt in events:
|
||||
try:
|
||||
irwin_id = evt.get("irwin_id")
|
||||
if not irwin_id:
|
||||
|
|
@ -379,11 +386,10 @@ class EnvironmentalStore:
|
|||
(irwin_id,)).fetchone()
|
||||
first_sight = row is None
|
||||
|
||||
old_or_undated = (
|
||||
declared is None or (now - int(declared)) >= max_age)
|
||||
|
||||
# (1) Cold-start silent-seed — seed as already-broadcast, no emit.
|
||||
if first_sight and within_boot_grace and old_or_undated:
|
||||
# (1) Cold-start silent-seed — EVERY first-sight fire on the
|
||||
# first poll, regardless of age; seed as already-broadcast, no
|
||||
# emit.
|
||||
if first_sight and cold_start:
|
||||
conn.execute(
|
||||
"INSERT INTO fires(irwin_id, incident_name, "
|
||||
"current_acres, current_contained_pct, lat, lon, "
|
||||
|
|
@ -432,6 +438,14 @@ class EnvironmentalStore:
|
|||
logger.exception(
|
||||
"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:
|
||||
"""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.
|
||||
|
||||
Scenarios (mirror the gate-sequence intent of test_fire_refactor.py):
|
||||
1. Cold-start silent-seed: old/undated first-sight within boot grace -> NO
|
||||
broadcast, `fires` row seeded already-broadcast.
|
||||
1. Cold-start silent-seed: ANY first-sight fire on the FIRST poll (undated OR
|
||||
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
|
||||
the deferred commit runs, last_broadcast_acres == 3000.
|
||||
3. Containment rise past cooldown -> Update.
|
||||
4. Unchanged OR within cooldown -> suppress (no emit).
|
||||
5. Fresh ignition (first-sight, declared within 48h) -> New; does NOT re-spam
|
||||
on the next unchanged poll (the state-write latched last_broadcast_*).
|
||||
5. Later-poll ignition: a fire absent at boot that FIRST appears on a later
|
||||
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 +
|
||||
_on_broadcast_committed onto the emitted Event; to_event stamps canonical
|
||||
`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):
|
||||
conn, _clk = env
|
||||
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)])
|
||||
store._ingest("nifc", adapter)
|
||||
|
||||
assert captured == [], "cold-start must broadcast NOTHING"
|
||||
assert store._fires_seeded is True, "first non-empty poll marks fires seeded"
|
||||
row = conn.execute(
|
||||
"SELECT last_broadcast_acres, last_broadcast_at, "
|
||||
"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
|
||||
|
||||
|
||||
# ── 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 ─────────────────────
|
||||
def test_growth_update_and_commit(env):
|
||||
conn, clk = env
|
||||
|
|
@ -209,17 +233,30 @@ def test_no_change_suppresses(env):
|
|||
assert captured == [], "no forward change must be suppressed"
|
||||
|
||||
|
||||
# ── 5. fresh ignition -> New, and no re-spam on the next unchanged poll ──────
|
||||
def test_fresh_ignition_new_then_no_respam(env):
|
||||
# ── 5. later-poll ignition -> New, and no re-spam on the next unchanged poll ──
|
||||
def test_later_poll_ignition_new_then_no_respam(env):
|
||||
conn, clk = env
|
||||
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)
|
||||
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]
|
||||
assert ev.data.get("irwin_id") == "IRWIN-FRESH-9"
|
||||
assert ev.data.get("category") == "wildfire_declared"
|
||||
assert ev.data.get("is_update") is False
|
||||
# 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.
|
||||
clk.t = _NOW + 10 * 3600
|
||||
captured.clear()
|
||||
adapter.set_batch([_raw_fire(**fresh)])
|
||||
adapter.set_batch([_raw_fire(acres=2410, contained=10),
|
||||
_raw_fire(**fresh)])
|
||||
store._ingest("nifc", adapter)
|
||||
assert captured == [], "latched New must not re-spam on unchanged re-poll"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue