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:
2026-06-16 00:27:11 +02:00
parent c40fa3c934
commit b9bb1f7d10
4 changed files with 219 additions and 1 deletions

View File

@@ -21,6 +21,11 @@ from shard_wiki.engine.extension import (
) )
from shard_wiki.engine.kernel import EngineKernel from shard_wiki.engine.kernel import EngineKernel
from shard_wiki.engine.links import extract_wikilinks from shard_wiki.engine.links import extract_wikilinks
from shard_wiki.engine.profile import (
ProfileContribution,
derive_profile,
engine_base_profile,
)
__all__ = [ __all__ = [
"EngineKernel", "EngineKernel",
@@ -35,4 +40,7 @@ __all__ = [
"StaticProvider", "StaticProvider",
"ActivationResolver", "ActivationResolver",
"feature_control_provider", "feature_control_provider",
"engine_base_profile",
"ProfileContribution",
"derive_profile",
] ]

View 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()

View 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)

View File

@@ -96,7 +96,7 @@ subset; absent-provider falls back to defaults; context scoping works.
```task ```task
id: SHARD-WP-0014-T4 id: SHARD-WP-0014-T4
status: todo status: done
priority: high priority: high
state_hub_task_id: "15fc8db7-cd80-4675-b387-81aa9bc7d308" state_hub_task_id: "15fc8db7-cd80-4675-b387-81aa9bc7d308"
``` ```