From ff93d4416650f533e1a06e43d8876ab793830a78 Mon Sep 17 00:00:00 2001 From: Nikolay Miroshnichenko Date: Tue, 7 Jul 2026 12:24:24 +0200 Subject: [PATCH] feat: positional status pairing + self-reporting commands, app-program parser v2 (ComObjectRef merge, version pick), authoritative main names, grader range-name evidence (v0.8.0) Closes #3, closes #4, closes #5, closes #6 --- .github/workflows/ci.yml | 3 + CHANGELOG.md | 34 +++++++- nickol_knx_mcp/__init__.py | 2 +- nickol_knx_mcp/advanced.py | 21 ++++- nickol_knx_mcp/analyze.py | 36 +++++++-- nickol_knx_mcp/appprog_parser.py | 82 +++++++++++++++++-- nickol_knx_mcp/pairing.py | 60 ++++++++++++++ pyproject.toml | 2 +- tests/test_v08_fixes.py | 133 +++++++++++++++++++++++++++++++ 9 files changed, 358 insertions(+), 15 deletions(-) create mode 100644 tests/test_v08_fixes.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c70142..b0cdde2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: - name: App-program parser (synthetic .knxprod round-trip) run: python tests/test_appprog_parser.py + - name: v0.8 fixes (positional pairing, ref-merge, range names) + run: python tests/test_v08_fixes.py + - name: Console script is installed run: | python -c "import importlib.metadata as m; print('entry points:', [e.name for e in m.entry_points(group='console_scripts') if e.name == 'nickol-knx-mcp'])" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d3b8c7..186bc70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] — 2026-07-07 + +**Lessons from a second field project (a different integrator naming school):** positional +command/status pairing, ref-level application programs, and two detector blind spots. All four +changes were validated against a real 859-GA as-built project. + +### Added + +- **Positional command/status pairing** (#4, `pairing.py` / `analyze.py`). A widespread naming + school keeps feedback in a *parallel middle group* (command `1/0/s` ↔ status `1/1/s`) with the + GA name duplicated 1:1 and no status keyword at all — invisible to lexical pairing. + `detect_missing_status` now also pairs positionally (same main, same sub, different middle, + identical name, DPT-compatible) and recognises **self-reporting commands** (the actuator's + R+T communication object linked to the command GA itself). On the field project this cut + missing-status warnings from 339 to 89 — the ~90 that are actually real — and reports a + `status_pairing_summary` info with both counters. +- **Application-program parser v2** (#6, `appprog_parser.py`). Vendors like HDL, Ekinex and + Creatrol publish object names/DPTs/flags on `` while the base `` is a + near-empty stub. The parser now merges ref-level data over the base (conservatively: refs only + fill empty fields; a DPT is taken only when every declaring ref agrees, disagreeing variants are + recorded as `dpt_variants`, never guessed). Unverified DPTs on the field project's 15 device + models dropped 4341 → 1167 (−73%). The app-program version is now picked by the **highest + ApplicationVersion** instead of list position — a stale v16 was being parsed where v18 existed. + ### Fixed +- **`main_group_unnamed` read the wrong names** (#3, `analyze.py`): main-group names are now taken + from the authoritative `group_ranges` (a GA record's `main_name` can carry a middle range's + name), eliminating both false "unnamed" findings and masked truly-unnamed mains. +- **Completeness grader missed pattern-named ranges** (#5, `advanced.py`): a middle range literally + called «Сцены» full of plain-named scene GAs now counts as scene evidence — the grader reads + main/middle range names in addition to GA names (and the scene token matches Russian plural + forms). - **Pinned `mcp>=1.10,<2`** — the MCP Python SDK v2 (in alpha) renames `mcp.server.fastmcp.FastMCP` to `mcp.server.MCPServer`; without the upper bound a future `pip install` would pull v2 and break the server import. Verified against the SDK docs. @@ -299,7 +330,8 @@ Initial public beta. - Tested end-to-end on a synthetic project only; real-world `.knxproj` testing is ongoing (see the call for testers in the README). -[Unreleased]: https://github.com/NickoScope/nickol-knx-mcp/compare/v0.7.0...HEAD +[Unreleased]: https://github.com/NickoScope/nickol-knx-mcp/compare/v0.8.0...HEAD +[0.8.0]: https://github.com/NickoScope/nickol-knx-mcp/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/NickoScope/nickol-knx-mcp/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/NickoScope/nickol-knx-mcp/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/NickoScope/nickol-knx-mcp/compare/v0.4.0...v0.5.0 diff --git a/nickol_knx_mcp/__init__.py b/nickol_knx_mcp/__init__.py index b421cf5..8b78c5b 100644 --- a/nickol_knx_mcp/__init__.py +++ b/nickol_knx_mcp/__init__.py @@ -1,2 +1,2 @@ """nickol-knx-mcp: design-time KNX/ETS project assistant MCP server.""" -__version__ = "0.7.0" +__version__ = "0.8.0" diff --git a/nickol_knx_mcp/advanced.py b/nickol_knx_mcp/advanced.py index 7ce0bb2..5f2e57e 100644 --- a/nickol_knx_mcp/advanced.py +++ b/nickol_knx_mcp/advanced.py @@ -78,18 +78,37 @@ _PATTERNS = { "astro / meteo": ("солнц", "азимут", "восход", "закат", "метео", "sun ", "meteo"), "monitoring": ("мониторинг", "статус работы", "неисправ", "monitoring", "fault"), "deep metering": ("счётчик", "потреблен", "тариф", "meter", "consumption"), - "scenes": ("сцена", "сценар", "scene", "szene"), + "scenes": ("сцен", "scene", "szene"), "reserves": ("резерв", "reserve", "spare"), "debug main": ("отладк", "временно", "debug", "logic for"), } +def _range_names_text(project: LoadedProject) -> str: + """All main/middle GroupRange names, lowercased, as extra pattern evidence. + + Integrators often name a whole middle range for a pattern ("Сцены", + "Мониторинг") while the GAs inside carry plain load names — grading by GA + names alone misses the pattern entirely, so range names count too. + """ + out: list[str] = [] + for mrange in (project.raw.get("group_ranges") or {}).values(): + out.append((mrange.get("name") or "").lower()) + for srange in (mrange.get("group_ranges") or {}).values(): + out.append((srange.get("name") or "").lower()) + return " \n ".join(out) + + def completeness_grade(project: LoadedProject) -> dict[str, Any]: """Grade a project: bare functional skeleton vs as-built grade (§ alex-skill patterns).""" names = " \n ".join(g.name.lower() for g in project.gas.values()) + range_names = _range_names_text(project) present, missing = {}, [] for label, toks in _PATTERNS.items(): n = sum(names.count(t) for t in toks) + if n == 0: + # a main/middle RANGE named for the pattern is evidence too + n = sum(range_names.count(t) for t in toks) present[label] = n if n == 0: missing.append(label) diff --git a/nickol_knx_mcp/analyze.py b/nickol_knx_mcp/analyze.py index b51be8e..76697ef 100644 --- a/nickol_knx_mcp/analyze.py +++ b/nickol_knx_mcp/analyze.py @@ -15,7 +15,8 @@ from collections import defaultdict from typing import Any, Optional from .project import LoadedProject, GARecord, STATUS_KEYWORDS -from .pairing import find_status, function_status_pairs, base_tokens +from .pairing import (find_status, function_status_pairs, base_tokens, + positional_status, self_reporting) from .intent import INTENT_FUNCTIONAL, INTENT_RESERVE, INTENT_SCRATCH SEVERITY_ERROR = "error" @@ -95,11 +96,19 @@ def validate_naming(project: LoadedProject, addresses=addrs, )) - # Main-group names present? + # Main-group names present? Read them from the project's GroupRanges — the + # authoritative source. GARecord.main_name from the parser can carry a + # MIDDLE range's name instead of the main's (first-seen shadowing), which + # both hid truly unnamed mains and mislabeled named ones. main_named: dict[int, str] = {} - for ga in project.gas.values(): - if ga.main is not None and ga.main not in main_named: - main_named[ga.main] = ga.main_name + for mkey, mrange in (project.raw.get("group_ranges") or {}).items(): + head = str(mkey).split("/")[0] + if head.isdigit(): + main_named[int(head)] = mrange.get("name") or "" + if not main_named: # fallback for synthetic projects without group_ranges + for ga in project.gas.values(): + if ga.main is not None and ga.main not in main_named: + main_named[ga.main] = ga.main_name for main, mname in sorted(main_named.items()): if not mname: findings.append(_finding( @@ -225,6 +234,7 @@ def detect_missing_status(project: LoadedProject) -> list[dict[str, Any]]: # All status-like GAs become pairing candidates (project-wide). status_gas = [ga for ga in project.gas.values() if _is_status_ga(ga)] + n_positional = n_self_report = 0 for addr, ga in project.gas.items(): if addr in covered: continue @@ -238,6 +248,15 @@ def detect_missing_status(project: LoadedProject) -> list[dict[str, Any]]: same_main = [s for s in status_gas if s.main == ga.main] match = find_status(ga, same_main) or find_status(ga, status_gas) if match is None: + # positional school: parallel status middle, identical name, same sub + pos = positional_status(ga, project) + if pos is not None: + n_positional += 1 + continue + # actuator's R+T status object linked to the command GA itself + if self_reporting(ga, project): + n_self_report += 1 + continue if _is_central_macro(ga.name): findings.append(_finding( SEVERITY_INFO, "central_macro_no_status", addr, @@ -253,6 +272,13 @@ def detect_missing_status(project: LoadedProject) -> list[dict[str, Any]]: name=ga.name, dpt=ga.dpt, category=ga.category, )) + if n_positional or n_self_report: + findings.append(_finding( + SEVERITY_INFO, "status_pairing_summary", "-", + f"{n_positional} command(s) paired positionally (parallel status middle, " + f"identical name) and {n_self_report} self-reporting (actuator R+T object " + "on the command GA) — no separate status GA needed.", + )) return findings diff --git a/nickol_knx_mcp/appprog_parser.py b/nickol_knx_mcp/appprog_parser.py index 511c761..fad3323 100644 --- a/nickol_knx_mcp/appprog_parser.py +++ b/nickol_knx_mcp/appprog_parser.py @@ -24,6 +24,7 @@ from typing import Any, Optional _ATTR_RE = re.compile(r'(\w+)="([^"]*)"') _COMOBJ_RE = re.compile(r"]*?)/?>") +_COMREF_RE = re.compile(r"]*?)/?>") _HW_SPLIT_RE = re.compile(r" Optional[str]: def _parse_comobjects(xml_text: str) -> list[dict[str, Any]]: - objs: list[dict[str, Any]] = [] + """Parse the object model from base ```` elements, then enrich + from ````. + + Some vendors (Zennio, ABB, EOS…) publish the full model on the base + elements; others (HDL, Ekinex, Creatrol…) leave the base as a near-empty + stub and put names/DPTs/flags on the refs. Merging is conservative: a ref + value only FILLS a field the base left empty, and the DPT is taken only + when every ref of that object that declares one AGREES — refs are + parameter-dependent variants, and a disagreeing DPT stays honestly unset + rather than guessed. + """ + base: dict[str, dict[str, Any]] = {} # base Id -> raw attrs for m in _COMOBJ_RE.finditer(xml_text): a = _attrs(m.group(1)) - if "Number" not in a: - continue + if "Number" in a and a.get("Id"): + base[a["Id"]] = a + elif "Number" in a: + base[f"_anon{len(base)}"] = a + + refs: dict[str, list[dict[str, str]]] = {} + for m in _COMREF_RE.finditer(xml_text): + a = _attrs(m.group(1)) + rid = a.get("RefId") + if rid: + refs.setdefault(rid, []).append(a) + + def _merged(a: dict[str, str], rlist: list[dict[str, str]]) -> dict[str, str]: + out = dict(a) + for field in ("Text", "Name", "FunctionText", "ObjectSize", + "ReadFlag", "WriteFlag", "CommunicationFlag", + "TransmitFlag", "UpdateFlag"): + if not out.get(field): + vals = [r[field] for r in rlist if r.get(field)] + if vals: + out[field] = vals[0] + if not out.get("DatapointType"): + dpts = {r["DatapointType"] for r in rlist if r.get("DatapointType")} + if len(dpts) == 1: + out["DatapointType"] = dpts.pop() + elif len(dpts) > 1: + out["_dpt_variants"] = ", ".join(sorted(dpts)) + return out + + objs: list[dict[str, Any]] = [] + for oid, a in base.items(): + a = _merged(a, refs.get(oid, [])) w = a.get("WriteFlag", "").lower() == "enabled" t = a.get("TransmitFlag", "").lower() == "enabled" r = a.get("ReadFlag", "").lower() == "enabled" @@ -103,7 +145,7 @@ def _parse_comobjects(xml_text: str) -> list[dict[str, Any]]: number = int(a["Number"]) except ValueError: continue - objs.append({ + rec = { "number": number, "name": text, "internal_name": a.get("Name"), @@ -114,7 +156,11 @@ def _parse_comobjects(xml_text: str) -> list[dict[str, Any]]: "R": r, "W": w, "T": t, "U": a.get("UpdateFlag", "").lower() == "enabled"}, "role": _role(w, t, r, text), - }) + } + if a.get("_dpt_variants"): + rec["dpt_variants"] = ", ".join( + sorted(_dpt(x) or x for x in a["_dpt_variants"].split(", "))) + objs.append(rec) objs.sort(key=lambda o: o["number"]) return objs @@ -152,6 +198,30 @@ def _detect_blocks(objs: list[dict[str, Any]]) -> dict[str, Any]: return {"blocks": blocks, "general_objects": general, "logic_function_objects": lf} +def _pick_app_ref(refs: list[str]) -> Optional[str]: + """Pick the NEWEST application program by its version segment. + + ``ApplicationProgramRef`` ids look like ``M-0073_A-1105-12-ABCD`` — the third + dash-segment is the application version in hex. Hardware entries can list + several app versions, and the newest is NOT always the last listed (seen in + the field: an HDL DALI gateway listing v18 before v16), so positional + ``[-1]`` picked a stale program. Fall back to the last entry when the id + doesn't match the expected shape. + """ + if not refs: + return None + + def ver(ref: str) -> int: + m = re.match(r"M-[0-9A-Fa-f]+_A-[0-9A-Fa-f]+-([0-9A-Fa-f]+)-", ref) + try: + return int(m.group(1), 16) if m else -1 + except ValueError: + return -1 + + best = max(refs, key=ver) + return best if ver(best) >= 0 else refs[-1] + + def _hardware_map(xml_text: str) -> list[dict[str, Any]]: """From a Hardware.xml, list {order_number, name, app_refs[]} (latest app last). @@ -198,7 +268,7 @@ def parse_project(path: str, password: Optional[str] = None) -> dict[str, Any]: continue for entry in _hardware_map(hw_xml): hw_count += 1 - app_ref = entry["app_refs"][-1] if entry["app_refs"] else None + app_ref = _pick_app_ref(entry["app_refs"]) app_path = f"{mcode}/{app_ref}.xml" if app_ref else None if not app_path or app_path not in appfiles: devices.append({ diff --git a/nickol_knx_mcp/pairing.py b/nickol_knx_mcp/pairing.py index 763522a..41246ec 100644 --- a/nickol_knx_mcp/pairing.py +++ b/nickol_knx_mcp/pairing.py @@ -62,6 +62,66 @@ def find_status(command: GARecord, candidates: Iterable[GARecord]) -> Optional[G return best +# --------------------------------------------------------------------------- # +# Positional pairing school (parallel status middles, identical names) +# --------------------------------------------------------------------------- # +def _norm_name(s: str) -> str: + return " ".join((s or "").lower().split()) + + +def positional_status(command: GARecord, project) -> Optional[GARecord]: + """Find a status GA by POSITION instead of by name marker. + + A widespread naming school (common around HDL-style projects) keeps statuses + in a *parallel middle group*: command in ``main/m/s``, feedback in + ``main/m'/s`` — same main, same sub, different middle — and duplicates the + GA name 1:1 with no status suffix at all. A lexical marker search can never + pair these, so `detect_missing_status` would cry wolf on every command. + + Deliberately conservative to avoid false pairs: requires exact sub match and + an identical normalised name (or identical-plus-status-keyword), and a + DPT-compatible candidate per STATUS_COMPAT. + """ + want = _norm_name(command.name) + if not want or command.main is None or command.sub is None: + return None + compat = STATUS_COMPAT.get(command.dpt_main or -1, {command.dpt_main}) + for c in project.gas.values(): + if c.address == command.address: + continue + if c.main != command.main or c.sub != command.sub: + continue + if c.middle == command.middle: + continue + n = _norm_name(c.name) + if n != want: + # allow the "same name + status word" variant of the same school + if not (n.startswith(want) + and any(k in n[len(want):] for k in STATUS_KEYWORDS)): + continue + if c.dpt_main is not None and c.dpt_main not in compat: + continue + return c + return None + + +def self_reporting(ga: GARecord, project) -> bool: + """True when the actuator reports state on the command GA itself. + + Some integrators link the actuator's *status* communication object to the + same group address as the command (the CO carries Read+Transmit flags), so + the GA is its own feedback. There is then no separate status GA to find — + and none is missing. + """ + cos = (project.raw or {}).get("communication_objects", {}) or {} + for cid in (ga.co_ids or []): + co = cos.get(cid) or {} + fl = co.get("flags") or {} + if fl.get("read") and fl.get("transmit"): + return True + return False + + # --------------------------------------------------------------------------- # # Authoritative pairing from ETS Functions (roles) # --------------------------------------------------------------------------- # diff --git a/pyproject.toml b/pyproject.toml index 31965c7..55b6d17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nickol-knx-mcp" -version = "0.7.0" +version = "0.8.0" description = "Design-time KNX/ETS6 project assistant as an MCP server (parse .knxproj, validate, generate HA YAML + ETS CSV/XML). No live bus access." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_v08_fixes.py b/tests/test_v08_fixes.py new file mode 100644 index 0000000..94bf3ce --- /dev/null +++ b/tests/test_v08_fixes.py @@ -0,0 +1,133 @@ +"""v0.8 fixes, synthetic and self-contained (issues #3-#6). + +1. positional command/status pairing (parallel middles, identical names) +2. self-reporting commands (actuator R+T object on the command GA) +3. main_group_unnamed reads authoritative GroupRange names +4. completeness grader counts pattern-named ranges +5. app-program parser: ComObjectRef merge + newest-version pick +""" +import sys, os, zipfile, tempfile +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from nickol_knx_mcp.project import build_loaded_from_raw +from nickol_knx_mcp.analyze import detect_missing_status, validate_naming +from nickol_knx_mcp.advanced import completeness_grade +from nickol_knx_mcp.appprog_parser import parse_project + + +def ga(addr, name, main, sub, co_ids=None): + return { + "name": name, "identifier": f"GA-{addr}", "raw_address": 0, + "address": addr, "project_uid": None, + "dpt": ({"main": main, "sub": sub} if main is not None else None), + "data_secure": False, "communication_object_ids": co_ids or [], + "description": "", "comment": "", + } + + +def make(gas, cos=None, ranges=None): + raw = { + "info": {"project_id": "P-T", "name": "SynthV08", "last_modified": "", + "group_address_style": "ThreeLevel", "guid": "x", "created_by": "t", + "schema_version": "21", "tool_version": "t", "xknxproject_version": "t", + "language_code": "ru-RU"}, + "communication_objects": cos or {}, + "devices": {}, "topology": {}, + "group_addresses": {g["address"]: g for g in gas}, + "group_ranges": ranges or {}, + "functions": {}, + } + return build_loaded_from_raw(raw, "synthetic-v08") + + +# 1) POSITIONAL PAIRING: command 1/0/7 and status 1/1/7 share the exact name — +# no lexical marker anywhere; must NOT be flagged. An unpaired command must stay flagged. +proj = make([ + ga("1/0/7", "Light-Кухня-Люстра-onoff", 1, 1), + ga("1/1/7", "Light-Кухня-Люстра-onoff", 1, 1), # parallel middle, same sub, plain 1.001 — invisible to the lexical matcher + ga("2/0/9", "Штора Кабинет вверх/вниз", 1, 8), # no pair anywhere -> warning +]) +f = detect_missing_status(proj) +warn = [x for x in f if x["code"] == "missing_status_address"] +assert len(warn) == 1 and warn[0]["address"] == "2/0/9", warn +summary = [x for x in f if x["code"] == "status_pairing_summary"] +# both directions count (the parallel-middle twin is itself command-classified), +# which is fine: neither GA lacks feedback +assert summary and "2 command(s) paired positionally" in summary[0]["message"], summary + +# 2) SELF-REPORTING: the actuator's R+T object sits on the command GA itself. +cos = { + "CO-W": {"name": "cmd", "number": 1, "flags": {"read": False, "write": True, + "communication": True, "transmit": False, "update": False}, + "dpts": [{"main": 1, "sub": 1}], "group_address_links": ["1/0/1"]}, + "CO-RT": {"name": "state", "number": 2, "flags": {"read": True, "write": False, + "communication": True, "transmit": True, "update": False}, + "dpts": [{"main": 1, "sub": 1}], "group_address_links": ["1/0/1"]}, +} +proj = make([ga("1/0/1", "Розетка Кабинет", 1, 1, co_ids=["CO-W", "CO-RT"])], cos=cos) +f = detect_missing_status(proj) +assert not [x for x in f if x["code"] == "missing_status_address"], f +assert any("self-reporting" in x["message"] for x in f if x["code"] == "status_pairing_summary") + +# 3) MAIN NAMES from GroupRanges: main 1 IS named in ranges (GA-level main_name may +# lie); main 2 truly unnamed -> exactly one finding, for main 2. +ranges = { + "1": {"name": "Освещение", "group_ranges": {"1/0": {"name": "Команды"}}}, + "2": {"name": "", "group_ranges": {}}, +} +proj = make([ga("1/0/1", "Свет Кухня статус", 1, 11), ga("2/0/1", "Штора Спальня вверх/вниз", 1, 8)], + ranges=ranges) +un = [x for x in validate_naming(proj) if x["code"] == "main_group_unnamed"] +assert len(un) == 1 and un[0]["address"].startswith("2/"), un + +# 4) GRADER counts pattern-named ranges: GA names are plain, the middle is called «Сцены». +ranges = {"1": {"name": "Свет", "group_ranges": {"1/5": {"name": "Сцены"}}}} +proj = make([ga("1/5/1", "Гостиная вечер", 18, 1)], ranges=ranges) +g = completeness_grade(proj) +assert g["patterns_present"].get("scenes"), g + +# 5) PARSER v2: ref-level merge + version pick. +HW = """ + + + + + + + + +""" +APP_V18 = """ + + + + + + + + + + + +""" +APP_V16 = APP_V18.replace("A-0001-12-AAAA", "A-0001-10-BBBB").replace( + 'ApplicationVersion="18"', 'ApplicationVersion="16"').replace("Switch A", "OLD Switch") + +with tempfile.TemporaryDirectory() as d: + arc = os.path.join(d, "refvendor.knxprod") + with zipfile.ZipFile(arc, "w") as z: + z.writestr("M-0073/Hardware.xml", HW) + z.writestr("M-0073/M-0073_A-0001-12-AAAA.xml", APP_V18) + z.writestr("M-0073/M-0073_A-0001-10-BBBB.xml", APP_V16) + res = parse_project(arc) + dev = res["devices"][0] + # newest app (v18, hex 12) picked even though listed FIRST is fine — but the + # stale one (hex 10) was listed LAST, which the old [-1] logic would pick. + assert dev["application_program"]["app_id"].endswith("12-AAAA"), dev["application_program"] + o1 = [o for o in dev["comm_objects"] if o["number"] == 1][0] + assert o1["dpt"] == "1.001" and o1["name"] == "Switch A", o1 # merged from ref + assert o1["flags"]["W"] is True, o1 # flag from ref + o2 = [o for o in dev["comm_objects"] if o["number"] == 2][0] + assert o2["dpt"] is None and "5.001" in o2.get("dpt_variants", ""), o2 # refs disagree -> honest + +print("OK — v0.8: positional pairing, self-reporting, range names (naming+grader), ref-merge + version pick")