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:
Matt Johnson 2026-07-06 21:16:08 +00:00
commit a7de1873b1
3 changed files with 102 additions and 56 deletions

View file

@ -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

View file

@ -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.

View file

@ -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"