Extension Framework
Gridalyn is an analysis and integration framework: components from outside the
gridalyn codebase can be registered to serve a role contract without editing
the SDK. The governing principle is discoverable but never silent — an
extension that participates in a run is declared, versioned and recorded in
provenance.
This page documents the foundation. The generic engine lives in
gridalyn/foundation/platform/extensions.py; the six per-role registries
(power-flow backend, surrogate, policy, channel model, observation producer,
network adapter) are open to external registration, each through a public
register_<role>_extension host API. A semantic capability can also arrive
from an extension a study declares — see
Contributing a semantic capability.
Try it
Scaffold a real extension package before reading the contract it satisfies:
scaffolded extension 'acme_backend' at /tmp/ext-demo/acme_backend
next: install the package, then run `gridalyn extension validate acme_backend`
Three files land under /tmp/ext-demo/acme_backend/: acme_backend.py (the
module — descriptor + factory), pyproject.toml (the entry-point wiring),
test_acme_backend.py (the conformance smoke test). The rest of this page
explains what each of those three files is for.
What an extension is
An extension is a component that conforms to a role contract and carries an
ExtensionDescriptor:
extension_id— stable ID the extension resolves by (explicit, never discovered ambiently).role— the contract the extension serves (e.g.powerflow_backend). The engine treats it as data; role semantics belong to the caller.name,version— human-readable identity, recorded in provenance.contract_version— the role-contract version the extension conforms to. The engine refuses a descriptor whose version it does not support; there is no silent fallback.source— where the registration came from:core(shipped in gridalyn),host(registered at runtime by the embedding application), orentry_point(discovered from a declared entry point / namespace walk).entry_point_group,module_hash— discovery and content pinning when known.
The generic ExtensionRegistry stores descriptors plus factories keyed by
extension_id and knows nothing about roles. It exposes register,
get_descriptor, list_descriptors and resolve with located, remediating
errors.
Registration sources
| Source | How | Provenance |
|---|---|---|
core |
gridalyn's own shipped defaults (unchanged behaviour) | source: "core" |
host |
register_extension(factory, descriptor=...) from the embedding application's entry script or notebook |
source: "host" |
entry_point |
declared gridalyn.extensions entry-point group / namespace walk, loaded on demand |
source: "entry_point", version, module_hash |
register_extension is the public host API: a third-party component conforms
to a role contract, builds an ExtensionDescriptor, and registers it — no edit
to the gridalyn codebase required.
Per-role host surface
Each shipped role has a registry, a role-specific descriptor, and a public
register_<role>_extension convenience that mirrors register_extension and
routes into the role's default registry (a cached singleton, so a
registration made through the host API stays resolvable through the default
path). Every role descriptor carries contract_version and every registry
rejects an unsupported version at registration with a located
UnsupportedContractVersionError naming the supported versions — there is no
silent fallback, so an extension that participates is declared, versioned and
never silent.
| Role | Registry | Descriptor | Host registration API | contract_version |
|---|---|---|---|---|
| Power-flow backend | PowerFlowBackendRegistry |
PowerFlowBackendDescriptor |
register_powerflow_backend_extension |
"1" |
| Surrogate | SurrogateRegistry |
SurrogateDescriptor |
register_surrogate_extension |
"1" |
| Voltage-control policy | PolicyRegistry |
PolicyDescriptor |
register_policy_extension |
"1" |
| Channel model | ChannelModelRegistry |
ChannelModelDescriptor |
register_channel_model_extension |
"1" |
| Observation producer | ObservationProducerRegistry |
ObservationProducerDescriptor |
register_observation_producer_extension |
"1" |
| Network adapter | NetworkAdapterRegistry |
NetworkAdapterDescriptor |
register_network_adapter_extension |
"1" |
The observation-producer convenience takes the producer callable itself rather
than a factory — producers are functions with nothing to instantiate. Each
convenience accepts an optional registry= argument to target a specific
registry instance (defaults to the role's shared default registry); all six
conveniences are exported from the layer facades (gridalyn.simulation for
backend/surrogate/policy/channel model, gridalyn.twin for producer/adapter).
The role descriptors that embed in run manifests expose as_dict() with a
JSON-native shape that includes contract_version; the twin descriptors
(producer, adapter) are metadata-only and have no as_dict().
Discovery & Capabilities
The entry_point source is wired: a package ships an extension
by declaring an entry point in the gridalyn.extensions group, and gridalyn
sees it without loading it. Awareness and resolution are deliberately
separate operations:
- Awareness —
gridalyn extension list. Walks the entry-point group and reports every installed extension (extension_id, version, contract version, source) without importing any module.list_entry_point_metadatais the stdlib primitive behind it. - Resolution — declared-only.
load_entry_point_extensions(group, declared_ids)imports only the IDs a caller names. Ambient entries are never loaded. An extension module exposes a callablefactoryand anExtensionDescriptordescriptor; the loader stampssource="entry_point", theentry_point_group, and a contentmodule_hash, and registers into the default registry. An undeclared ID is a located error naming what is installed; a module that does not follow the convention is a locatedImportError. gridalyn extension validate ID...loads the declared IDs and reports their provenance facts, exiting non-zero on any failure. If an extension declaresREQUIRED_CAPABILITIESthat its environment cannot meet, it is surfaced asMissingCapabilityError— registered but not ready is never silent.
A project declares which extensions its runs resolve through
spec.inputs.extensions in project.yaml — bare IDs in the default
gridalyn.extensions group, or {id, group} mappings. load_declared_extensions
and resolve_declared_extensions (in gridalyn.projects.model_inputs) read
and load that declaration, and since bd 4ky.8 the runner acts on it: before any
stage runs, register_declared_extensions (in gridalyn.projects.extension_roles)
loads the declared extensions and routes each to the registry of the role it
serves, so a declaration that cannot be honoured fails the run up front. A
project that declares nothing loads nothing, so its governed behavior stays
unchanged; the only manifest change any run sees is the always-present empty
extensions: [] entry (a deliberate additive-key re-base — see the
run-provenance docs).
Extensible capabilities. The core capability set (geo, sim, ops —
truly-optional modules in OPTIONAL_CAPABILITY_MODULES) stays fixed. An
external package may declare NEW capability keys through the
gridalyn.capabilities entry-point group: its module exposes
CAPABILITY_MODULES, a dict shaped like the core map. require_capabilities
merges those declarations additively — an extra may only add new capabilities,
never redefine the core set, and never an empty (always-green) one. The
capability contract test validates this external format.
Contributing a semantic capability
An extension whose descriptor declares role="semantic_capability" contributes a
semantic capability: its factory returns a
gridalyn.twin.semantic.vocabulary.SemanticCapability — namespaces, semantic
types, relationships with their axioms, and an emitter. A study that lists the
extension in spec.inputs.extensions can build a semantic graph with that
capability exactly as it would with one gridalyn ships.
- Registered where the build happens. The runner registers a study's
declared extensions before its first stage. A stage runs as its own process
and inherits none of that, so a stage that builds calls
script.resolve_extensions()first — a no-op for a study that declares nothing. Registering the same extension twice in one process is a no-op; a capability ID already held by a different source or version is refused, because one ID names one declaration. - Checked, not trusted. A factory that returns anything but a
SemanticCapabilityis a locatedTypeError. The capability's emitter is held to the same profile as a shipped one: its types and predicates must be declared, and the validator checks domain, range and cardinality. - Declared-only. An extension that is installed but not declared is never
loaded, so a build that asks for its capability fails with
UnknownSemanticCapabilityError, naming the capabilities that are registered — loudly, not silently. - Interaction protocols are not open. An extension declaring
role="interaction_protocol"is refused. The protocol set ingridalyn/operations/interaction/conversations.pyis closed by design: a conversation's legality must not depend on what happens to be installed. - Other roles keep today's behaviour: they stay in the generic registry and
are recorded in
provenance.extensions.
A complete, runnable example ships at examples/extensions/feeder_criticality/:
an extension that adds a criticality assessment to every distribution
transformer, and a study at examples/extensions/feeder_criticality/study/ that
declares it (spec.inputs.extensions: [feeder_criticality]) and builds its graph
with it. examples/extensions/feeder_criticality/run_example.py exposes the
extension's entry point the way an installation does and runs the study for
real; with --without-declaration, the same study fails its build.
tests/test_extension_semantic_capability.py pins both.
Authoring an extension
Authoring is first-class: gridalyn extension new <name> [--role ROLE]
[--target DIR] scaffolds a conformant extension package into DIR/<name>/
(defaulting to the current directory). The scaffold writes three files:
<name>.py— the extension module. It exposes the two attributes the loader requires:descriptor, anExtensionDescriptorinstance declaringextension_id,role,name,versionandcontract_version; andfactory, a callable returning the role's component. When the extension needs optional capabilities, the module also declaresREQUIRED_CAPABILITIES(a tuple of capability names such as("sim",)).pyproject.toml— wires the entry point: under[project.entry-points."gridalyn.extensions"], the line<name> = "<module>"(module-only value; the loader reads the module and findsfactory/descriptorinside it).test_<name>.py— a smoke test asserting the descriptor is conformant (contract_versioninSUPPORTED_CONTRACT_VERSIONS, factory callable).
For example, gridalyn extension new acme_backend --role powerflow_backend
produces a module whose descriptor has extension_id="acme_backend",
role="powerflow_backend", contract_version="1", and a factory returning
a placeholder the author replaces with a real component. --force overwrites
an existing directory; a name containing path separators is refused with a
located error.
The engine is not modified by any of this: scaffold_extension only writes
files that already conform to the module convention, and the loader is the
same one that resolves hand-written extensions.
A complete, committed example is shipped at
examples/extensions/hello_world/ (scaffolded with
gridalyn extension new hello_world --role data_source); its provenance note
(scaffold.yaml) records the exact command that produced it, so the example
is auditable and reproducible.
Validating an extension
After installing the package (so its entry point is visible to
importlib.metadata), check the two sides of the loop:
gridalyn extension list— awareness: reports the installed extension (extension_id, version, contract version, source) without importing it.gridalyn extension validate <id>— resolution: loads exactly that ID through the declared-only path, reports its provenance facts, and exits non-zero if the ID is undeclared, unimportable, or registered but not ready (itsREQUIRED_CAPABILITIEScannot be met on the current install — surfaced asMissingCapabilityError). Registered but not ready is never silent.
Provenance
provenance.extensions is a JSON-native snapshot of the extensions
in the generic engine's DEFAULT_REGISTRY (id, role, name, version, contract
version, source, entry-point group, module hash), sorted by extension_id —
populated whenever an extension is registered (host) or loaded (entry point)
into the runner's process before the manifest is written — since bd 4ky.8 that
includes every extension a study declares, which the runner registers before it
writes the manifest. Role-level provenance
records which extension served a role: provenance.powerflow_backend carries
extension_id/extension_source/extension_version when the resolved backend
is an external extension (source != "core"), and provenance.channel_model
carries the same three keys when a study declares a channel model served by an
extension. The remaining roles will reach the manifest the same way in a future
release. A plugin may be discoverable, but it is never silent.
Compatibility
SUPPORTED_CONTRACT_VERSIONS is the guard: a descriptor whose
contract_version is not supported is rejected at registration with a located
error naming the supported versions. This keeps an incompatible extension from
changing results without appearing correctly in provenance.
Design
The full architecture and the EMFlow-inspired discovery model live in the
internal design exploration (planning documents are not shipped with the
package). The engine itself is stdlib-only so foundation remains the bottom
layer with no upward imports.