mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 17:04: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,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