generated from coulomb/repo-seed
feat(engine): capability profile derived from active extensions (WP-0014 T4, E-5)
engine/profile.py: engine_base_profile() (kernel-only c2-minimum profile), ProfileContribution (an extension's ON_PROFILE contribution: axis raises + verbs), derive_profile() folds active extensions' contributions onto the base in deterministic order then validate() — so configuration->capability is one chain and composition can never yield an impossible profile (encrypted+native-query rejected). 5 tests green, 96 total, pyflakes clean. Marks T4 done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,11 @@ from shard_wiki.engine.extension import (
|
||||
)
|
||||
from shard_wiki.engine.kernel import EngineKernel
|
||||
from shard_wiki.engine.links import extract_wikilinks
|
||||
from shard_wiki.engine.profile import (
|
||||
ProfileContribution,
|
||||
derive_profile,
|
||||
engine_base_profile,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EngineKernel",
|
||||
@@ -35,4 +40,7 @@ __all__ = [
|
||||
"StaticProvider",
|
||||
"ActivationResolver",
|
||||
"feature_control_provider",
|
||||
"engine_base_profile",
|
||||
"ProfileContribution",
|
||||
"derive_profile",
|
||||
]
|
||||
|
||||
112
src/shard_wiki/engine/profile.py
Normal file
112
src/shard_wiki/engine/profile.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Capability profile derived from active extensions (WikiEngineCoreArchitecture E-5).
|
||||
|
||||
The engine's §A `CapabilityProfile` is **computed**, not hand-set: start from the kernel base
|
||||
profile, then fold each active extension's `on_profile` contribution (in the runtime's
|
||||
deterministic order), then `validate()`. This realizes the chain *configuration (which
|
||||
extensions) → capability (the profile) → conformance* — activating an extension raises the
|
||||
shard's advertised capabilities, and composition can never yield an impossible profile (validate
|
||||
rejects it, §6.5).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
|
||||
from shard_wiki.engine.extension import ActiveExtensions, Hook
|
||||
from shard_wiki.model import (
|
||||
AccessGrant,
|
||||
Addressing,
|
||||
AttachmentMode,
|
||||
CapabilityProfile,
|
||||
ContentOpacity,
|
||||
History,
|
||||
MergeModel,
|
||||
NativeQuery,
|
||||
OperationalEnvelope,
|
||||
Substrate,
|
||||
Translation,
|
||||
Verb,
|
||||
WriteGranularity,
|
||||
)
|
||||
from shard_wiki.provenance import Liveness
|
||||
|
||||
__all__ = ["engine_base_profile", "ProfileContribution", "derive_profile"]
|
||||
|
||||
# Profile fields an extension may *raise* via on_profile (substrate/attachment are kernel-fixed).
|
||||
_OVERRIDABLE = (
|
||||
"write_granularity",
|
||||
"content_opacity",
|
||||
"liveness",
|
||||
"history",
|
||||
"merge_model",
|
||||
"addressing",
|
||||
"native_query",
|
||||
"translation",
|
||||
"access_grant",
|
||||
)
|
||||
|
||||
|
||||
def engine_base_profile() -> CapabilityProfile:
|
||||
"""The kernel-only (no extensions) capability profile — the c2-minimum engine shard."""
|
||||
return CapabilityProfile(
|
||||
substrate=Substrate.FILES,
|
||||
attachment_mode=AttachmentMode.IN_ENGINE_HOST,
|
||||
write_granularity=WriteGranularity.PER_PAGE,
|
||||
content_opacity=ContentOpacity.TRANSPARENT,
|
||||
operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED,
|
||||
access_grant=AccessGrant.OPEN,
|
||||
liveness=Liveness.STATIC,
|
||||
history=History.INTERNAL_ONLY,
|
||||
merge_model=MergeModel.NONE,
|
||||
addressing=Addressing.PATH,
|
||||
native_query=NativeQuery.NONE,
|
||||
translation=Translation.NATIVE,
|
||||
supported_verbs=frozenset({Verb.READ, Verb.WRITE}),
|
||||
).validate()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProfileContribution:
|
||||
"""An extension's contribution to the derived profile (returned from its ON_PROFILE hook).
|
||||
|
||||
A non-``None`` axis overrides that axis; ``verbs_add`` are unioned in. Order = the runtime's
|
||||
deterministic dispatch order, so later extensions win on a contested axis."""
|
||||
|
||||
write_granularity: WriteGranularity | None = None
|
||||
content_opacity: ContentOpacity | None = None
|
||||
liveness: Liveness | None = None
|
||||
history: History | None = None
|
||||
merge_model: MergeModel | None = None
|
||||
addressing: Addressing | None = None
|
||||
native_query: NativeQuery | None = None
|
||||
translation: Translation | None = None
|
||||
access_grant: AccessGrant | None = None
|
||||
verbs_add: frozenset[Verb] = frozenset()
|
||||
|
||||
|
||||
def derive_profile(
|
||||
active: ActiveExtensions, base: CapabilityProfile | None = None
|
||||
) -> CapabilityProfile:
|
||||
"""Fold active extensions' ON_PROFILE contributions onto ``base`` and validate the result.
|
||||
|
||||
Raises :class:`~shard_wiki.model.ProfileError` if the composed profile is impossible — so an
|
||||
activation set can never advertise an invalid capability profile.
|
||||
"""
|
||||
profile = base or engine_base_profile()
|
||||
contributions = active.dispatch_collect(Hook.ON_PROFILE)
|
||||
|
||||
overrides: dict[str, object] = {}
|
||||
verbs: set[Verb] = set(profile.supported_verbs)
|
||||
for contrib in contributions:
|
||||
if not isinstance(contrib, ProfileContribution):
|
||||
continue
|
||||
for field_name in _OVERRIDABLE:
|
||||
value = getattr(contrib, field_name)
|
||||
if value is not None:
|
||||
overrides[field_name] = value
|
||||
verbs |= set(contrib.verbs_add)
|
||||
|
||||
return dataclasses.replace(
|
||||
profile, supported_verbs=frozenset(verbs), **overrides
|
||||
).validate()
|
||||
98
tests/test_engine_profile.py
Normal file
98
tests/test_engine_profile.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tests for capability-profile-derived-from-extensions (SHARD-WP-0014 T4, E-5)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from shard_wiki.engine import (
|
||||
Extension,
|
||||
ExtensionRuntime,
|
||||
Hook,
|
||||
ProfileContribution,
|
||||
derive_profile,
|
||||
engine_base_profile,
|
||||
)
|
||||
from shard_wiki.model import (
|
||||
Addressing,
|
||||
ContentOpacity,
|
||||
NativeQuery,
|
||||
ProfileError,
|
||||
Verb,
|
||||
)
|
||||
|
||||
|
||||
class StructExt(Extension):
|
||||
id = "ext.struct"
|
||||
|
||||
def hooks(self):
|
||||
return {
|
||||
Hook.ON_PROFILE: lambda payload, ctx: ProfileContribution(
|
||||
verbs_add=frozenset({Verb.STRUCTURED_PAYLOAD})
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class AddrExt(Extension):
|
||||
id = "ext.addr"
|
||||
|
||||
def hooks(self):
|
||||
return {
|
||||
Hook.ON_PROFILE: lambda payload, ctx: ProfileContribution(
|
||||
addressing=Addressing.SPAN, verbs_add=frozenset({Verb.TRANSCLUDE_SOURCE})
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class EncryptExt(Extension):
|
||||
id = "ext.encrypt"
|
||||
|
||||
def hooks(self):
|
||||
return {Hook.ON_PROFILE: lambda p, c: ProfileContribution(content_opacity=ContentOpacity.ENCRYPTED)}
|
||||
|
||||
|
||||
class QueryExt(Extension):
|
||||
id = "ext.query"
|
||||
|
||||
def hooks(self):
|
||||
return {Hook.ON_PROFILE: lambda p, c: ProfileContribution(native_query=NativeQuery.DB_QUERY)}
|
||||
|
||||
|
||||
def _active(*exts, ids=None):
|
||||
rt = ExtensionRuntime()
|
||||
for e in exts:
|
||||
rt.register(e)
|
||||
return rt.activate(ids if ids is not None else {e.id for e in exts})
|
||||
|
||||
|
||||
def test_base_profile_is_valid_kernel_minimum():
|
||||
p = engine_base_profile()
|
||||
assert p.supports(Verb.READ) and p.supports(Verb.WRITE)
|
||||
assert not p.supports(Verb.STRUCTURED_PAYLOAD)
|
||||
assert p.addressing is Addressing.PATH
|
||||
|
||||
|
||||
def test_no_extensions_yields_base():
|
||||
rt = ExtensionRuntime()
|
||||
active = rt.activate(set())
|
||||
assert derive_profile(active) == engine_base_profile()
|
||||
|
||||
|
||||
def test_activating_extension_raises_the_profile():
|
||||
active = _active(StructExt(), AddrExt())
|
||||
p = derive_profile(active)
|
||||
assert p.supports(Verb.STRUCTURED_PAYLOAD) # from ext.struct
|
||||
assert p.supports(Verb.TRANSCLUDE_SOURCE) # from ext.addr
|
||||
assert p.addressing is Addressing.SPAN # raised by ext.addr
|
||||
assert p.supports(Verb.READ) # base preserved
|
||||
|
||||
|
||||
def test_profile_changes_with_active_set():
|
||||
only_struct = derive_profile(_active(StructExt(), AddrExt(), ids={"ext.struct"}))
|
||||
both = derive_profile(_active(StructExt(), AddrExt()))
|
||||
assert not only_struct.supports(Verb.TRANSCLUDE_SOURCE)
|
||||
assert both.supports(Verb.TRANSCLUDE_SOURCE) # E-5: profile reflects what's active
|
||||
|
||||
|
||||
def test_composition_cannot_yield_an_impossible_profile():
|
||||
# encrypted opacity + native query violates §6.5 implication rules -> derive must reject.
|
||||
active = _active(EncryptExt(), QueryExt())
|
||||
with pytest.raises(ProfileError):
|
||||
derive_profile(active)
|
||||
@@ -96,7 +96,7 @@ subset; absent-provider falls back to defaults; context scoping works.
|
||||
|
||||
```task
|
||||
id: SHARD-WP-0014-T4
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "15fc8db7-cd80-4675-b387-81aa9bc7d308"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user