mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
fix(P3): root-cause suppression — an empty-name GA no longer cascades
Council #2: the empty GA 2/5/2 spawned missing_status + policy_taxonomy_outlier + a lighting classification on top of its real defect. An empty name means the classifier can't be trusted, so detect_missing_status and check_policy now skip blank-name GAs; the single root cause is empty_name (check_naming). Demo 2/5/2: 3 findings -> 1. tests/test_root_cause.py; full suite green (10 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
10169275e3
commit
84c1c7dbd9
@@ -61,6 +61,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Root-cause suppression (external-review noise).** A GA with an **empty name** has an unreliable
|
||||
classification, so it no longer cascades into a `missing_status_address` warning and a
|
||||
`policy_taxonomy_outlier` — the single root cause (`empty_name` from `check_naming`) is kept and the
|
||||
dependent findings are suppressed. On the demo house the empty `2/5/2` now yields one finding instead
|
||||
of three. `tests/test_root_cause.py`.
|
||||
|
||||
- **Noise & unsafe repairs surfaced by an external expert review on the demo house.** (1) Scene-control
|
||||
GAs (DPT 17/18) are no longer flagged as `missing_status` — they recall a preset and have no single
|
||||
state to read back (now `scene_no_status`, INFO), and `suggest_repairs` no longer synthesises a bogus
|
||||
|
||||
@@ -249,6 +249,8 @@ def detect_missing_status(project: LoadedProject) -> list[dict[str, Any]]:
|
||||
continue
|
||||
if ga.intent != INTENT_FUNCTIONAL:
|
||||
continue # reserve / logic / scratch GAs need no status by design
|
||||
if not (ga.name or "").strip():
|
||||
continue # root cause is empty_name (check_naming); classification unreliable
|
||||
if ga.kind != "command":
|
||||
continue
|
||||
if ga.dpt_main is None:
|
||||
|
||||
@@ -131,6 +131,8 @@ def check_policy(project: LoadedProject, policy: dict[str, Any]) -> dict[str, An
|
||||
if ga.intent != INTENT_FUNCTIONAL or ga.main is None:
|
||||
continue
|
||||
seen_mains.add(ga.main)
|
||||
if not (ga.name or "").strip():
|
||||
continue # empty name -> unreliable classification; root cause is empty_name
|
||||
|
||||
# 1. main-group taxonomy conformance (only when the domain is known)
|
||||
allowed = mg.get(ga.main)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Root-cause suppression (external-review noise): a GA with an empty name has an
|
||||
unreliable classification, so it must NOT cascade into missing_status and policy
|
||||
taxonomy findings — the single root cause is `empty_name` (check_naming).
|
||||
"""
|
||||
from nickol_knx_mcp.project import build_loaded_from_raw
|
||||
from nickol_knx_mcp.analyze import validate_naming, detect_missing_status
|
||||
from nickol_knx_mcp.policy import check_policy, load_policy
|
||||
|
||||
|
||||
def _ga(addr, name, dmain, dsub):
|
||||
return {"name": name, "identifier": f"GA-{addr}", "raw_address": 0, "address": addr,
|
||||
"project_uid": None, "dpt": {"main": dmain, "sub": dsub}, "data_secure": False,
|
||||
"communication_object_ids": [], "description": "", "comment": ""}
|
||||
|
||||
|
||||
def _proj(gas):
|
||||
raw = {"info": {"group_address_style": "ThreeLevel", "schema_version": "21"},
|
||||
"group_addresses": gas, "communication_objects": {}, "devices": {},
|
||||
"functions": {}, "topology": {}, "group_ranges": {}}
|
||||
return build_loaded_from_raw(raw, "t.knxproj")
|
||||
|
||||
|
||||
def main():
|
||||
# main 2 is a clear shutter zone; an empty-name 5.001 command sits in it
|
||||
gas = {
|
||||
"2/0/1": _ga("2/0/1", "Blind A up/down", 1, 8),
|
||||
"2/0/2": _ga("2/0/2", "Blind B up/down", 1, 8),
|
||||
"2/0/3": _ga("2/0/3", "Blind C up/down", 1, 8),
|
||||
"2/5/2": _ga("2/5/2", "", 5, 1), # EMPTY name, 5.001 command
|
||||
}
|
||||
p = _proj(gas)
|
||||
|
||||
naming = {f["code"] for f in validate_naming(p) if "2/5/2" in str(f.get("address", ""))}
|
||||
status = [f for f in detect_missing_status(p) if "2/5/2" in str(f.get("address", ""))]
|
||||
policy = [f for f in check_policy(p, load_policy())["findings"]
|
||||
if "2/5/2" in str(f.get("address", ""))]
|
||||
|
||||
assert "empty_name" in naming, naming # the single root cause is kept
|
||||
assert status == [], f"empty-name GA must not cascade to missing_status: {status}"
|
||||
assert policy == [], f"empty-name GA must not cascade to policy taxonomy: {policy}"
|
||||
|
||||
# sanity: a NAMED command in the same setup is still checked
|
||||
gas["2/0/9"] = _ga("2/0/9", "Blind D up/down", 1, 8)
|
||||
p2 = _proj(gas)
|
||||
assert any(f["address"] == "2/0/9" for f in detect_missing_status(p2)), \
|
||||
"named command must still be flagged for missing status"
|
||||
|
||||
print("test_root_cause: OK — empty-name GA yields only empty_name; no missing_status / "
|
||||
"policy cascade; named GAs still checked.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user