mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
feat(v0.6-3a.1): trim adapter_config registry to CONFIG-only per Matt config-vs-code rule + log-on-delete safety net for orphan cleanup
Drops 35 of the v0.6-3a-draft 77 keys + adds 1 net-new key
(firms.dedup_distance_m) for a final count of 43. The trim rules:
CONFIG (lives in adapter_config, surfaces in the GUI):
where we send (channels), how often (cadences/schedules),
thresholds (magnitude floors, severity gates, distance radius,
cooldown durations, freshness windows), curation data (which
sites/states/codes), toggles (enabled, include_in_llm_context,
drop_zero_magnitude).
CODE (stays in handlers, never reaches the GUI):
sentence templates, emoji choices, mapping/translation functions
(TomTom icon_map, ITD sub_type_map, Central adapter_map and
category_map), rendering logic (anchor priority order,
expires-bucket formatting, threshold-state labels), heuristic
logic (band_conditions Kp/SFI -> Good/Fair/Poor function).
Per-adapter outcome (kept | killed):
wfigs 4 | 4 (cooldown_seconds, anchor_max_mi, two re-broadcast toggles)
nws 3 | 4 (broadcast_severities, tombstone_msgtypes, warning_suffix_promotes)
usgs_quake 6 | 3 (centroid, radius, PAGER list, 3 mag floors)
swpc 3 | 7 (three storm-tier floors)
usgs_nwis 2 | 4 (parameter_codes, broadcast_on_recede)
incident 2 | 0 (freshness_seconds, broadcast_on_update)
tomtom_incidents 2 | 1 (drop_zero_magnitude, drop_non_present)
state_511_atis 1 | 0 (skipped_states)
itd_511 0 | 3 (all sub_type maps/emoji/phrase = CODE)
central 1 | 2 (severity_thresholds)
dispatcher 4 | 0 (LRU cap, prune params, retention days)
band_conditions 3 | 6 (SWPC freshness + HamQSL endpoint config)
geocoder 6 | 1 (Photon endpoint + town-OSM curation + cache cap)
firms 4 | 1* (confidence_floor, frp_floor, bbox, dedup_distance_m)
pipeline 2 | 0 (inhibitor TTL, grouper window)
* firms: dedup_lat_lon_decimals is replaced by dedup_distance_m=5 per
Matt s call (user-facing unit is meters, not decimal places; the
handler will internally translate to quantization step in v0.6-3b).
adapter_meta stays at 15 rows -- itd_511 keeps its include_in_llm_context
toggle even with zero config keys.
Live-DB cleanup:
meshai/adapter_config/__init__.py:prune_orphans(conn) DELETEs every
adapter_config row whose (adapter, key) is no longer in REGISTRY. Each
delete is INFO-logged with the prefix "adapter_config orphan removed:"
so docker logs carry the paper trail. Called from init_db() after
seed_defaults; idempotent (zero deletes on every subsequent boot).
Cache is invalidated when any orphan is removed.
adapter_meta is NOT pruned -- meta rows are cheap and useful even for
adapters that ended up with zero config keys.
Tests (34 cases, replaces v0.6-3a 24-case set):
- Registry count is 43; ADAPTER_META is 15
- Seed lands every REGISTRY + ADAPTER_META row; idempotent; never
overwrites user edits
- prune_orphans removes a synthetic legacy row, logs at INFO with the
exact prefix, leaves known keys untouched, leaves adapter_meta
untouched, invalidates the accessor cache
- Accessor returns correctly-typed values incl new
firms.dedup_distance_m
- Guard tests: no key in REGISTRY contains "emoji", ends with "_map",
or contains "template" / "prefix" (catches CODE leaking back in)
Test count: 721 -> 731 (+10 net: +5 prune cases, +1 firms.dedup_distance_m,
+3 CODE-guard cases, +1 registry-count assertion).
Refs Matt s locked CONFIG-vs-CODE rule.
This commit is contained in:
parent
cb3c5aec7e
commit
68dcbc74d0
4 changed files with 326 additions and 503 deletions
|
|
@ -1,14 +1,24 @@
|
|||
"""v0.6-3a meshai/adapter_config package.
|
||||
"""v0.6-3a.1 meshai/adapter_config package.
|
||||
|
||||
Public API:
|
||||
from meshai.adapter_config import adapter_config, invalidate_cache, seed_defaults
|
||||
from meshai.adapter_config import (
|
||||
adapter_config,
|
||||
invalidate_cache,
|
||||
seed_defaults,
|
||||
prune_orphans,
|
||||
)
|
||||
|
||||
`adapter_config` is the typed accessor singleton. `invalidate_cache()` drops
|
||||
the read-side cache (called by /api/adapter-config PUT in v0.6-3c).
|
||||
`seed_defaults(conn)` is called from `meshai.persistence.db.init_db()` after
|
||||
the migration runner finishes; it INSERT OR IGNOREs one row per registry
|
||||
entry so first-deploy behavior matches every existing handler constant
|
||||
exactly.
|
||||
`adapter_config` is the typed accessor singleton.
|
||||
`invalidate_cache()` drops the read-side cache (called by /api/adapter-config
|
||||
PUT in v0.6-3c).
|
||||
`seed_defaults(conn)` populates adapter_config + adapter_meta from REGISTRY.
|
||||
`prune_orphans(conn)` deletes adapter_config rows whose (adapter, key) is no
|
||||
longer in REGISTRY -- the safety net for trimming the registry between
|
||||
deploys. Every delete is logged at INFO level so docker logs carry a
|
||||
paper trail of which keys disappeared.
|
||||
|
||||
Both seed and prune are called from meshai.persistence.db.init_db() and are
|
||||
idempotent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -33,6 +43,7 @@ __all__ = [
|
|||
"adapter_config",
|
||||
"invalidate_cache",
|
||||
"seed_defaults",
|
||||
"prune_orphans",
|
||||
"REGISTRY",
|
||||
"ADAPTER_META",
|
||||
"all_adapters",
|
||||
|
|
@ -44,18 +55,16 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
def seed_defaults(conn: sqlite3.Connection) -> tuple[int, int]:
|
||||
"""Populate adapter_config + adapter_meta from the defaults registry.
|
||||
"""Populate adapter_config + adapter_meta from REGISTRY + ADAPTER_META.
|
||||
|
||||
Idempotent: INSERT OR IGNORE never overwrites a user-edited row. Run
|
||||
after the v6 migration creates the tables; safe to re-run on every
|
||||
init_db().
|
||||
Idempotent: INSERT OR IGNORE never overwrites a user-edited row. Safe
|
||||
to re-run on every init_db().
|
||||
|
||||
Returns:
|
||||
(config_rows_inserted, meta_rows_inserted) -- 0/0 when fully seeded.
|
||||
(config_rows_inserted, meta_rows_inserted)
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
# adapter_config rows.
|
||||
cfg_inserted = 0
|
||||
for (adapter, key), spec in REGISTRY.items():
|
||||
default_json = json.dumps(spec["default"])
|
||||
|
|
@ -69,7 +78,6 @@ def seed_defaults(conn: sqlite3.Connection) -> tuple[int, int]:
|
|||
if cur.rowcount > 0:
|
||||
cfg_inserted += 1
|
||||
|
||||
# adapter_meta rows.
|
||||
meta_inserted = 0
|
||||
for adapter, meta in ADAPTER_META.items():
|
||||
cur = conn.execute(
|
||||
|
|
@ -89,3 +97,48 @@ def seed_defaults(conn: sqlite3.Connection) -> tuple[int, int]:
|
|||
cfg_inserted, meta_inserted,
|
||||
)
|
||||
return cfg_inserted, meta_inserted
|
||||
|
||||
|
||||
def prune_orphans(conn: sqlite3.Connection) -> int:
|
||||
"""Delete adapter_config rows whose (adapter, key) is no longer in REGISTRY.
|
||||
|
||||
Each delete is logged at INFO level with the prefix
|
||||
'adapter_config orphan removed:' so the docker log captures a paper
|
||||
trail. First boot after a registry trim shows N log lines (one per
|
||||
removed key); every subsequent boot shows zero.
|
||||
|
||||
Idempotent. adapter_meta is intentionally NOT pruned -- meta rows are
|
||||
cheap and a previously-known adapter dropping all its config keys
|
||||
still wants the include_in_llm_context toggle preserved (e.g. itd_511
|
||||
after v0.6-3a.1).
|
||||
|
||||
Returns:
|
||||
Count of rows deleted.
|
||||
"""
|
||||
valid_keys = set(REGISTRY.keys())
|
||||
existing = conn.execute(
|
||||
"SELECT adapter, key, value_json FROM adapter_config"
|
||||
).fetchall()
|
||||
|
||||
removed = 0
|
||||
for r in existing:
|
||||
adapter = r["adapter"]
|
||||
key = r["key"]
|
||||
if (adapter, key) in valid_keys:
|
||||
continue
|
||||
logger.info(
|
||||
"adapter_config orphan removed: %s.%s = %s",
|
||||
adapter, key, r["value_json"],
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM adapter_config WHERE adapter=? AND key=?",
|
||||
(adapter, key),
|
||||
)
|
||||
removed += 1
|
||||
|
||||
if removed > 0:
|
||||
# The accessor cache may hold a now-deleted key. Invalidating
|
||||
# forces every next read to round-trip through the DB (cache
|
||||
# miss -> DB miss -> registry fallback / AttributeError).
|
||||
invalidate_cache()
|
||||
return removed
|
||||
|
|
|
|||
|
|
@ -1,75 +1,53 @@
|
|||
"""v0.6-3a single source-of-truth for adapter_config defaults.
|
||||
"""v0.6-3a.1 trimmed adapter_config defaults registry.
|
||||
|
||||
Every module-level magic-number constant flagged by the audit doc
|
||||
(/mnt/c/Users/mtthw/OneDrive/Documents/Claude/Projects/meshai/v0.6-phase1-audit.md)
|
||||
Section A.1 through A.12 lives here. The migration runs first (v6.sql
|
||||
creates the empty tables); init_db() then calls seed_defaults() which
|
||||
INSERT OR IGNOREs one row per (adapter, key) entry below, copying
|
||||
value_json = default_json.
|
||||
Per Matt's locked CONFIG-vs-CODE rule:
|
||||
|
||||
**Behavior contract**: every default below MUST match the current
|
||||
hardcoded value in the corresponding handler EXACTLY. v0.6-3b will
|
||||
swap the handler's constant for an `adapter_config.<adapter>.<key>`
|
||||
read. Until that wiring lands, this registry is the documentation of
|
||||
intent; nothing reads from it at runtime yet.
|
||||
CONFIG (lives here):
|
||||
where we send (channels), how often (cadences/schedules),
|
||||
thresholds (magnitude floors, severity gates, distance radius,
|
||||
cooldown durations, freshness windows), curation data (which
|
||||
sites/states/codes), toggles (enabled, include_in_llm_context,
|
||||
drop_zero_magnitude).
|
||||
|
||||
Adding a new tunable in the future:
|
||||
CODE (stays in the handlers; not surfaced to the GUI):
|
||||
sentence templates, emoji choices, mapping/translation functions
|
||||
(TomTom icon_map, ITD sub_type_map, Central adapter_map and
|
||||
category_map), rendering logic (anchor priority order,
|
||||
expires-buckets formatting, threshold-state labels), heuristic
|
||||
logic (band_conditions Kp/SFI -> Good/Fair/Poor function).
|
||||
|
||||
Trimmed from the v0.6-3a draft of 77 keys down to 43. The 34 dropped
|
||||
keys are removed from the live DB on first boot by prune_orphans(),
|
||||
which logs each delete at INFO level so docker logs carry a paper trail.
|
||||
|
||||
Adding a new tunable:
|
||||
1. Add an entry to REGISTRY below with default + type + description.
|
||||
2. The next container restart calls seed_defaults() which
|
||||
2. Confirm it matches the CONFIG rule (if you're tempted to add a
|
||||
sentence template, an emoji, or a translation map, STOP -- that's
|
||||
CODE).
|
||||
3. The next container restart calls seed_defaults() which
|
||||
INSERT OR IGNOREs the row.
|
||||
3. Wire the handler to read from adapter_config.<adapter>.<key>.
|
||||
4. Wire the handler to read from adapter_config.<adapter>.<key>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
# -------- REGISTRY --------------------------------------------------------
|
||||
#
|
||||
# Schema: REGISTRY[(adapter, key)] = {
|
||||
# "default": <python value>, # the seed value
|
||||
# "type": "int" | "float" | "str" | "bool" | "json",
|
||||
# "description": "...",
|
||||
# }
|
||||
#
|
||||
# `json` type covers list, dict, and nested structures. Decoded as
|
||||
# json.loads(value_json) by the accessor.
|
||||
#
|
||||
# Citations in comments point at the current source-of-truth in the
|
||||
# handler code -- these MUST stay in lock-step until v0.6-3b lands.
|
||||
|
||||
# REGISTRY[(adapter, key)] = {"default": ..., "type": ..., "description": ...}
|
||||
# Type vocabulary: "int" | "float" | "str" | "bool" | "json"
|
||||
REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
||||
|
||||
# =================================================================
|
||||
# WFIGS -- central/wfigs_handler.py
|
||||
# WFIGS -- 4 settings (cooldown, anchor radius, two re-broadcast toggles)
|
||||
# =================================================================
|
||||
("wfigs", "cooldown_seconds"): {
|
||||
"default": 28800, # central/wfigs_handler.py:43 (8*60*60)
|
||||
"default": 28800, # central/wfigs_handler.py:43
|
||||
"type": "int",
|
||||
"description": "Per-fire broadcast cooldown in seconds (forward-only Update gate).",
|
||||
},
|
||||
("wfigs", "prefix_new"): {
|
||||
"default": "New",
|
||||
"type": "str",
|
||||
"description": "Wire-string prefix for the first broadcast of a fire.",
|
||||
},
|
||||
("wfigs", "prefix_update"): {
|
||||
"default": "Update",
|
||||
"type": "str",
|
||||
"description": "Wire-string prefix for subsequent Update broadcasts.",
|
||||
},
|
||||
("wfigs", "emoji"): {
|
||||
"default": "🔥",
|
||||
"type": "str",
|
||||
"description": "Wire-string lead emoji.",
|
||||
},
|
||||
("wfigs", "anchor_priority"): {
|
||||
"default": ["geocoder_city", "nearest_town", "landclass", "county_state"],
|
||||
"type": "json",
|
||||
"description": "Ordered location-anchor fallback chain for the broadcast.",
|
||||
},
|
||||
("wfigs", "anchor_max_mi"): {
|
||||
"default": 100.0, # central/wfigs_handler.py:322 (nearest_town max_distance_mi)
|
||||
"default": 100.0, # central/wfigs_handler.py:322
|
||||
"type": "float",
|
||||
"description": "Max distance (mi) for the nearest_town anchor fallback.",
|
||||
},
|
||||
|
|
@ -85,7 +63,7 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
|||
},
|
||||
|
||||
# =================================================================
|
||||
# NWS -- central/nws_handler.py
|
||||
# NWS -- 3 settings (severity gate, tombstone msgTypes, suffix-promote toggle)
|
||||
# =================================================================
|
||||
("nws", "broadcast_severities"): {
|
||||
"default": ["Extreme", "Severe"], # nws_handler.py:43
|
||||
|
|
@ -102,43 +80,9 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
|||
"type": "bool",
|
||||
"description": "Promote category-name-ending-in-_warning to Severe when CAP severity is missing.",
|
||||
},
|
||||
("nws", "event_emoji_map"): {
|
||||
"default": [ # nws_handler.py:49-68 (ordered substring matches)
|
||||
["tornado", "🌪️"],
|
||||
["severe thunderstorm", "🌩️"],
|
||||
["thunderstorm", "🌩️"],
|
||||
["flash flood", "🌊"],
|
||||
["flood", "🌊"],
|
||||
["winter storm", "❄️"],
|
||||
["blizzard", "❄️"],
|
||||
["ice storm", "❄️"],
|
||||
["ice", "❄️"],
|
||||
["excessive heat", "🌡️"],
|
||||
["heat", "🌡️"],
|
||||
["high wind", "🌬️"],
|
||||
["wind", "🌬️"],
|
||||
["fire weather", "🔥"],
|
||||
["red flag", "🔥"],
|
||||
["air quality", "😷"],
|
||||
["freeze", "🥶"],
|
||||
["frost", "🥶"],
|
||||
],
|
||||
"type": "json",
|
||||
"description": "Ordered [substring, emoji] pairs; first case-insensitive substring match wins.",
|
||||
},
|
||||
("nws", "expires_short_thresholds_s"): {
|
||||
"default": [21600, 604800], # nws_handler.py:109-113 (6h, 7d)
|
||||
"type": "json",
|
||||
"description": "[under_threshold_s, weekly_threshold_s] for 'until <time>' rendering buckets.",
|
||||
},
|
||||
("nws", "default_emoji"): {
|
||||
"default": "⚠️", # nws_handler.py:86 fallback
|
||||
"type": "str",
|
||||
"description": "Wire emoji when no event_emoji_map substring matches.",
|
||||
},
|
||||
|
||||
# =================================================================
|
||||
# USGS_QUAKE -- central/quake_handler.py
|
||||
# USGS_QUAKE -- 6 settings (regional geography + 3 mag floors + PAGER set)
|
||||
# =================================================================
|
||||
("usgs_quake", "regional_centroid"): {
|
||||
"default": [44.36, -114.61], # quake_handler.py:36-37 (Idaho centroid)
|
||||
|
|
@ -168,26 +112,11 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
|||
("usgs_quake", "escalate_mag_floor"): {
|
||||
"default": 5.0, # quake_handler.py:76
|
||||
"type": "float",
|
||||
"description": "Magnitude floor for the visual ⚠️ escalation emoji.",
|
||||
},
|
||||
("usgs_quake", "emoji_routine"): {
|
||||
"default": "🌐",
|
||||
"type": "str",
|
||||
"description": "Wire emoji for non-escalated quakes.",
|
||||
},
|
||||
("usgs_quake", "emoji_escalate"): {
|
||||
"default": "⚠️",
|
||||
"type": "str",
|
||||
"description": "Wire emoji for quakes at or above escalate_mag_floor.",
|
||||
},
|
||||
("usgs_quake", "emoji_tsunami"): {
|
||||
"default": "🚨",
|
||||
"type": "str",
|
||||
"description": "Wire emoji for any tsunami_warning, overrides other emoji.",
|
||||
"description": "Magnitude floor for the visual escalation emoji.",
|
||||
},
|
||||
|
||||
# =================================================================
|
||||
# SWPC -- central/swpc_handler.py
|
||||
# SWPC -- 3 settings (three storm-tier broadcast floors)
|
||||
# =================================================================
|
||||
("swpc", "geomag_kp_floor"): {
|
||||
"default": 7.0, # swpc_handler.py:66-68 (Kp >= 7 = G3)
|
||||
|
|
@ -204,87 +133,38 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
|||
"type": "float",
|
||||
"description": "Proton flux floor in pfu (>=10 = S1 minor radiation storm).",
|
||||
},
|
||||
("swpc", "proton_energy_labels"): {
|
||||
"default": ["10", ">=10", ">10", ">=10 MeV", ">=10MeV",
|
||||
"30", ">=30", ">=30 MeV", ">=50 MeV",
|
||||
">=100 MeV", ">=100"], # swpc_handler.py:109-111
|
||||
"type": "json",
|
||||
"description": "Accepted energy-channel labels for proton flux validation.",
|
||||
},
|
||||
("swpc", "emoji_geomag"): {"default": "🌌", "type": "str", "description": "Geomag-storm wire emoji."},
|
||||
("swpc", "emoji_flare"): {"default": "🔆", "type": "str", "description": "Solar-flare wire emoji."},
|
||||
("swpc", "emoji_proton"): {"default": "☢️", "type": "str", "description": "Solar-radiation-storm wire emoji."},
|
||||
("swpc", "wire_geomag_template"): {
|
||||
"default": "{emoji} {label} geomagnetic storm ({code}/{scalar}) -- HF degraded, aurora possible",
|
||||
"type": "str",
|
||||
"description": "Format template for geomag broadcasts. Available fields: emoji, code, label, scalar.",
|
||||
},
|
||||
("swpc", "wire_flare_template"): {
|
||||
"default": "{emoji} Major solar flare ({code}/{scalar}) -- HF radio fading ~30 min, GPS may glitch",
|
||||
"type": "str",
|
||||
"description": "Format template for flare broadcasts.",
|
||||
},
|
||||
("swpc", "wire_proton_template"): {
|
||||
"default": "{emoji} Solar radiation storm ({code}/{scalar}) -- polar HF radio affected",
|
||||
"type": "str",
|
||||
"description": "Format template for proton broadcasts.",
|
||||
},
|
||||
|
||||
# =================================================================
|
||||
# USGS_NWIS -- central/nwis_handler.py + central/idaho_gauge_sites.py
|
||||
# (the 9-site curation table itself is commit #4 -- gauge_sites)
|
||||
# USGS_NWIS -- 2 settings (parameter-code curation + recede toggle)
|
||||
# =================================================================
|
||||
("usgs_nwis", "parameter_codes"): {
|
||||
"default": ["00060", "00065"], # nwis_handler.py:57
|
||||
"type": "json",
|
||||
"description": "USGS parameter codes the handler processes (00060=discharge, 00065=gage height).",
|
||||
},
|
||||
("usgs_nwis", "threshold_labels"): {
|
||||
"default": { # nwis_handler.py:60-65
|
||||
"action": "action stage",
|
||||
"flood_minor": "minor flooding",
|
||||
"flood_moderate": "moderate flooding",
|
||||
"flood_major": "major flooding",
|
||||
},
|
||||
"type": "json",
|
||||
"description": "Human-readable phrase per threshold_state band.",
|
||||
},
|
||||
("usgs_nwis", "emoji"): {
|
||||
"default": "🌊",
|
||||
"type": "str",
|
||||
"description": "Wire emoji.",
|
||||
},
|
||||
("usgs_nwis", "threshold_rank"): {
|
||||
"default": ["normal", "action", "flood_minor", "flood_moderate", "flood_major"], # idaho_gauge_sites.py:110
|
||||
"type": "json",
|
||||
"description": "Threshold rank from low to high (upward-crossing detector).",
|
||||
},
|
||||
("usgs_nwis", "broadcast_on_recede"): {
|
||||
"default": False, # nwis_handler.py:204-209 (silent on recede)
|
||||
"default": False, # nwis_handler.py:204-209
|
||||
"type": "bool",
|
||||
"description": "Broadcast when a gauge transitions DOWN through a threshold band.",
|
||||
},
|
||||
("usgs_nwis", "prefix_new"): {
|
||||
"default": "New",
|
||||
"type": "str",
|
||||
"description": "Wire prefix for the first upward-crossing broadcast.",
|
||||
},
|
||||
|
||||
# =================================================================
|
||||
# INCIDENT -- central/incident_handler.py (shared across 3 sources)
|
||||
# INCIDENT -- 2 settings (shared freshness gate + Update-after-New toggle)
|
||||
# =================================================================
|
||||
("incident", "freshness_seconds"): {
|
||||
"default": 1800, # incident_handler.py:49 + central_normalizer.py:917 default
|
||||
"default": 1800, # incident_handler.py:49 + central_normalizer.py:917
|
||||
"type": "int",
|
||||
"description": "Drop incidents older than this many seconds.",
|
||||
},
|
||||
("incident", "broadcast_on_update"): {
|
||||
"default": False, # incident_handler.py:594-602 (v0.5.9 REVISED: no Update)
|
||||
"default": False, # incident_handler.py:594-602 (v0.5.9 REVISED)
|
||||
"type": "bool",
|
||||
"description": "Re-broadcast on magnitude bump / delay growth / icon flip after first New.",
|
||||
},
|
||||
|
||||
# ---- TomTom-specific ----
|
||||
# =================================================================
|
||||
# TOMTOM_INCIDENTS -- 2 settings (per-source drop toggles)
|
||||
# =================================================================
|
||||
("tomtom_incidents", "drop_zero_magnitude"): {
|
||||
"default": True, # incident_handler.py:250
|
||||
"type": "bool",
|
||||
|
|
@ -295,147 +175,31 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
|||
"type": "bool",
|
||||
"description": "Drop envelopes whose time_validity != 'present'.",
|
||||
},
|
||||
("tomtom_incidents", "icon_map"): {
|
||||
"default": { # incident_handler.py:64-79 (int keys serialized as str via json)
|
||||
"0": "incident", "1": "accident", "2": "fog", "3": "danger",
|
||||
"4": "rain", "5": "ice", "6": "jam", "7": "lane_closed",
|
||||
"8": "road_closed", "9": "road_works", "10": "wind",
|
||||
"11": "flooding", "12": "broken_down", "14": "incident",
|
||||
},
|
||||
"type": "json",
|
||||
"description": "TomTom icon_category int -> canonical sub_type mapping.",
|
||||
},
|
||||
|
||||
# ---- state_511_atis specific ----
|
||||
# =================================================================
|
||||
# STATE_511_ATIS -- 1 setting (states to skip in favor of itd_511)
|
||||
# =================================================================
|
||||
("state_511_atis", "skipped_states"): {
|
||||
"default": ["ID"], # incident_handler.py:459-470 (v0.5.9 GAMMA cutover)
|
||||
"default": ["ID"], # incident_handler.py:459-470 (v0.5.9 GAMMA)
|
||||
"type": "json",
|
||||
"description": "States whose state_511_atis envelopes are silently skipped (handled by itd_511 instead).",
|
||||
},
|
||||
|
||||
# ---- itd_511 specific ----
|
||||
("itd_511", "sub_type_map"): {
|
||||
"default": { # incident_handler.py:83-115 (shared with state_511)
|
||||
"crash": "accident",
|
||||
"incident": "incident",
|
||||
"debrisOnRoadway": "debris",
|
||||
"disabledVehicle": "disabled_vehicle",
|
||||
"vehicleOnFire": "vehicle_on_fire",
|
||||
"wildfire": "incident",
|
||||
"wildfireInArea": "incident",
|
||||
"leftLaneBlocked": "lane_closed",
|
||||
"onRampBlocked": "ramp_closed",
|
||||
"roadwayBlocked": "road_closed",
|
||||
"roadConstruction": "road_works",
|
||||
"pavementMarkingOperations": "road_works",
|
||||
"pavementMarkingOperations ": "road_works",
|
||||
"utilityWork": "road_works",
|
||||
"singleLineTraffic:AlternatingDirections": "lane_closed",
|
||||
"roadMaintenanceOperations": "road_works",
|
||||
"pavingOperations": "road_works",
|
||||
"bridgeConstruction": "road_works",
|
||||
"bridgeMaintenanceOperations": "road_works",
|
||||
"flaggingOperation": "lane_closed",
|
||||
"brushControl": "road_works",
|
||||
"constructionWork": "road_works",
|
||||
"guardrailRepairs": "road_works",
|
||||
"workOnTheShoulder": "road_works",
|
||||
"nightTimeConstructionWork": "road_works",
|
||||
"bridgeInspectionWork": "road_works",
|
||||
"longTermRoadConstruction": "road_works",
|
||||
"workOnUndergroundServices": "road_works",
|
||||
"roadsideCleanupCrew": "road_works",
|
||||
"RampRestriction": "lane_closed",
|
||||
"parade": "parade",
|
||||
},
|
||||
"type": "json",
|
||||
"description": "ITD/state_511 event_sub_type -> canonical sub_type mapping.",
|
||||
},
|
||||
("itd_511", "sub_type_emoji"): {
|
||||
"default": { # incident_handler.py:118-139
|
||||
"accident": "🚨", "jam": "🚗", "road_closed": "🚫", "closure": "🚫",
|
||||
"road_works": "🚧", "lane_closed": "🟠", "ramp_closed": "🟠",
|
||||
"debris": "⚠️", "vehicle_on_fire": "🔥", "disabled_vehicle": "🛑",
|
||||
"ice": "⚠️", "fog": "⚠️", "flooding": "🌊", "wind": "🌬️",
|
||||
"broken_down": "🛞", "danger": "⚠️", "rain": "⚠️",
|
||||
"incident": "⚠️", "special_event": "🎪", "parade": "🎪",
|
||||
},
|
||||
"type": "json",
|
||||
"description": "Canonical sub_type -> emoji.",
|
||||
},
|
||||
("itd_511", "sub_type_phrase"): {
|
||||
"default": { # incident_handler.py:142-163
|
||||
"accident": "crash", "jam": "jam", "road_closed": "road closed",
|
||||
"closure": "closure", "road_works": "road works",
|
||||
"lane_closed": "lane closed", "ramp_closed": "ramp closed",
|
||||
"debris": "debris on roadway", "vehicle_on_fire": "vehicle fire",
|
||||
"disabled_vehicle": "disabled vehicle", "ice": "icy conditions",
|
||||
"fog": "fog", "flooding": "flooding", "wind": "high wind",
|
||||
"broken_down": "broken-down vehicle", "danger": "dangerous conditions",
|
||||
"rain": "heavy rain", "incident": "incident",
|
||||
"special_event": "special event", "parade": "parade",
|
||||
},
|
||||
"type": "json",
|
||||
"description": "Canonical sub_type -> human-readable noun phrase.",
|
||||
},
|
||||
# =================================================================
|
||||
# ITD_511 -- 0 settings (its maps/emoji/phrases are CODE, not config)
|
||||
# =================================================================
|
||||
|
||||
# =================================================================
|
||||
# CENTRAL consumer -- central/consumer.py
|
||||
# CENTRAL consumer -- 1 setting (severity-int bucket boundaries)
|
||||
# =================================================================
|
||||
("central", "adapter_map"): {
|
||||
"default": { # consumer.py:183-199 CENTRAL_ADAPTER_TO_SOURCE
|
||||
"wfigs_incidents": "fires",
|
||||
"wfigs_perimeters": "fires",
|
||||
"nwis": "usgs",
|
||||
"swpc_alerts": "swpc",
|
||||
"swpc_kindex": "swpc",
|
||||
"swpc_protons": "swpc",
|
||||
"wzdx": "traffic",
|
||||
"tomtom_incidents": "traffic",
|
||||
"state_511_atis": "roads511",
|
||||
"itd_511": "roads511",
|
||||
"firms": "firms",
|
||||
},
|
||||
"type": "json",
|
||||
"description": "Central adapter name -> meshai source name remap.",
|
||||
},
|
||||
("central", "category_map"): {
|
||||
"default": [ # consumer.py:203-228 ordered prefix -> flat
|
||||
["wx.alert", "weather_warning"],
|
||||
["wx.", "weather_statement"],
|
||||
["fire.hotspot", "wildfire_hotspot"],
|
||||
["fire.incident", "wildfire_incident"],
|
||||
["fire.perimeter", "wildfire_incident"],
|
||||
["fire.", "wildfire_incident"],
|
||||
["quake.", "earthquake_event"],
|
||||
["hydro.", "stream_flow"],
|
||||
["space.alert", "rf_propagation_alert"],
|
||||
["space.kindex", "geomagnetic_storm"],
|
||||
["space.proton", "solar_radiation_storm"],
|
||||
["space.", "geomagnetic_storm"],
|
||||
["disaster.", "disaster_event"],
|
||||
["traffic_flow", "traffic_flow"],
|
||||
["traffic_cameras", "traffic_camera"],
|
||||
["work_zone", "work_zone"],
|
||||
["incident", "road_incident"],
|
||||
["closure", "road_closure"],
|
||||
["traffic.", "traffic_congestion"],
|
||||
],
|
||||
"type": "json",
|
||||
"description": "Ordered [prefix, flat_category] pairs; first prefix match wins.",
|
||||
},
|
||||
("central", "severity_thresholds"): {
|
||||
"default": { # consumer.py:264-288 (map_severity)
|
||||
"routine_max": 1,
|
||||
"priority_max": 2,
|
||||
"immediate_min": 3,
|
||||
},
|
||||
"default": {"routine_max": 1, "priority_max": 2, "immediate_min": 3},
|
||||
"type": "json",
|
||||
"description": "Central int severity buckets: 0..routine_max -> routine, priority_max -> priority, >= immediate_min -> immediate.",
|
||||
},
|
||||
|
||||
# =================================================================
|
||||
# DISPATCHER -- notifications/pipeline/dispatcher.py
|
||||
# DISPATCHER -- 4 settings (LRU cap + cooldown prune params + retention)
|
||||
# =================================================================
|
||||
("dispatcher", "dedup_lru_max"): {
|
||||
"default": 10000, # pipeline/dispatcher.py:28
|
||||
|
|
@ -443,129 +207,68 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
|||
"description": "In-memory dedup OrderedDict cap. Disk has a 7-day window which may exceed this.",
|
||||
},
|
||||
("dispatcher", "cooldown_prune_size"): {
|
||||
"default": 1024, # pipeline/dispatcher.py:_COOLDOWN_INMEM_PRUNE_THRESHOLD
|
||||
"default": 1024, # _COOLDOWN_INMEM_PRUNE_THRESHOLD
|
||||
"type": "int",
|
||||
"description": "In-memory cooldown map size that triggers a 2*cooldown_s prune.",
|
||||
},
|
||||
("dispatcher", "cooldown_prune_multiplier"): {
|
||||
"default": 2, # pipeline/dispatcher.py:184 (2 * cooldown_s)
|
||||
"default": 2, # pipeline/dispatcher.py:184 (2*cooldown_s)
|
||||
"type": "int",
|
||||
"description": "Cooldown-prune cutoff multiplier (rows older than N * cooldown_s deleted).",
|
||||
"description": "Cooldown-prune cutoff multiplier (rows older than N*cooldown_s deleted).",
|
||||
},
|
||||
("dispatcher", "dedup_db_retention_days"): {
|
||||
"default": 7, # pipeline/dispatcher.py:_DEDUP_DB_RETENTION_S = 7*86400
|
||||
"default": 7, # _DEDUP_DB_RETENTION_S
|
||||
"type": "int",
|
||||
"description": "Days a (source, event_id) dedup row stays on disk before the on-insert cleanup deletes it.",
|
||||
},
|
||||
|
||||
# =================================================================
|
||||
# BAND_CONDITIONS -- notifications/scheduled/band_conditions.py
|
||||
# BAND_CONDITIONS -- 3 settings (SWPC freshness + HamQSL endpoint config)
|
||||
# (schedule, tz, enabled stay in YAML config.notifications.band_conditions_*)
|
||||
# =================================================================
|
||||
("band_conditions", "swpc_freshness_seconds"): {
|
||||
"default": 21600, # band_conditions.py:45 (_SWPC_FRESHNESS_S)
|
||||
"default": 21600, # band_conditions.py:45
|
||||
"type": "int",
|
||||
"description": "If swpc_events readings older than this, fall through to HamQSL.",
|
||||
},
|
||||
("band_conditions", "slot_labels"): {
|
||||
"default": { # band_conditions.py:54-58
|
||||
"06:00": ["☀️", "Day Propagation"],
|
||||
"14:00": ["🌞", "Day Propagation"],
|
||||
"22:00": ["🌙", "Night Propagation"],
|
||||
},
|
||||
"type": "json",
|
||||
"description": "Per-slot [emoji, headline] -- chosen by SLOT time, not actual fire time.",
|
||||
},
|
||||
("band_conditions", "band_order"): {
|
||||
"default": ["80-40m", "30-20m", "17-15m", "12-10m"], # band_conditions.py:62
|
||||
"type": "json",
|
||||
"description": "Wire-string band-row order. Matches HamQSL groupings.",
|
||||
},
|
||||
("band_conditions", "hamqsl_url"): {
|
||||
"default": "https://www.hamqsl.com/solarxml.php", # band_conditions.py:65
|
||||
"default": "https://www.hamqsl.com/solarxml.php",
|
||||
"type": "str",
|
||||
"description": "HamQSL solarxml fallback URL (public, no auth).",
|
||||
"description": "HamQSL solarxml fallback URL.",
|
||||
},
|
||||
("band_conditions", "hamqsl_timeout_s"): {
|
||||
"default": 5, # band_conditions.py:66
|
||||
"default": 5,
|
||||
"type": "int",
|
||||
"description": "HamQSL fetch timeout.",
|
||||
},
|
||||
("band_conditions", "rating_emoji"): {
|
||||
"default": { # band_conditions.py:49
|
||||
"Good": "🟢", "Fair": "🟡", "Poor": "🔴",
|
||||
},
|
||||
"type": "json",
|
||||
"description": "Per-rating colour emoji.",
|
||||
},
|
||||
# Heuristic threshold blob -- one document so the GUI can render it as
|
||||
# a structured editor in v0.6-3c.
|
||||
("band_conditions", "heuristic"): {
|
||||
"default": { # band_conditions.py:194-240 (_heuristic_ratings)
|
||||
# All thresholds map to Good / Fair / Poor.
|
||||
# Format: {band: {day_or_night: {rule_name: value}}}
|
||||
"80-40m": {
|
||||
"night": {"good_kp_lt": 4, "good_sfi_gt": 70, "fair_kp_lt": 5},
|
||||
"day": {"fair_kp_lt": 4},
|
||||
},
|
||||
"30-20m": {
|
||||
"day": {"good_sfi_gt": 120, "good_kp_lt": 4,
|
||||
"fair_sfi_min": 80, "fair_sfi_max": 120, "fair_kp_lt": 6,
|
||||
"poor_sfi_lt": 80, "poor_kp_min": 6},
|
||||
"night": {"good_sfi_gt": 110, "good_kp_lt": 4, "fair_kp_lt": 5},
|
||||
},
|
||||
"17-15m": {
|
||||
"day": {"good_sfi_gt": 120, "fair_sfi_min": 90, "fair_sfi_max": 120, "fair_kp_lt": 5},
|
||||
},
|
||||
"12-10m": {
|
||||
"day": {"good_sfi_gt": 140, "fair_sfi_min": 110, "fair_sfi_max": 140},
|
||||
},
|
||||
},
|
||||
"type": "json",
|
||||
"description": "Per-band day/night Good/Fair/Poor thresholds. Edit by hand only.",
|
||||
},
|
||||
("band_conditions", "fallback_kp"): {
|
||||
"default": 9.0, # band_conditions.py:196 (state default when sfi missing)
|
||||
"type": "float",
|
||||
"description": "Default Kp when none persisted (worst-case).",
|
||||
},
|
||||
("band_conditions", "fallback_sfi"): {
|
||||
"default": 50.0, # band_conditions.py:197 (state default)
|
||||
"type": "float",
|
||||
"description": "Default SFI when none persisted (worst-case).",
|
||||
},
|
||||
|
||||
# =================================================================
|
||||
# GEOCODER -- central_normalizer.py (Photon section)
|
||||
# GEOCODER -- 6 settings (Photon endpoint + curation + cache size)
|
||||
# =================================================================
|
||||
("geocoder", "photon_url"): {
|
||||
"default": "http://100.64.0.24:2322", # central_normalizer.py:282
|
||||
"default": "http://100.64.0.24:2322",
|
||||
"type": "str",
|
||||
"description": "Photon base URL (Tailscale-internal Echo6 instance).",
|
||||
},
|
||||
("geocoder", "photon_timeout_s"): {
|
||||
"default": 2.0, # central_normalizer.py:284
|
||||
"default": 2.0,
|
||||
"type": "float",
|
||||
"description": "Photon HTTP timeout.",
|
||||
},
|
||||
("geocoder", "photon_radius_km"): {
|
||||
"default": 80, # central_normalizer.py:285
|
||||
"default": 80,
|
||||
"type": "int",
|
||||
"description": "Photon /reverse search radius (~50 mi default).",
|
||||
},
|
||||
("geocoder", "photon_limit"): {
|
||||
"default": 10, # central_normalizer.py:286
|
||||
"default": 10,
|
||||
"type": "int",
|
||||
"description": "Photon /reverse max features per call.",
|
||||
},
|
||||
("geocoder", "town_osm_values"): {
|
||||
"default": ["city", "town", "village", "hamlet", "suburb", "locality"], # central_normalizer.py:289
|
||||
"default": ["city", "town", "village", "hamlet", "suburb", "locality"],
|
||||
"type": "json",
|
||||
"description": "OSM place classes we accept as 'town' for nearest_town.",
|
||||
},
|
||||
("geocoder", "h3_resolution"): {
|
||||
"default": 7, # central_normalizer.py:296
|
||||
"type": "int",
|
||||
"description": "H3 resolution for the Photon-cache hex grid (~5 km cells).",
|
||||
"description": "OSM place classes that count as a town for the nearest_town anchor.",
|
||||
},
|
||||
("geocoder", "h3_cache_max"): {
|
||||
"default": 10000, # central_normalizer.py:297
|
||||
|
|
@ -574,7 +277,8 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
|||
},
|
||||
|
||||
# =================================================================
|
||||
# FIRMS -- central/firms_handler.py (v0.6-1)
|
||||
# FIRMS -- 4 settings (confidence floor + FRP floor + spatial bbox +
|
||||
# dedup quantization distance in METERS)
|
||||
# =================================================================
|
||||
("firms", "confidence_floor"): {
|
||||
"default": "low", # firms_handler.py FIRMS_CONFIDENCE_FLOOR
|
||||
|
|
@ -591,22 +295,28 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
|||
"type": "json",
|
||||
"description": "Optional [min_lat, min_lon, max_lat, max_lon] spatial filter (null = no filter).",
|
||||
},
|
||||
("firms", "dedup_lat_lon_decimals"): {
|
||||
"default": 5, # firms_handler.py _DEDUP_LAT_LON_DECIMALS
|
||||
("firms", "dedup_distance_m"): {
|
||||
# v0.6-3a.1 (Matt's call): user-facing unit is METERS, not decimal
|
||||
# places. firms_handler internally translates this to a lat/lon
|
||||
# quantization step (1 deg ~ 111 km so step_deg = m / 111_000).
|
||||
# Default 5m is slightly coarser than the v0.6-1 implementation's
|
||||
# 1.1m (round(.,5)) -- the actual wire-up + index update lands in
|
||||
# v0.6-3b (firms handler wiring step).
|
||||
"default": 5,
|
||||
"type": "int",
|
||||
"description": "Decimal places for lat/lon dedup-key rounding (5 = ~1.1 m).",
|
||||
"description": "Distance in meters within which two FIRMS pixel observations from the same satellite + acquisition time are considered duplicates.",
|
||||
},
|
||||
|
||||
# =================================================================
|
||||
# PIPELINE (inhibitor + grouper -- audit doc A.9)
|
||||
# PIPELINE (Inhibitor + Grouper) -- 2 settings
|
||||
# =================================================================
|
||||
("pipeline", "inhibitor_ttl_seconds"): {
|
||||
"default": 1800, # pipeline/inhibitor.py:27 constructor default, never config-driven
|
||||
"default": 1800, # pipeline/inhibitor.py:27 default
|
||||
"type": "int",
|
||||
"description": "How long an inhibit_key remains active after the originating event.",
|
||||
},
|
||||
("pipeline", "grouper_window_seconds"): {
|
||||
"default": 60, # pipeline/grouper.py:27 constructor default
|
||||
"default": 60, # pipeline/grouper.py:27 default
|
||||
"type": "int",
|
||||
"description": "How long to hold a group_key before emitting downstream.",
|
||||
},
|
||||
|
|
@ -615,10 +325,11 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
|
|||
|
||||
# -------- ADAPTER_META ----------------------------------------------------
|
||||
#
|
||||
# One row per adapter the GUI surfaces. include_in_llm_context controls
|
||||
# whether the env_reporter (commit #5) is allowed to read this adapter's
|
||||
# tables when assembling LLM context for a DM. Defaults to True per
|
||||
# Matt's locked refinement.
|
||||
# Per-adapter metadata. One row per adapter the GUI surfaces; the row
|
||||
# survives even when an adapter has zero config keys, because the
|
||||
# include_in_llm_context toggle is still meaningful (the user wants the
|
||||
# LLM to be able to see traffic_events from itd_511 even though all of
|
||||
# its render-side stuff is now CODE).
|
||||
|
||||
ADAPTER_META: dict[str, dict[str, Any]] = {
|
||||
"wfigs": {
|
||||
|
|
@ -674,7 +385,7 @@ ADAPTER_META: dict[str, dict[str, Any]] = {
|
|||
"central": {
|
||||
"display_name": "Central consumer routing",
|
||||
"include_in_llm_context": False,
|
||||
"description": "Adapter <-> source remap, hierarchical category map, severity buckets. Operational, not LLM-relevant.",
|
||||
"description": "Adapter <-> source remap + severity buckets. Operational, not LLM-relevant.",
|
||||
},
|
||||
"dispatcher": {
|
||||
"display_name": "Dispatcher state",
|
||||
|
|
@ -682,9 +393,9 @@ ADAPTER_META: dict[str, dict[str, Any]] = {
|
|||
"description": "Cold-start anchor, cumulative drop counters, cooldown + dedup state. Useful for 'why did we drop X?' answers.",
|
||||
},
|
||||
"geocoder": {
|
||||
"display_name": "Geocoder (Photon + H3)",
|
||||
"display_name": "Geocoder (Photon)",
|
||||
"include_in_llm_context": False,
|
||||
"description": "Photon-reverse + H3 cache settings. Operational, not LLM-relevant.",
|
||||
"description": "Photon-reverse settings + town-class curation. Operational, not LLM-relevant.",
|
||||
},
|
||||
"incident": {
|
||||
"display_name": "Incident pipeline (shared settings)",
|
||||
|
|
|
|||
|
|
@ -129,10 +129,11 @@ def init_db(path: Optional[str] = None) -> sqlite3.Connection:
|
|||
INSERT OR IGNORE so a re-run is a clean no-op."""
|
||||
conn = get_db(path)
|
||||
try:
|
||||
from meshai.adapter_config import seed_defaults
|
||||
from meshai.adapter_config import seed_defaults, prune_orphans
|
||||
seed_defaults(conn)
|
||||
prune_orphans(conn)
|
||||
except Exception:
|
||||
logger.exception("init_db: adapter_config seed_defaults failed")
|
||||
logger.exception("init_db: adapter_config seed/prune failed")
|
||||
return conn
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
"""v0.6-3a foundation tests: migration, seed, accessor.
|
||||
"""v0.6-3a foundation tests: migration, seed, accessor, orphan prune.
|
||||
|
||||
No handler is wired to adapter_config in 3a (that lands in 3b). These
|
||||
tests verify the foundation in isolation:
|
||||
- v6 migration creates adapter_config + adapter_meta
|
||||
- seed_defaults seeds every REGISTRY + ADAPTER_META row
|
||||
- seed_defaults is idempotent (re-run is a no-op)
|
||||
- accessor returns typed values for int / float / str / bool / json
|
||||
- cache hit: second read does not re-hit the DB
|
||||
- invalidate_cache forces reload
|
||||
- registry fallback when the DB row is missing (defensive)
|
||||
- unknown key raises AttributeError
|
||||
v0.6-3a.1: trimmed registry to 43 keys per Matt's CONFIG-vs-CODE rule.
|
||||
prune_orphans cleans up rows that were in the v0.6-3a draft but no longer
|
||||
in the trimmed REGISTRY. Tests now assert the 43-key count and exercise
|
||||
the prune path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -22,6 +17,7 @@ from meshai.adapter_config import (
|
|||
adapter_config,
|
||||
invalidate_cache,
|
||||
seed_defaults,
|
||||
prune_orphans,
|
||||
REGISTRY,
|
||||
ADAPTER_META,
|
||||
)
|
||||
|
|
@ -35,11 +31,6 @@ from meshai.persistence import db as persistence_db
|
|||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
"""Forces a fresh DB AND clears the in-memory accessor cache.
|
||||
|
||||
The conftest autouse fixture also redirects MESHAI_DB_PATH, but
|
||||
explicit cleanup here makes the tests robust to ordering.
|
||||
"""
|
||||
p = str(tmp_path / "ac-test.sqlite")
|
||||
monkeypatch.setenv("MESHAI_DB_PATH", p)
|
||||
persistence_db._initialised.clear()
|
||||
|
|
@ -71,30 +62,39 @@ def test_schema_meta_at_v6(fresh_db):
|
|||
|
||||
|
||||
def test_adapter_config_type_check_constrains_vocabulary(fresh_db):
|
||||
"""The CHECK constraint on `type` rejects unknown labels."""
|
||||
with pytest.raises(Exception):
|
||||
fresh_db.execute(
|
||||
"INSERT INTO adapter_config(adapter, key, value_json, default_json, "
|
||||
"type, description, updated_at) VALUES (?,?,?,?,?,?,?)",
|
||||
("x", "y", "1", "1", "integer", "", 0.0), # 'integer' is not in vocab
|
||||
("x", "y", "1", "1", "integer", "", 0.0),
|
||||
)
|
||||
|
||||
|
||||
# ---------- registry shape -----------------------------------------------
|
||||
|
||||
|
||||
def test_registry_at_43_entries():
|
||||
"""v0.6-3a.1 trim: 43 CONFIG-only keys (was 77 in v0.6-3a draft)."""
|
||||
assert len(REGISTRY) == 43, (
|
||||
f"REGISTRY should have 43 entries after CONFIG-vs-CODE trim; got {len(REGISTRY)}. "
|
||||
f"If a sentence template / emoji / heuristic snuck in, it belongs in CODE not config."
|
||||
)
|
||||
|
||||
|
||||
def test_adapter_meta_at_15(fresh_db):
|
||||
assert len(ADAPTER_META) == 15
|
||||
|
||||
|
||||
# ---------- seed ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_seed_populates_every_registry_row(fresh_db):
|
||||
"""Every (adapter, key) in REGISTRY lands in adapter_config."""
|
||||
rows = fresh_db.execute("SELECT adapter, key FROM adapter_config").fetchall()
|
||||
db_keys = {(r["adapter"], r["key"]) for r in rows}
|
||||
assert db_keys == set(REGISTRY.keys()), (
|
||||
f"DB missing {set(REGISTRY) - db_keys}, "
|
||||
f"extra in DB {db_keys - set(REGISTRY)}"
|
||||
)
|
||||
assert db_keys == set(REGISTRY.keys())
|
||||
|
||||
|
||||
def test_seed_value_matches_registry_default(fresh_db):
|
||||
"""value_json == default_json on first seed; both = json.dumps(default)."""
|
||||
for (adapter, key), spec in REGISTRY.items():
|
||||
row = fresh_db.execute(
|
||||
"SELECT value_json, default_json, type FROM adapter_config "
|
||||
|
|
@ -111,25 +111,14 @@ def test_seed_populates_every_adapter_meta_row(fresh_db):
|
|||
rows = fresh_db.execute("SELECT adapter, include_in_llm_context FROM adapter_meta").fetchall()
|
||||
db_adapters = {r["adapter"] for r in rows}
|
||||
assert db_adapters == set(ADAPTER_META.keys())
|
||||
for r in rows:
|
||||
spec = ADAPTER_META[r["adapter"]]
|
||||
expected = 1 if spec.get("include_in_llm_context", True) else 0
|
||||
assert r["include_in_llm_context"] == expected
|
||||
|
||||
|
||||
def test_seed_is_idempotent(fresh_db):
|
||||
"""Re-running seed_defaults inserts zero new rows."""
|
||||
a, b = seed_defaults(fresh_db)
|
||||
assert a == 0 and b == 0
|
||||
# Row counts unchanged.
|
||||
cfg = fresh_db.execute("SELECT COUNT(*) FROM adapter_config").fetchone()[0]
|
||||
meta = fresh_db.execute("SELECT COUNT(*) FROM adapter_meta").fetchone()[0]
|
||||
assert cfg == len(REGISTRY)
|
||||
assert meta == len(ADAPTER_META)
|
||||
|
||||
|
||||
def test_seed_does_not_overwrite_user_edits(fresh_db):
|
||||
"""A user-edited value_json survives a re-seed (INSERT OR IGNORE)."""
|
||||
fresh_db.execute(
|
||||
"UPDATE adapter_config SET value_json=? WHERE adapter=? AND key=?",
|
||||
("999", "wfigs", "cooldown_seconds"),
|
||||
|
|
@ -139,85 +128,145 @@ def test_seed_does_not_overwrite_user_edits(fresh_db):
|
|||
"SELECT value_json FROM adapter_config "
|
||||
"WHERE adapter='wfigs' AND key='cooldown_seconds'"
|
||||
).fetchone()
|
||||
assert row["value_json"] == "999", "seed must not overwrite user edits"
|
||||
assert row["value_json"] == "999"
|
||||
|
||||
|
||||
# ---------- accessor -----------------------------------------------------
|
||||
# ---------- prune_orphans -------------------------------------------------
|
||||
|
||||
|
||||
def test_prune_orphans_removes_unknown_keys(fresh_db, caplog):
|
||||
"""A row whose (adapter, key) is no longer in REGISTRY is deleted on
|
||||
the next prune_orphans, and the delete is logged at INFO."""
|
||||
fresh_db.execute(
|
||||
"INSERT INTO adapter_config(adapter, key, value_json, default_json, "
|
||||
"type, description, updated_at) VALUES (?,?,?,?,?,?,?)",
|
||||
("wfigs", "deprecated_legacy_key", "\"old\"", "\"old\"",
|
||||
"str", "", 0.0),
|
||||
)
|
||||
caplog.set_level(logging.INFO, logger="meshai.adapter_config")
|
||||
removed = prune_orphans(fresh_db)
|
||||
assert removed == 1
|
||||
msgs = [r.getMessage() for r in caplog.records
|
||||
if r.name.startswith("meshai.adapter_config")]
|
||||
assert any(
|
||||
"adapter_config orphan removed: wfigs.deprecated_legacy_key" in m
|
||||
for m in msgs
|
||||
), f"expected orphan-removed log line; got: {msgs}"
|
||||
# Row gone.
|
||||
assert fresh_db.execute(
|
||||
"SELECT 1 FROM adapter_config WHERE adapter=? AND key=?",
|
||||
("wfigs", "deprecated_legacy_key"),
|
||||
).fetchone() is None
|
||||
|
||||
|
||||
def test_prune_orphans_idempotent(fresh_db):
|
||||
assert prune_orphans(fresh_db) == 0
|
||||
assert prune_orphans(fresh_db) == 0
|
||||
|
||||
|
||||
def test_prune_orphans_does_not_touch_known_keys(fresh_db):
|
||||
"""Every REGISTRY row survives the prune."""
|
||||
before = {(r["adapter"], r["key"]) for r in fresh_db.execute(
|
||||
"SELECT adapter, key FROM adapter_config"
|
||||
).fetchall()}
|
||||
prune_orphans(fresh_db)
|
||||
after = {(r["adapter"], r["key"]) for r in fresh_db.execute(
|
||||
"SELECT adapter, key FROM adapter_config"
|
||||
).fetchall()}
|
||||
assert before == after == set(REGISTRY.keys())
|
||||
|
||||
|
||||
def test_prune_orphans_does_not_touch_adapter_meta(fresh_db):
|
||||
"""A previously-known adapter whose config keys all moved to CODE
|
||||
keeps its adapter_meta row (for the include_in_llm_context toggle)."""
|
||||
before = fresh_db.execute("SELECT COUNT(*) FROM adapter_meta").fetchone()[0]
|
||||
prune_orphans(fresh_db)
|
||||
after = fresh_db.execute("SELECT COUNT(*) FROM adapter_meta").fetchone()[0]
|
||||
assert before == after == len(ADAPTER_META)
|
||||
|
||||
|
||||
def test_prune_orphans_invalidates_cache(fresh_db):
|
||||
"""If a key disappears, any cached read of it should NOT linger."""
|
||||
invalidate_cache()
|
||||
# Prime cache with a key that will become orphan.
|
||||
fresh_db.execute(
|
||||
"INSERT INTO adapter_config(adapter, key, value_json, default_json, "
|
||||
"type, description, updated_at) VALUES (?,?,?,?,?,?,?)",
|
||||
("wfigs", "ghost", "42", "42", "int", "", 0.0),
|
||||
)
|
||||
# We can't read it via accessor (not in REGISTRY -> no fallback) so
|
||||
# we just verify the cache is empty after prune.
|
||||
prune_orphans(fresh_db)
|
||||
assert accessor_mod._cache == {}, "cache should be cleared after orphan prune"
|
||||
|
||||
|
||||
# ---------- accessor ------------------------------------------------------
|
||||
|
||||
|
||||
def test_accessor_returns_int(fresh_db):
|
||||
invalidate_cache()
|
||||
v = adapter_config.wfigs.cooldown_seconds
|
||||
assert isinstance(v, int)
|
||||
assert v == 28800
|
||||
assert adapter_config.wfigs.cooldown_seconds == 28800
|
||||
|
||||
|
||||
def test_accessor_returns_float(fresh_db):
|
||||
invalidate_cache()
|
||||
v = adapter_config.usgs_quake.global_mag_floor
|
||||
assert isinstance(v, float)
|
||||
assert v == 3.0
|
||||
assert adapter_config.usgs_quake.global_mag_floor == 3.0
|
||||
|
||||
|
||||
def test_accessor_returns_str(fresh_db):
|
||||
invalidate_cache()
|
||||
v = adapter_config.geocoder.photon_url
|
||||
assert isinstance(v, str)
|
||||
assert v == "http://100.64.0.24:2322"
|
||||
assert adapter_config.geocoder.photon_url == "http://100.64.0.24:2322"
|
||||
|
||||
|
||||
def test_accessor_returns_bool(fresh_db):
|
||||
invalidate_cache()
|
||||
v = adapter_config.tomtom_incidents.drop_zero_magnitude
|
||||
assert isinstance(v, bool)
|
||||
assert v is True
|
||||
assert adapter_config.tomtom_incidents.drop_zero_magnitude is True
|
||||
|
||||
|
||||
def test_accessor_returns_json_list(fresh_db):
|
||||
invalidate_cache()
|
||||
v = adapter_config.nws.broadcast_severities
|
||||
assert v == ["Extreme", "Severe"]
|
||||
assert adapter_config.nws.broadcast_severities == ["Extreme", "Severe"]
|
||||
|
||||
|
||||
def test_accessor_returns_json_dict(fresh_db):
|
||||
invalidate_cache()
|
||||
v = adapter_config.usgs_nwis.threshold_labels
|
||||
assert v["action"] == "action stage"
|
||||
v = adapter_config.central.severity_thresholds
|
||||
assert v == {"routine_max": 1, "priority_max": 2, "immediate_min": 3}
|
||||
|
||||
|
||||
def test_accessor_returns_json_none(fresh_db):
|
||||
"""A registry default of None survives the round-trip."""
|
||||
invalidate_cache()
|
||||
v = adapter_config.firms.bbox
|
||||
assert v is None
|
||||
assert adapter_config.firms.bbox is None
|
||||
|
||||
|
||||
def test_firms_dedup_distance_m_default(fresh_db):
|
||||
"""v0.6-3a.1 Matt's call: user-facing unit is meters, default 5."""
|
||||
invalidate_cache()
|
||||
v = adapter_config.firms.dedup_distance_m
|
||||
assert isinstance(v, int)
|
||||
assert v == 5
|
||||
|
||||
|
||||
# ---------- cache --------------------------------------------------------
|
||||
|
||||
|
||||
def test_cache_hits_second_read(fresh_db):
|
||||
"""Second read of the same key bypasses the DB."""
|
||||
invalidate_cache()
|
||||
# Prime the cache.
|
||||
_ = adapter_config.wfigs.cooldown_seconds
|
||||
# Patch _load_from_db to fail; if the cache works we never call it.
|
||||
with patch.object(accessor_mod, "_load_from_db",
|
||||
side_effect=AssertionError("cache miss -- went to DB")):
|
||||
side_effect=AssertionError("cache miss")):
|
||||
v = adapter_config.wfigs.cooldown_seconds
|
||||
assert v == 28800
|
||||
|
||||
|
||||
def test_invalidate_forces_reload(fresh_db):
|
||||
"""After invalidate_cache the next read hits the DB and sees fresh values."""
|
||||
invalidate_cache()
|
||||
_ = adapter_config.wfigs.cooldown_seconds # prime cache
|
||||
|
||||
_ = adapter_config.wfigs.cooldown_seconds
|
||||
fresh_db.execute(
|
||||
"UPDATE adapter_config SET value_json=? WHERE adapter='wfigs' AND key='cooldown_seconds'",
|
||||
("3600",),
|
||||
)
|
||||
# Without invalidation, the cached 28800 is returned.
|
||||
assert adapter_config.wfigs.cooldown_seconds == 28800
|
||||
assert adapter_config.wfigs.cooldown_seconds == 28800 # still cached
|
||||
invalidate_cache()
|
||||
assert adapter_config.wfigs.cooldown_seconds == 3600
|
||||
|
||||
|
|
@ -226,17 +275,13 @@ def test_invalidate_forces_reload(fresh_db):
|
|||
|
||||
|
||||
def test_registry_fallback_when_db_row_missing(fresh_db, caplog):
|
||||
"""If the seed somehow missed a key, the accessor still returns the
|
||||
registry default (with a WARNING)."""
|
||||
invalidate_cache()
|
||||
# Delete one row to simulate the missing-seed case.
|
||||
fresh_db.execute(
|
||||
"DELETE FROM adapter_config WHERE adapter='wfigs' AND key='cooldown_seconds'"
|
||||
)
|
||||
import logging
|
||||
caplog.set_level(logging.WARNING, logger="meshai.adapter_config._accessor")
|
||||
v = adapter_config.wfigs.cooldown_seconds
|
||||
assert v == 28800 # from REGISTRY
|
||||
assert v == 28800
|
||||
assert any("missing from DB" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
|
|
@ -246,18 +291,7 @@ def test_unknown_key_raises(fresh_db):
|
|||
_ = adapter_config.wfigs.no_such_key
|
||||
|
||||
|
||||
def test_unknown_adapter_section_still_works_until_key_lookup(fresh_db):
|
||||
"""Accessing adapter_config.<unknown> returns a section object;
|
||||
the AttributeError comes on the key dereference."""
|
||||
invalidate_cache()
|
||||
section = adapter_config.nonexistent_adapter
|
||||
assert section is not None # section object exists
|
||||
with pytest.raises(AttributeError):
|
||||
_ = section.any_key
|
||||
|
||||
|
||||
def test_setattr_blocked(fresh_db):
|
||||
"""Writes via the accessor are forbidden -- use the API."""
|
||||
invalidate_cache()
|
||||
with pytest.raises(AttributeError):
|
||||
adapter_config.wfigs.cooldown_seconds = 999
|
||||
|
|
@ -267,7 +301,6 @@ def test_setattr_blocked(fresh_db):
|
|||
|
||||
|
||||
def test_every_registry_default_round_trips_through_json():
|
||||
"""Every default must json.dumps + json.loads to an equivalent value."""
|
||||
for (adapter, key), spec in REGISTRY.items():
|
||||
encoded = json.dumps(spec["default"])
|
||||
decoded = json.loads(encoded)
|
||||
|
|
@ -281,9 +314,34 @@ def test_every_registry_type_is_in_vocabulary():
|
|||
|
||||
|
||||
def test_adapter_meta_includes_every_registry_adapter():
|
||||
"""Every adapter referenced in REGISTRY should have a meta row so the
|
||||
GUI can render an include_in_llm_context toggle for it."""
|
||||
reg_adapters = {a for a, _ in REGISTRY}
|
||||
meta_adapters = set(ADAPTER_META)
|
||||
missing = reg_adapters - meta_adapters
|
||||
assert not missing, f"adapters in REGISTRY but missing ADAPTER_META: {missing}"
|
||||
|
||||
|
||||
# ---------- guard against CODE leaking back into the registry -----------
|
||||
|
||||
|
||||
def test_no_emoji_keys_in_registry():
|
||||
"""Emoji choices are CODE, not config (Matt's locked rule)."""
|
||||
for (adapter, key) in REGISTRY:
|
||||
assert "emoji" not in key, (
|
||||
f"{adapter}.{key} looks like an emoji setting; emojis are CODE"
|
||||
)
|
||||
|
||||
|
||||
def test_no_template_keys_in_registry():
|
||||
"""Sentence templates are CODE."""
|
||||
for (adapter, key) in REGISTRY:
|
||||
assert "template" not in key and "prefix" not in key, (
|
||||
f"{adapter}.{key} looks like a sentence template / prefix; sentences are CODE"
|
||||
)
|
||||
|
||||
|
||||
def test_no_map_keys_in_registry():
|
||||
"""Translation maps are CODE (TomTom icon_map, ITD sub_type_map, etc.)."""
|
||||
for (adapter, key) in REGISTRY:
|
||||
assert not key.endswith("_map"), (
|
||||
f"{adapter}.{key} looks like a translation map; mapping functions are CODE"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue