From f271f6eb5538c94d8dfee73d39a9c8d7f9829555 Mon Sep 17 00:00:00 2001 From: Cameron Brooks Date: Tue, 23 Jun 2026 17:38:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(conformance):=20gate=20default-signal-set?= =?UTF-8?q?=20(=C2=A77.2)=20and=20call=20results=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add conformance assertions so the Wave-1 Tier-0 fixes T0.2 (non-empty default signal set) and T0.6 (declared call results are populated) are gated, not just done. Closes the harness-coverage item anolis-protocol#41. - test_default_read_returns_declared_subset: an empty-signal_ids read on a device declaring signals returns a non-empty subset of the declared signals. - test_call_declared_results_are_populated: a synchronous OK call to a function declaring results populates CallResponse.results (keyed by the declared names); iterates candidates and exempts async acceptance. Codify the backing requirements in semantics.md so the harness gates a MUST, not a SHOULD: §7.2 now requires a non-empty default set for any device type declaring >=1 signal; §8.1 now requires synchronous successful calls to populate declared results. sim and ezo are gated green by both assertions. bread's conformance mock opens no session (every read/call is UNAVAILABLE) and declares no zero-required-arg result function, so both skip on bread — tracked in anolishq/anolis-provider-bread#62. --- .../anolis_conformance/test_adpp_core.py | 76 +++++++++++++++++++ docs/semantics.md | 15 +++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/conformance/anolis_conformance/test_adpp_core.py b/conformance/anolis_conformance/test_adpp_core.py index 10ad5b5..d02b024 100644 --- a/conformance/anolis_conformance/test_adpp_core.py +++ b/conformance/anolis_conformance/test_adpp_core.py @@ -99,6 +99,39 @@ def test_read_signals_response_shape(ready_client: AdppClient, codes, status_tex assert value.HasField("value"), f"signal {value.signal_id} missing a value" +def test_default_read_returns_declared_subset(ready_client: AdppClient, codes, status_text) -> None: + # semantics.md §7.2: for a device type declaring >=1 signal, an empty-`signal_ids` + # read returns the DEFAULT signal set — a non-empty subset of the declared + # signals. The set is provider-curated, so this asserts "non-empty ⊆ declared", + # NOT an exact membership. (This is T0.2: a provider that returns nothing on a + # default read fails §7.2.) + checked = 0 + for d in ready_client.list_devices().list_devices.devices: + caps = ready_client.describe_device(d.device_id).describe_device.capabilities + declared = {s.signal_id for s in caps.signals} + if not declared: + continue + resp = ready_client.read_signals(d.device_id) # empty signal_ids => default set + # A mock backend may legitimately report UNAVAILABLE; the set contract is + # only observable on an OK read. + if resp.status.code == codes.UNAVAILABLE: + continue + assert resp.status.code == codes.OK, ( + f"default read must be OK or UNAVAILABLE; got {status_text(resp)}" + ) + default_ids = {v.signal_id for v in resp.read_signals.values} + assert default_ids, ( + f"device {d.device_id} declares signals but a default read returned none " + "(§7.2 requires a non-empty default signal set)" + ) + assert default_ids <= declared, ( + f"default read returned undeclared signals {default_ids - declared}" + ) + checked += 1 + if checked == 0: + pytest.skip("no readable device declares signals") + + def test_read_unknown_signal_consistent(ready_client: AdppClient, codes, status_text) -> None: # semantics.md 7.4: a provider MUST choose ONE consistent behavior for an # unknown signal id — either fail CODE_NOT_FOUND, OR return partial results @@ -179,6 +212,49 @@ def _first_device_with_functions(ready_client: AdppClient): return None, [] +def _devices_with_result_functions(ready_client: AdppClient): + """Yield (device_id, FunctionSpec) for every no-required-arg function that + declares results. No-required-arg so the call can be made safely.""" + for d in ready_client.list_devices().list_devices.devices: + caps = ready_client.describe_device(d.device_id).describe_device.capabilities + for fn in caps.functions: + if fn.results and not any(a.required for a in fn.args): + yield d.device_id, fn + + +def test_call_declared_results_are_populated(ready_client: AdppClient, codes, status_text) -> None: + # semantics.md §8.1 + call.proto: CallResponse.results is a map keyed by + # ArgSpec.name from FunctionSpec.results. A SYNCHRONOUS successful call to a + # function that DECLARES results MUST populate them — this is T0.6: declare + # *and* populate, not declare-then-return-empty. An async-accepted call + # (CODE_OK with operation_id set, §8.1) observes results later via signals and + # is exempt. We try every candidate function and assert on the first that + # reaches a synchronous OK (a mock backend may report UNAVAILABLE for some). + candidates = list(_devices_with_result_functions(ready_client)) + if not candidates: + pytest.skip("no zero-required-arg function declares results") + for dev, fn in candidates: + resp = ready_client.call(dev, function_id=fn.function_id) + if resp.status.code == codes.UNAVAILABLE: + continue # mock backend cannot actuate this one; try the next + assert resp.status.code == codes.OK, ( + f"valid call to {fn.name} must be accepted; got {status_text(resp)}" + ) + if resp.call.operation_id: + continue # accepted asynchronously; results observed via signals (§8.1) + declared = {a.name for a in fn.results} + returned = set(resp.call.results.keys()) + assert returned, ( + f"function {fn.name} declares results {sorted(declared)} but CallResponse.results " + "is empty (§8.1: a synchronous successful call must populate declared results)" + ) + assert returned <= declared, ( + f"CallResponse.results contains undeclared keys {returned - declared}" + ) + return + pytest.skip("no result-declaring function reached a synchronous OK call") + + def test_call_valid_no_arg_function_accepted(ready_client: AdppClient, codes, status_text) -> None: # A well-formed call to a function with no required args must be ACCEPTED: # CODE_OK, or CODE_UNAVAILABLE if the mock backend cannot actuate. It must not diff --git a/docs/semantics.md b/docs/semantics.md index 5b1ac73..f69d09d 100644 --- a/docs/semantics.md +++ b/docs/semantics.md @@ -193,10 +193,14 @@ In v1: ### 7.2 Default signals -Each provider MUST define, per device type, a **stable subset of signals** -designated as **default signals**. +For any device type that declares at least one signal, a provider MUST define a +**stable, non-empty subset of signals** designated as **default signals**. - Default signals are returned when `ReadSignalsRequest.signal_ids` is empty. +- A default read (empty `signal_ids`) on a device that declares signals MUST + therefore return a non-empty set of values whose `signal_id`s are a subset of + the declared signals. (The subset is provider-curated and need not be the full + declared set.) - Default signals SHOULD represent low-cost, routinely useful telemetry suitable for dashboards and polling loops. - Expensive or rarely-used signals SHOULD NOT be default. @@ -235,6 +239,13 @@ providers MUST document how results are observed (typically via signals). (Recommended for v1: synchronous execution.) +When a function declares results (`FunctionSpec.results`), a **synchronous** +successful call (`CODE_OK` with no `operation_id`) MUST populate +`CallResponse.results`, keyed by the declared `ArgSpec.name`s — a provider that +declares results MUST NOT return an empty result map for a completed call. +Asynchronous acceptance (`CODE_OK` with `operation_id` set) observes results +later (per above), so it carries no such obligation. + ### 8.2 Idempotency - `idempotency_key` is an optional retry hint.