Skip to content

Commit b25e936

Browse files
authored
Merge pull request #570 from Brad-Edwards/201-participant-feature-support
added: expose participant feature support declarations
2 parents 59cfd43 + 17238a0 commit b25e936

4 files changed

Lines changed: 175 additions & 1 deletion

File tree

changelog.d/201.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Expose API-407 participant feature-support declarations through backend manifest capability helpers and preserve them in rendered backend-manifest v2 payloads.

implementations/python/packages/aces_backend_protocols/capabilities.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
)
1212
from aces_contracts.controlled_vocabularies import validate_controlled_vocabulary_scope_values
1313
from aces_contracts.manifest_authority import validate_backend_supported_contract_versions
14-
from aces_contracts.vocabulary import WorkflowFeature, WorkflowStatePredicateFeature
14+
from aces_contracts.vocabulary import ParticipantFeatureSupportLevel, WorkflowFeature, WorkflowStatePredicateFeature
1515

1616
PARTICIPANT_RUNTIME_ROLE_SCOPE = "capabilities.participant_runtime.supported_participant_roles"
1717
PARTICIPANT_RUNTIME_BEHAVIOR_FEATURE_SCOPE = "capabilities.participant_runtime.supported_behavior_features"
@@ -195,6 +195,66 @@ def __post_init__(self) -> None:
195195
raise ValueError("EvaluatorCapabilities must support scoring, objectives, or both")
196196

197197

