From 68dcbc74d06a35217694840bf0fc09df67dfd7d3 Mon Sep 17 00:00:00 2001 From: "Matt Johnson (via Claude)" Date: Fri, 5 Jun 2026 18:09:49 +0000 Subject: [PATCH] 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. --- meshai/adapter_config/__init__.py | 83 +++- meshai/adapter_config/defaults.py | 511 +++++------------------- meshai/persistence/db.py | 5 +- tests/test_adapter_config_foundation.py | 226 +++++++---- 4 files changed, 324 insertions(+), 501 deletions(-) diff --git a/meshai/adapter_config/__init__.py b/meshai/adapter_config/__init__.py index c255bf9..fb6d805 100644 --- a/meshai/adapter_config/__init__.py +++ b/meshai/adapter_config/__init__.py @@ -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 diff --git a/meshai/adapter_config/defaults.py b/meshai/adapter_config/defaults.py index 0f061a8..2704cfc 100644 --- a/meshai/adapter_config/defaults.py +++ b/meshai/adapter_config/defaults.py @@ -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..` -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... + 4. Wire the handler to read from adapter_config... """ from __future__ import annotations from typing import Any -# -------- REGISTRY -------------------------------------------------------- -# -# Schema: REGISTRY[(adapter, key)] = { -# "default": , # 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,528 +63,260 @@ 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 + "default": ["Extreme", "Severe"], # nws_handler.py:43 "type": "json", "description": "CAP severity strings allowed onto the mesh.", }, ("nws", "tombstone_msgtypes"): { - "default": ["Cancel", "Expire"], # nws_handler.py:46 + "default": ["Cancel", "Expire"], # nws_handler.py:46 "type": "json", "description": "CAP msgType values that mark an alert as gone.", }, ("nws", "warning_suffix_promotes"): { - "default": True, # nws_handler.py:172 + "default": True, # nws_handler.py:172 "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