From f49a30ca1ebb6c28b968b4a6b2d170ef85a50e57 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Fri, 19 Jun 2026 08:10:00 +0100 Subject: [PATCH 01/14] feat(lattice): read-only device-mapping core (model + merge + sensor resolve) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure, dependency-free model: Fragment/Node/Sensor/AccessPath, merge_fragments (one node per device by serial, ranked access paths, combined sensors), resolve_sensor (preferred entity), and the device_fragment builder. READ-ONLY by design — control is a separate, deferred model. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/lattice.py | 195 +++++++++++++++++++++++++++++++++++ apps/predbat/test_lattice.py | 122 ++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 apps/predbat/lattice.py create mode 100644 apps/predbat/test_lattice.py diff --git a/apps/predbat/lattice.py b/apps/predbat/lattice.py new file mode 100644 index 000000000..34c2d6354 --- /dev/null +++ b/apps/predbat/lattice.py @@ -0,0 +1,195 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System - Lattice device-mapping core (read-only) +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +"""Pure, dependency-free Lattice device-mapping model. + +Each integration publishes a *fragment* describing the devices it can see — their identity, +type, access paths, and the sensors they expose (referencing existing entities). Fragments merge +by identity into one site graph: a device seen via two integrations becomes ONE node carrying +both providers' access paths (ranked) and the union of its sensors. + +READ-ONLY by design: this maps the network and inventories sensors. Control is a separate model +(a common intent/shape/binding API) and deliberately not part of this — see the lattice-spec repo. +No PredBat/Home Assistant dependencies, so it can be unit-tested standalone. +""" +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class AccessPath: + """A way to reach a node (a provider/transport with a ranked preference).""" + + id: str + provider: str + locality: str = "local" + transport: str = "" + preference: int = 0 + + @staticmethod + def from_dict(d): + """Build an AccessPath from its wire dict.""" + return AccessPath( + id=d["id"], + provider=d.get("provider", ""), + locality=d.get("locality", "local"), + transport=d.get("transport", ""), + preference=int(d.get("preference", 0)), + ) + + +@dataclass +class Sensor: + """A telemetry reading a node exposes, referencing the existing entity that carries it.""" + + capability: str + unit: str = "" + entity: str = "" + access_path: str = "" + + @staticmethod + def from_dict(d): + """Build a Sensor from its wire dict.""" + return Sensor(capability=d["capability"], unit=d.get("unit", ""), entity=d.get("entity", ""), access_path=d.get("accessPath", "")) + + +@dataclass +class Node: + """A device in the graph, identified by id (serial), with access paths + sensors.""" + + id: str + kind: str + device_type: str + access_paths: list = field(default_factory=list) + sensors: list = field(default_factory=list) + + def sensor(self, name) -> Optional[Sensor]: + """Return the first Sensor matching name, or None.""" + for s in self.sensors: + if s.capability == name: + return s + return None + + @staticmethod + def from_dict(d): + """Build a Node from its wire dict.""" + return Node( + id=d["id"], + kind=d.get("kind", ""), + device_type=d.get("deviceType", ""), + access_paths=[AccessPath.from_dict(a) for a in d.get("accessPaths", [])], + sensors=[Sensor.from_dict(s) for s in d.get("sensors", [])], + ) + + +@dataclass +class Fragment: + """A producer's slice of the topology: nodes + relationships + its provider id.""" + + provider: str + nodes: list = field(default_factory=list) + relationships: list = field(default_factory=list) + name: str = "" + version: str = "0.1.0" + + @staticmethod + def from_dict(d): + """Build a Fragment from a producer's wire dict.""" + prod = d.get("producer", {}) + return Fragment( + provider=prod.get("provider", ""), + name=prod.get("name", ""), + version=d.get("topologyVersion", "0.1.0"), + nodes=[Node.from_dict(n) for n in d.get("nodes", [])], + relationships=list(d.get("relationships", [])), + ) + + +@dataclass +class SiteGraph: + """The merged site: one node per physical device, carrying all producers' access paths.""" + + nodes: list = field(default_factory=list) + relationships: list = field(default_factory=list) + + def node(self, node_id) -> Optional[Node]: + """Return the node with this id, or None.""" + for n in self.nodes: + if n.id == node_id: + return n + return None + + +def merge_fragments(fragments) -> SiteGraph: + """Merge producer fragments into one site graph, keyed by node id (serial). + + Same id from multiple producers becomes one node carrying every producer's access paths + (ranked by preference desc) and the union of its sensors. Distinct ids become sibling nodes. + Relationships are combined. + """ + by_id = {} + order = [] + relationships = [] + for frag in fragments: + for n in frag.nodes: + if n.id not in by_id: + by_id[n.id] = Node(id=n.id, kind=n.kind, device_type=n.device_type, access_paths=list(n.access_paths), sensors=list(n.sensors)) + order.append(n.id) + else: + existing = by_id[n.id] + seen_ap = {ap.id for ap in existing.access_paths} + existing.access_paths.extend(ap for ap in n.access_paths if ap.id not in seen_ap) + seen_sensor = {(s.capability, s.access_path) for s in existing.sensors} + existing.sensors.extend(s for s in n.sensors if (s.capability, s.access_path) not in seen_sensor) + relationships.extend(frag.relationships) + for n in by_id.values(): + n.access_paths.sort(key=lambda ap: ap.preference, reverse=True) + return SiteGraph(nodes=[by_id[i] for i in order], relationships=relationships) + + +def resolve_sensor(site, capability, node_id): + """Return the preferred entity for a node's sensor (highest-preference access path), or None. + + When a device is seen via several providers, this picks the sensor on the most-preferred + available access path. Pure read resolution — no control. + """ + node = site.node(node_id) + if node is None: + return None + best = None + best_pref = None + pref = {ap.id: ap.preference for ap in node.access_paths} + for s in node.sensors: + if s.capability != capability or not s.entity: + continue + p = pref.get(s.access_path, 0) + if best is None or p > best_pref: + best, best_pref = s.entity, p + return best + + +def device_fragment(devices, provider, name, transport, preference, locality): + """Build a read-only producer fragment from plain device data. + + Each device dict needs a `serial` (skipped if missing) and may carry `device_type` and a + `sensors` list of {capability, unit, entity}. Every device becomes a node on one access path + advertising those sensors. Pure — no PredBat deps, no control. + """ + nodes = [] + for dev in devices: + serial = dev.get("serial") + if not serial: + continue + sensors = [{"capability": s["capability"], "unit": s.get("unit", ""), "entity": s.get("entity", ""), "accessPath": provider} for s in dev.get("sensors", [])] + nodes.append( + { + "id": serial, + "kind": dev.get("kind", "inverter"), + "deviceType": str(dev.get("device_type", "")).lower(), + "accessPaths": [{"id": provider, "provider": provider, "locality": locality, "transport": transport, "preference": preference}], + "sensors": sensors, + } + ) + return {"topologyVersion": "0.1.0", "scope": "fragment", "producer": {"name": name, "provider": provider}, "nodes": nodes, "relationships": []} diff --git a/apps/predbat/test_lattice.py b/apps/predbat/test_lattice.py new file mode 100644 index 000000000..320761fd4 --- /dev/null +++ b/apps/predbat/test_lattice.py @@ -0,0 +1,122 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System - Lattice device-mapping unit tests (read-only) +# ----------------------------------------------------------------------------- +"""Unit tests for the pure Lattice device-mapping core (no PredBat / Home Assistant).""" +import unittest + +from lattice import Fragment, Sensor, AccessPath, merge_fragments, resolve_sensor, device_fragment + + +class TestModel(unittest.TestCase): + """Fragment parses from the wire dict shape (devices + access paths + sensors).""" + + def test_fragment_from_dict(self): + """A producer dict becomes a typed Fragment with nodes/sensors/access paths.""" + f = Fragment.from_dict( + { + "topologyVersion": "0.1.0", + "scope": "fragment", + "producer": {"name": "Local gateway", "provider": "local-gateway"}, + "nodes": [ + { + "id": "INV-1", + "kind": "inverter", + "deviceType": "ge-aio", + "accessPaths": [{"id": "gw-local", "provider": "local-gateway", "locality": "local", "transport": "modbus", "preference": 10}], + "sensors": [{"capability": "soc", "unit": "%", "entity": "sensor.predbat_gateway_inv1_soc", "accessPath": "gw-local"}], + } + ], + } + ) + self.assertEqual(f.provider, "local-gateway") + n = f.nodes[0] + self.assertEqual(n.id, "INV-1") + self.assertEqual(n.access_paths[0].preference, 10) + self.assertEqual(n.sensor("soc").entity, "sensor.predbat_gateway_inv1_soc") + self.assertIsNone(n.sensor("nope")) + + def test_defaults(self): + """AccessPath/Sensor from_dict tolerate minimal dicts.""" + self.assertEqual(AccessPath.from_dict({"id": "x"}).preference, 0) + self.assertEqual(Sensor.from_dict({"capability": "soc"}).entity, "") + + +class TestMerge(unittest.TestCase): + """Same serial from two producers becomes one node with both access paths + combined sensors.""" + + def _frag(self, provider, pref, entity): + return Fragment.from_dict( + { + "producer": {"provider": provider}, + "nodes": [ + { + "id": "INV-1", + "kind": "inverter", + "deviceType": "ge-aio", + "accessPaths": [{"id": provider, "provider": provider, "preference": pref}], + "sensors": [{"capability": "soc", "unit": "%", "entity": entity, "accessPath": provider}], + } + ], + } + ) + + def test_merge_by_serial(self): + """One device via gateway + cloud => one node, 2 access paths (gateway first), 2 soc sensors.""" + merged = merge_fragments([self._frag("local-gateway", 10, "sensor.gw_soc"), self._frag("ge-cloud", 1, "sensor.cloud_soc")]) + self.assertEqual(len(merged.nodes), 1) + node = merged.nodes[0] + self.assertEqual([ap.provider for ap in node.access_paths], ["local-gateway", "ge-cloud"]) + self.assertEqual(len(node.sensors), 2) + + def test_distinct_serials_are_siblings(self): + """Different serials remain separate nodes.""" + a = self._frag("local-gateway", 10, "sensor.a") + b = self._frag("ge-cloud", 1, "sensor.b") + b.nodes[0].id = "INV-2" + self.assertEqual(len(merge_fragments([a, b]).nodes), 2) + + +class TestResolveSensor(unittest.TestCase): + """resolve_sensor returns the entity on the highest-preference access path.""" + + def test_prefers_highest_preference(self): + """A device seen via gateway(10) + cloud(1) resolves soc to the gateway's entity.""" + merged = merge_fragments( + [ + Fragment.from_dict(device_fragment([{"serial": "INV-1", "sensors": [{"capability": "soc", "entity": "sensor.gw_soc"}]}], provider="local-gateway", name="GW", transport="modbus", preference=10, locality="local")), + Fragment.from_dict(device_fragment([{"serial": "INV-1", "sensors": [{"capability": "soc", "entity": "sensor.cloud_soc"}]}], provider="ge-cloud", name="Cloud", transport="https", preference=1, locality="cloud")), + ] + ) + self.assertEqual(resolve_sensor(merged, "soc", "INV-1"), "sensor.gw_soc") + self.assertIsNone(resolve_sensor(merged, "soc", "NOPE")) + self.assertIsNone(resolve_sensor(merged, "power", "INV-1")) + + +class TestDeviceFragment(unittest.TestCase): + """device_fragment builds a read-only fragment from plain device data.""" + + def test_build(self): + """Each device becomes a node with its access path + declared sensors; no control anywhere.""" + f = Fragment.from_dict( + device_fragment( + [{"serial": "CH-1", "device_type": "GIVENERGY_AIO", "sensors": [{"capability": "soc", "unit": "%", "entity": "sensor.ch1_soc"}, {"capability": "battery_power", "unit": "W", "entity": "sensor.ch1_power"}]}], + provider="local-gateway", + name="GW", + transport="modbus", + preference=10, + locality="local", + ) + ) + n = f.nodes[0] + self.assertEqual(n.id, "CH-1") + self.assertEqual(n.device_type, "givenergy_aio") + self.assertEqual({s.capability for s in n.sensors}, {"soc", "battery_power"}) + self.assertFalse(hasattr(n, "capabilities")) # no control surface + + def test_skips_without_serial(self): + """Devices with no serial are skipped (cannot be identity-keyed).""" + self.assertEqual(device_fragment([{"device_type": "x"}], provider="p", name="n", transport="t", preference=1, locality="local")["nodes"], []) + + +if __name__ == "__main__": + unittest.main() From 104da38dfd2639b21f445a1136147d6e0278269b Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Fri, 19 Jun 2026 08:14:05 +0100 Subject: [PATCH 02/14] feat(lattice): device-map projection + component + enable flag LatticeProjection discovers any component publishing a fragment, merges them into the site map, and resolves a device's sensor to its preferred entity. LatticeComponent (registered, phase 2) rebuilds + logs the merged map when lattice_projection_enable is on; no-op when off (default). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/components.py | 2 + apps/predbat/config.py | 6 ++ apps/predbat/lattice_component.py | 34 +++++++++++ apps/predbat/lattice_projection.py | 69 +++++++++++++++++++++ apps/predbat/test_lattice.py | 98 ++++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 apps/predbat/lattice_component.py create mode 100644 apps/predbat/lattice_projection.py diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 3236c1845..ed5a1eab6 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -44,6 +44,7 @@ HAS_GATEWAY = False GatewayMQTT = None from load_ml_component import LoadMLComponent +from lattice_component import LatticeComponent from datetime import datetime, timezone, timedelta import asyncio import os @@ -51,6 +52,7 @@ COMPONENT_LIST = { "storage": {"class": StorageComponent, "name": "Storage", "args": {}, "can_restart": True, "phase": 0}, + "lattice": {"class": LatticeComponent, "name": "Lattice Device Map", "args": {}, "can_restart": True, "phase": 2}, "db": { "class": DatabaseManager, "name": "Database Manager", diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 0a1bbdb2f..0d5c7cb10 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -44,6 +44,12 @@ "type": "switch", "default": False, }, + { + "name": "lattice_projection_enable", + "friendly_name": "Lattice Device Map (experimental)", + "type": "switch", + "default": False, + }, { "name": "active", "friendly_name": "Predbat Active", diff --git a/apps/predbat/lattice_component.py b/apps/predbat/lattice_component.py new file mode 100644 index 000000000..8902814f6 --- /dev/null +++ b/apps/predbat/lattice_component.py @@ -0,0 +1,34 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System - Lattice device-map component (read-only) +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +"""Builds the merged Lattice device map inside the live PredBat system. + +When `lattice_projection_enable` is on, this rebuilds the merged site map from every producer +component each cycle and logs it — which devices are on the network, their access paths, and the +sensors each exposes. READ-ONLY observability; it does not control anything. No-op when off. +""" +from component_base import ComponentBase +from lattice_projection import LatticeProjection + + +class LatticeComponent(ComponentBase): + """Live host for the read-only Lattice device map.""" + + def initialize(self, **kwargs): + """Create the projection over the PredBat base.""" + self.projection = LatticeProjection(self.base) + self.run_timeout = 60 + + async def run(self, seconds, first): + """Rebuild + log the merged device map when enabled; no-op when disabled.""" + if not self.projection.enabled(): + return True + site = self.projection.refresh() + self.log("Lattice: merged device map — {} device(s)".format(len(site.nodes))) + for node in site.nodes: + providers = [ap.provider for ap in node.access_paths] + sensors = [s.capability for s in node.sensors] + self.log("Lattice: device {} ({}) via {} — sensors {}".format(node.id, node.device_type, providers, sensors)) + return True diff --git a/apps/predbat/lattice_projection.py b/apps/predbat/lattice_projection.py new file mode 100644 index 000000000..00c8be68d --- /dev/null +++ b/apps/predbat/lattice_projection.py @@ -0,0 +1,69 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System - Lattice device-map projection (read-only) +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +"""Collects device fragments from the integration components and merges them into one site map. + +READ-ONLY: this maps the devices on the network and inventories their sensors. It does not control +anything — control is a separate, deferred model (a common intent/shape/binding API; see lattice-spec). +""" +from lattice import merge_fragments, resolve_sensor, Fragment + + +class LatticeProjection: + """Discovers producer components, merges their fragments, and exposes the merged device map.""" + + def __init__(self, base): + """Hold the PredBat base for component access and logging.""" + self.base = base + self.site = None + + def enabled(self): + """True when device mapping is switched on (default off).""" + return bool(self.base.get_arg("lattice_projection_enable", False)) + + def _producers(self): + """Yield (name, component) for every registered component that publishes a fragment. + + Discovery is data-driven: ANY component implementing lattice_fragment is a producer, so a + new integration is mapped here with no change. + """ + registry = getattr(self.base, "components", None) + if registry is None: + return + for name in registry.get_all(): + comp = registry.get_component(name) + if comp is not None and hasattr(comp, "lattice_fragment"): + yield name, comp + + def refresh(self): + """Re-collect fragments from all producers and rebuild the merged site map.""" + fragments = [] + for name, comp in self._producers(): + try: + fragments.append(Fragment.from_dict(comp.lattice_fragment())) + except Exception as exc: # a bad producer must not break the others + self.base.log("Warn: lattice: producer {} failed: {}".format(name, exc)) + self.site = merge_fragments(fragments) + return self.site + + def live_providers(self): + """Provider ids whose producing component currently reports alive.""" + live = set() + for _name, comp in self._producers(): + try: + if comp.is_alive(): + for node in self.site.nodes if self.site else []: + for ap in node.access_paths: + live.add(ap.provider) + break + except Exception: + continue + return live + + def sensor_entity(self, capability, node_id): + """Return the preferred entity for a device's sensor (best access path), or None.""" + if self.site is None: + return None + return resolve_sensor(self.site, capability, node_id) diff --git a/apps/predbat/test_lattice.py b/apps/predbat/test_lattice.py index 320761fd4..177982692 100644 --- a/apps/predbat/test_lattice.py +++ b/apps/predbat/test_lattice.py @@ -5,6 +5,7 @@ import unittest from lattice import Fragment, Sensor, AccessPath, merge_fragments, resolve_sensor, device_fragment +from lattice_projection import LatticeProjection class TestModel(unittest.TestCase): @@ -118,5 +119,102 @@ def test_skips_without_serial(self): self.assertEqual(device_fragment([{"device_type": "x"}], provider="p", name="n", transport="t", preference=1, locality="local")["nodes"], []) +class _FakeComp: + """A stand-in producer component exposing lattice_fragment() and is_alive().""" + + def __init__(self, fragment, alive=True): + self._fragment = fragment + self._alive = alive + + def lattice_fragment(self): + return self._fragment + + def is_alive(self): + return self._alive + + +class _FakeComponents: + """A stand-in component registry.""" + + def __init__(self, mapping): + self._mapping = mapping + + def get_component(self, name): + return self._mapping.get(name) + + def get_all(self): + return list(self._mapping.keys()) + + +class _FakeBase: + """A minimal PredBat base for dependency-injected tests.""" + + def __init__(self, components, args=None): + self.components = components + self._args = args or {} + self.args = self._args + self.logs = [] + self.local_tz = None + self.prefix = "predbat" + + def get_arg(self, name, default=None): + return self._args.get(name, default) + + def log(self, message): + self.logs.append(message) + + +class TestLatticeProjection(unittest.TestCase): + """The projection discovers producers, merges them, and resolves sensors — read-only.""" + + def _proj(self, **args): + gw = device_fragment([{"serial": "INV-1", "sensors": [{"capability": "soc", "entity": "sensor.gw_soc"}]}], provider="local-gateway", name="GW", transport="modbus", preference=10, locality="local") + cloud = device_fragment([{"serial": "INV-1", "sensors": [{"capability": "soc", "entity": "sensor.cloud_soc"}]}], provider="ge-cloud", name="Cloud", transport="https", preference=1, locality="cloud") + fox = device_fragment([{"serial": "FOX-1", "sensors": []}], provider="fox-cloud", name="Fox", transport="https", preference=1, locality="cloud") + comps = _FakeComponents({"gateway": _FakeComp(gw), "gecloud": _FakeComp(cloud), "fox": _FakeComp(fox)}) + proj = LatticeProjection(_FakeBase(comps, args or None)) + proj.refresh() + return proj + + def test_merges_and_discovers_any_brand(self): + """All producers (incl. a third brand) auto-merge into the device map.""" + proj = self._proj() + self.assertEqual({n.id for n in proj.site.nodes}, {"INV-1", "FOX-1"}) + + def test_sensor_entity_prefers_gateway(self): + """A device seen via gateway + cloud resolves soc to the gateway entity.""" + self.assertEqual(self._proj().sensor_entity("soc", "INV-1"), "sensor.gw_soc") + + def test_enabled_default_off(self): + """Mapping is off unless lattice_projection_enable is set.""" + self.assertFalse(self._proj().enabled()) + self.assertTrue(self._proj(lattice_projection_enable=True).enabled()) + + +class TestLatticeComponent(unittest.IsolatedAsyncioTestCase): + """The component builds + logs the device map when enabled; no-op when off.""" + + def _base(self, enabled): + gw = device_fragment([{"serial": "INV-1", "sensors": [{"capability": "soc", "entity": "sensor.gw_soc"}]}], provider="local-gateway", name="GW", transport="modbus", preference=10, locality="local") + comps = _FakeComponents({"gateway": _FakeComp(gw)}) + return _FakeBase(comps, {"lattice_projection_enable": True} if enabled else None) + + async def test_noop_when_disabled(self): + from lattice_component import LatticeComponent + + comp = LatticeComponent(self._base(enabled=False)) + self.assertTrue(await comp.run(0, True)) + self.assertIsNone(comp.projection.site) + self.assertEqual(comp.base.logs, []) + + async def test_logs_map_when_enabled(self): + from lattice_component import LatticeComponent + + comp = LatticeComponent(self._base(enabled=True)) + self.assertTrue(await comp.run(0, True)) + self.assertEqual(len(comp.projection.site.nodes), 1) + self.assertTrue(any("device map" in m for m in comp.base.logs)) + + if __name__ == "__main__": unittest.main() From b3a39d68429e17a1e4b217c0a9dc2cb46facc919 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Fri, 19 Jun 2026 08:15:41 +0100 Subject: [PATCH 03/14] feat(lattice): gateway read-only producer (device map + sensors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GatewayMQTT.lattice_fragment maps each battery inverter it sees (identity + type) on a local-Modbus access path and references the gateway sensor entities (soc/power/temperature) that already exist. Read-only — no control. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/gateway.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index 3fbaefd67..3612f9cf5 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -648,6 +648,33 @@ def _inject_inverter_entities(self, inv, suffix): self.dashboard_item(f"sensor.{pfx}_battery_charge_today", round(energy.battery_charge_today_wh / 1000.0, 2), attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_charge_today", {}), app="gateway") self.dashboard_item(f"sensor.{pfx}_battery_discharge_today", round(energy.battery_discharge_today_wh / 1000.0, 2), attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_discharge_today", {}), app="gateway") + def lattice_fragment(self): + """Read-only Lattice fragment: the inverters this gateway sees, plus their sensors. + + Maps each battery inverter (identity + type) on a local-Modbus access path and references + the gateway sensor entities that already exist for it. Read-only — no control. + """ + from lattice import device_fragment + + sensor_suffixes = [("soc", "%", "soc"), ("battery_power", "W", "battery_power"), ("pv_power", "W", "pv_power"), ("grid_power", "W", "grid_power"), ("load_power", "W", "load_power"), ("temperature", "C", "battery_temperature")] + devices = [] + status = getattr(self, "_last_status", None) + for inv in status.inverters if status else []: + if not (inv.battery.ByteSize() > 0 or inv.battery.capacity_wh > 0): + continue + base = "{}_gateway_{}".format(self.prefix, inv.serial[-6:].lower()) + sensors = [{"capability": cap, "unit": unit, "entity": "sensor.{}_{}".format(base, suffix)} for cap, unit, suffix in sensor_suffixes] + devices.append({"serial": inv.serial, "device_type": self._lattice_device_type(inv.type), "sensors": sensors}) + return device_fragment(devices, provider="local-gateway", name="Local gateway", transport="modbus", preference=10, locality="local") + + @staticmethod + def _lattice_device_type(type_value): + """Best-effort readable device type from the gateway proto inverter-type enum (e.g. 'givenergy_aio').""" + try: + return pb.InverterType.Name(type_value).replace("INVERTER_TYPE_", "").lower() + except Exception: + return "inverter" + def _needs_reconfigure(self, status): """Whether automatic_config should (re-)run for this status. From 73fc554620bccae800afca4887d02a6ef0c37d66 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Fri, 19 Jun 2026 08:15:42 +0100 Subject: [PATCH 04/14] feat(lattice): GE-Cloud read-only producer (device map + sensor inventory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GECloudDirect.lattice_fragment publishes its cloud-reachable devices (topology + soc/power sensor inventory) on a low-preference cloud access path, so a device also seen by the gateway resolves to the gateway entity. Read-only — no control. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/gecloud.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/predbat/gecloud.py b/apps/predbat/gecloud.py index b4ce3e640..61818ca8d 100644 --- a/apps/predbat/gecloud.py +++ b/apps/predbat/gecloud.py @@ -254,6 +254,13 @@ def initialize(self, ge_cloud_direct, api_key, automatic): self.requests_total = 0 self.failures_total = 0 + def lattice_fragment(self): + """Read-only Lattice fragment: GE-Cloud devices (topology + sensor inventory).""" + from lattice import device_fragment + + devices = [{"serial": str(s), "device_type": "ge-aio", "sensors": [{"capability": "soc", "unit": "%"}, {"capability": "battery_power", "unit": "W"}]} for s in getattr(self, "device_list", []) or []] + return device_fragment(devices, provider="ge-cloud", name="GivEnergy Cloud", transport="https", preference=1, locality="cloud") + async def switch_event(self, entity_id, service): """ Switch event From cc1edeb5c08bd683e33d26744d9bc90a14a9f12f Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Fri, 19 Jun 2026 08:16:32 +0100 Subject: [PATCH 05/14] feat(lattice): Fox read-only producer (device map + sensor inventory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FoxAPI.lattice_fragment publishes its cloud devices (real deviceSN identity + soc/power sensor inventory) on a cloud access path. Read-only — no control. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/fox.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 57878aaa8..ac427034a 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -1618,6 +1618,14 @@ async def publish_data(self): self.dashboard_item(entity_id, state=state, attributes=attributes, app="fox") + def lattice_fragment(self): + """Read-only Lattice fragment: FoxESS cloud devices (topology + sensor inventory).""" + from lattice import device_fragment + + serials = [d.get("deviceSN") for d in (getattr(self, "device_list", None) or []) if isinstance(d, dict) and d.get("deviceSN")] + devices = [{"serial": str(s), "device_type": "fox", "sensors": [{"capability": "soc", "unit": "%"}, {"capability": "battery_power", "unit": "W"}]} for s in serials] + return device_fragment(devices, provider="fox-cloud", name="FoxESS Cloud", transport="https", preference=1, locality="cloud") + async def write_setting_from_event(self, entity_id, value, is_number=False): """ Handle write events From 4478734f7638368b2130ff7757f13c5a5499f757 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Fri, 19 Jun 2026 08:16:34 +0100 Subject: [PATCH 06/14] feat(lattice): Solax read-only producer (device map + sensor inventory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SolaxAPI.lattice_fragment publishes its cloud plants (topology + soc sensor inventory) on a cloud access path. Read-only — no control. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/solax.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/predbat/solax.py b/apps/predbat/solax.py index fd349467f..074dc81b5 100644 --- a/apps/predbat/solax.py +++ b/apps/predbat/solax.py @@ -453,6 +453,13 @@ async def switch_event(self, entity_id, service): if "_battery_schedule_" in entity_id: await self.write_battery_schedule_event(entity_id, service) + def lattice_fragment(self): + """Read-only Lattice fragment: Solax cloud plants (topology + sensor inventory).""" + from lattice import device_fragment + + devices = [{"serial": str(p), "device_type": "solax", "sensors": [{"capability": "soc", "unit": "%"}]} for p in getattr(self, "plant_list", []) or []] + return device_fragment(devices, provider="solax-cloud", name="Solax Cloud", transport="https", preference=1, locality="cloud") + async def write_battery_schedule_event(self, entity_id, value): """ Write a battery schedule based on an event From 3851e515c01ae80b45acd807c0f8f03f96faa048 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Fri, 19 Jun 2026 08:16:35 +0100 Subject: [PATCH 07/14] feat(lattice): Solis read-only producer (device map + sensor inventory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SolisAPI.lattice_fragment publishes its cloud inverters (topology + soc/power sensor inventory) on a cloud access path. Read-only — no control. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/solis.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/predbat/solis.py b/apps/predbat/solis.py index c792490b2..69a117b3d 100644 --- a/apps/predbat/solis.py +++ b/apps/predbat/solis.py @@ -578,6 +578,13 @@ async def decode_time_windows_v2(self, inverter_sn): self.log("Solis API: Decoded time windows v2 for {}: {}".format(inverter_sn, result)) # Debug log return result + def lattice_fragment(self): + """Read-only Lattice fragment: Solis cloud inverters (topology + sensor inventory).""" + from lattice import device_fragment + + devices = [{"serial": str(s), "device_type": "solis", "sensors": [{"capability": "soc", "unit": "%"}, {"capability": "battery_power", "unit": "W"}]} for s in getattr(self, "inverter_sn", []) or []] + return device_fragment(devices, provider="solis-cloud", name="Solis Cloud", transport="https", preference=1, locality="cloud") + async def reset_charge_windows_if_needed(self, inverter_sn): """ Predbat only uses 1 slot so disable the others to avoid conflicts. From 9a18ab2a5f861f30f2f8986b4f4bb39d67881db9 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Mon, 29 Jun 2026 06:47:01 +0100 Subject: [PATCH 08/14] feat(lattice): authority-ranked merge mirroring the lattice-spec reference Pure Python port of editor/src/merge-engine.ts: merge(docs) -> {"site", "warnings"}. Operates on raw dicts (corpus wire shape); independent of existing dataclasses and does not replace merge_fragments. 16 unit tests cover precedence, tombstones, bag/wholesale/collection-union overrides, refs, docVersion digest, and errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .cspell/custom-dictionary-workspace.txt | 4 + apps/predbat/lattice.py | 244 ++++++++++++++++++++++++ apps/predbat/test_lattice_merge.py | 165 ++++++++++++++++ 3 files changed, 413 insertions(+) create mode 100644 apps/predbat/test_lattice_merge.py diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 73974babf..f78ace2dc 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -60,6 +60,7 @@ compareform configform connack Consolas +contribs coro cprofile creds @@ -96,6 +97,7 @@ diverter dlimit dnoregion docstrings +docversion dstamp dstart dwindow @@ -313,6 +315,7 @@ recp Redownload regionid regname +rels remainings remotecontrol resetmidnight @@ -408,6 +411,7 @@ twinx tzfile tzpath unconfigured +unioned unsmoothed unstaged useid diff --git a/apps/predbat/lattice.py b/apps/predbat/lattice.py index 34c2d6354..9fa7125d5 100644 --- a/apps/predbat/lattice.py +++ b/apps/predbat/lattice.py @@ -14,6 +14,7 @@ (a common intent/shape/binding API) and deliberately not part of this — see the lattice-spec repo. No PredBat/Home Assistant dependencies, so it can be unit-tested standalone. """ +import json as _json from dataclasses import dataclass, field from typing import Optional @@ -193,3 +194,246 @@ def device_fragment(devices, provider, name, transport, preference, locality): } ) return {"topologyVersion": "0.1.0", "scope": "fragment", "producer": {"name": name, "provider": provider}, "nodes": nodes, "relationships": []} + + +def _authority_of(doc): + """Producer authority (default 0), the primary merge precedence; bools are not authorities.""" + a = doc.get("producer", {}).get("authority") if isinstance(doc, dict) else None + return a if isinstance(a, int) and not isinstance(a, bool) else 0 + + +def _recency_of(doc): + """Document docVersion (default 0), the recency tiebreak.""" + v = doc.get("docVersion") if isinstance(doc, dict) else None + return v if isinstance(v, int) and not isinstance(v, bool) else 0 + + +def _major_of(version): + """Major component of a semver-ish topologyVersion string.""" + return str(version if version is not None else "").split(".")[0] + + +def _better(a, b): + """True if rank a outranks rank b: higher authority, then higher recency, then earlier order.""" + if a[0] != b[0]: + return a[0] > b[0] + if a[1] != b[1]: + return a[1] > b[1] + return a[2] < b[2] + + +def _top_by(contribs): + """Return the highest-ranked (item, rank) contributor.""" + best = contribs[0] + for cur in contribs[1:]: + if _better(cur[1], best[1]): + best = cur + return best + + +def _pick_field(contribs, field, node_id, warnings): + """Highest-ranked setter of a scalar/object field; warn on equal-precedence conflicts.""" + best = None + best_rank = None + found = False + for item, rank in contribs: + if field not in item: + continue + if not found or _better(rank, best_rank): + best = item[field] + best_rank = rank + found = True + if not found: + return None + for item, rank in contribs: + if field not in item: + continue + if rank[0] == best_rank[0] and rank[1] == best_rank[1] and item[field] != best: + warnings.append('node "{}" field "{}": conflicting values at equal precedence; kept first'.format(node_id, field)) + break + return best + + +def _merge_bag(contribs, field): + """Per-key bag merge (attributes/parameters): each key from its highest-ranked setter.""" + by_key = {} + for item, rank in contribs: + bag = item.get(field) + if not isinstance(bag, dict): + continue + for k, v in bag.items(): + if k not in by_key or _better(rank, by_key[k][1]): + by_key[k] = (v, rank) + if not by_key: + return None + return {k: v for k, (v, _r) in by_key.items()} + + +def _merge_collection(contribs, field, key_of): + """Identity-keyed collection union; highest-ranked entry per key wins; tombstones omitted.""" + order = [] + by_key = {} + for item, rank in contribs: + entries = item.get(field) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, dict): + continue + key = key_of(entry) + if key is None: + continue + if key not in by_key: + order.append(key) + if key not in by_key or _better(rank, by_key[key][1]): + by_key[key] = (entry, rank) + out = [] + for key in order: + entry = by_key[key][0] + if entry.get("removed") is True: + continue + out.append({k: v for k, v in entry.items() if k != "removed"}) + return out + + +def _offer_key(offer): + """Offer identity (capability, accessPath); a derived offer keys on capability alone.""" + if offer.get("capability") is None: + return None + ap = offer.get("accessPath") + return "{}|{}".format(offer["capability"], "" if ap is None else ap) + + +def _digest(obj): + """Deterministic positive-integer content digest (FNV-1a, 31-bit) over canonical JSON.""" + s = _json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + h = 0x811C9DC5 + for ch in s: + h ^= ord(ch) + h = (h * 0x01000193) & 0xFFFFFFFF + return (h % 2147483647) + 1 + + +def _merge_node(contribs, warnings): + """Build one merged node from its (non-tombstone) contributors.""" + top = _top_by(contribs) + node_id = str(top[0]["id"]) + node = {"id": top[0]["id"], "kind": _pick_field(contribs, "kind", node_id, warnings)} + device_type = _pick_field(contribs, "deviceType", node_id, warnings) + if device_type is not None: + node["deviceType"] = device_type + aggregate = _pick_field(contribs, "aggregate", node_id, warnings) + if aggregate is not None: + node["aggregate"] = aggregate + attributes = _merge_bag(contribs, "attributes") + if attributes: + node["attributes"] = attributes + parameters = _merge_bag(contribs, "parameters") + if parameters: + node["parameters"] = parameters + access_paths = _merge_collection(contribs, "accessPaths", lambda ap: str(ap["id"]) if ap.get("id") is not None else None) + access_paths.sort(key=lambda ap: (-(ap.get("preference") or 0), str(ap.get("id")))) + if access_paths: + node["accessPaths"] = access_paths + capabilities = _merge_collection(contribs, "capabilities", _offer_key) + if capabilities: + node["capabilities"] = capabilities + return node + + +def merge(docs): + """Compose producer fragments + upstream overlays into one authority-ranked site doc. + + Pure mirror of the lattice-spec reference (editor/src/merge-engine.ts), pinned by the + conformance/merge/ corpus. Returns {"site": , "warnings": [str]}. Raises ValueError + on an empty input list or a mismatched topologyVersion major. docVersion is a content digest + (implementation-defined; normalised out of the cross-language comparison). + """ + warnings = [] + inputs = [d for d in (docs or []) if isinstance(d, dict)] + if not inputs: + raise ValueError("cannot merge an empty document list") + majors = {_major_of(d.get("topologyVersion")) for d in inputs} + if len(majors) > 1: + raise ValueError("cannot merge incompatible topologyVersion majors: " + ", ".join(sorted(majors))) + + ranked = [(doc, (_authority_of(doc), _recency_of(doc), order)) for order, doc in enumerate(inputs)] + top = _top_by(ranked) + + node_order = [] + node_contribs = {} + for doc, rank in ranked: + for n in doc.get("nodes") or []: + if not isinstance(n, dict) or n.get("id") is None: + continue + nid = str(n["id"]) + if nid not in node_contribs: + node_contribs[nid] = [] + node_order.append(nid) + node_contribs[nid].append((n, rank)) + + surviving = set() + merged_nodes = [] + for nid in node_order: + contribs = node_contribs[nid] + if _top_by(contribs)[0].get("removed") is True: + continue + surviving.add(nid) + merged_nodes.append(_merge_node([(it, rk) for it, rk in contribs if it.get("removed") is not True], warnings)) + + rel_order = [] + rel_contribs = {} + for doc, rank in ranked: + for rel in doc.get("relationships") or []: + if not isinstance(rel, dict) or rel.get("from") is None or rel.get("to") is None or rel.get("type") is None: + continue + key = "{}|{}|{}".format(rel["from"], rel["to"], rel["type"]) + if key not in rel_contribs: + rel_contribs[key] = [] + rel_order.append(key) + rel_contribs[key].append((rel, rank)) + merged_rels = [] + for key in rel_order: + winner = _top_by(rel_contribs[key])[0] + if winner.get("removed") is True: + continue + if str(winner["from"]) not in surviving or str(winner["to"]) not in surviving: + warnings.append("relationship {} dropped: endpoint not in merged node set".format(key)) + continue + merged_rels.append({k: v for k, v in winner.items() if k != "removed"}) + + next_ref = 1 + for node in merged_nodes: + caps = node.get("capabilities") + if not isinstance(caps, list): + continue + ref_by_cap = {} + for offer in caps: + cap = str(offer.get("capability")) + if cap not in ref_by_cap: + ref_by_cap[cap] = next_ref + next_ref += 1 + offer["ref"] = ref_by_cap[cap] + + device_types = _merge_collection(ranked, "deviceTypes", lambda dt: str(dt["key"]) if dt.get("key") is not None else None) + + top_tv = top[0].get("topologyVersion") + site = { + "topologyVersion": "0.1.0" if top_tv is None else top_tv, + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [{"name": d.get("producer", {}).get("name"), "provider": d.get("producer", {}).get("provider"), "authority": _authority_of(d), "docVersion": _recency_of(d)} for d, _r in ranked], + }, + "nodes": merged_nodes, + } + if device_types: + site["deviceTypes"] = device_types + id_contribs = [(d, r) for d, r in ranked if d.get("id") is not None] + if id_contribs: + site["id"] = _top_by(id_contribs)[0]["id"] + if merged_rels: + site["relationships"] = merged_rels + site["docVersion"] = _digest(site) + return {"site": site, "warnings": warnings} diff --git a/apps/predbat/test_lattice_merge.py b/apps/predbat/test_lattice_merge.py new file mode 100644 index 000000000..71e416811 --- /dev/null +++ b/apps/predbat/test_lattice_merge.py @@ -0,0 +1,165 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System - Lattice authority-ranked merge unit tests +# ----------------------------------------------------------------------------- +"""Unit tests for the authority-ranked Lattice merge (pure; mirrors the lattice-spec reference).""" +import unittest + +from lattice import merge + + +def _frag(**over): + """Build a minimal fragment dict with sensible defaults, overridable per test.""" + base = {"topologyVersion": "0.1.0", "scope": "fragment", "producer": {"name": "p", "provider": "x", "authority": 0}, "nodes": []} + base.update(over) + return base + + +class TestMergeRules(unittest.TestCase): + """The authority-ranked merge obeys precedence, override modes, tombstones and provenance.""" + + def test_union_ranks_access_paths_and_unions_caps(self): + """Same node via two peer providers becomes one node with ranked paths and unioned caps.""" + a = _frag( + producer={"name": "gw", "provider": "gw", "authority": 0}, + docVersion=1, + nodes=[{"id": "INV", "kind": "inverter", "accessPaths": [{"id": "gw-local", "provider": "gw", "preference": 10}], "capabilities": [{"capability": "battery.soc", "accessPath": "gw-local", "read": {}}]}], + ) + b = _frag( + producer={"name": "cloud", "provider": "cloud", "authority": 0}, + docVersion=1, + nodes=[ + { + "id": "INV", + "kind": "inverter", + "accessPaths": [{"id": "vendor-cloud", "provider": "cloud", "preference": 1}], + "capabilities": [{"capability": "battery.soc", "accessPath": "vendor-cloud", "read": {}}, {"capability": "battery.power", "accessPath": "vendor-cloud", "read": {}}], + } + ], + ) + site = merge([a, b])["site"] + node = site["nodes"][0] + self.assertEqual([ap["id"] for ap in node["accessPaths"]], ["gw-local", "vendor-cloud"]) + self.assertEqual([o["capability"] for o in node["capabilities"]], ["battery.soc", "battery.soc", "battery.power"]) + self.assertEqual(node["capabilities"][0]["ref"], node["capabilities"][1]["ref"]) + self.assertNotEqual(node["capabilities"][0]["ref"], node["capabilities"][2]["ref"]) + + def test_override_is_per_field(self): + """Higher authority wins a conflicting scalar; discovered fields it omits survive.""" + disc = _frag(nodes=[{"id": "N", "kind": "inverter", "attributes": {"ratedW": 5000}}]) + over = _frag(producer={"name": "i", "provider": "installer", "authority": 50}, nodes=[{"id": "N", "kind": "gateway"}]) + site = merge([disc, over])["site"] + self.assertEqual(site["nodes"][0]["kind"], "gateway") + self.assertEqual(site["nodes"][0]["attributes"]["ratedW"], 5000) + + def test_attributes_merge_per_key(self): + """Bag fields merge per key by authority.""" + disc = _frag(nodes=[{"id": "N", "kind": "inverter", "attributes": {"ratedW": 5000, "phase": 1}}]) + over = _frag(producer={"name": "i", "provider": "installer", "authority": 50}, nodes=[{"id": "N", "kind": "inverter", "attributes": {"phase": 3}}]) + self.assertEqual(merge([disc, over])["site"]["nodes"][0]["attributes"], {"ratedW": 5000, "phase": 3}) + + def test_aggregate_overridden_wholesale(self): + """Aggregate is a cohesive object overridden wholesale by the highest authority.""" + disc = _frag(nodes=[{"id": "G", "kind": "gateway", "aggregate": {"serves": True, "minChildren": 2}}]) + over = _frag(producer={"name": "i", "provider": "installer", "authority": 50}, nodes=[{"id": "G", "kind": "gateway", "aggregate": {"serves": False}}]) + self.assertEqual(merge([disc, over])["site"]["nodes"][0]["aggregate"], {"serves": False}) + + def test_node_tombstone_drops_node_and_relationships(self): + """A node tombstone removes the node and any relationship referencing it (with a warning).""" + disc = _frag(nodes=[{"id": "A", "kind": "gateway"}, {"id": "B", "kind": "inverter"}], relationships=[{"from": "A", "to": "B", "type": "contains"}]) + over = _frag(producer={"name": "i", "provider": "installer", "authority": 50}, nodes=[{"id": "B", "removed": True}]) + out = merge([disc, over]) + self.assertEqual([n["id"] for n in out["site"]["nodes"]], ["A"]) + self.assertNotIn("relationships", out["site"]) + self.assertTrue(any("dropped" in w for w in out["warnings"])) + + def test_offer_tombstone_removes_one_offer(self): + """An access-path offer tombstone removes only that offer.""" + disc = _frag(nodes=[{"id": "N", "kind": "inverter", "accessPaths": [{"id": "ap", "provider": "x"}], "capabilities": [{"capability": "battery.soc", "accessPath": "ap", "read": {}}, {"capability": "battery.power", "accessPath": "ap", "read": {}}]}]) + over = _frag(producer={"name": "i", "provider": "installer", "authority": 50}, nodes=[{"id": "N", "kind": "inverter", "capabilities": [{"capability": "battery.power", "accessPath": "ap", "removed": True}]}]) + self.assertEqual([o["capability"] for o in merge([disc, over])["site"]["nodes"][0]["capabilities"]], ["battery.soc"]) + + def test_bare_capability_tombstone_targets_derived_offer(self): + """A bare-capability tombstone removes only the access-path-less (derived) offer.""" + disc = _frag( + nodes=[ + { + "id": "N", + "kind": "inverter", + "accessPaths": [{"id": "ap", "provider": "x"}], + "capabilities": [{"capability": "meter.load_power", "accessPath": "ap", "read": {}}, {"capability": "meter.load_power", "derived": {"op": "sum", "inputs": []}}], + } + ] + ) + over = _frag(producer={"name": "i", "provider": "installer", "authority": 50}, nodes=[{"id": "N", "kind": "inverter", "capabilities": [{"capability": "meter.load_power", "removed": True}]}]) + offers = merge([disc, over])["site"]["nodes"][0]["capabilities"] + self.assertEqual(len(offers), 1) + self.assertEqual(offers[0]["accessPath"], "ap") + + def test_recency_breaks_authority_tie(self): + """Equal authority, higher docVersion wins.""" + a = _frag(producer={"name": "a", "provider": "a", "authority": 10}, docVersion=1, nodes=[{"id": "N", "kind": "inverter"}]) + b = _frag(producer={"name": "b", "provider": "b", "authority": 10}, docVersion=5, nodes=[{"id": "N", "kind": "gateway"}]) + self.assertEqual(merge([a, b])["site"]["nodes"][0]["kind"], "gateway") + + def test_full_tie_warns_and_keeps_first(self): + """Equal authority and docVersion with conflicting values: first wins, warning emitted.""" + a = _frag(nodes=[{"id": "N", "kind": "inverter"}]) + b = _frag(nodes=[{"id": "N", "kind": "gateway"}]) + out = merge([a, b]) + self.assertEqual(out["site"]["nodes"][0]["kind"], "inverter") + self.assertTrue(any("conflicting values" in w for w in out["warnings"])) + + def test_survival_after_rediscovery(self): + """A fresh higher-docVersion discovery does not clobber a higher-authority overlay.""" + over = _frag(producer={"name": "i", "provider": "installer", "authority": 50}, docVersion=1, nodes=[{"id": "N", "kind": "gateway"}]) + disc2 = _frag(docVersion=9, nodes=[{"id": "N", "kind": "inverter", "attributes": {"ratedW": 6000}}]) + site = merge([disc2, over])["site"] + self.assertEqual(site["nodes"][0]["kind"], "gateway") + self.assertEqual(site["nodes"][0]["attributes"]["ratedW"], 6000) + + def test_tombstone_noop_when_absent(self): + """Tombstoning an absent element changes nothing and emits no warning.""" + disc = _frag(nodes=[{"id": "N", "kind": "inverter"}]) + over = _frag(producer={"name": "i", "provider": "installer", "authority": 50}, nodes=[{"id": "GHOST", "removed": True}]) + out = merge([disc, over]) + self.assertEqual([n["id"] for n in out["site"]["nodes"]], ["N"]) + self.assertEqual(out["warnings"], []) + + def test_device_types_carried(self): + """Top-level deviceTypes are unioned by key and carried into the merged site.""" + frag = _frag(producer={"name": "gw", "provider": "gw", "authority": 0}, docVersion=1, deviceTypes=[{"key": "ge-aio", "capabilities": [{"capability": "battery.soc", "read": {}}]}], nodes=[{"id": "INV", "kind": "inverter", "deviceType": "ge-aio"}]) + self.assertEqual([d["key"] for d in merge([frag])["site"]["deviceTypes"]], ["ge-aio"]) + + def test_site_id_from_highest_authority_setter(self): + """site.id comes from the highest-authority input that sets id, not merely the top input.""" + disc = _frag(id="site:home", nodes=[{"id": "INV", "kind": "inverter"}]) + over = _frag(producer={"name": "i", "provider": "installer", "authority": 50}, nodes=[{"id": "INV", "kind": "gateway"}]) + self.assertEqual(merge([disc, over])["site"]["id"], "site:home") + + def test_synthetic_producer_and_docversion(self): + """Merged producer is synthetic with input provenance; docVersion is a positive int digest.""" + a = _frag(producer={"name": "gw", "provider": "gw", "authority": 0}, docVersion=3, nodes=[{"id": "N", "kind": "inverter"}]) + b = _frag(producer={"name": "cloud", "provider": "cloud", "authority": 0}, docVersion=7, nodes=[{"id": "M", "kind": "meter"}]) + site = merge([a, b])["site"] + self.assertEqual(site["producer"]["provider"], "lattice-merge") + self.assertEqual([i["provider"] for i in site["producer"]["inputs"]], ["gw", "cloud"]) + self.assertIsInstance(site["docVersion"], int) + self.assertGreater(site["docVersion"], 0) + c = _frag(producer={"name": "cloud", "provider": "cloud", "authority": 0}, docVersion=7, nodes=[{"id": "M", "kind": "gateway"}]) + self.assertNotEqual(site["docVersion"], merge([a, c])["site"]["docVersion"]) + + def test_empty_input_raises(self): + """Merging zero documents raises (no valid zero-node site).""" + with self.assertRaises(ValueError): + merge([]) + + def test_incompatible_major_raises(self): + """Inputs with different topologyVersion majors cannot be merged.""" + a = _frag(topologyVersion="0.1.0", nodes=[{"id": "N", "kind": "inverter"}]) + b = _frag(topologyVersion="1.0.0", nodes=[{"id": "N", "kind": "inverter"}]) + with self.assertRaises(ValueError): + merge([a, b]) + + +if __name__ == "__main__": + unittest.main() From 4ac461b4c1311bceb5f78998be9790449928419d Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Mon, 29 Jun 2026 06:51:14 +0100 Subject: [PATCH 09/14] test(lattice): pin site.id authority-wins among setters + direct relationship tombstone Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/test_lattice_merge.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/predbat/test_lattice_merge.py b/apps/predbat/test_lattice_merge.py index 71e416811..f506fe320 100644 --- a/apps/predbat/test_lattice_merge.py +++ b/apps/predbat/test_lattice_merge.py @@ -160,6 +160,21 @@ def test_incompatible_major_raises(self): with self.assertRaises(ValueError): merge([a, b]) + def test_site_id_higher_authority_wins_when_both_set(self): + """Among inputs that set id, the highest-authority one wins (not merely the first setter).""" + low = {"topologyVersion": "0.1.0", "scope": "fragment", "id": "site:low", "producer": {"name": "gw", "provider": "gw", "authority": 10}, "docVersion": 1, "nodes": [{"id": "N", "kind": "inverter"}]} + high = {"topologyVersion": "0.1.0", "scope": "fragment", "id": "site:high", "producer": {"name": "i", "provider": "installer", "authority": 50}, "docVersion": 1, "nodes": [{"id": "N", "kind": "inverter"}]} + self.assertEqual(merge([low, high])["site"]["id"], "site:high") + self.assertEqual(merge([high, low])["site"]["id"], "site:high") + + def test_relationship_tombstone_removes_relationship(self): + """A higher-authority overlay can tombstone a relationship directly (endpoints survive).""" + disc = _frag(nodes=[{"id": "A", "kind": "gateway"}, {"id": "B", "kind": "inverter"}], relationships=[{"from": "A", "to": "B", "type": "contains"}]) + over = _frag(producer={"name": "i", "provider": "installer", "authority": 50}, nodes=[{"id": "A", "kind": "gateway"}, {"id": "B", "kind": "inverter"}], relationships=[{"from": "A", "to": "B", "type": "contains", "removed": True}]) + out = merge([disc, over]) + self.assertNotIn("relationships", out["site"]) + self.assertEqual([n["id"] for n in out["site"]["nodes"]], ["A", "B"]) + if __name__ == "__main__": unittest.main() From fb0fb4180c0f06ad569165071cf12d6931b90682 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Mon, 29 Jun 2026 07:00:53 +0100 Subject: [PATCH 10/14] test(lattice): pin merge to the lattice-spec conformance corpus (12 cases) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/test_lattice_merge_corpus.py | 39 ++ .../tests/lattice_merge_corpus/SOURCE.md | 9 + .../tests/lattice_merge_corpus/cases.json | 118 +++++ .../tests/lattice_merge_corpus/expected.json | 489 ++++++++++++++++++ 4 files changed, 655 insertions(+) create mode 100644 apps/predbat/test_lattice_merge_corpus.py create mode 100644 apps/predbat/tests/lattice_merge_corpus/SOURCE.md create mode 100644 apps/predbat/tests/lattice_merge_corpus/cases.json create mode 100644 apps/predbat/tests/lattice_merge_corpus/expected.json diff --git a/apps/predbat/test_lattice_merge_corpus.py b/apps/predbat/test_lattice_merge_corpus.py new file mode 100644 index 000000000..941f27a0b --- /dev/null +++ b/apps/predbat/test_lattice_merge_corpus.py @@ -0,0 +1,39 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System - Lattice merge cross-language conformance test +# ----------------------------------------------------------------------------- +"""Pins batpred's merge to the language-neutral lattice-spec corpus (vendored, normalized).""" + +import json +import os +import unittest + +from lattice import merge + +_CORPUS = os.path.join(os.path.dirname(__file__), "tests", "lattice_merge_corpus") + + +def _normalize(result): + """Return {site, warnings} with the implementation-defined site.docVersion removed.""" + site = dict(result["site"]) + site.pop("docVersion", None) + return {"site": site, "warnings": result["warnings"]} + + +class TestMergeCorpus(unittest.TestCase): + """Every corpus case must merge to the golden {site, warnings} (docVersion normalized out).""" + + def test_corpus(self): + """batpred merge == the lattice-spec reference on all vendored cases.""" + with open(os.path.join(_CORPUS, "cases.json"), encoding="utf-8") as handle: + cases = json.load(handle) + with open(os.path.join(_CORPUS, "expected.json"), encoding="utf-8") as handle: + expected = json.load(handle) + self.assertEqual(len(cases), len(expected)) + for case in cases: + with self.subTest(case=case["name"]): + self.assertIn(case["name"], expected) + self.assertEqual(_normalize(merge(case["inputs"])), expected[case["name"]]) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/predbat/tests/lattice_merge_corpus/SOURCE.md b/apps/predbat/tests/lattice_merge_corpus/SOURCE.md new file mode 100644 index 000000000..fa95b54c8 --- /dev/null +++ b/apps/predbat/tests/lattice_merge_corpus/SOURCE.md @@ -0,0 +1,9 @@ +# Vendored Lattice merge corpus + +Copied verbatim from the canonical source: +`Predictive-Cloud-Ltd/lattice-spec` → `conformance/merge/{cases.json,expected.json}`. + +The golden is **normalized**: the merged `site.docVersion` is removed (it is an +implementation-defined content digest). batpred's `merge` is pinned to produce the same +`{site, warnings}` after the same normalization. Refresh by re-copying both files when the +canonical corpus changes. diff --git a/apps/predbat/tests/lattice_merge_corpus/cases.json b/apps/predbat/tests/lattice_merge_corpus/cases.json new file mode 100644 index 000000000..80807a769 --- /dev/null +++ b/apps/predbat/tests/lattice_merge_corpus/cases.json @@ -0,0 +1,118 @@ +[ + { + "name": "union: same node via two peer providers — ranked access paths + unioned caps", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + "nodes": [{ "id": "INV", "kind": "inverter", "accessPaths": [{ "id": "gw-local", "provider": "gw", "preference": 10 }], + "capabilities": [{ "capability": "battery.soc", "accessPath": "gw-local", "unit": "%", "read": { "protocol": "modbus", "address": 60 } }] }] }, + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "cloud", "provider": "cloud", "authority": 0 }, "docVersion": 1, + "nodes": [{ "id": "INV", "kind": "inverter", "accessPaths": [{ "id": "vendor-cloud", "provider": "cloud", "preference": 1 }], + "capabilities": [ + { "capability": "battery.soc", "accessPath": "vendor-cloud", "unit": "%", "read": { "protocol": "cloud-api", "address": "/soc" } }, + { "capability": "battery.power", "accessPath": "vendor-cloud", "unit": "W", "read": { "protocol": "cloud-api", "address": "/power" } }] }] } + ] + }, + { + "name": "override: higher-authority overlay corrects kind, discovered attributes intact", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "inverter", "attributes": { "ratedW": 5000 } }] }, + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "gateway" }] } + ] + }, + { + "name": "sparse-override: high-authority overlay sets only kind, keeps discovered access paths + capabilities", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "inverter", "attributes": { "ratedW": 5000 }, "accessPaths": [{ "id": "ap", "provider": "gw", "preference": 10 }], + "capabilities": [{ "capability": "battery.soc", "accessPath": "ap", "unit": "%", "read": { "protocol": "modbus", "address": 60 } }] }] }, + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "gateway" }] } + ] + }, + { + "name": "add: overlay introduces a node + a contains edge discovery missed", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + "nodes": [{ "id": "GW", "kind": "gateway" }] }, + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + "nodes": [{ "id": "INV", "kind": "inverter" }], "relationships": [{ "from": "GW", "to": "INV", "type": "contains" }] } + ] + }, + { + "name": "remove-node: overlay tombstones a phantom node; its relationships drop", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + "nodes": [{ "id": "GW", "kind": "gateway" }, { "id": "PHANTOM", "kind": "inverter" }], + "relationships": [{ "from": "GW", "to": "PHANTOM", "type": "contains" }] }, + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + "nodes": [{ "id": "PHANTOM", "removed": true }] } + ] + }, + { + "name": "remove-offer: overlay tombstones a misdetected capability on a real node", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "inverter", "accessPaths": [{ "id": "ap", "provider": "gw" }], + "capabilities": [ + { "capability": "battery.soc", "accessPath": "ap", "unit": "%", "read": { "protocol": "modbus", "address": 60 } }, + { "capability": "battery.temperature", "accessPath": "ap", "unit": "degC", "read": { "protocol": "modbus", "address": 61 } }] }] }, + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "inverter", "capabilities": [{ "capability": "battery.temperature", "accessPath": "ap", "removed": true }] }] } + ] + }, + { + "name": "recency: equal authority, higher docVersion wins", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 10 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "inverter" }] }, + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw2", "provider": "gw", "authority": 10 }, "docVersion": 5, + "nodes": [{ "id": "N", "kind": "gateway" }] } + ] + }, + { + "name": "tie warns: equal authority and docVersion, conflicting kind — first wins + warning", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "a", "provider": "a", "authority": 10 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "inverter" }] }, + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "b", "provider": "b", "authority": 10 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "gateway" }] } + ] + }, + { + "name": "survival: a fresh rediscovery does not clobber the upstream override", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 9, + "nodes": [{ "id": "N", "kind": "inverter", "attributes": { "ratedW": 6000 } }] }, + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "gateway" }] } + ] + }, + { + "name": "tombstone-noop: tombstoning an absent element changes nothing", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "inverter" }] }, + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + "nodes": [{ "id": "GHOST", "removed": true }] } + ] + }, + { + "name": "deviceTypes: descriptor carried into the site so node deviceType resolves", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + "deviceTypes": [{ "key": "ge-aio", "capabilities": [{ "capability": "battery.soc", "read": { "protocol": "modbus", "address": 60 } }] }], + "nodes": [{ "id": "INV", "kind": "inverter", "deviceType": "ge-aio" }] } + ] + }, + { + "name": "site.id: survives from low-authority discovery when a high-authority overlay omits id", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "id": "site:home", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + "nodes": [{ "id": "INV", "kind": "inverter" }] }, + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + "nodes": [{ "id": "INV", "kind": "gateway" }] } + ] + } +] diff --git a/apps/predbat/tests/lattice_merge_corpus/expected.json b/apps/predbat/tests/lattice_merge_corpus/expected.json new file mode 100644 index 000000000..969e608f8 --- /dev/null +++ b/apps/predbat/tests/lattice_merge_corpus/expected.json @@ -0,0 +1,489 @@ +{ + "union: same node via two peer providers — ranked access paths + unioned caps": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 0, + "docVersion": 1 + }, + { + "name": "cloud", + "provider": "cloud", + "authority": 0, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "INV", + "kind": "inverter", + "accessPaths": [ + { + "id": "gw-local", + "provider": "gw", + "preference": 10 + }, + { + "id": "vendor-cloud", + "provider": "cloud", + "preference": 1 + } + ], + "capabilities": [ + { + "capability": "battery.soc", + "accessPath": "gw-local", + "unit": "%", + "read": { + "protocol": "modbus", + "address": 60 + }, + "ref": 1 + }, + { + "capability": "battery.soc", + "accessPath": "vendor-cloud", + "unit": "%", + "read": { + "protocol": "cloud-api", + "address": "/soc" + }, + "ref": 1 + }, + { + "capability": "battery.power", + "accessPath": "vendor-cloud", + "unit": "W", + "read": { + "protocol": "cloud-api", + "address": "/power" + }, + "ref": 2 + } + ] + } + ] + }, + "warnings": [] + }, + "override: higher-authority overlay corrects kind, discovered attributes intact": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 0, + "docVersion": 1 + }, + { + "name": "installer", + "provider": "installer", + "authority": 50, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "N", + "kind": "gateway", + "attributes": { + "ratedW": 5000 + } + } + ] + }, + "warnings": [] + }, + "sparse-override: high-authority overlay sets only kind, keeps discovered access paths + capabilities": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 0, + "docVersion": 1 + }, + { + "name": "installer", + "provider": "installer", + "authority": 50, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "N", + "kind": "gateway", + "attributes": { + "ratedW": 5000 + }, + "accessPaths": [ + { + "id": "ap", + "provider": "gw", + "preference": 10 + } + ], + "capabilities": [ + { + "capability": "battery.soc", + "accessPath": "ap", + "unit": "%", + "read": { + "protocol": "modbus", + "address": 60 + }, + "ref": 1 + } + ] + } + ] + }, + "warnings": [] + }, + "add: overlay introduces a node + a contains edge discovery missed": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 0, + "docVersion": 1 + }, + { + "name": "installer", + "provider": "installer", + "authority": 50, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "GW", + "kind": "gateway" + }, + { + "id": "INV", + "kind": "inverter" + } + ], + "relationships": [ + { + "from": "GW", + "to": "INV", + "type": "contains" + } + ] + }, + "warnings": [] + }, + "remove-node: overlay tombstones a phantom node; its relationships drop": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 0, + "docVersion": 1 + }, + { + "name": "installer", + "provider": "installer", + "authority": 50, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "GW", + "kind": "gateway" + } + ] + }, + "warnings": [ + "relationship GW|PHANTOM|contains dropped: endpoint not in merged node set" + ] + }, + "remove-offer: overlay tombstones a misdetected capability on a real node": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 0, + "docVersion": 1 + }, + { + "name": "installer", + "provider": "installer", + "authority": 50, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "N", + "kind": "inverter", + "accessPaths": [ + { + "id": "ap", + "provider": "gw" + } + ], + "capabilities": [ + { + "capability": "battery.soc", + "accessPath": "ap", + "unit": "%", + "read": { + "protocol": "modbus", + "address": 60 + }, + "ref": 1 + } + ] + } + ] + }, + "warnings": [] + }, + "recency: equal authority, higher docVersion wins": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 10, + "docVersion": 1 + }, + { + "name": "gw2", + "provider": "gw", + "authority": 10, + "docVersion": 5 + } + ] + }, + "nodes": [ + { + "id": "N", + "kind": "gateway" + } + ] + }, + "warnings": [] + }, + "tie warns: equal authority and docVersion, conflicting kind — first wins + warning": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "a", + "provider": "a", + "authority": 10, + "docVersion": 1 + }, + { + "name": "b", + "provider": "b", + "authority": 10, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "N", + "kind": "inverter" + } + ] + }, + "warnings": [ + "node \"N\" field \"kind\": conflicting values at equal precedence; kept first" + ] + }, + "survival: a fresh rediscovery does not clobber the upstream override": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 0, + "docVersion": 9 + }, + { + "name": "installer", + "provider": "installer", + "authority": 50, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "N", + "kind": "gateway", + "attributes": { + "ratedW": 6000 + } + } + ] + }, + "warnings": [] + }, + "tombstone-noop: tombstoning an absent element changes nothing": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 0, + "docVersion": 1 + }, + { + "name": "installer", + "provider": "installer", + "authority": 50, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "N", + "kind": "inverter" + } + ] + }, + "warnings": [] + }, + "deviceTypes: descriptor carried into the site so node deviceType resolves": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 0, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "INV", + "kind": "inverter", + "deviceType": "ge-aio" + } + ], + "deviceTypes": [ + { + "key": "ge-aio", + "capabilities": [ + { + "capability": "battery.soc", + "read": { + "protocol": "modbus", + "address": 60 + } + } + ] + } + ] + }, + "warnings": [] + }, + "site.id: survives from low-authority discovery when a high-authority overlay omits id": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 0, + "docVersion": 1 + }, + { + "name": "installer", + "provider": "installer", + "authority": 50, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "INV", + "kind": "gateway" + } + ], + "id": "site:home" + }, + "warnings": [] + } +} From 88e5af8099831580d56b3d87d27dbd9dd90e5f64 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Mon, 29 Jun 2026 09:32:24 +0100 Subject: [PATCH 11/14] test(lattice): refresh vendored merge corpus to 13 cases (codepoint tiebreak) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/lattice_merge_corpus/cases.json | 8 ++++ .../tests/lattice_merge_corpus/expected.json | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/apps/predbat/tests/lattice_merge_corpus/cases.json b/apps/predbat/tests/lattice_merge_corpus/cases.json index 80807a769..5d8690753 100644 --- a/apps/predbat/tests/lattice_merge_corpus/cases.json +++ b/apps/predbat/tests/lattice_merge_corpus/cases.json @@ -114,5 +114,13 @@ { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, "nodes": [{ "id": "INV", "kind": "gateway" }] } ] + }, + { + "name": "tiebreak: equal-preference access paths sort by id codepoint order", + "inputs": [ + { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + "nodes": [{ "id": "INV", "kind": "inverter", + "accessPaths": [{ "id": "a-local", "provider": "gw", "preference": 5 }, { "id": "B-cloud", "provider": "cloud", "preference": 5 }] }] } + ] } ] diff --git a/apps/predbat/tests/lattice_merge_corpus/expected.json b/apps/predbat/tests/lattice_merge_corpus/expected.json index 969e608f8..4aa86908e 100644 --- a/apps/predbat/tests/lattice_merge_corpus/expected.json +++ b/apps/predbat/tests/lattice_merge_corpus/expected.json @@ -485,5 +485,42 @@ "id": "site:home" }, "warnings": [] + }, + "tiebreak: equal-preference access paths sort by id codepoint order": { + "site": { + "topologyVersion": "0.1.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 0, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "INV", + "kind": "inverter", + "accessPaths": [ + { + "id": "B-cloud", + "provider": "cloud", + "preference": 5 + }, + { + "id": "a-local", + "provider": "gw", + "preference": 5 + } + ] + } + ] + }, + "warnings": [] } } From fb528e8432786af91593329c7ba06c3ad8f25184 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Mon, 29 Jun 2026 23:57:28 +0100 Subject: [PATCH 12/14] test(lattice): pin resolve to the lattice-spec conformance corpus Add a pure resolve(doc, capability, side, intent, offline, altitude) to lattice.py, mirroring the lattice-spec reference (editor/src/resolve-engine.ts): read vs control routing, ranked access-path fallback, aggregate delegation/expansion, ownership, derived reads, and intent clamping. Returns the observable result dict. Vendor conformance/resolve/{cases,expected}.json (byte-identical) + a runner that projects the observable FIELD set and asserts batpred == the reference on all 6 cases. This makes batpred a provably-identical second-language resolve adopter, alongside the existing merge corpus. Reviewed (sonnet) vs the TS line-by-line incl. branches the corpus doesn't cover; folded in: JS-style number stringification in display labels (integer floats lose the trailing .0, e.g. cap/2 label), nullish reducer default, and a NaN-intent guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/lattice.py | 238 ++++++++++++++++++ apps/predbat/test_lattice_resolve_corpus.py | 76 ++++++ .../tests/lattice_resolve_corpus/cases.json | 86 +++++++ .../lattice_resolve_corpus/expected.json | 139 ++++++++++ 4 files changed, 539 insertions(+) create mode 100644 apps/predbat/test_lattice_resolve_corpus.py create mode 100644 apps/predbat/tests/lattice_resolve_corpus/cases.json create mode 100644 apps/predbat/tests/lattice_resolve_corpus/expected.json diff --git a/apps/predbat/lattice.py b/apps/predbat/lattice.py index 9fa7125d5..249c05895 100644 --- a/apps/predbat/lattice.py +++ b/apps/predbat/lattice.py @@ -437,3 +437,241 @@ def merge(docs): site["relationships"] = merged_rels site["docVersion"] = _digest(site) return {"site": site, "warnings": warnings} + + +# ----------------------------------------------------------------------------- +# Resolve: a {capability, side} query against a site doc -> routing/clamp plan. +# Pure mirror of the lattice-spec reference (editor/src/resolve-engine.ts), +# pinned by the conformance/resolve/ corpus. Independent of merge above. +# ----------------------------------------------------------------------------- + + +def _as_num(x): + """Return x if it is a real number (not a bool), else None.""" + return x if isinstance(x, (int, float)) and not isinstance(x, bool) else None + + +def _num_str(x): + """Render a number as JavaScript String() would: an integer-valued float loses its trailing '.0'.""" + if isinstance(x, float) and x.is_integer(): + return str(int(x)) + return str(x) + + +def _fmt_param(v): + """Render a transform value-or-ref param for display.""" + if isinstance(v, dict) and "ref" in v: + factor = v.get("factor") + return "ref {}×{}".format(v["ref"], _num_str(factor)) if factor is not None else "ref {}".format(v["ref"]) + return _num_str(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else str(v) + + +def format_transform(t): + """Render a binding transform as a short display string (mirrors the editor); None if not a transform.""" + if not isinstance(t, dict) or not isinstance(t.get("kind"), str): + return None + if t["kind"] == "pipeline": + steps = [s for s in (format_transform(step) for step in (t.get("steps") or [])) if s] + return "pipeline[{}]".format(", ".join(steps)) + keys = [k for k in ("num", "den", "scale", "offset", "min", "max") if t.get(k) is not None] + extra = [] + if t.get("round") and t.get("round") != "trunc": + extra.append("round={}".format(t["round"])) + if t.get("onRefUnavailable") == "max": + extra.append("fail-open") + parts = ["{}={}".format(k, _fmt_param(t[k])) for k in keys] + extra + return t["kind"] if not parts else "{}({})".format(t["kind"], ", ".join(parts)) + + +def _format_derived(d): + """Render a derived-read spec (sum/ratio over sibling capabilities) as a short display string.""" + if not isinstance(d, dict): + return None + if d.get("op") == "sum": + parts = [] + for i in d.get("inputs") or []: + w = i.get("weight") + prefix = "{}×".format(_num_str(w)) if (w is not None and w != 1) else "" + parts.append("{}{}".format(prefix, i.get("ref"))) + return "sum({})".format(" + ".join(parts)) + if d.get("op") == "ratio": + num = d.get("num") or {} + den = d.get("den") or {} + nf = num.get("factor") + num_prefix = "{}×".format(_num_str(nf)) if (nf is not None and nf != 1) else "" + return "ratio({}{} / {})".format(num_prefix, num.get("ref"), den.get("ref")) + return d.get("op") + + +def _resolve_max(max_spec, node): + """Resolve a control max bound to {value, label}; value is None when only known at runtime.""" + if isinstance(max_spec, (int, float)) and not isinstance(max_spec, bool): + return {"value": max_spec, "label": _num_str(max_spec)} + if isinstance(max_spec, dict) and "source" in max_spec: + return {"value": None, "label": "source {}".format(max_spec["source"])} + attrs = node.get("attributes") or {} + rated = attrs.get("ratedW") + cap = attrs.get("capacityWh") + if max_spec == "rated": + return {"value": rated, "label": "rated = {}".format(_num_str(rated) if rated is not None else "?")} + if isinstance(max_spec, str) and "capacity/2" in max_spec: + half = (cap / 2) if cap is not None else None + if half is not None and rated is not None: + v = min(half, rated) + else: + v = half if half is not None else rated + return {"value": v, "label": "min(cap/2 = {}, rated = {}) = {}".format(_num_str(half) if half is not None else "?", _num_str(rated) if rated is not None else "?", _num_str(v) if v is not None else "?")} + return {"value": None, "label": "—" if max_spec is None else str(max_spec)} + + +def _children_of(doc, node_id, rel): + """Ids of nodes reached from node_id by a relationship of the given type.""" + return [r["to"] for r in (doc.get("relationships") or []) if r.get("from") == node_id and r.get("type") == rel] + + +def _nodes_offering(doc, cap, side): + """Nodes offering cap on the requested side, each with its matching capability offers.""" + out = [] + for n in doc.get("nodes") or []: + offers = [c for c in (n.get("capabilities") or []) if c.get("capability") == cap and ((c.get("read") or c.get("derived")) if side == "read" else c.get("control"))] + if offers: + out.append({"node": n, "offers": offers}) + return out + + +def resolve(doc, capability, side, intent=None, offline=None, altitude="auto"): + """Resolve a {capability, side} query against a site doc to a routing/clamp plan. + + Pure mirror of the lattice-spec reference (editor/src/resolve-engine.ts), pinned by the + conformance/resolve/ corpus: read vs control routing, ranked access-path fallback, aggregate + delegation/expansion, ownership, derived reads, and intent clamping. `offline` is a set of + access-path ids; `altitude` is "auto", "aggregate", or "leaves". Returns the observable result. + """ + offline = offline or set() + offering = _nodes_offering(doc, capability, side) + if not offering: + return {"ok": False, "side": side, "message": 'No node offers {} for "{}".'.format(side, capability)} + + # Control altitude: a qualifying aggregator can take one delegated command (its firmware fans + # out to its children); otherwise the hub expands to the leaves directly. + agg = None + agg_priority = -1 + for o in offering: + a = o["node"].get("aggregate") + if not (a and a.get("serves")): + continue + over = a.get("over") or "contains" + if len(_children_of(doc, o["node"]["id"], over)) >= (a.get("minChildren") or 0) and (a.get("priority") or 0) >= agg_priority: + agg = o + agg_priority = a.get("priority") or 0 + leaves = [o for o in offering if not (o["node"].get("aggregate") or {}).get("serves")] + + ownership_note = None + if agg and altitude in ("auto", "aggregate"): + route_nodes = [agg] + strategy = "delegated" + else: + route_nodes = leaves if leaves else offering + strategy = "expanded" if len(route_nodes) > 1 else "direct" + if agg and altitude == "leaves": + ownership_note = "{} serves as coordinator for these — commanding the leaves directly contends with it (§6). Prefer the aggregate altitude.".format(agg["node"]["id"]) + elif not agg and altitude == "aggregate": + ownership_note = "No qualifying coordinator for this capability — falling back to per-leaf (expanded)." + + # The chosen altitude claims a group of nodes; the other altitude is then off-limits (ownership). + if strategy == "delegated": + owned_nodes = _children_of(doc, agg["node"]["id"], (agg["node"].get("aggregate") or {}).get("over") or "contains") + else: + owned_nodes = [o["node"]["id"] for o in route_nodes] + + primary = route_nodes[0] + node = primary["node"] + + # A derived read is computed from sibling capabilities — no access path / binding to rank. + if side == "read": + d_offer = next((c for c in (node.get("capabilities") or []) if c.get("capability") == capability and c.get("derived") and not c.get("read")), None) + if d_offer: + return {"ok": True, "side": side, "node": node["id"], "nodeKind": node.get("kind"), "unit": d_offer.get("unit"), "derived": _format_derived(d_offer.get("derived"))} + + # Rank this node's offers (= access paths) by preference, descending. + ap_by_id = {a["id"]: a for a in (node.get("accessPaths") or []) if a.get("id") is not None} + ranked = [x for x in ({"offer": offer, "ap": ap_by_id.get(offer.get("accessPath"))} for offer in primary["offers"]) if x["ap"]] + ranked.sort(key=lambda x: -(x["ap"].get("preference") or 0)) + + result = { + "ok": False, + "side": side, + "node": node["id"], + "nodeKind": node.get("kind"), + "routeNodeCount": len(route_nodes), + "strategy": strategy, + "planNodes": [o["node"]["id"] for o in route_nodes], + "ownedNodes": owned_nodes, + } + if primary["offers"]: + first_reducer = primary["offers"][0].get("reducer") + reducer = (first_reducer if first_reducer is not None else "mean") if len(route_nodes) > 1 else first_reducer + if reducer is not None: + result["reducer"] = reducer + distribution = next((o.get("distribution") for o in primary["offers"] if o.get("distribution")), None) + if distribution is not None: + result["distribution"] = distribution + if ownership_note is not None: + result["ownershipNote"] = ownership_note + + chosen_idx = next((i for i, x in enumerate(ranked) if x["ap"]["id"] not in offline), -1) + if chosen_idx < 0: + result["message"] = "All access paths offline — unresolved." + return result + result["ok"] = True + result["chosenAccessPath"] = ranked[chosen_idx]["ap"]["id"] + result["fellBack"] = chosen_idx > 0 + + chosen_offer = ranked[chosen_idx]["offer"] + b = chosen_offer.get("read") if side == "read" else chosen_offer.get("control") + if b: + binding = {} + for key in ("protocol", "op", "address"): + if b.get(key) is not None: + binding[key] = b[key] + tf = format_transform(b.get("transform")) + if tf is not None: + binding["transform"] = tf + if b.get("readModifyWrite"): + binding["readModifyWrite"] = True + result["binding"] = binding + for key in ("unit", "shape", "tier", "controlGroup"): + if chosen_offer.get(key) is not None: + result[key] = chosen_offer[key] + + if side == "control" and chosen_offer.get("controlGroup"): + members = [] + for o in node.get("capabilities") or []: + if o.get("controlGroup") == chosen_offer["controlGroup"] and o.get("accessPath") == chosen_offer.get("accessPath"): + s = o.get("groupSlot") or {} + if s.get("field"): + where = " → {}".format(s["field"]) + elif s.get("bits"): + where = " → bits[{}..{}]".format(s["bits"]["lsb"], s["bits"]["lsb"] + s["bits"]["width"] - 1) + else: + where = "" + members.append("{}{}".format(o.get("capability"), where)) + result["groupMembers"] = members + + if side == "control" and intent is not None and intent == intent: # intent == intent excludes NaN + cons = chosen_offer.get("constraints") or {} + mn = _as_num(cons.get("min")) + if mn is None: + mn = 0 + mx = _resolve_max(cons.get("max"), node) + c = intent + if c < mn: + c = mn + if mx["value"] is not None and c > mx["value"]: + c = mx["value"] + result["intent"] = intent + result["clamped"] = c + result["clampMin"] = mn + result["clampMaxLabel"] = mx["label"] + + return result diff --git a/apps/predbat/test_lattice_resolve_corpus.py b/apps/predbat/test_lattice_resolve_corpus.py new file mode 100644 index 000000000..799de2583 --- /dev/null +++ b/apps/predbat/test_lattice_resolve_corpus.py @@ -0,0 +1,76 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System - Lattice resolve cross-language conformance test +# ----------------------------------------------------------------------------- +"""Pins batpred's resolve to the language-neutral lattice-spec corpus (vendored).""" + +import json +import os +import unittest + +from lattice import resolve + +_CORPUS = os.path.join(os.path.dirname(__file__), "tests", "lattice_resolve_corpus") + +# The observable result of a resolution — the behaviour every language's resolver must agree on +# (mirrors editor/scripts/resolve-runner.mjs FIELDS). +_FIELDS = [ + "ok", + "side", + "node", + "nodeKind", + "chosenAccessPath", + "fellBack", + "reducer", + "routeNodeCount", + "strategy", + "planNodes", + "distribution", + "ownedNodes", + "ownershipNote", + "unit", + "shape", + "tier", + "controlGroup", + "groupMembers", + "derived", + "clamped", + "clampMin", + "clampMaxLabel", + "binding", + "intent", + "message", +] + + +def _project(result): + """Keep the observable FIELD set, dropping absent keys — the cross-language comparison form.""" + return {k: result[k] for k in _FIELDS if k in result and result[k] is not None} + + +class TestResolveCorpus(unittest.TestCase): + """Every corpus case must resolve to the golden projected result.""" + + def test_corpus(self): + """batpred resolve == the lattice-spec reference on all vendored cases.""" + with open(os.path.join(_CORPUS, "cases.json"), encoding="utf-8") as handle: + cases = json.load(handle) + with open(os.path.join(_CORPUS, "expected.json"), encoding="utf-8") as handle: + expected = json.load(handle) + self.assertEqual(len(cases), len(expected)) + for case in cases: + with self.subTest(case=case["name"]): + self.assertIn(case["name"], expected) + query = case["query"] + got = resolve( + case["doc"], + query["capability"], + query["side"], + query.get("intent"), + set(query.get("offline") or []), + query.get("altitude") or "auto", + ) + self.assertEqual(_project(got), expected[case["name"]]) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/predbat/tests/lattice_resolve_corpus/cases.json b/apps/predbat/tests/lattice_resolve_corpus/cases.json new file mode 100644 index 000000000..485865182 --- /dev/null +++ b/apps/predbat/tests/lattice_resolve_corpus/cases.json @@ -0,0 +1,86 @@ +[ + { + "name": "read: single offer resolves to its node + access path", + "query": { "capability": "battery.soc", "side": "read" }, + "doc": { + "topologyVersion": "0.2.0", "scope": "site", "docVersion": 1, "producer": { "name": "p", "provider": "x" }, + "nodes": [{ "id": "INV", "kind": "inverter", "accessPaths": [{ "id": "ap", "provider": "p", "preference": 10 }], + "capabilities": [{ "capability": "battery.soc", "ref": 1, "accessPath": "ap", "unit": "%", + "read": { "protocol": "modbus", "op": "read_input", "address": 60, "transform": { "kind": "identity" } } }] }] + } + }, + { + "name": "read: ranked access-path fallback when the preferred path is offline", + "query": { "capability": "battery.soc", "side": "read", "offline": ["gw-local"] }, + "doc": { + "topologyVersion": "0.2.0", "scope": "site", "docVersion": 1, "producer": { "name": "p", "provider": "x" }, + "nodes": [{ "id": "INV", "kind": "inverter", + "accessPaths": [{ "id": "gw-local", "provider": "gw", "preference": 10 }, { "id": "vendor-cloud", "provider": "cloud", "preference": 1 }], + "capabilities": [ + { "capability": "battery.soc", "ref": 1, "accessPath": "gw-local", "unit": "%", "read": { "protocol": "modbus", "address": 60 } }, + { "capability": "battery.soc", "ref": 1, "accessPath": "vendor-cloud", "unit": "%", "read": { "protocol": "cloud-api", "address": "/soc" } } + ] }] + } + }, + { + "name": "control: setpoint clamps an over-range intent to max", + "query": { "capability": "battery.charge_power_limit", "side": "control", "intent": 9000 }, + "doc": { + "topologyVersion": "0.2.0", "scope": "site", "docVersion": 1, "producer": { "name": "p", "provider": "x" }, + "nodes": [{ "id": "INV", "kind": "inverter", "attributes": { "ratedW": 5000 }, + "accessPaths": [{ "id": "ap", "provider": "p", "preference": 10 }], + "capabilities": [{ "capability": "battery.charge_power_limit", "ref": 1, "accessPath": "ap", "unit": "W", "shape": "setpoint", "tier": 1, + "constraints": { "min": 0, "max": "rated", "scope": "node" }, + "control": { "protocol": "modbus", "op": "write_single", "address": 80, "transform": { "kind": "identity" } } }] }] + } + }, + { + "name": "control: aggregate coordinator takes one delegated command for its children", + "query": { "capability": "battery.charge_power_limit", "side": "control", "intent": 3000, "altitude": "auto" }, + "doc": { + "topologyVersion": "0.2.0", "scope": "site", "docVersion": 1, "producer": { "name": "p", "provider": "x" }, + "nodes": [ + { "id": "GW", "kind": "gateway", "aggregate": { "serves": true, "minChildren": 2, "priority": 10, "over": "contains" }, + "accessPaths": [{ "id": "gw", "provider": "gw", "preference": 10 }], + "capabilities": [{ "capability": "battery.charge_power_limit", "ref": 1, "accessPath": "gw", "unit": "W", "shape": "setpoint", "tier": 1, + "distribution": "replicate", "control": { "protocol": "modbus", "address": 90 } }] }, + { "id": "INV1", "kind": "inverter", "accessPaths": [{ "id": "ap1", "provider": "p", "preference": 5 }], + "capabilities": [{ "capability": "battery.charge_power_limit", "ref": 1, "accessPath": "ap1", "unit": "W", "shape": "setpoint", "tier": 1, "control": { "protocol": "modbus", "address": 80 } }] }, + { "id": "INV2", "kind": "inverter", "accessPaths": [{ "id": "ap2", "provider": "p", "preference": 5 }], + "capabilities": [{ "capability": "battery.charge_power_limit", "ref": 1, "accessPath": "ap2", "unit": "W", "shape": "setpoint", "tier": 1, "control": { "protocol": "modbus", "address": 80 } }] } + ], + "relationships": [{ "from": "GW", "to": "INV1", "type": "contains" }, { "from": "GW", "to": "INV2", "type": "contains" }] + } + }, + { + "name": "control: same capability, divergent format per access path — picks the higher-preference local path", + "query": { "capability": "battery.target_soc", "side": "control", "intent": 80 }, + "doc": { + "topologyVersion": "0.2.0", "scope": "site", "docVersion": 1, "producer": { "name": "p", "provider": "x" }, + "nodes": [{ "id": "INV", "kind": "inverter", + "accessPaths": [{ "id": "gw-local", "provider": "gw", "preference": 10 }, { "id": "vendor-cloud", "provider": "cloud", "preference": 1 }], + "capabilities": [ + { "capability": "battery.target_soc", "ref": 1, "accessPath": "gw-local", "unit": "%", "shape": "setpoint", "tier": 1, + "constraints": { "min": 0, "max": 100, "scope": "capability" }, "control": { "protocol": "modbus", "address": 82 } }, + { "capability": "battery.target_soc", "ref": 1, "accessPath": "vendor-cloud", "unit": "%", "shape": "schedule", "tier": 2, + "scheduleSpec": { "maxSlots": 8, "slotFields": ["target_soc"], "endBound": "inclusive", "requiresDefaultMode": true }, + "constraints": { "min": 0, "max": 100, "scope": "capability" }, "control": { "protocol": "cloud-api", "address": "/sched", "readModifyWrite": true } } + ] }] + } + }, + { + "name": "read: a derived value is computed from sibling capabilities (no access path)", + "query": { "capability": "meter.load_power", "side": "read" }, + "doc": { + "topologyVersion": "0.2.0", "scope": "site", "docVersion": 1, "producer": { "name": "p", "provider": "x" }, + "nodes": [{ "id": "GW", "kind": "gateway", "accessPaths": [{ "id": "gw", "provider": "gw", "preference": 10 }], + "capabilities": [ + { "capability": "pv.power", "ref": 1, "accessPath": "gw", "unit": "W", "read": { "protocol": "modbus", "address": 101 } }, + { "capability": "meter.grid_power", "ref": 2, "accessPath": "gw", "unit": "W", "read": { "protocol": "modbus", "address": 100 } }, + { "capability": "battery.power", "ref": 3, "accessPath": "gw", "unit": "W", "read": { "protocol": "modbus", "address": 102 } }, + { "capability": "meter.load_power", "ref": 4, "unit": "W", + "derived": { "op": "sum", "inputs": [{ "ref": "pv.power", "weight": 1 }, { "ref": "meter.grid_power", "weight": -1 }, { "ref": "battery.power", "weight": -1 }] } } + ] }] + } + } +] diff --git a/apps/predbat/tests/lattice_resolve_corpus/expected.json b/apps/predbat/tests/lattice_resolve_corpus/expected.json new file mode 100644 index 000000000..57bec6e60 --- /dev/null +++ b/apps/predbat/tests/lattice_resolve_corpus/expected.json @@ -0,0 +1,139 @@ +{ + "read: single offer resolves to its node + access path": { + "ok": true, + "side": "read", + "node": "INV", + "nodeKind": "inverter", + "chosenAccessPath": "ap", + "fellBack": false, + "routeNodeCount": 1, + "strategy": "direct", + "planNodes": [ + "INV" + ], + "ownedNodes": [ + "INV" + ], + "unit": "%", + "binding": { + "protocol": "modbus", + "op": "read_input", + "address": 60, + "transform": "identity" + } + }, + "read: ranked access-path fallback when the preferred path is offline": { + "ok": true, + "side": "read", + "node": "INV", + "nodeKind": "inverter", + "chosenAccessPath": "vendor-cloud", + "fellBack": true, + "routeNodeCount": 1, + "strategy": "direct", + "planNodes": [ + "INV" + ], + "ownedNodes": [ + "INV" + ], + "unit": "%", + "binding": { + "protocol": "cloud-api", + "address": "/soc" + } + }, + "control: setpoint clamps an over-range intent to max": { + "ok": true, + "side": "control", + "node": "INV", + "nodeKind": "inverter", + "chosenAccessPath": "ap", + "fellBack": false, + "routeNodeCount": 1, + "strategy": "direct", + "planNodes": [ + "INV" + ], + "ownedNodes": [ + "INV" + ], + "unit": "W", + "shape": "setpoint", + "tier": 1, + "clamped": 5000, + "clampMin": 0, + "clampMaxLabel": "rated = 5000", + "binding": { + "protocol": "modbus", + "op": "write_single", + "address": 80, + "transform": "identity" + }, + "intent": 9000 + }, + "control: aggregate coordinator takes one delegated command for its children": { + "ok": true, + "side": "control", + "node": "GW", + "nodeKind": "gateway", + "chosenAccessPath": "gw", + "fellBack": false, + "routeNodeCount": 1, + "strategy": "delegated", + "planNodes": [ + "GW" + ], + "distribution": "replicate", + "ownedNodes": [ + "INV1", + "INV2" + ], + "unit": "W", + "shape": "setpoint", + "tier": 1, + "clamped": 3000, + "clampMin": 0, + "clampMaxLabel": "—", + "binding": { + "protocol": "modbus", + "address": 90 + }, + "intent": 3000 + }, + "control: same capability, divergent format per access path — picks the higher-preference local path": { + "ok": true, + "side": "control", + "node": "INV", + "nodeKind": "inverter", + "chosenAccessPath": "gw-local", + "fellBack": false, + "routeNodeCount": 1, + "strategy": "direct", + "planNodes": [ + "INV" + ], + "ownedNodes": [ + "INV" + ], + "unit": "%", + "shape": "setpoint", + "tier": 1, + "clamped": 80, + "clampMin": 0, + "clampMaxLabel": "100", + "binding": { + "protocol": "modbus", + "address": 82 + }, + "intent": 80 + }, + "read: a derived value is computed from sibling capabilities (no access path)": { + "ok": true, + "side": "read", + "node": "GW", + "nodeKind": "gateway", + "unit": "W", + "derived": "sum(pv.power + -1×meter.grid_power + -1×battery.power)" + } +} From 480f065dd7924a98bb9912a9e86b976e84ab9be9 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Tue, 30 Jun 2026 08:21:56 +0100 Subject: [PATCH 13/14] fix(lattice): merge topologyVersion fallback 0.1.0 -> 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the merge() site topologyVersion default with the frozen spec and the canonical TS reference (merge-engine.ts) and the gateway merge_eval port. Only reached when an input doc omits topologyVersion (the corpus always sets it, so all conformance cases are unaffected) — this keeps the Python and C++ merge ports consistent on that fallback branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/lattice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/predbat/lattice.py b/apps/predbat/lattice.py index 249c05895..8bf1689a0 100644 --- a/apps/predbat/lattice.py +++ b/apps/predbat/lattice.py @@ -419,7 +419,7 @@ def merge(docs): top_tv = top[0].get("topologyVersion") site = { - "topologyVersion": "0.1.0" if top_tv is None else top_tv, + "topologyVersion": "0.2.0" if top_tv is None else top_tv, "scope": "site", "producer": { "name": "lattice-merge", From b7546f00e883ea183b143411934d950c8b4ee617 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Tue, 30 Jun 2026 09:37:25 +0100 Subject: [PATCH 14/14] fix(lattice): merge tombstone barrier (re-vendor 14-case corpus) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror lattice-spec merge fix (PR #28): a tombstone is a barrier — when a node is re-added above a removal, only contributors ranked strictly above the highest tombstone participate in _merge_node, so sub-barrier (lower-authority) discovery data is not resurrected. Re-vendor the 14-case conformance/merge corpus (adds the 'tombstone-barrier' golden) which verifies it. 13->14 corpus cases pass; all lattice tests green (32). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/lattice.py | 9 ++- .../tests/lattice_merge_corpus/cases.json | 60 +++++++++++------- .../tests/lattice_merge_corpus/expected.json | 63 +++++++++++++++---- 3 files changed, 94 insertions(+), 38 deletions(-) diff --git a/apps/predbat/lattice.py b/apps/predbat/lattice.py index 8bf1689a0..7ddb05d35 100644 --- a/apps/predbat/lattice.py +++ b/apps/predbat/lattice.py @@ -379,7 +379,14 @@ def merge(docs): if _top_by(contribs)[0].get("removed") is True: continue surviving.add(nid) - merged_nodes.append(_merge_node([(it, rk) for it, rk in contribs if it.get("removed") is not True], warnings)) + # Tombstone barrier: a re-add above a removal must not resurrect sub-barrier (lower-authority) + # discovery data, so only contributors ranked strictly above the highest tombstone merge. + barrier = None + for it, rk in contribs: + if it.get("removed") is True and (barrier is None or _better(rk, barrier)): + barrier = rk + live = [(it, rk) for it, rk in contribs if it.get("removed") is not True and (barrier is None or _better(rk, barrier))] + merged_nodes.append(_merge_node(live, warnings)) rel_order = [] rel_contribs = {} diff --git a/apps/predbat/tests/lattice_merge_corpus/cases.json b/apps/predbat/tests/lattice_merge_corpus/cases.json index 5d8690753..e0dab0641 100644 --- a/apps/predbat/tests/lattice_merge_corpus/cases.json +++ b/apps/predbat/tests/lattice_merge_corpus/cases.json @@ -2,10 +2,10 @@ { "name": "union: same node via two peer providers — ranked access paths + unioned caps", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, "nodes": [{ "id": "INV", "kind": "inverter", "accessPaths": [{ "id": "gw-local", "provider": "gw", "preference": 10 }], "capabilities": [{ "capability": "battery.soc", "accessPath": "gw-local", "unit": "%", "read": { "protocol": "modbus", "address": 60 } }] }] }, - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "cloud", "provider": "cloud", "authority": 0 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "cloud", "provider": "cloud", "authority": 0 }, "docVersion": 1, "nodes": [{ "id": "INV", "kind": "inverter", "accessPaths": [{ "id": "vendor-cloud", "provider": "cloud", "preference": 1 }], "capabilities": [ { "capability": "battery.soc", "accessPath": "vendor-cloud", "unit": "%", "read": { "protocol": "cloud-api", "address": "/soc" } }, @@ -15,93 +15,93 @@ { "name": "override: higher-authority overlay corrects kind, discovered attributes intact", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, "nodes": [{ "id": "N", "kind": "inverter", "attributes": { "ratedW": 5000 } }] }, - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, "nodes": [{ "id": "N", "kind": "gateway" }] } ] }, { "name": "sparse-override: high-authority overlay sets only kind, keeps discovered access paths + capabilities", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, "nodes": [{ "id": "N", "kind": "inverter", "attributes": { "ratedW": 5000 }, "accessPaths": [{ "id": "ap", "provider": "gw", "preference": 10 }], "capabilities": [{ "capability": "battery.soc", "accessPath": "ap", "unit": "%", "read": { "protocol": "modbus", "address": 60 } }] }] }, - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, "nodes": [{ "id": "N", "kind": "gateway" }] } ] }, { "name": "add: overlay introduces a node + a contains edge discovery missed", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, "nodes": [{ "id": "GW", "kind": "gateway" }] }, - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, "nodes": [{ "id": "INV", "kind": "inverter" }], "relationships": [{ "from": "GW", "to": "INV", "type": "contains" }] } ] }, { "name": "remove-node: overlay tombstones a phantom node; its relationships drop", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, "nodes": [{ "id": "GW", "kind": "gateway" }, { "id": "PHANTOM", "kind": "inverter" }], "relationships": [{ "from": "GW", "to": "PHANTOM", "type": "contains" }] }, - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, "nodes": [{ "id": "PHANTOM", "removed": true }] } ] }, { "name": "remove-offer: overlay tombstones a misdetected capability on a real node", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, "nodes": [{ "id": "N", "kind": "inverter", "accessPaths": [{ "id": "ap", "provider": "gw" }], "capabilities": [ { "capability": "battery.soc", "accessPath": "ap", "unit": "%", "read": { "protocol": "modbus", "address": 60 } }, { "capability": "battery.temperature", "accessPath": "ap", "unit": "degC", "read": { "protocol": "modbus", "address": 61 } }] }] }, - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, "nodes": [{ "id": "N", "kind": "inverter", "capabilities": [{ "capability": "battery.temperature", "accessPath": "ap", "removed": true }] }] } ] }, { "name": "recency: equal authority, higher docVersion wins", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 10 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 10 }, "docVersion": 1, "nodes": [{ "id": "N", "kind": "inverter" }] }, - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw2", "provider": "gw", "authority": 10 }, "docVersion": 5, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw2", "provider": "gw", "authority": 10 }, "docVersion": 5, "nodes": [{ "id": "N", "kind": "gateway" }] } ] }, { "name": "tie warns: equal authority and docVersion, conflicting kind — first wins + warning", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "a", "provider": "a", "authority": 10 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "a", "provider": "a", "authority": 10 }, "docVersion": 1, "nodes": [{ "id": "N", "kind": "inverter" }] }, - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "b", "provider": "b", "authority": 10 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "b", "provider": "b", "authority": 10 }, "docVersion": 1, "nodes": [{ "id": "N", "kind": "gateway" }] } ] }, { "name": "survival: a fresh rediscovery does not clobber the upstream override", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 9, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 9, "nodes": [{ "id": "N", "kind": "inverter", "attributes": { "ratedW": 6000 } }] }, - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, "nodes": [{ "id": "N", "kind": "gateway" }] } ] }, { "name": "tombstone-noop: tombstoning an absent element changes nothing", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, "nodes": [{ "id": "N", "kind": "inverter" }] }, - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, "nodes": [{ "id": "GHOST", "removed": true }] } ] }, { "name": "deviceTypes: descriptor carried into the site so node deviceType resolves", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, "deviceTypes": [{ "key": "ge-aio", "capabilities": [{ "capability": "battery.soc", "read": { "protocol": "modbus", "address": 60 } }] }], "nodes": [{ "id": "INV", "kind": "inverter", "deviceType": "ge-aio" }] } ] @@ -109,18 +109,30 @@ { "name": "site.id: survives from low-authority discovery when a high-authority overlay omits id", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "id": "site:home", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "id": "site:home", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, "nodes": [{ "id": "INV", "kind": "inverter" }] }, - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, "nodes": [{ "id": "INV", "kind": "gateway" }] } ] }, { "name": "tiebreak: equal-preference access paths sort by id codepoint order", "inputs": [ - { "topologyVersion": "0.1.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, "nodes": [{ "id": "INV", "kind": "inverter", "accessPaths": [{ "id": "a-local", "provider": "gw", "preference": 5 }, { "id": "B-cloud", "provider": "cloud", "preference": 5 }] }] } ] + }, + { + "name": "tombstone-barrier: re-add above a removal does not resurrect sub-barrier discovery", + "inputs": [ + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "gw", "provider": "gw", "authority": 0 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "inverter", "attributes": { "ratedW": 5000 }, "accessPaths": [{ "id": "ap", "provider": "gw", "preference": 10 }], + "capabilities": [{ "capability": "battery.soc", "accessPath": "ap", "unit": "%", "read": { "protocol": "modbus", "address": 60 } }] }] }, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "installer", "provider": "installer", "authority": 50 }, "docVersion": 1, + "nodes": [{ "id": "N", "removed": true }] }, + { "topologyVersion": "0.2.0", "scope": "fragment", "producer": { "name": "admin", "provider": "admin", "authority": 100 }, "docVersion": 1, + "nodes": [{ "id": "N", "kind": "gateway" }] } + ] } ] diff --git a/apps/predbat/tests/lattice_merge_corpus/expected.json b/apps/predbat/tests/lattice_merge_corpus/expected.json index 4aa86908e..19b72c1dc 100644 --- a/apps/predbat/tests/lattice_merge_corpus/expected.json +++ b/apps/predbat/tests/lattice_merge_corpus/expected.json @@ -1,7 +1,7 @@ { "union: same node via two peer providers — ranked access paths + unioned caps": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -76,7 +76,7 @@ }, "override: higher-authority overlay corrects kind, discovered attributes intact": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -110,7 +110,7 @@ }, "sparse-override: high-authority overlay sets only kind, keeps discovered access paths + capabilities": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -163,7 +163,7 @@ }, "add: overlay introduces a node + a contains edge discovery missed": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -205,7 +205,7 @@ }, "remove-node: overlay tombstones a phantom node; its relationships drop": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -238,7 +238,7 @@ }, "remove-offer: overlay tombstones a misdetected capability on a real node": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -287,7 +287,7 @@ }, "recency: equal authority, higher docVersion wins": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -318,7 +318,7 @@ }, "tie warns: equal authority and docVersion, conflicting kind — first wins + warning": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -351,7 +351,7 @@ }, "survival: a fresh rediscovery does not clobber the upstream override": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -385,7 +385,7 @@ }, "tombstone-noop: tombstoning an absent element changes nothing": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -416,7 +416,7 @@ }, "deviceTypes: descriptor carried into the site so node deviceType resolves": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -456,7 +456,7 @@ }, "site.id: survives from low-authority discovery when a high-authority overlay omits id": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -488,7 +488,7 @@ }, "tiebreak: equal-preference access paths sort by id codepoint order": { "site": { - "topologyVersion": "0.1.0", + "topologyVersion": "0.2.0", "scope": "site", "producer": { "name": "lattice-merge", @@ -522,5 +522,42 @@ ] }, "warnings": [] + }, + "tombstone-barrier: re-add above a removal does not resurrect sub-barrier discovery": { + "site": { + "topologyVersion": "0.2.0", + "scope": "site", + "producer": { + "name": "lattice-merge", + "provider": "lattice-merge", + "inputs": [ + { + "name": "gw", + "provider": "gw", + "authority": 0, + "docVersion": 1 + }, + { + "name": "installer", + "provider": "installer", + "authority": 50, + "docVersion": 1 + }, + { + "name": "admin", + "provider": "admin", + "authority": 100, + "docVersion": 1 + } + ] + }, + "nodes": [ + { + "id": "N", + "kind": "gateway" + } + ] + }, + "warnings": [] } }