198+
def _validate_unique_non_empty_strings(field_name: str, values: tuple[str, ...]) -> None:
199+
if any(not value.strip() for value in values):
200+
raise ValueError(f"{field_name} must not contain empty strings")
201+
if len(set(values)) != len(values):
202+
raise ValueError(f"{field_name} must not contain duplicate values")
203+
204+
205+
def _validate_participant_feature_support_term(feature: str) -> None:
206+
errors: list[str] = []
207+
for scope in (PARTICIPANT_RUNTIME_BEHAVIOR_FEATURE_SCOPE, PARTICIPANT_RUNTIME_INTERACTION_FEATURE_SCOPE):
208+
try:
209+
validate_controlled_vocabulary_scope_values(scope, (feature,))
210+
except ValueError as exc:
211+
errors.append(str(exc))
212+
continue
213+
return
214+
raise ValueError(
215+
"ParticipantFeatureSupport.feature must be a governed participant behavior or interaction feature "
216+
f"term, or match the governed extension pattern; got {feature!r}; "
217+
f"validation details: {'; '.join(errors)}"
218+
)
219+
220+
221+
@dataclass(frozen=True)
222+
class ParticipantFeatureSupport:
223+
"""API-407 per-feature participant runtime support declaration."""
224+
225+
feature: str
226+
support_level: ParticipantFeatureSupportLevel | str
227+
constraint_refs: tuple[str, ...] = ()
228+
disclosure_refs: tuple[str, ...] = ()
229+
230+
def __post_init__(self) -> None:
231+
if not self.feature.strip():
232+
raise ValueError("ParticipantFeatureSupport.feature must be non-empty")
233+
_validate_participant_feature_support_term(self.feature)
234+
235+
try:
236+
support_level = (
237+
self.support_level
238+
if isinstance(self.support_level, ParticipantFeatureSupportLevel)
239+
else ParticipantFeatureSupportLevel(str(self.support_level))
240+
)
241+
except ValueError as exc:
242+
raise ValueError("ParticipantFeatureSupport.support_level must be a valid support level") from exc
243+
244+
constraint_refs = tuple(self.constraint_refs)
245+
disclosure_refs = tuple(self.disclosure_refs)
246+
_validate_unique_non_empty_strings("ParticipantFeatureSupport.constraint_refs", constraint_refs)
247+
_validate_unique_non_empty_strings("ParticipantFeatureSupport.disclosure_refs", disclosure_refs)
248+
if support_level != ParticipantFeatureSupportLevel.EXACT and not disclosure_refs:
249+
raise ValueError(
250+
"ParticipantFeatureSupport disclosure_refs must be non-empty when support_level is below exact"
251+
)
252+
253+
object.__setattr__(self, "support_level", support_level)
254+
object.__setattr__(self, "constraint_refs", constraint_refs)
255+
object.__setattr__(self, "disclosure_refs", disclosure_refs)
256+
257+
198258
@dataclass(frozen=True)
199259
class ParticipantRuntimeCapabilities:
200260
"""Participant-episode lifecycle support declaration.
@@ -217,6 +277,7 @@ class ParticipantRuntimeCapabilities:
217277
supported_participant_roles: frozenset[str] = frozenset()
218278
supported_behavior_features: frozenset[str] = frozenset()
219279
supported_interaction_features: frozenset[str] = frozenset()
280+
feature_support: tuple[ParticipantFeatureSupport, ...] = ()
220281
constraints: dict[str, str] = field(default_factory=dict)
221282

222283
def __post_init__(self) -> None:
@@ -252,6 +313,22 @@ def __post_init__(self) -> None:
252313
PARTICIPANT_RUNTIME_INTERACTION_FEATURE_SCOPE,
253314
self.supported_interaction_features,
254315
)
316+
feature_support = tuple(
317+
entry if isinstance(entry, ParticipantFeatureSupport) else ParticipantFeatureSupport(**entry)
318+
for entry in self.feature_support
319+
)
320+
feature_names = tuple(entry.feature for entry in feature_support)
321+
_validate_unique_non_empty_strings("ParticipantRuntimeCapabilities.feature_support", feature_names)
322+
supported_features = self.supported_behavior_features | self.supported_interaction_features
323+
for entry in feature_support:
324+
if (
325+
entry.support_level == ParticipantFeatureSupportLevel.UNSUPPORTED
326+
and entry.feature in supported_features
327+
):
328+
raise ValueError(
329+
"ParticipantRuntimeCapabilities.feature_support cannot declare a supported feature unsupported"
330+
)
331+
object.__setattr__(self, "feature_support", feature_support)
255332

256333

257334
@dataclass(frozen=True)

implementations/python/packages/aces_backend_protocols/manifest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
BackendCompatibilityModel,
1010
BackendManifestV2Model,
1111
ConceptBindingEntryModel,
12+
ParticipantFeatureSupportModel,
1213
RealizationSupportDeclarationModel,
1314
)
1415
from aces_contracts.manifest_authority import BACKEND_SUPPORTED_CONTRACT_IDS
@@ -97,6 +98,15 @@ def backend_manifest_v2_model(manifest: BackendManifest) -> BackendManifestV2Mod
9798
"supported_interaction_features": sorted(
9899
manifest.participant_runtime.supported_interaction_features
99100
),
101+
"feature_support": [
102+
ParticipantFeatureSupportModel(
103+
feature=entry.feature,
104+
support_level=entry.support_level,
105+
constraint_refs=list(entry.constraint_refs),
106+
disclosure_refs=list(entry.disclosure_refs),
107+
)
108+
for entry in manifest.participant_runtime.feature_support
109+
],
100110
"constraints": dict(manifest.participant_runtime.constraints),
101111
}
102112
if manifest.participant_runtime is not None

implementations/python/tests/test_backend_manifest.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
from dataclasses import replace
67
from pathlib import Path
78

89
import pytest
@@ -13,6 +14,7 @@
1314
PARTICIPANT_RUNTIME_ROLE_SCOPE,
1415
BackendManifest,
1516
OrchestratorCapabilities,
17+
ParticipantFeatureSupport,
1618
ParticipantRuntimeCapabilities,
1719
ProvisionerCapabilities,
1820
participant_runtime_capability_contract_gaps,
@@ -125,6 +127,7 @@ def test_backend_manifest_v2_declares_participant_capability_dimensions():
125127
"interference",
126128
"shared_state_change",
127129
]
130+
assert participant_runtime["feature_support"] == []
128131

129132
model = BackendManifestV2Model.model_validate(payload)
130133
assert model.capabilities.participant_runtime is not None
@@ -189,6 +192,89 @@ def test_participant_runtime_capabilities_validate_api_405_vocabularies():
189192
)
190193

191194

195+
def test_participant_feature_support_validates_api_407_declarations():
196+
declaration = ParticipantFeatureSupport(
197+
feature="coordination",
198+
support_level=ParticipantFeatureSupportLevel.EXACT,
199+
)
200+
201+
assert declaration.support_level == ParticipantFeatureSupportLevel.EXACT
202+
203+
with pytest.raises(ValueError, match="governed participant behavior or interaction feature"):
204+
ParticipantFeatureSupport(
205+
feature="custom_feature",
206+
support_level=ParticipantFeatureSupportLevel.EXACT,
207+
)
208+
209+
with pytest.raises(ValueError, match="disclosure_refs"):
210+
ParticipantFeatureSupport(
211+
feature="coordination",
212+
support_level=ParticipantFeatureSupportLevel.BOUNDED,
213+
)
214+
215+
unsupported_declaration = ParticipantFeatureSupport(
216+
feature="coordination",
217+
support_level=ParticipantFeatureSupportLevel.UNSUPPORTED,
218+
disclosure_refs=("disclosures.coordination.unsupported.v1",),
219+
)
220+
with pytest.raises(ValueError, match="supported feature unsupported"):
221+
ParticipantRuntimeCapabilities(
222+
name="participant-runtime",
223+
supported_participant_roles=frozenset({"blue"}),
224+
supported_behavior_features=frozenset({"action_contracts"}),
225+
supported_interaction_features=frozenset({"coordination"}),
226+
feature_support=(unsupported_declaration,),
227+
)
228+
229+
230+
def test_backend_manifest_payload_renders_api_407_feature_support_entries():
231+
manifest = create_stub_manifest()
232+
assert manifest.participant_runtime is not None
233+
participant_runtime = replace(
234+
manifest.participant_runtime,
235+
feature_support=(
236+
ParticipantFeatureSupport(
237+
feature="behavior_history",
238+
support_level=ParticipantFeatureSupportLevel.BOUNDED,
239+
constraint_refs=("constraints.behavior-history.retention-window",),
240+
disclosure_refs=("disclosures.behavior-history.bounded.v1",),
241+
),
242+
ParticipantFeatureSupport(
243+
feature="coordination",
244+
support_level=ParticipantFeatureSupportLevel.EXACT,
245+
),
246+
),
247+
)
248+
capabilities = replace(manifest.capabilities, participant_runtime=participant_runtime)
249+
manifest = BackendManifest(
250+
identity=manifest.identity,
251+
supported_contract_versions=manifest.supported_contract_versions,
252+
compatibility=manifest.compatibility,
253+
realization_support=manifest.realization_support,
254+
concept_bindings=manifest.concept_bindings,
255+
constraints=manifest.constraints,
256+
capabilities=capabilities,
257+
)
258+
259+
payload = backend_manifest_payload(manifest)
260+
261+
assert payload["capabilities"]["participant_runtime"]["feature_support"] == [
262+
{
263+
"feature": "behavior_history",
264+
"support_level": "bounded",
265+
"constraint_refs": ["constraints.behavior-history.retention-window"],
266+
"disclosure_refs": ["disclosures.behavior-history.bounded.v1"],
267+
},
268+
{
269+
"feature": "coordination",
270+
"support_level": "exact",
271+
"constraint_refs": [],
272+
"disclosure_refs": [],
273+
},
274+
]
275+
BackendManifestV2Model.model_validate(payload)
276+
277+
192278
def test_participant_runtime_capability_evidence_covers_standard_vocabularies():
193279
catalog_path = FIXTURES_ROOT / "concept-authority" / "controlled-vocabularies-v1" / "valid" / "reference.json"
194280
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))

0 commit comments

Comments
 (0)