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:
Matt Johnson (via Claude) 2026-06-05 18:09:49 +00:00
commit 68dcbc74d0
4 changed files with 326 additions and 503 deletions

View file

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