diff --git a/src/shard_wiki/engine/__init__.py b/src/shard_wiki/engine/__init__.py index dcb37e7..41f8861 100644 --- a/src/shard_wiki/engine/__init__.py +++ b/src/shard_wiki/engine/__init__.py @@ -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", ] diff --git a/src/shard_wiki/engine/profile.py b/src/shard_wiki/engine/profile.py new file mode 100644 index 0000000..534743b --- /dev/null +++ b/src/shard_wiki/engine/profile.py @@ -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() diff --git a/tests/test_engine_profile.py b/tests/test_engine_profile.py new file mode 100644 index 0000000..90c1429 --- /dev/null +++ b/tests/test_engine_profile.py @@ -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) diff --git a/workplans/SHARD-WP-0014-engine-implementation.md b/workplans/SHARD-WP-0014-engine-implementation.md index c7e4d4a..a3211e1 100644 --- a/workplans/SHARD-WP-0014-engine-implementation.md +++ b/workplans/SHARD-WP-0014-engine-implementation.md @@ -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" ```