diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 54a3a70..0cf1d3a 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -15,4 +15,7 @@ jobs:
with:
python-version: "3.12"
- run: uv sync --all-packages
- - run: uv tool run pip-audit
+ # CVE-2026-4539: pygments 2.19.2 (latest) — no fix available yet.
+ # Transitive dep from pytest, mkdocs-material, marimo, rich.
+ # Remove this ignore when pygments releases a patched version.
+ - run: uv tool run pip-audit --ignore-vuln CVE-2026-4539
diff --git a/CLAUDE.md b/CLAUDE.md
index d05beda..6894cee 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -80,7 +80,7 @@ gds-sim ← simulation engine (standalone — no gds-framework dep, only
Domain-neutral engine. Blocks with bidirectional typed interfaces, composed via four operators (`>>`, `|`, `.feedback()`, `.loop()`). A 3-stage compiler flattens composition trees into flat IR (blocks + wirings + hierarchy). Six generic verification checks (G-001..G-006) validate structural properties on the IR.
**Layer 1 — Specification Framework** (`spec.py`, `canonical.py`, `state.py`, `spaces.py`, `types/`):
-Where GDS theory lives. `GDSSpec` is the central registry for types, spaces, entities, blocks, wirings, and parameters. `project_canonical()` derives the formal `h = f ∘ g` decomposition. Seven semantic checks (SC-001..SC-007) validate domain properties on the spec.
+Where GDS theory lives. `GDSSpec` is the central registry for types, spaces, entities, blocks, wirings, and parameters. `project_canonical()` derives the formal `h = f ∘ g` decomposition. Nine semantic checks (SC-001..SC-009) validate domain properties on the spec.
These layers are loosely coupled — you can use the composition algebra without `GDSSpec`, and `GDSSpec` does not depend on the compiler.
@@ -140,7 +140,7 @@ Block roles (`BoundaryAction`, `Policy`, `Mechanism`, `ControlAction`) subclass
Both use the pluggable pattern: `Callable[[T], list[Finding]]`.
- **Generic checks (G-001..G-006)** operate on `SystemIR` — structural topology only
-- **Semantic checks (SC-001..SC-007)** operate on `GDSSpec` — domain properties (completeness, determinism, reachability, type safety, parameter references, canonical wellformedness)
+- **Semantic checks (SC-001..SC-009)** operate on `GDSSpec` — domain properties (completeness, determinism, reachability, type safety, parameter references, canonical wellformedness, admissibility references, transition read consistency)
- **Domain checks** operate on domain models (e.g., `StockFlowModel`, `ControlModel`) — pre-compilation structural validation
### Branching Workflow
diff --git a/context7.json b/context7.json
new file mode 100644
index 0000000..a74b0e8
--- /dev/null
+++ b/context7.json
@@ -0,0 +1,4 @@
+{
+ "url": "https://context7.com/blockscience/gds-core",
+ "public_key": "pk_LRmzAzjZYKfatA1uULFnD"
+}
\ No newline at end of file
diff --git a/docs/guides/paper-implementation-gap.md b/docs/guides/paper-implementation-gap.md
new file mode 100644
index 0000000..a32c710
--- /dev/null
+++ b/docs/guides/paper-implementation-gap.md
@@ -0,0 +1,494 @@
+# Paper vs. Implementation: Gap Analysis
+
+> Systematic comparison of the GDS software implementation against
+> Zargham & Shorish (2022), *Generalized Dynamical Systems Part I: Foundations*
+> (DOI: 10.57938/e8d456ea-d975-4111-ac41-052ce73cb0cc).
+>
+> Purpose: identify what the software faithfully implements, what it extends
+> beyond the paper, and what paper concepts remain unimplemented. Concludes
+> with a concrete bridge proposal.
+
+---
+
+## 1. Core Object Mapping
+
+### 1.1 Faithfully Implemented
+
+| Paper (Section 2) | Notation | Software | Notes |
+|---|---|---|---|
+| State Space (Def 2.1) | X | `Entity` + `StateVariable`; X = product of all entity variables | Product structure is explicit |
+| State (Def 2.2) | x in X | Dict of entity -> variable -> value | At runtime only (gds-sim) |
+| Trajectory (Def 2.3) | x_0, x_1, ... | gds-sim trajectory execution | Deferred to simulation package |
+| Input Space (Def 2.4) | U | `BoundaryAction.forward_out` ports | Structural only |
+| Input (Def 2.4) | u in U | Signal on boundary port | At runtime only |
+| State Update Map (Def 2.6) | f : X x U_x -> X | `Mechanism` blocks with `updates` field | Structural skeleton only -- f_struct (which entity/variable) is captured, f_behav (the function) is not stored |
+| Input Map (Def 2.8) | g : X -> U_x | `Policy` blocks | Same: structural identity only |
+| State Transition Map (Def 2.9) | h = f\|_x . g | `project_canonical()` computes formula | Declared composition, not executable |
+| GDS (Def 2.10) | {h, X} | `CanonicalGDS` dataclass | Faithful for structural identity |
+
+### 1.2 Structurally Implemented (Steps 1-2 of Bridge Proposal)
+
+| Paper (Section 2) | Notation | Software | Notes |
+|---|---|---|---|
+| Admissible Input Space (Def 2.5) | U_x subset U | `AdmissibleInputConstraint` — dependency graph (which state variables constrain which inputs) | Structural skeleton (R1). The actual constraint predicate is R3/lossy, same as TypeDef.constraint. SC-008 validates references. |
+| Restricted State Update Map (Def 2.7) | f\|_x : U_x -> X | `TransitionSignature` — read dependencies (which state variables a mechanism reads) | Structural skeleton (R1). Complements Mechanism.updates (writes). SC-009 validates references. |
+
+### 1.3 Not Implemented
+
+| Paper (Section 2-4) | Notation | What It Does | Why It Matters |
+|---|---|---|---|
+| Admissible Input Map (Def 2.5) | U : X -> P(U) | The actual function computing the admissible input set | R3 — requires runtime evaluation. The structural dependency graph is captured (see 1.2), but the computation is not. |
+| Metric on State Space (Asm 3.2) | d_X : X x X -> R | Distance between states | Required for contingent derivative, reachability rate |
+| Attainability Correspondence (Def 3.1) | F : X x R+ x R+ => X | Set of states reachable at time t from (x_0, t_0) | Foundation for reachability and controllability |
+| Contingent Derivative (Def 3.3) | D'F(x_0, t_0, t) | Generalized rate of change (set-valued) | Connects trajectories to input maps; enables existence proofs |
+| Constraint Set (Asm 3.5) | C(x, t; g) subset X | Compact, convex set restricting contingent derivatives | Required for Theorem 3.6 (existence of solutions) |
+| Existence of Solutions (Thm 3.6) | D'F = C(x_0, t_0) | Conditions under which a trajectory exists | The paper's core analytical result |
+| Reachable Set (Def 4.1) | R(x) = union{f(x,u)} | Immediately reachable states from x | Foundation for configuration space and controllability |
+| Configuration Space (Def 4.2) | X_C subset X | Mutually reachable connected component | Characterizes the "live" portion of state space |
+| Local Controllability (Thm 4.4) | 0-controllable from eta_0 | Conditions for steering to equilibrium | Engineering design guarantee |
+| Observability / Design Invariants (Sec 4.4) | P(x_i) = TRUE | Properties that should hold along trajectories | Design verification (invariant checking) |
+
+### 1.4 Software Extensions Beyond the Paper
+
+| Software Concept | Purpose | Paper Status |
+|---|---|---|
+| Composition algebra (>>, \|, .feedback(), .loop()) | Build h from typed, composable blocks | Paper takes h as given; no composition operators |
+| Bidirectional interfaces (F_in, F_out, B_in, B_out) | Contravariant/covariant flow directions | Paper has unidirectional f, g |
+| Four-role partition (Boundary, Policy, Mechanism, ControlAction) | Typed block classification | Paper has monolithic f and g |
+| Token-based type system | Structural auto-wiring via port name matching | No counterpart |
+| Parameters Theta (ParameterDef, ParameterSchema) | Explicit configuration space | Paper alludes to "factors that change h" but never formalizes |
+| Decision space D | Intermediate space between g and f | Paper's g maps X -> U_x directly |
+| Verification checks (G-001..G-006, SC-001..SC-009) | Structural validation | Paper assumes well-formed h |
+| Compiler pipeline (flatten -> wire -> hierarchy) | Build IR from composition tree | No counterpart |
+| SystemIR intermediate representation | Flat, inspectable system graph | No counterpart |
+| Domain DSLs (stockflow, control, games, software, business) | Domain-specific compilation to GDS | No counterpart |
+| Spaces (typed product spaces) | Signal spaces between blocks | Paper has U (input space) only |
+
+---
+
+## 2. Structural Divergences
+
+### 2.1 The Canonical Form Signature
+
+**Paper:**
+```
+g : X -> U_x (input map: state -> admissible input)
+f : X x U_x -> X (state update: state x input -> next state)
+h(x) = f(x, g(x)) (autonomous after g is fixed)
+```
+
+**Software:**
+```
+g : X x U -> D (policy: state x exogenous input -> decisions)
+f : X x D -> X (mechanism: state x decisions -> next state)
+h(x) = f(x, g(x, u)) (not autonomous -- exogenous U remains)
+```
+
+Key differences:
+
+1. **The software interposes a decision space D.** The paper's g selects
+ directly from the input space U. The software's g maps to a separate
+ decision space D, distinct from U. This adds an explicit "observation ->
+ decision" decomposition that the paper leaves inside g.
+
+2. **The software's h is not autonomous.** The paper's h(x) = f(x, g(x)) is
+ a function of state alone -- once g is chosen, the system is autonomous.
+ The software's canonical form retains exogenous inputs U, making h a
+ function of both state and environment.
+
+3. **Admissible input restriction is absent.** The paper's g maps to U_x
+ (state-dependent admissible subset). The software's BoundaryAction
+ produces inputs unconditionally -- U_x = U for all x.
+
+### 2.2 The Role Decomposition
+
+The paper has two maps (f and g) with no further internal structure. The
+software decomposes these into four block roles:
+
+```
+Paper g --> Software BoundaryAction + ControlAction + Policy
+Paper f --> Software Mechanism
+```
+
+The ControlAction role (endogenous observation) has no paper analog. It
+represents an internal decomposition of the paper's g into an observation
+stage feeding a decision stage. This is an engineering design, not a
+mathematical distinction from the paper.
+
+### 2.3 What the Paper Assumes That the Software Must Build
+
+The paper says "let h : X -> X be a state transition map" and proceeds to
+analyze its properties. The software must answer: *how do you construct h
+from components?*
+
+The entire composition algebra -- the block tree, the operators, the
+compiler, the wiring system, the token-based type matching -- is the
+software's answer to this question. It is the primary contribution of the
+implementation relative to the paper.
+
+---
+
+## 3. Analytical Machinery Gap
+
+The paper devotes approximately 40% of its content (Sections 3-4) to
+analytical machinery that the software does not implement. This machinery
+falls into two categories:
+
+### 3.1 Contingent Representation (Paper Section 3)
+
+**What it provides:** Given a GDS {h, X} and initial conditions (x_0, t_0),
+the contingent derivative D'F characterizes the set of directions in which
+the system can evolve. Under regularity conditions (Assumption 3.5: constraint
+set C is compact, convex, continuous), Theorem 3.6 guarantees the existence
+of a solution trajectory.
+
+**Why it matters:** This is the paper's mechanism for proving that a GDS
+specification is *realizable* -- that trajectories satisfying the constraints
+actually exist. Without it, a GDSSpec is a structural blueprint with no
+guarantee that it corresponds to a well-defined dynamical process.
+
+**Software status:** Entirely absent. The software can verify structural
+invariants (all variables updated, no cycles, references valid) but cannot
+determine whether the specified dynamics admit a trajectory.
+
+### 3.2 Differential Inclusion Representation (Paper Section 4)
+
+**What it provides:** The reachable set R(x) = union{f(x,u) : u in U_x}
+defines what's immediately reachable from x. The configuration space X_C
+is the mutually reachable connected component. Local controllability
+(Theorem 4.4) gives conditions under which the system can be steered to
+equilibrium.
+
+**Why it matters:** These are the tools for engineering design verification:
+- Can the system reach a desired operating point?
+- Can it be steered back after perturbation?
+- Is the reachable set consistent with safety constraints?
+
+**Software status:** SC-003 (reachability) checks structural graph
+reachability ("can signals propagate from block A to block B through
+wirings"). This is topological, not dynamical. The paper's reachability
+asks: "given concrete state x, which states x' can the system reach under
+some input sequence?" -- a fundamentally different question.
+
+### 3.3 The Remaining Gap
+
+The structural skeletons of U_x and f|_x are now captured
+(AdmissibleInputConstraint and TransitionSignature). What remains is the
+analytical machinery that *uses* these structures: the metric on X, the
+reachable set R(x), the configuration space X_C, and the contingent
+derivative. These require runtime evaluation of f and g — they are
+behavioral (R3), not structural.
+
+---
+
+## 4. Bridge Proposal
+
+The gap between paper and implementation can be bridged incrementally.
+Each step adds analytical capability while preserving the existing
+structural core. Steps are ordered by dependency and increasing difficulty.
+
+### Step 1: Admissible Input Map U_x -- IMPLEMENTED
+
+**What:** State-dependent input constraints on the specification.
+
+**Paper reference:** Definition 2.5 -- U : X -> P(U).
+
+**Implementation:** `gds.AdmissibleInputConstraint` (frozen Pydantic model
+in `gds/constraints.py`):
+
+```python
+from gds import AdmissibleInputConstraint
+
+spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="balance_limit",
+ boundary_block="market_order",
+ depends_on=[("agent", "balance")],
+ constraint=lambda state, u: u["quantity"] <= state["agent"]["balance"],
+ description="Cannot sell more than owned balance"
+ )
+)
+```
+
+**What was delivered:**
+- SC-008 (`check_admissibility_references`): validates boundary block exists,
+ is a BoundaryAction, depends_on references valid (entity, variable) pairs
+- `CanonicalGDS.admissibility_map`: populated by `project_canonical()`
+- `SpecQuery.admissibility_dependency_map()`: boundary -> state variable deps
+- OWL export/import with BNode-based tuple reification for depends_on
+- SHACL shapes for structural validation
+- Round-trip test (constraint callable is lossy, structural fields preserved)
+- Keyed by `name` (not `boundary_block`) to allow multiple constraints per
+ BoundaryAction
+
+**Structural vs. behavioral split:**
+- U_x_struct: the dependency relation (boundary -> state variables) -- R1
+- U_x_behav: the actual constraint function -- R3 (same as TypeDef.constraint)
+
+### Step 2: Restricted State Update Map f|_x -- IMPLEMENTED
+
+**What:** Mechanism read dependencies (which state variables a mechanism reads).
+
+**Paper reference:** Definition 2.7 -- f|_x : U_x -> X.
+
+**Implementation:** `gds.TransitionSignature` (frozen Pydantic model in
+`gds/constraints.py`):
+
+```python
+from gds import TransitionSignature
+
+spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ reads=[("Room", "temperature"), ("Environment", "outdoor_temp")],
+ depends_on_blocks=["Controller"],
+ preserves_invariant="energy conservation"
+ )
+)
+```
+
+**What was delivered:**
+- SC-009 (`check_transition_reads`): validates mechanism exists, is a
+ Mechanism, reads references valid (entity, variable) pairs,
+ depends_on_blocks references registered blocks
+- `CanonicalGDS.read_map`: populated by `project_canonical()`
+- `SpecQuery.mechanism_read_map()`, `SpecQuery.variable_readers()`
+- OWL export/import with BNode-based tuple reification for reads
+- SHACL shapes for structural validation
+- Round-trip test (structural fields preserved)
+- `writes` deliberately omitted -- `Mechanism.updates` already tracks those
+- One signature per mechanism (intentional simplification)
+
+### Step 3: Metric on State Space
+
+**What:** Equip X with a distance function, enabling the notion of "how far"
+states are from each other.
+
+**Paper reference:** Assumption 3.2 -- d_X : X x X -> R, a metric.
+
+**Concrete design:**
+
+```python
+@dataclass(frozen=True)
+class StateMetric:
+ """A metric on the state space, enabling distance-based analysis."""
+ name: str
+ metric: Callable[[dict, dict], float] # (state_1, state_2) -> distance
+ covers: list[str] # entity.variable names in scope
+ description: str = ""
+```
+
+For common cases, provide built-in metrics:
+
+```python
+# Euclidean metric over all numeric state variables
+euclidean_state_metric(spec: GDSSpec) -> StateMetric
+
+# Weighted metric with per-variable scaling
+weighted_state_metric(spec: GDSSpec, weights: dict[str, float]) -> StateMetric
+```
+
+**Impact:**
+- Enables Delta_x = d_X(x+, x) -- rate of change between successive states
+- Foundation for reachable set computation (Step 5)
+- Foundation for contingent derivative (Step 6)
+
+**Prerequisite:** Runtime state representation (gds-sim integration).
+
+**Structural vs. behavioral:** The metric itself is R3 (arbitrary callable).
+The declaration that "these state variables participate in the metric" is
+R1. The metric's properties (e.g., "Euclidean", "Hamming") could be R2
+if annotated as metadata.
+
+### Step 4: Reachable Set R(x)
+
+**What:** Given a concrete state x and the state update map f, compute the
+set of immediately reachable next states.
+
+**Paper reference:** Definition 4.1 -- R(x) = union_{u in U_x} {f(x, u)}.
+
+**Concrete design:**
+
+```python
+def reachable_set(
+ spec: GDSSpec,
+ state: dict,
+ f: Callable[[dict, dict], dict], # state update function
+ input_sampler: Callable[[], Iterable[dict]], # enumerates/samples U_x
+) -> set[dict]:
+ """Compute R(x) by evaluating f(x, u) for all admissible u."""
+```
+
+For finite/discrete input spaces, this is exact enumeration. For
+continuous input spaces, this requires sampling or symbolic analysis.
+
+**Impact:**
+- First dynamical analysis capability
+- Enables "what-if" analysis: "from this state, what states can I reach?"
+- Foundation for configuration space (Step 5)
+
+**Prerequisite:** Steps 1 (U_x), 3 (metric for measuring distance).
+Requires gds-sim or equivalent runtime.
+
+### Step 5: Configuration Space X_C
+
+**What:** The mutually reachable connected component of the state space --
+the set of states from which any other state in X_C is reachable.
+
+**Paper reference:** Definition 4.2 -- X_C subset X such that for each
+x in X_C, there exists x_0 and a reachable sequence reaching x.
+
+**Concrete design:**
+
+```python
+def configuration_space(
+ spec: GDSSpec,
+ f: Callable,
+ input_sampler: Callable,
+ initial_states: Iterable[dict],
+ max_depth: int = 100,
+) -> set[frozenset]:
+ """Compute X_C via BFS/DFS over R(x) from initial states."""
+```
+
+For finite state spaces, this is graph search over the reachability
+graph. For continuous state spaces, this requires approximation
+(grid-based, interval arithmetic, or abstraction).
+
+**Impact:**
+- Answers "is the target state reachable from the initial condition?"
+- Identifies disconnected components (states the system can never reach)
+- Foundation for controllability analysis
+
+**Prerequisite:** Step 4 (reachable set).
+
+### Step 6: Contingent Derivative (Research Frontier)
+
+**What:** The generalized derivative that characterizes the set of
+directions in which the system can evolve, given constraints.
+
+**Paper reference:** Definition 3.3, Theorem 3.6.
+
+**Why this is hard:** The contingent derivative requires:
+1. A metric on X (Step 3)
+2. The attainability correspondence F (requires iterating R(x) over time)
+3. Convergence analysis (sequences converging to x_0 with rate limit)
+4. The constraint set C(x, t; g) to be compact, convex, continuous
+
+This is the paper's deepest analytical contribution and the hardest to
+implement. It may be more appropriate as a separate analytical package
+(e.g., gds-analysis) rather than part of gds-framework.
+
+**Concrete approach:**
+
+For the special case of discrete-time systems with finite input spaces:
+- The contingent derivative reduces to the set of finite differences
+ Delta_x / Delta_t for all admissible transitions
+- Compactness of C is automatic (finite set)
+- Convexity may or may not hold (depends on the transitions)
+- Continuity must be checked numerically
+
+For continuous state spaces:
+- Requires interval arithmetic or symbolic differentiation
+- Significantly harder; likely requires external tools (e.g., sympy,
+ scipy, or a dedicated reachability library like CORA or JuliaReach)
+
+**Prerequisite:** Steps 3-5. Likely a separate package.
+
+### Step 7: Controllability Analysis (Research Frontier)
+
+**What:** Conditions under which the system can be steered from any state
+in a neighborhood to a target state.
+
+**Paper reference:** Theorem 4.4 (local controllability).
+
+**Requires:**
+- Reachable set R(x) with metric (Steps 3-4)
+- The boundary mapping partial_R to be Lipschitzian (numerical check)
+- Closed, convex values (property of R)
+- A controllable closed, strictly convex reachable process near target
+
+This is the most advanced analytical capability in the paper and would
+represent a significant research contribution if implemented. It connects
+GDS to classical control theory results (controllability, observability)
+in a generalized setting.
+
+**Suggested approach:** Start with the linear case (where controllability
+reduces to rank conditions on matrices) and generalize incrementally.
+This connects directly to RQ1 (MIMO semantics) in research-boundaries.md.
+
+---
+
+## 5. Dependency Graph
+
+```
+Step 1: AdmissibleInputConstraint (U_x declaration) -- DONE (gds-framework v0.2.3)
+Step 2: TransitionSignature (f|_x declaration) -- DONE (gds-framework v0.2.3)
+ |
+ v
+Step 3: StateMetric (d_X on X) -- requires runtime
+ |
+ v
+Step 4: Reachable Set R(x) -- requires Steps 1, 3
+ |
+ v
+Step 5: Configuration Space X_C -- requires Step 4
+ |
+ v
+Step 6: Contingent Derivative D'F -- research frontier
+Step 7: Local Controllability -- research frontier
+```
+
+Steps 1-2 are implemented in gds-framework with full OWL/SHACL support
+in gds-owl. Steps 3-5 require runtime evaluation and belong in gds-sim
+or a new gds-analysis package. Steps 6-7 are research-level and may
+warrant a dedicated analytical package or external tooling integration.
+
+---
+
+## 6. Package Placement
+
+| Step | Where | Status |
+|---|---|---|
+| 1 (U_x) | gds-framework (constraints.py, spec.py) | **Done** — SC-008, OWL, SHACL |
+| 2 (f\|_x signature) | gds-framework (constraints.py, spec.py, canonical.py) | **Done** — SC-009, OWL, SHACL |
+| 3 (metric) | gds-sim or gds-analysis | Requires concrete state values |
+| 4 (R(x)) | gds-analysis (new) | Dynamical computation |
+| 5 (X_C) | gds-analysis | Dynamical computation |
+| 6 (D'F) | gds-analysis | Research frontier |
+| 7 (controllability) | gds-analysis | Research frontier |
+
+A new `gds-analysis` package would depend on both `gds-framework`
+(for GDSSpec, CanonicalGDS) and `gds-sim` (for state evaluation), sitting
+at the top of the dependency graph:
+
+```
+gds-framework <-- gds-sim <-- gds-analysis
+ ^ |
+ | |
+ +----------------------------------+
+```
+
+---
+
+## 7. What Does Not Need Bridging
+
+Some paper concepts are intentionally absent for good architectural reasons:
+
+1. **Continuous-time dynamics (xdot = f(x(t)))** -- The paper presents this
+ as an alternative representation. The software is discrete-time by design.
+ Continuous-time would require a fundamentally different execution model.
+ Per RQ2 in research-boundaries.md, temporal semantics should remain
+ domain-local.
+
+2. **The full attainability correspondence F as an axiomatic foundation** --
+ The paper notes (Section 3.2) that Roxin's original work defined GDS via
+ the attainability correspondence. The software defines GDS via the
+ composition algebra instead. These are equivalent starting points that
+ lead to different tooling.
+
+3. **Convexity requirements on C(x, t; g)** -- The paper's existence theorem
+ (Theorem 3.6) requires convexity. Many real systems (discrete decisions,
+ combinatorial action spaces) violate this. The software should not impose
+ convexity as a requirement -- it should report when convexity holds
+ (as metadata) and note when existence theorems apply.
diff --git a/docs/guides/verification.md b/docs/guides/verification.md
index b72ba81..b9b9c49 100644
--- a/docs/guides/verification.md
+++ b/docs/guides/verification.md
@@ -7,7 +7,7 @@ A hands-on walkthrough of the three verification layers in GDS, using deliberate
| Layer | Checks | Operates on | Catches |
|-------|--------|-------------|---------|
| **Generic** | G-001..G-006 | `SystemIR` | Structural topology errors |
-| **Semantic** | SC-001..SC-007 | `GDSSpec` | Domain property violations |
+| **Semantic** | SC-001..SC-009 | `GDSSpec` | Domain property violations |
| **Domain** | SF-001..SF-005 | DSL model | DSL-specific errors |
Each layer operates on a different representation, and the layers are complementary: a model can pass all generic checks but fail semantic checks (and vice versa).
@@ -357,6 +357,8 @@ gds_findings = [f for f in report.findings if f.check_id.startswith("G-")]
| SC-005 | Parameter refs | Unregistered params |
| SC-006 | Canonical f | No mechanisms |
| SC-007 | Canonical X | No state space |
+| SC-008 | Admissibility refs | Invalid boundary block or state deps |
+| SC-009 | Transition reads | Invalid mechanism reads or block deps |
### Domain Checks (StockFlowModel)
diff --git a/docs/owl/api/export.md b/docs/owl/api/export.md
new file mode 100644
index 0000000..abdc508
--- /dev/null
+++ b/docs/owl/api/export.md
@@ -0,0 +1,5 @@
+# gds_owl.export
+
+Pydantic to RDF graph export functions.
+
+::: gds_owl.export
diff --git a/docs/owl/api/import.md b/docs/owl/api/import.md
new file mode 100644
index 0000000..5b86660
--- /dev/null
+++ b/docs/owl/api/import.md
@@ -0,0 +1,5 @@
+# gds_owl.import_
+
+RDF graph to Pydantic import functions.
+
+::: gds_owl.import_
diff --git a/docs/owl/api/init.md b/docs/owl/api/init.md
new file mode 100644
index 0000000..a9f1c95
--- /dev/null
+++ b/docs/owl/api/init.md
@@ -0,0 +1,8 @@
+# gds_owl
+
+Public API -- top-level exports.
+
+::: gds_owl
+ options:
+ show_submodules: false
+ members: false
diff --git a/docs/owl/api/ontology.md b/docs/owl/api/ontology.md
new file mode 100644
index 0000000..e3af4cc
--- /dev/null
+++ b/docs/owl/api/ontology.md
@@ -0,0 +1,5 @@
+# gds_owl.ontology
+
+OWL class hierarchy (TBox) -- core schema definitions.
+
+::: gds_owl.ontology
diff --git a/docs/owl/api/serialize.md b/docs/owl/api/serialize.md
new file mode 100644
index 0000000..ede77be
--- /dev/null
+++ b/docs/owl/api/serialize.md
@@ -0,0 +1,5 @@
+# gds_owl.serialize
+
+RDF serialization utilities (Turtle format).
+
+::: gds_owl.serialize
diff --git a/docs/owl/api/shacl.md b/docs/owl/api/shacl.md
new file mode 100644
index 0000000..807d4bb
--- /dev/null
+++ b/docs/owl/api/shacl.md
@@ -0,0 +1,5 @@
+# gds_owl.shacl
+
+SHACL shape library for validating GDS RDF graphs.
+
+::: gds_owl.shacl
diff --git a/docs/owl/api/sparql.md b/docs/owl/api/sparql.md
new file mode 100644
index 0000000..405ea1e
--- /dev/null
+++ b/docs/owl/api/sparql.md
@@ -0,0 +1,5 @@
+# gds_owl.sparql
+
+SPARQL query templates for GDS analysis.
+
+::: gds_owl.sparql
diff --git a/docs/owl/getting-started.md b/docs/owl/getting-started.md
new file mode 100644
index 0000000..085081f
--- /dev/null
+++ b/docs/owl/getting-started.md
@@ -0,0 +1,99 @@
+# Getting Started
+
+## Installation
+
+```bash
+pip install gds-owl
+```
+
+For SHACL validation:
+
+```bash
+pip install gds-owl[shacl]
+```
+
+## Build an Ontology
+
+The core ontology defines OWL classes and properties for all GDS concepts:
+
+```python
+from gds_owl import build_core_ontology, to_turtle
+
+ontology = build_core_ontology()
+print(to_turtle(ontology))
+```
+
+This produces a Turtle document with classes like `gds-core:GDSSpec`, `gds-core:Mechanism`, `gds-core:Policy`, etc.
+
+## Export a Spec to RDF
+
+```python
+from gds import GDSSpec, typedef, entity, state_var
+from gds.blocks.roles import Mechanism
+from gds.types.interface import Interface, port
+from gds_owl import spec_to_graph, to_turtle
+
+# Build a minimal spec
+Float = typedef("Float", float)
+spec = GDSSpec(name="Example")
+spec.collect(
+ Float,
+ entity("Tank", level=state_var(Float)),
+ Mechanism(
+ name="Fill",
+ interface=Interface(forward_in=(port("Flow Rate"),)),
+ updates=[("Tank", "level")],
+ ),
+)
+
+# Export to RDF
+graph = spec_to_graph(spec)
+print(to_turtle(graph))
+```
+
+## Import Back from RDF
+
+```python
+from rdflib import Graph
+from gds_owl import graph_to_spec, to_turtle, spec_to_graph
+
+# Round-trip: Pydantic -> Turtle -> Pydantic
+graph = spec_to_graph(spec)
+turtle_str = to_turtle(graph)
+
+g2 = Graph()
+g2.parse(data=turtle_str, format="turtle")
+spec2 = graph_to_spec(g2)
+
+assert spec2.name == "Example"
+assert "Tank" in spec2.entities
+```
+
+## Validate with SHACL
+
+```python
+from gds_owl import build_all_shapes, validate_graph, spec_to_graph
+
+graph = spec_to_graph(spec)
+shapes = build_all_shapes()
+
+conforms, results_graph, results_text = validate_graph(graph, shapes)
+print(f"Conforms: {conforms}")
+if not conforms:
+ print(results_text)
+```
+
+## Query with SPARQL
+
+```python
+from gds_owl import TEMPLATES, spec_to_graph
+
+graph = spec_to_graph(spec)
+
+# List all blocks
+for template in TEMPLATES:
+ if template.name == "list_blocks":
+ results = graph.query(template.query)
+ for row in results:
+ print(row)
+```
diff --git a/docs/owl/guide/formal-representability.md b/docs/owl/guide/formal-representability.md
new file mode 100644
index 0000000..4057d1b
--- /dev/null
+++ b/docs/owl/guide/formal-representability.md
@@ -0,0 +1,808 @@
+# Representability Analysis: GDS in OWL/SHACL/SPARQL
+
+A design rationale document classifying which GDS concepts can and cannot
+be represented in semantic web formalisms, grounded in the
+compositionality-temporality boundary and the canonical decomposition
+h = f ∘ g.
+
+---
+
+## Overview
+
+### The representation boundary is h = f ∘ g
+
+The GDS canonical decomposition h = f ∘ g is not just mathematical
+notation — it is the exact line where formal representation changes
+character:
+
+- **g** (policy mapping): which blocks connect to what, in what roles,
+ through what wires. Fully representable — by design, GDSSpec stores no
+ behavioral content here.
+- **f_struct** (update map): "Mechanism M updates Entity E variable V."
+ Fully representable — a finite relation.
+- **f_behav** (transition function): "Given state x and decisions d,
+ compute new state x'." Not representable — arbitrary computation.
+
+Everything to the left of f_behav is topology. Everything at f_behav and
+beyond is computation. OWL/SHACL/SPARQL live on the topology side. Python
+lives on both sides.
+
+### Composition: structure preserved, process lost
+
+The five composition operators (>>, |, feedback, loop) and their resulting
+trees survive OWL round-trip perfectly. You can reconstruct exactly how a
+system was assembled.
+
+The *process* of composition — auto-wiring via token overlap, port
+matching, construction-time validation — requires Python string computation
+that SPARQL cannot replicate. But this gap is **moot in practice**: gds-owl
+materializes both the tokens (as `typeToken` literals) and the wired
+connections (as explicit `WiringIR` edges) during export. The RDF consumer
+never needs to recompute what Python already computed.
+
+This reveals a general design principle: **materialize computation results
+as data before export, and the representation gap closes for practical
+purposes.**
+
+### Temporality: structure preserved, semantics lost
+
+A `TemporalLoop` (physical state at t feeds sensors at t+1) and a
+`CorecursiveLoop` (decisions at round t feed observations at round t+1)
+have identical RDF representation: covariant wiring from inner.forward_out
+to inner.forward_in with an exit_condition string. OWL captures "there is
+a loop" but not "what kind of time this loop means." The interpretation
+requires knowing which DSL compiled the system — that is metadata
+(preserved via `gds-ir:sourceLabel`), not topology.
+
+State evolution itself — computing x_{t+1} = f(x_t, g(x_t, u_t)) — is
+fundamentally not representable. You need a runtime, period.
+
+### The data/computation duality
+
+The same pattern recurs at every level of GDS:
+
+| Data (representable) | Computation (not representable) |
+|---|---|
+| Token sets on ports | The `tokenize()` function that produces them |
+| Wired connections | The auto-wiring process that discovers them |
+| Constraint bounds (0 <= x <= 1) | Arbitrary `Callable[[Any], bool]` constraints |
+| Update map (M updates E.V) | Transition function (how V changes) |
+| Read map (M reads E.V) | Actual data flow at runtime |
+| Admissibility deps (B depends on E.V) | Admissibility predicate (is input legal given state?) |
+| Equilibrium structure (which games compose how) | Equilibrium computation (finding Nash equilibria) |
+| Wiring graph topology (can A reach B?) | Signal propagation (does A's output actually affect B?) |
+
+If you materialize computation results as data before crossing the
+boundary, the gap shrinks to what genuinely requires a runtime: simulation,
+constraint evaluation, and equilibrium solving.
+
+### The validation stack
+
+The three semantic web formalisms serve architecturally distinct roles —
+a validation stack, not a containment chain:
+
+| Layer | Formalism | Role | Example |
+|---|---|---|---|
+| Vocabulary | OWL | Defines what things *are* | "A Mechanism is a kind of AtomicBlock" |
+| Local constraints | SHACL-core | Validates individual nodes | "Every Mechanism must update >= 1 state variable" |
+| Graph patterns | SPARQL | Validates cross-node relationships | "No two mechanisms update the same (entity, variable) pair" |
+| Computation | Python | Evaluates functions, evolves state | "Given x=0.5, f(x) = 0.7" |
+
+Each step adds expressiveness and loses decidability guarantees. The
+R1/R2/R3 tier system in this document maps directly onto this stack.
+
+### Architectural consequences
+
+1. **RDF is a viable structural interchange format.** Of 15 verification
+ checks, 6 are SHACL-expressible, 6 more with SPARQL, only 2 genuinely
+ need Python. The structural skeleton carries the vast majority of system
+ information.
+
+2. **Games are naturally ontological.** When h = g (no state, no f), the
+ GDSSpec projection is lossless. Games are morphisms between spaces, not
+ state machines. Game composition maps cleanly to OWL because it is all
+ structure.
+
+3. **Dynamical systems degrade gracefully.** Each mechanism contributes one
+ representable fact (what it updates) and one non-representable fact (how
+ it computes). The structural skeleton is always complete; what degrades
+ is the fraction of total content it represents.
+
+4. **The canonical form is architecturally load-bearing.** By separating
+ "what connects to what" (g) from "what the connections compute"
+ (f_behav), GDS provides a clean cut point for partial representation,
+ cross-tool interop, and formal reasoning.
+
+The representability boundary is Rice's theorem applied to system
+specifications: you can represent everything about a system except what
+its programs actually do. The canonical decomposition h = f ∘ g makes this
+boundary explicit and exploitable.
+
+---
+
+## 1. Preliminaries
+
+### 1.1 GDS Formal Objects
+
+**Definition 1.1 (Composition Algebra).** The GDS composition algebra is
+a tuple (Block, >>, |, fb, loop) with operations inspired by symmetric
+monoidal categories with feedback. The operations satisfy the expected
+algebraic properties (associativity of >> and |, commutativity of |) by
+construction, but the full categorical axioms (interchange law, coherence
+conditions, traced monoidal structure for feedback) have not been formally
+verified.
+
+The components are:
+
+- **Objects** are Interfaces: I = (F_in, F_out, B_in, B_out), each a tuple
+ of Ports
+- **Morphisms** are Blocks: typed components with bidirectional interfaces
+- **>>** (sequential): first ; second with token-overlap validation
+- **|** (parallel): left tensor right, no shared wires
+- **fb** (feedback): contravariant backward flow within timestep
+- **loop** (temporal): covariant forward flow across timesteps
+
+**Definition 1.2 (Token System).** Port names carry structural type
+information via tokenization:
+
+```
+tokenize : PortName -> P(Token)
+
+Split on {' + ', ', '}, then lowercase each part.
+"Temperature + Setpoint" |-> {"temperature", "setpoint"}
+"Heater Command" |-> {"heater command"}
+```
+
+Token overlap is the auto-wiring predicate:
+
+```
+compatible(p1, p2) := tokenize(p1.name) ∩ tokenize(p2.name) != empty
+```
+
+**Definition 1.3 (GDSSpec).** A specification is an 8-tuple:
+
+```
+S = (T, Sp, E, B, W, Theta, A, Sig)
+
+T : Name -> TypeDef (type registry)
+Sp : Name -> Space (typed product spaces)
+E : Name -> Entity (state holders with typed variables)
+B : Name -> Block (typed compositional blocks)
+W : Name -> SpecWiring (named compositions with explicit wires)
+Theta : Name -> ParameterDef (configuration space)
+A : Name -> AdmissibleInputConstraint (state-dependent input constraints)
+Sig : MechName -> TransitionSignature (mechanism read dependencies)
+```
+
+While presented as an 8-tuple, these components are cross-referencing:
+blocks reference types, wirings reference blocks, entities reference types,
+admissibility constraints reference boundary blocks and entity variables,
+transition signatures reference mechanisms and entity variables.
+GDSSpec is more precisely a labeled graph of registries with typed edges.
+
+**Definition 1.4 (Canonical Decomposition).** The projection
+pi : GDSSpec -> CanonicalGDS yields:
+
+```
+C = (X, U, D, Theta, g, f, h, A_deps, R_deps)
+
+X = product_{(e,v) in E} TypeDef(e.variables[v]) state space
+U = {(b, p) : b in B_boundary, p in b.forward_out} input space
+D = {(b, p) : b in B_policy, p in b.forward_out} decision space
+g : X x U -> D policy mapping
+f : X x D -> X state transition
+h_theta: X -> X where h = f ∘ g composed transition
+A_deps = {(name, {(e,v)}) : ac in A} admissibility dependencies
+R_deps = {(mech, {(e,v)}) : sig in Sig} mechanism read dependencies
+```
+
+**Definition 1.5 (Role Partition).** Blocks partition into disjoint roles:
+
+```
+B = B_boundary disjoint-union B_control disjoint-union B_policy disjoint-union B_mechanism
+
+B_boundary : forward_in = empty (exogenous input)
+B_mechanism : backward_in = backward_out = empty (state update)
+B_policy : no structural constraints (decision logic)
+B_control : no structural constraints (endogenous feedback)
+```
+
+**Definition 1.6 (TypeDef).** A type definition carries two levels:
+
+```
+TypeDef = (name, python_type, constraint, units)
+
+python_type : type (language-level type object)
+constraint : Optional[Any -> bool] (runtime validation predicate)
+```
+
+The constraint field admits arbitrary Callable — this is Turing-complete.
+
+### 1.2 Semantic Web Formal Objects
+
+**Definition 1.7 (OWL DL).** OWL DL is based on the description logic
+SROIQ(D). It provides class-level entailment under the **open-world
+assumption** (OWA): absence of a statement does not imply its negation.
+
+- **Class declarations**: C, with subsumption C1 sqsubseteq C2
+- **Object properties**: R : C1 -> C2 (binary relations between individuals)
+- **Datatype properties**: R : C -> Literal (attributes with XSD types)
+- **Restrictions**: cardinality (min/max), value constraints, disjointness
+
+Key property: **every entailment query terminates** (decidable).
+
+**Definition 1.8 (SHACL).** The Shapes Constraint Language validates RDF
+graphs against declared shapes under the **closed-world assumption** (CWA):
+the graph is taken as complete, and missing data counts as a violation.
+
+- **Node shapes**: target a class, constrain its properties
+- **Property shapes**: cardinality (sh:minCount, sh:maxCount), datatype,
+ class membership
+- **SPARQL-based constraints**: sh:sparql embeds SELECT queries as validators
+
+SHACL is not a reasoning system — it validates data, not entailment.
+
+**Definition 1.9 (SPARQL 1.1).** A query language for pattern matching
+and aggregation over RDF graphs:
+
+- **Property paths**: transitive closure (p+), alternatives (p1|p2)
+- **Negation**: FILTER NOT EXISTS { pattern }
+- **Aggregation**: GROUP BY, HAVING, COUNT
+- **Graph patterns**: triple patterns with variables, OPTIONAL, UNION
+
+Key limitation: **no mutable state, no unbounded recursion, no string
+computation** beyond regex matching.
+
+**Remark 1.10 (Complementary formalisms).** OWL, SHACL, and SPARQL solve
+different problems under different assumptions:
+
+- OWL DL: class-level entailment (OWA, monotonic)
+- SHACL: graph shape validation (CWA, non-monotonic)
+- SPARQL: graph pattern queries with aggregation and negation
+
+They do not form a simple containment chain. However, for the specific
+purpose of **enforcing constraints on GDS-exported RDF graphs**, we
+distinguish three tiers of validation expressiveness:
+
+```
+SHACL-core (node/property shapes) < SPARQL graph patterns < Turing-complete
+```
+
+OWL defines the vocabulary (classes, properties, subsumption). SHACL-core
+— node shapes, property shapes with cardinality/datatype/class constraints,
+but *without* sh:sparql — validates individual nodes against local
+constraints. SPARQL graph patterns (standalone or embedded in SHACL via
+sh:sparql) can express cross-node patterns: negation-as-failure, transitive
+closure, aggregation. None can express arbitrary computation.
+
+This three-level ordering directly motivates the R1/R2/R3 tiers in
+Definition 2.2: R1 maps to OWL + SHACL-core, R2 maps to SPARQL, R3
+exceeds all three formalisms.
+
+---
+
+## 2. Representability Classification
+
+**Definition 2.1 (Representation Function).** Let rho be the mapping from
+GDS concepts to RDF graphs implemented by gds-owl's export functions:
+
+```
+rho_spec : GDSSpec -> Graph (spec_to_graph)
+rho_ir : SystemIR -> Graph (system_ir_to_graph)
+rho_can : CanonicalGDS -> Graph (canonical_to_graph)
+rho_ver : VerificationReport -> Graph (report_to_graph)
+```
+
+And rho^{-1} the inverse mapping (import functions):
+
+```
+rho^{-1}_spec : Graph -> GDSSpec (graph_to_spec)
+rho^{-1}_ir : Graph -> SystemIR (graph_to_system_ir)
+rho^{-1}_can : Graph -> CanonicalGDS (graph_to_canonical)
+rho^{-1}_ver : Graph -> VerificationReport (graph_to_report)
+```
+
+**Remark 2.1 (Bijectivity caveats).** rho is injective on structural
+fields but not surjective onto all possible RDF graphs (only well-formed
+GDS graphs are in the image). rho^{-1} is a left inverse on structural
+fields: rho^{-1}(rho(c)) =_struct c. Three edge cases weaken strict
+bijectivity:
+
+1. **Ordering**: RDF multi-valued properties are unordered. Port lists and
+ wire lists may return in different order after round-trip. Equality is
+ set-based, not sequence-based.
+2. **Blank nodes**: Space fields and update map entries use RDF blank nodes.
+ These have no stable identity across serializations. Structural equality
+ compares by content (field name + type), not by node identity.
+3. **Lossy fields**: TypeDef.constraint and
+ AdmissibleInputConstraint.constraint are always None after import.
+ TypeDef.python_type falls back to `str` for types not in the built-in
+ map. These are documented R3 losses, not bijectivity failures.
+
+The round-trip suite (test_roundtrip.py: TestSpecRoundTrip,
+TestSystemIRRoundTrip, TestCanonicalRoundTrip, TestReportRoundTrip)
+verifies structural equality under these conventions for all four
+rho/rho^{-1} pairs.
+
+**Definition 2.2 (Representability Tiers).** A GDS concept c belongs to:
+
+**R1 (Fully representable)** if rho^{-1}(rho(c)) is structurally equal
+to c. The round-trip preserves all fields. Invariants on c are expressible
+as OWL class/property structure or SHACL cardinality/class shapes.
+
+**R2 (Structurally representable)** if rho preserves identity, topology,
+and classification, but loses behavioral content. Validation requires
+SPARQL graph pattern queries (negation-as-failure, transitive closure,
+aggregation) that exceed SHACL's node/property shape expressiveness. The
+behavioral projection, if any, is not representable.
+
+**R3 (Not representable)** if no finite OWL/SHACL/SPARQL expression can
+capture the concept. The gap is fundamental — it follows from:
+- **Rice's theorem**: any non-trivial semantic property of programs is
+ undecidable
+- **The halting problem**: arbitrary Callable may not terminate
+- **Computational class separation**: string parsing and temporal execution
+ exceed the expressiveness of all three formalisms
+
+---
+
+## 3. Layer 0 Representability: Composition Algebra
+
+**Property 3.1 (Composition Tree is R1).** The block composition tree —
+including all 5 block types (AtomicBlock, StackComposition,
+ParallelComposition, FeedbackLoop, TemporalLoop) with their structural
+fields — is fully representable in OWL.
+
+*Argument.* The representation function rho maps:
+
+```
+AtomicBlock |-> gds-core:AtomicBlock (owl:Class)
+StackComposition |-> gds-core:StackComposition + first, second (owl:ObjectProperty)
+ParallelComposition |-> gds-core:ParallelComposition + left, right
+FeedbackLoop |-> gds-core:FeedbackLoop + inner
+TemporalLoop |-> gds-core:TemporalLoop + inner
+```
+
+The OWL class hierarchy mirrors the Python class hierarchy. The
+`first`, `second`, `left`, `right`, `inner` object properties capture the
+tree structure. The round-trip test `test_roundtrip.py::
+TestSystemIRRoundTrip` verifies structural equality after
+Graph -> Turtle -> Graph -> Pydantic.
+
+The interface (F_in, F_out, B_in, B_out) is represented via four
+object properties (hasForwardIn, hasForwardOut, hasBackwardIn,
+hasBackwardOut) each pointing to Port individuals with portName and
+typeToken datatype properties. Port ordering within a direction may differ
+(RDF is unordered), but the *set* of ports is preserved.
+
+**Property 3.2 (Token Data R1, Auto-Wiring Process R3).** The materialized
+token data (the frozenset of strings on each Port) is R1. The auto-wiring
+process that uses tokenize() to discover connections is R3.
+
+*Argument.* Each Port stores `type_tokens: frozenset[str]`. These are
+exported as multiple `gds-core:typeToken` literals per Port individual. The
+round-trip preserves the token set exactly (unordered collection ->
+multi-valued RDF property -> unordered collection).
+
+Since gds-owl already materializes tokens during export, the RDF consumer
+never needs to run tokenize(). The tokens are data, not computation. The
+R3 classification applies specifically to **auto-wiring as a process**:
+discovering which ports should connect by computing token overlap from port
+name strings. This requires the tokenize() function (string splitting +
+lowercasing). While SPARQL CONSTRUCT can generate new triples from pattern
+matches, it cannot generate an unbounded number of new nodes from a single
+string value — the split points must be known at query-write time. Since
+GDS port names use variable numbers of delimiters, a fixed SPARQL query
+cannot handle all cases.
+
+In practice this is a moot point: the *wired connections* are exported as
+explicit WiringIR edges (R1). Only the *process of discovering them* is not
+replicable.
+
+**Property 3.3 (Block Roles are R1).** The role partition
+B = B_boundary disjoint-union B_control disjoint-union B_policy disjoint-union B_mechanism
+is fully representable as OWL disjoint union classes (owl:disjointUnionOf).
+
+*Argument.* Each role maps to an OWL class (gds-core:BoundaryAction,
+gds-core:Policy, gds-core:Mechanism, gds-core:ControlAction), all declared
+as subclasses of gds-core:AtomicBlock. Role-specific structural constraints
+are SHACL-expressible:
+
+- BoundaryAction: `sh:maxCount 0` on `hasForwardIn` (no forward inputs)
+- Mechanism: `sh:maxCount 0` on `hasBackwardIn` and `hasBackwardOut`
+- Mechanism: `sh:minCount 1` on `updatesEntry` (must update state)
+
+**Proposition 3.4 (Operators: Structure R1, Validation R3).** The
+composition operators `>>`, `|`, `.feedback()`, `.loop()` are R1 as
+*structure* (the resulting tree is preserved in RDF) but R3 as *process*
+(the validation logic run during construction cannot be replicated).
+
+Specifically:
+- `>>` validates token overlap between first.forward_out and
+ second.forward_in — requires tokenize() (R3)
+- `.loop()` enforces COVARIANT-only on temporal_wiring — the flag check
+ is R1 (SHACL on direction property), but port matching uses tokens (R3)
+
+---
+
+## 4. Layer 1 Representability: Specification Framework
+
+**Property 4.1 (GDSSpec Structure is R1).** The 8-tuple
+S = (T, Sp, E, B, W, Theta, A, Sig) round-trips through OWL losslessly
+for all structural fields.
+
+*Argument.* Each component maps to an OWL class with named properties:
+
+```
+TypeDef |-> gds-core:TypeDef + name, pythonType, units, hasConstraint
+Space |-> gds-core:Space + name, description, hasField -> SpaceField
+Entity |-> gds-core:Entity + name, description, hasVariable -> StateVariable
+Block |-> gds-core:{role class} + name, kind, hasInterface, usesParameter, ...
+SpecWiring |-> gds-core:SpecWiring + name, wiringBlock, hasWire -> Wire
+ParameterDef |-> gds-core:ParameterDef + name, paramType, lowerBound, upperBound
+AdmissibleInputConstraint |-> gds-core:AdmissibleInputConstraint + name, constrainsBoundary,
+ hasDependency -> AdmissibilityDep
+TransitionSignature |-> gds-core:TransitionSignature + signatureForMechanism,
+ hasReadEntry -> TransitionReadEntry
+```
+
+The `test_roundtrip.py::TestSpecRoundTrip` suite verifies: types, spaces,
+entities, blocks (with role, params, updates), parameters, wirings,
+admissibility constraints, and transition signatures all survive the
+round-trip. Documented exceptions: TypeDef.constraint (Property 4.2) and
+AdmissibleInputConstraint.constraint (Property 4.5) — both lossy for the
+same reason (arbitrary Callable).
+
+**Property 4.2 (Constraint Predicates).** The constraints used in practice
+across all GDS DSLs (numeric bounds, non-negativity, probability ranges)
+are expressible in SHACL (`sh:minInclusive`, `sh:maxInclusive`). This
+covers Probability, NonNegativeFloat, PositiveInt, and most GDS built-in
+types. These specific constraints are R2.
+
+The general case — TypeDef.constraint : Optional[Callable[[Any], bool]] —
+is R3. By Rice's theorem, any non-trivial semantic property of such
+functions is undecidable:
+
+- Given two constraints c1, c2, the question "do c1 and c2 accept the
+ same values?" is undecidable (equivalence of arbitrary programs)
+- Given a constraint c, the question "does c accept any value?" is
+ undecidable (non-emptiness of the accepted set)
+
+OWL DL is decidable (SROIQ). SHACL with SPARQL constraints is decidable
+on finite graphs. Neither can embed an undecidable problem. This
+theoretical limit rarely applies to real GDS specifications, where
+constraints are simple numeric bounds.
+
+**Observation 4.3 (Policy Mapping g is R1 by Design).** By design, GDSSpec
+stores no behavioral content for policy blocks. A Policy block is defined
+by what it connects to (interface, wiring position, parameter dependencies),
+not by what it computes. Consequently, the policy mapping g in the
+canonical form h = f ∘ g is fully characterized by structural fields, all
+of which are R1 (Property 3.1, 3.3, 4.1).
+
+This is a design decision, not a mathematical necessity — one could
+imagine a framework that attaches executable policy functions to blocks.
+GDS deliberately does not, keeping the specification layer structural.
+
+**Property 4.4 (State Transition f Decomposes).** The state transition f
+decomposes as a tuple f = ⟨f_struct, f_read, f_behav⟩ where:
+
+```
+f_struct : B_mechanism -> P(E x V)
+ The explicit write mapping from mechanisms to state variables.
+ "Mechanism M updates Entity E variable V."
+ This is a finite relation — R1. (Stored in Mechanism.updates.)
+
+f_read : B_mechanism -> P(E x V)
+ The explicit read mapping from mechanisms to state variables.
+ "Mechanism M reads Entity E variable V to compute its update."
+ This is a finite relation — R1. (Stored in TransitionSignature.reads.)
+
+f_behav : X x D -> X
+ The endomorphism on the state space parameterized by decisions.
+ "Given current state x and decisions d, compute next state x'."
+ This is an arbitrary computable function — R3.
+```
+
+Together, f_struct and f_read provide a complete structural data-flow
+picture of each mechanism: what it reads and what it writes. Only
+f_behav — the function that transforms reads into writes — remains R3.
+
+The composed system h = f ∘ g inherits: the structural decomposition
+(which blocks compose into h, via what wirings) is R1. The execution
+semantics (what h actually computes given inputs) is R3.
+
+**Property 4.5 (Admissible Input Constraints follow the f_struct/f_behav
+pattern).** An AdmissibleInputConstraint (Paper Def 2.5: U_x) decomposes
+as:
+
+```
+U_x_struct : A -> P(E x V)
+ The dependency relation: "BoundaryAction B's admissible outputs
+ depend on Entity E variable V."
+ This is a finite relation — R1.
+
+U_x_behav : (state, input) -> bool
+ The actual admissibility predicate: "is this input admissible
+ given this state?"
+ This is an arbitrary Callable — R3.
+```
+
+The structural part (name, boundary_block, depends_on) round-trips
+through OWL. The constraint callable is exported as a boolean
+`admissibilityHasConstraint` flag (present/absent) and imported as
+None — the same pattern as TypeDef.constraint. SC-008 validates that
+the structural references are well-formed (boundary block exists and
+is a BoundaryAction, depends_on references valid entity.variable pairs).
+
+**Property 4.6 (Transition Signatures follow the same pattern).**
+A TransitionSignature (Paper Def 2.7: f|_x) provides:
+
+```
+f_read : Sig -> P(E x V)
+ The read dependency relation: "Mechanism M reads Entity E variable V."
+ This is a finite relation — R1.
+
+f_block_deps : Sig -> P(B)
+ Which upstream blocks feed this mechanism.
+ This is a finite relation — R1.
+```
+
+Combined with the existing update_map (f_struct: which variables a
+mechanism *writes*), TransitionSignature completes the structural
+picture: now both reads and writes of every mechanism are declared.
+SC-009 validates that the structural references are well-formed.
+
+The actual transition function (what M computes from its reads to
+produce new values for its writes) remains R3 — it is an arbitrary
+computable function, never stored in GDSSpec.
+
+---
+
+## 5. Verification Check Classification
+
+Each of the 15 GDS verification checks is classified by whether
+SHACL/SPARQL can express it on the exported RDF graph, with practical
+impact noted.
+
+### 5.1 Generic Checks (on SystemIR)
+
+| Check | Property | Tier | Justification | Practical Impact |
+|---|---|---|---|---|
+| **G-001** | Domain/codomain matching | **R3** | Requires tokenize() — string splitting computation | Low: wired connections already exported as explicit edges |
+| **G-002** | Signature completeness | **R1** | Cardinality check on signature fields. SHACL sh:minCount. | Covered by SHACL |
+| **G-003** | Direction consistency | **R1** (flags) / **R3** (ports) | Flag contradiction is boolean — SHACL expressible. Port matching uses tokens (R3). | Flags covered; port check deferred to Python |
+| **G-004** | Dangling wirings | **R2** | WiringIR source/target are string literals (datatype properties), not object property references. Checking that a string name appears in the set of BlockIR names requires SPARQL negation-as-failure on string matching. Unlike SC-005 where `usesParameter` is an object property. | Expressible via SPARQL |
+| **G-005** | Sequential type compatibility | **R3** | Same tokenize() requirement as G-001 | Low: same mitigation as G-001 |
+| **G-006** | Covariant acyclicity (DAG) | **R2** | Cycle detection = self-reachability under transitive closure on materialized covariant edges. SPARQL: `ASK { ?v gds-ir:covariantSuccessor+ ?v }`. Requires materializing the filtered edge relation (direction="covariant" and is_temporal=false) first. | Expressible with preprocessing |
+
+### 5.2 Semantic Checks (on GDSSpec)
+
+| Check | Property | Tier | Justification | Practical Impact |
+|---|---|---|---|---|
+| **SC-001** | Completeness | **R2** | SPARQL: LEFT JOIN Entity.variables with Mechanism.updatesEntry, FILTER NOT EXISTS for orphans. | Expressible |
+| **SC-002** | Determinism | **R2** | SPARQL: GROUP BY (entity, variable) within wiring, HAVING COUNT(mechanism) > 1. | Expressible |
+| **SC-003** | Reachability | **R2** | SPARQL property paths on the wiring graph. Note: follows directed wiring edges (wireSource -> wireTarget), respecting flow direction. | Expressible |
+| **SC-004** | Type safety | **R2** | Wire.space is a string literal; checking membership in the set of registered Space names requires SPARQL, not SHACL sh:class (which works on object properties, as in SC-005). | Expressible via SPARQL |
+| **SC-005** | Parameter references | **R1** | SHACL sh:class on usesParameter targets. Already implemented in gds-owl shacl.py. | Covered by SHACL |
+| **SC-006** | f non-empty | **R1** | Equivalent to SHACL `sh:qualifiedMinCount 1` with `sh:qualifiedValueShape [sh:class gds-core:Mechanism]` on the spec node. (SPARQL illustration: `ASK { ?m a gds-core:Mechanism }`) | Covered by SHACL-core |
+| **SC-007** | X non-empty | **R1** | Same pattern: SHACL `sh:qualifiedMinCount 1` for StateVariable. (SPARQL illustration: `ASK { ?sv a gds-core:StateVariable }`) | Covered by SHACL-core |
+| **SC-008** | Admissibility references | **R1** | SHACL: `constrainsBoundary` must target a `BoundaryAction` (sh:class). Dependency entries (AdmissibilityDep) validated structurally. | Covered by SHACL |
+| **SC-009** | Transition read consistency | **R1** | SHACL: `signatureForMechanism` must target a `Mechanism` (sh:class). Read entries (TransitionReadEntry) validated structurally. | Covered by SHACL |
+
+### 5.3 Summary
+
+```
+R1 (SHACL-core): G-002, SC-005, SC-006, SC-007, SC-008, SC-009 = 6
+R2 (SPARQL): G-004, G-006, SC-001, SC-002, SC-003, SC-004 = 6
+R3 (Python-only): G-001, G-005 = 2
+Mixed (R1 + R3): G-003 (flag check R1, port matching R3) = 1
+```
+
+The R1/R2 boundary is mechanically determined: R1 = expressible in
+SHACL-core (no sh:sparql), R2 = requires SPARQL graph patterns.
+
+The R3 checks share a single root cause: **token-based port name matching
+requires string computation that exceeds SPARQL's value space operations**.
+In practice, this is mitigated by materializing tokens during export — the
+connections themselves are always R1 as explicit wiring edges.
+
+---
+
+## 6. Classification Summary
+
+**Definition 6.1 (Structural/Behavioral Partition).** We define:
+
+```
+G_struct = { composition tree, block interfaces, role partition,
+ wiring topology, update targets, parameter schema,
+ space/entity structure, canonical form metadata,
+ admissibility dependency graph (U_x_struct),
+ transition read dependencies (f_read) }
+
+G_behav = { transition functions (f_behav), constraint predicates,
+ admissibility predicates (U_x_behav),
+ auto-wiring process, construction-time validation,
+ scheduling/execution semantics }
+```
+
+**Consistency Check 6.1.** The structural/behavioral partition we define
+aligns exactly with the R1+R2 / R3 classification. This is a consistency
+property of our taxonomy, not an independent mathematical result — we
+defined G_struct and G_behav to capture what is and isn't representable.
+
+By exhaustive classification in Sections 3-5:
+
+G_struct concepts and their tiers:
+- Composition tree: R1 (Property 3.1)
+- Block interfaces: R1 (Property 3.1)
+- Role partition: R1 (Property 3.3)
+- Wiring topology: R1 (Property 4.1)
+- Update targets: R1 (Property 4.4, f_struct)
+- Parameter schema: R1 (Property 4.1)
+- Space/entity structure: R1 (Property 4.1)
+- Admissibility dependency graph (U_x_struct): R1 (Property 4.5)
+- Transition read dependencies (f_read): R1 (Property 4.6)
+- Acyclicity: R2 (Section 5.1, G-006)
+- Completeness/determinism: R2 (Section 5.2, SC-001, SC-002)
+- Reference validation (dangling wirings): R2 (Section 5.1, G-004)
+
+G_behav concepts and their tiers:
+- Transition functions: R3 (Property 4.4, f_behav)
+- Constraint predicates: R3 (Property 4.2, general case)
+- Admissibility predicates (U_x_behav): R3 (Property 4.5)
+- Auto-wiring process: R3 (Property 3.2)
+- Construction validation: R3 (Proposition 3.4)
+- Scheduling semantics: R3 (not stored in GDSSpec — external)
+
+No G_struct concept is R3. No G_behav concept is R1 or R2.
+
+**Property 6.2 (Canonical Form as Representability Boundary).** In the
+decomposition h = f ∘ g:
+
+```
+g is entirely in G_struct (R1, by Observation 4.3)
+f = ⟨f_struct, f_behav⟩ (R1 + R3, by Property 4.4)
+h = structural skeleton + behavioral core
+```
+
+The canonical form cleanly separates what ontological formalisms can
+express (g, f_struct) from what requires a runtime (f_behav).
+
+**Corollary 6.3 (GDSSpec Projection of Games is Fully Representable).**
+When h = g (the OGS case: X = empty, f = empty), the GDSSpec-level
+structure is fully representable. The OGS canonical bridge
+(spec_bridge.py) maps all atomic games to Policy blocks, producing h = g
+with no behavioral f component. By Observation 4.3, g is entirely R1.
+
+Note: game-theoretic behavioral content — payoff functions, utility
+computation, equilibrium strategies — resides in OpenGame subclass methods
+and external solvers, outside GDSSpec scope, and is therefore R3. The
+corollary applies to the specification-level projection, not to full game
+analysis.
+
+**Corollary 6.4 (Dynamical Systems Degrade Gracefully).** For systems
+with h = f ∘ g where f != empty, the structural skeleton (g + f_struct)
+is always complete in OWL. Each mechanism adds one update target to
+f_struct (R1) and one transition function to f_behav (R3). The "what" is
+never lost — only the "how."
+
+**Remark 6.5 (TemporalLoop vs CorecursiveLoop in OWL).** OWL cannot
+distinguish a temporal loop (physical state persistence, e.g., control
+systems) from a corecursive loop (strategic message threading, e.g.,
+repeated games). CorecursiveLoop (defined in gds-games as
+`ogs.dsl.composition.CorecursiveLoop`, a TemporalLoop subclass for
+repeated game semantics) shares identical structural representation: both
+use covariant wiring from inner.forward_out to inner.forward_in with an
+exit_condition string. The semantic difference — "state at t feeds sensors
+at t+1" vs "decisions at round t feed observations at round t+1" — is an
+interpretation, not topology.
+
+In practice this is benign: gds-owl preserves the DSL source label
+(gds-ir:sourceLabel on SystemIR), so consumers can recover which DSL
+compiled the system and interpret temporal wirings accordingly.
+
+---
+
+## 7. Analysis Domain Classification
+
+Each type of analysis on a GDS specification maps to a representability
+tier based on what it requires:
+
+### 7.1 R1: Fully Expressible (OWL Classes + Properties)
+
+| Analysis | Nature | GDS Implementation | Why R1 |
+|---|---|---|---|
+| What connects to what | Static topology | SpecQuery.dependency_graph() | Wiring graph is R1 |
+| How blocks compose | Static structure | HierarchyNodeIR tree | Composition tree is R1 |
+| Which blocks are which roles | Static classification | project_canonical() partition | Role partition is R1 |
+| Which params affect which blocks | Static dependency | SpecQuery.param_to_blocks() | usesParameter relation is R1 |
+| Which state variables constrain which inputs | Static dependency | SpecQuery.admissibility_dependency_map() | U_x_struct is R1 |
+| Which state variables does a mechanism read | Static dependency | SpecQuery.mechanism_read_map() | f_read is R1 |
+| Game classification | Static strategic | PatternIR game_type field | Metadata on blocks, R1 |
+
+### 7.2 R2: SPARQL-Expressible (Graph Queries + Aggregation)
+
+| Analysis | Nature | GDS Implementation | Why R2 |
+|---|---|---|---|
+| Is the wiring graph acyclic? | Structural invariant | G-006 (DFS) | Transitive self-reachability on finite graph |
+| Does every state variable have an updater? | Structural invariant | SC-001 | Left-join with negation |
+| Are there write conflicts? | Structural invariant | SC-002 | Group-by with count > 1 |
+| Are all references valid? | Structural invariant | G-004, SC-004 | Reference validation |
+| Can block A reach block B? | Structural reachability | SC-003 | Property path on wiring graph |
+
+### 7.3 R3: Python-Only (Requires Runtime)
+
+| Analysis | Nature | GDS Implementation | Why R3 |
+|---|---|---|---|
+| State evolution over time | Dynamic temporal | gds-sim execution | Requires evaluating f repeatedly |
+| Constraint satisfaction | Dynamic behavioral | TypeDef.constraint() | General case: Rice's theorem |
+| Auto-wiring computation | Dynamic structural | tokenize() + overlap | String parsing exceeds SPARQL |
+| Actual signal propagation | Dynamic behavioral | simulation with concrete values | Requires computing g(x, u) |
+| Scheduling/delay semantics | Dynamic temporal | execution model | Not stored in GDS — external |
+| Equilibrium computation | Dynamic strategic | game solvers | Computing Nash equilibria is PPAD-complete |
+
+Note the distinction: **equilibrium structure** (which games exist, how
+they compose) is R1. **Equilibrium computation** (finding the actual
+equilibrium strategies) is R3. This parallels the f_struct / f_behav
+split: the structure of the analysis is representable; the computation
+of the analysis is not.
+
+---
+
+## 8. Five Formal Correspondences
+
+### Correspondence 1: Static Topology <-> OWL Class/Property Hierarchy
+
+```
+rho : (blocks, wirings, interfaces, ports) <-> OWL individuals + object properties
+```
+
+R1. The composition tree, wiring graph, and port structure map to OWL
+individuals connected by named object properties.
+
+### Correspondence 2: Structural Invariants <-> SHACL Shapes + SPARQL Queries
+
+```
+{G-002, G-004, G-006, SC-001..SC-009} <-> SHACL + SPARQL
+```
+
+R1 or R2 depending on the check. SHACL-core captures cardinality and
+class-membership constraints (6 checks: G-002, SC-005..SC-009). SPARQL
+captures graph-pattern queries requiring negation, transitivity,
+aggregation, or cross-node string matching (6 checks). The 2 remaining
+checks (G-001, G-005) require tokenization. G-003 splits: flag check R1,
+port matching R3.
+
+### Correspondence 3: Dynamic Behavior <-> Python Runtime Only
+
+```
+{TypeDef.constraint (general), f_behav, auto-wiring, scheduling} <-> Python
+```
+
+R3. Fundamental. These require Turing-complete computation. The boundary
+is Rice's theorem (for predicates) and computational class separation
+(for string parsing and temporal execution).
+
+### Correspondence 4: Equilibrium Structure <-> Naturally Structural
+
+```
+h = g (OGS canonical form) <-> GDSSpec projection is lossless
+```
+
+R1 for the specification-level projection. When a system has no state
+(X = empty, f = empty), its GDSSpec is purely compositional. Game-theoretic
+behavioral content (payoff functions, equilibrium solvers) is outside
+GDSSpec and therefore R3.
+
+### Correspondence 5: Reachability <-> Structural Part R2, Dynamical Part R3
+
+```
+Structural reachability : "can signals reach from A to B?" -> R2 (SPARQL property paths)
+Dynamical reachability : "does signal actually propagate?" -> R3 (requires evaluating g and f)
+```
+
+The structural question asks about the *topology* of the wiring graph.
+SPARQL property paths (`?a successor+ ?b`) answer this on finite graphs.
+The dynamical question asks about *actual propagation* given concrete
+state values and policy functions — this requires executing the system.
diff --git a/docs/owl/guide/representability.md b/docs/owl/guide/representability.md
new file mode 100644
index 0000000..b45df42
--- /dev/null
+++ b/docs/owl/guide/representability.md
@@ -0,0 +1,22 @@
+# Representability
+
+The formal representability analysis classifies which GDS concepts can and cannot be represented in OWL/SHACL/SPARQL.
+
+See the full analysis: [formal-representability.md](https://github.com/BlockScience/gds-core/blob/dev/packages/gds-owl/docs/formal-representability.md)
+
+## Key Results
+
+The canonical decomposition `h = f . g` is the representation boundary:
+
+- **g** (policy mapping) is entirely R1 — fully representable in OWL
+- **f_struct** (update map: "who updates what") is R1
+- **f_behav** (transition function: "how values change") is R3 — not representable
+
+## Verification Check Classification
+
+| Tier | Checks | Count |
+|------|--------|-------|
+| R1 (SHACL-core) | G-002, SC-005..SC-009 | 6 |
+| R2 (SPARQL) | G-004, G-006, SC-001..SC-004 | 6 |
+| R3 (Python-only) | G-001, G-005 | 2 |
+| Mixed | G-003 (flags R1, ports R3) | 1 |
diff --git a/docs/owl/index.md b/docs/owl/index.md
new file mode 100644
index 0000000..5324ec7
--- /dev/null
+++ b/docs/owl/index.md
@@ -0,0 +1,92 @@
+# gds-owl
+
+[](https://pypi.org/project/gds-owl/)
+[](https://pypi.org/project/gds-owl/)
+[](https://github.com/BlockScience/gds-core/blob/main/LICENSE)
+
+**OWL/Turtle, SHACL, and SPARQL for GDS specifications** — semantic web interoperability for compositional systems.
+
+## What is this?
+
+`gds-owl` exports GDS specifications to RDF/OWL and imports them back, enabling interoperability with semantic web tooling. It provides:
+
+- **OWL ontology** — class hierarchy mirroring GDS types (blocks, roles, entities, spaces, parameters)
+- **RDF export/import** — lossless round-trip for structural fields (Pydantic → Turtle → Pydantic)
+- **SHACL shapes** — constraint validation on exported RDF graphs (structural + semantic)
+- **SPARQL queries** — pre-built query templates for common GDS analysis patterns
+- **Formal representability analysis** — documented classification of what survives the OWL boundary
+
+## Architecture
+
+```
+gds-framework (pip install gds-framework)
+|
+| Domain-neutral composition algebra, typed spaces,
+| state model, verification engine, flat IR compiler.
+|
++-- gds-owl (pip install gds-owl)
+ |
+ | OWL ontology (TBox), RDF export/import (ABox),
+ | SHACL validation, SPARQL query templates.
+ |
+ +-- Your application
+ |
+ | Ontology browsers, SPARQL endpoints,
+ | cross-tool interoperability.
+```
+
+## Key Concepts
+
+### Representability Tiers
+
+Not everything in a GDS specification can be represented in OWL:
+
+| Tier | What | Formalism | Example |
+|------|------|-----------|---------|
+| **R1** | Fully representable | OWL + SHACL | Block interfaces, role partition, wiring topology |
+| **R2** | Structurally representable | SPARQL | Cycle detection, completeness, determinism |
+| **R3** | Not representable | Python only | Transition functions, constraint predicates, auto-wiring |
+
+The canonical decomposition `h = f . g` is the boundary: `g` (policy mapping) is entirely R1, `f` splits into structural (R1) and behavioral (R3).
+
+### Round-Trip Guarantees
+
+The export/import cycle preserves all structural fields. Known lossy fields:
+
+- `TypeDef.constraint` — arbitrary `Callable`, imported as `None`
+- `TypeDef.python_type` — falls back to `str` for unmapped types
+- `AdmissibleInputConstraint.constraint` — same as TypeDef.constraint
+
+### Four Export Targets
+
+| Function | Input | Output |
+|----------|-------|--------|
+| `spec_to_graph()` | `GDSSpec` | RDF graph (ABox) |
+| `system_ir_to_graph()` | `SystemIR` | RDF graph (ABox) |
+| `canonical_to_graph()` | `CanonicalGDS` | RDF graph (ABox) |
+| `report_to_graph()` | `VerificationReport` | RDF graph (ABox) |
+
+## Installation
+
+```bash
+pip install gds-owl
+
+# With SHACL validation support:
+pip install gds-owl[shacl]
+```
+
+## Quick Example
+
+```python
+from gds import GDSSpec
+from gds_owl import spec_to_graph, to_turtle, graph_to_spec
+
+# Export a spec to Turtle
+spec = GDSSpec(name="My System")
+graph = spec_to_graph(spec)
+print(to_turtle(graph))
+
+# Import back
+spec2 = graph_to_spec(graph)
+assert spec2.name == spec.name
+```
diff --git a/mkdocs.yml b/mkdocs.yml
index 421aab0..eda5e4a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -109,6 +109,11 @@ plugins:
- guides/dsl-roadmap.md
- guides/research-boundaries.md
- guides/view-stratification.md
+ OWL (gds-owl):
+ - {owl/index.md: "OWL/SHACL/SPARQL for GDS specifications — semantic web interoperability"}
+ - {owl/getting-started.md: "Export a spec to Turtle, import back, validate with SHACL"}
+ - owl/guide/*.md
+ - owl/api/*.md
PSUU (gds-psuu):
- {psuu/index.md: "Parameter space search under uncertainty for gds-sim"}
- {psuu/getting-started.md: "Installation + first parameter sweep"}
@@ -319,6 +324,20 @@ nav:
- gds_software.dependency.compile: software/api/dep-compile.md
- gds_software.dependency.checks: software/api/dep-checks.md
- gds_software.verification: software/api/verification.md
+ - OWL:
+ - Overview: owl/index.md
+ - Getting Started: owl/getting-started.md
+ - User Guide:
+ - Representability: owl/guide/representability.md
+ - Formal Analysis: owl/guide/formal-representability.md
+ - API Reference:
+ - gds_owl: owl/api/init.md
+ - gds_owl.ontology: owl/api/ontology.md
+ - gds_owl.export: owl/api/export.md
+ - gds_owl.import_: owl/api/import.md
+ - gds_owl.shacl: owl/api/shacl.md
+ - gds_owl.sparql: owl/api/sparql.md
+ - gds_owl.serialize: owl/api/serialize.md
- PSUU:
- Overview: psuu/index.md
- Getting Started: psuu/getting-started.md
diff --git a/packages/gds-control/gds_control/dsl/compile.py b/packages/gds-control/gds_control/dsl/compile.py
index cadb62f..370d238 100644
--- a/packages/gds-control/gds_control/dsl/compile.py
+++ b/packages/gds-control/gds_control/dsl/compile.py
@@ -378,6 +378,21 @@ def compile_model(model: ControlModel) -> GDSSpec:
)
)
+ # 7. Register transition signatures (mechanism read dependencies)
+ from gds.constraints import TransitionSignature
+
+ for state in model.states:
+ driving_controllers = [
+ ctrl.name for ctrl in model.controllers if state.name in ctrl.drives
+ ]
+ spec.register_transition_signature(
+ TransitionSignature(
+ mechanism=_dynamics_block_name(state.name),
+ reads=[(state.name, "value")],
+ depends_on_blocks=driving_controllers,
+ )
+ )
+
return spec
diff --git a/packages/gds-control/tests/test_integration.py b/packages/gds-control/tests/test_integration.py
index 70eac72..05ba77f 100644
--- a/packages/gds-control/tests/test_integration.py
+++ b/packages/gds-control/tests/test_integration.py
@@ -52,6 +52,25 @@ def open_loop_model():
)
+class TestTransitionSignatures:
+ def test_siso_signature(self, siso_model):
+ spec = compile_model(siso_model)
+ assert len(spec.transition_signatures) == 1
+ ts = spec.transition_signatures["x Dynamics"]
+ assert ("x", "value") in ts.reads
+ assert "K" in ts.depends_on_blocks
+
+ def test_mimo_signatures(self, mimo_model):
+ spec = compile_model(mimo_model)
+ assert len(spec.transition_signatures) == 2
+ ts1 = spec.transition_signatures["x1 Dynamics"]
+ assert ("x1", "value") in ts1.reads
+ assert "K1" in ts1.depends_on_blocks
+ ts2 = spec.transition_signatures["x2 Dynamics"]
+ assert ("x2", "value") in ts2.reads
+ assert "K2" in ts2.depends_on_blocks
+
+
class TestSISOIntegration:
def test_compile_and_canonical(self, siso_model):
spec = compile_model(siso_model)
diff --git a/packages/gds-examples/control/thermostat/model.py b/packages/gds-examples/control/thermostat/model.py
index 098c83f..640153c 100644
--- a/packages/gds-examples/control/thermostat/model.py
+++ b/packages/gds-examples/control/thermostat/model.py
@@ -29,6 +29,7 @@
from gds.blocks.composition import Wiring
from gds.blocks.roles import BoundaryAction, ControlAction, Mechanism, Policy
from gds.compiler.compile import compile_system
+from gds.constraints import AdmissibleInputConstraint
from gds.ir.models import FlowDirection, SystemIR
from gds.spaces import Space
from gds.spec import GDSSpec, SpecWiring, Wire
@@ -255,6 +256,17 @@ def build_spec() -> GDSSpec:
)
)
+ # Admissibility constraint: sensor reading depends on room temperature.
+ # Paper Def 2.5 — U_x: the observation is state-dependent.
+ spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="sensor_state_dependency",
+ boundary_block="Temperature Sensor",
+ depends_on=[("Room", "temperature")],
+ description="Sensor reading depends on actual room temperature",
+ )
+ )
+
return spec
diff --git a/packages/gds-examples/control/thermostat/test_model.py b/packages/gds-examples/control/thermostat/test_model.py
index c1ae40c..797d5e9 100644
--- a/packages/gds-examples/control/thermostat/test_model.py
+++ b/packages/gds-examples/control/thermostat/test_model.py
@@ -146,6 +146,20 @@ def test_spec_has_four_params(self):
spec = build_spec()
assert set(spec.parameters.keys()) == {"setpoint", "Kp", "Ki", "Kd"}
+ def test_admissibility_constraint_registered(self):
+ spec = build_spec()
+ assert len(spec.admissibility_constraints) == 1
+ ac = spec.admissibility_constraints["sensor_state_dependency"]
+ assert ac.boundary_block == "Temperature Sensor"
+ assert ("Room", "temperature") in ac.depends_on
+
+ def test_admissibility_sc008_passes(self):
+ from gds.verification.spec_checks import check_admissibility_references
+
+ spec = build_spec()
+ findings = check_admissibility_references(spec)
+ assert all(f.passed for f in findings)
+
class TestVerification:
def test_generic_checks_pass(self):
diff --git a/packages/gds-examples/games/insurance/model.py b/packages/gds-examples/games/insurance/model.py
index 19d889d..3e78976 100644
--- a/packages/gds-examples/games/insurance/model.py
+++ b/packages/gds-examples/games/insurance/model.py
@@ -27,6 +27,7 @@
from gds.blocks.roles import BoundaryAction, ControlAction, Mechanism, Policy
from gds.compiler.compile import compile_system
+from gds.constraints import AdmissibleInputConstraint
from gds.ir.models import SystemIR
from gds.spaces import Space
from gds.spec import GDSSpec, SpecWiring, Wire
@@ -279,6 +280,20 @@ def build_spec() -> GDSSpec:
)
)
+ # Admissibility constraint: claim amount bounded by insurer reserve.
+ # Paper Def 2.5 — U_x: the set of admissible inputs depends on state.
+ spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="solvency_constraint",
+ boundary_block="Claim Arrival",
+ depends_on=[("Insurer", "reserve")],
+ constraint=lambda state, u: (
+ u.get("amount", 0) <= state["Insurer"]["reserve"]
+ ),
+ description="Claim amount cannot exceed insurer reserve (solvency)",
+ )
+ )
+
return spec
diff --git a/packages/gds-examples/games/insurance/test_model.py b/packages/gds-examples/games/insurance/test_model.py
index 39a6b82..90824b2 100644
--- a/packages/gds-examples/games/insurance/test_model.py
+++ b/packages/gds-examples/games/insurance/test_model.py
@@ -144,6 +144,20 @@ def test_spec_has_three_params(self):
"coverage_limit",
}
+ def test_admissibility_constraint_registered(self):
+ spec = build_spec()
+ assert len(spec.admissibility_constraints) == 1
+ ac = spec.admissibility_constraints["solvency_constraint"]
+ assert ac.boundary_block == "Claim Arrival"
+ assert ("Insurer", "reserve") in ac.depends_on
+
+ def test_admissibility_sc008_passes(self):
+ from gds.verification.spec_checks import check_admissibility_references
+
+ spec = build_spec()
+ findings = check_admissibility_references(spec)
+ assert all(f.passed for f in findings)
+
class TestVerification:
def test_ir_compilation(self):
diff --git a/packages/gds-framework/CLAUDE.md b/packages/gds-framework/CLAUDE.md
index db26f4d..53cd9c7 100644
--- a/packages/gds-framework/CLAUDE.md
+++ b/packages/gds-framework/CLAUDE.md
@@ -70,6 +70,8 @@ from gds import (
check_type_safety, # (spec) -> list[Finding] SC-004
check_parameter_references, # (spec) -> list[Finding] SC-005
check_canonical_wellformedness, # (spec) -> list[Finding] SC-006/SC-007
+ check_admissibility_references, # (spec) -> list[Finding] SC-008
+ check_transition_reads, # (spec) -> list[Finding] SC-009
# Custom checks
gds_check, get_custom_checks, all_checks, # decorator + registries
@@ -213,7 +215,7 @@ canonical = gds.project_canonical(spec) # derives h = f . g
Domain-neutral engine. Blocks with bidirectional typed interfaces, composed via `>>`, `|`, `.feedback()`, `.loop()`. A 3-stage compiler flattens composition trees into flat IR. Six generic checks (G-001..G-006) validate structural properties.
**Layer 1 — Specification Framework** (`spec.py`, `canonical.py`, `state.py`, `spaces.py`, `types/`):
-GDS theory layer. `GDSSpec` registry for types, spaces, entities, blocks, wirings, parameters. `project_canonical()` derives formal `h = f . g` decomposition. Seven semantic checks (SC-001..SC-007) validate domain properties.
+GDS theory layer. `GDSSpec` registry for types, spaces, entities, blocks, wirings, parameters, admissibility constraints, and transition signatures. `project_canonical()` derives formal `h = f . g` decomposition. Nine semantic checks (SC-001..SC-009) validate domain properties.
Layers are loosely coupled: use the composition algebra without `GDSSpec`, or use `GDSSpec` without the compiler.
diff --git a/packages/gds-framework/gds/__init__.py b/packages/gds-framework/gds/__init__.py
index 9e21277..ee31510 100644
--- a/packages/gds-framework/gds/__init__.py
+++ b/packages/gds-framework/gds/__init__.py
@@ -41,6 +41,9 @@
flatten_blocks,
)
+# ── Structural annotations ────────────────────────────────
+from gds.constraints import AdmissibleInputConstraint, TransitionSignature
+
# ── Convenience helpers ────────────────────────────────────
from gds.helpers import (
all_checks,
@@ -98,17 +101,20 @@
from gds.verification.engine import verify
from gds.verification.findings import Finding, Severity, VerificationReport
from gds.verification.spec_checks import (
+ check_admissibility_references,
check_canonical_wellformedness,
check_completeness,
check_determinism,
check_parameter_references,
check_reachability,
+ check_transition_reads,
check_type_safety,
)
__all__ = [
"EMPTY",
"TERMINAL",
+ "AdmissibleInputConstraint",
"AgentID",
"AtomicBlock",
"Block",
@@ -154,6 +160,7 @@
"TemporalLoop",
"Timestamp",
"TokenAmount",
+ "TransitionSignature",
"TypeDef",
"VerificationReport",
"Wire",
@@ -161,11 +168,13 @@
"WiringIR",
"WiringOrigin",
"all_checks",
+ "check_admissibility_references",
"check_canonical_wellformedness",
"check_completeness",
"check_determinism",
"check_parameter_references",
"check_reachability",
+ "check_transition_reads",
"check_type_safety",
"compile_system",
"entity",
diff --git a/packages/gds-framework/gds/canonical.py b/packages/gds-framework/gds/canonical.py
index 2a1712d..303bc21 100644
--- a/packages/gds-framework/gds/canonical.py
+++ b/packages/gds-framework/gds/canonical.py
@@ -59,6 +59,12 @@ class CanonicalGDS(BaseModel):
# Mechanism update targets: (entity, variable) per mechanism
update_map: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = ()
+ # Admissibility deps: (constraint_name, ((entity, var), ...))
+ admissibility_map: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = ()
+
+ # Mechanism read deps: (mechanism_name, ((entity, var), ...))
+ read_map: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = ()
+
@property
def has_parameters(self) -> bool:
"""True if the system has any parameters."""
@@ -136,6 +142,18 @@ def project_canonical(spec: GDSSpec) -> CanonicalGDS:
updates = tuple(tuple(pair) for pair in block.updates)
update_map.append((bname, updates)) # type: ignore[arg-type]
+ # 7. Admissibility dependencies
+ admissibility_map: list[tuple[str, tuple[tuple[str, str], ...]]] = []
+ for ac_name, ac in spec.admissibility_constraints.items():
+ deps = tuple(tuple(pair) for pair in ac.depends_on)
+ admissibility_map.append((ac_name, deps)) # type: ignore[arg-type]
+
+ # 8. Transition read map
+ read_map: list[tuple[str, tuple[tuple[str, str], ...]]] = []
+ for mname, ts in spec.transition_signatures.items():
+ reads = tuple(tuple(pair) for pair in ts.reads)
+ read_map.append((mname, reads)) # type: ignore[arg-type]
+
return CanonicalGDS(
state_variables=tuple(state_variables),
parameter_schema=parameter_schema,
@@ -146,4 +164,6 @@ def project_canonical(spec: GDSSpec) -> CanonicalGDS:
policy_blocks=tuple(policy_blocks),
mechanism_blocks=tuple(mechanism_blocks),
update_map=tuple(update_map),
+ admissibility_map=tuple(admissibility_map),
+ read_map=tuple(read_map),
)
diff --git a/packages/gds-framework/gds/constraints.py b/packages/gds-framework/gds/constraints.py
new file mode 100644
index 0000000..5556ba1
--- /dev/null
+++ b/packages/gds-framework/gds/constraints.py
@@ -0,0 +1,65 @@
+"""Structural annotations linking blocks to state variable dependencies.
+
+Paper Definitions 2.5 and 2.7 from Zargham & Shorish (2022),
+*Generalized Dynamical Systems Part I: Foundations*.
+
+AdmissibleInputConstraint declares that a BoundaryAction's output
+is constrained by state (U_x). TransitionSignature declares the
+read dependency graph of a Mechanism's transition (f|_x).
+
+Both follow the f_struct / f_behav split: the dependency graph
+(which block reads/writes which state variables) is structural (R1).
+The actual constraint/transition function is behavioral (R3).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable # noqa: TC003
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class AdmissibleInputConstraint(BaseModel):
+ """Declares that a BoundaryAction's output is constrained by state.
+
+ Paper Definition 2.5: U : X -> P(U), returning the set of
+ admissible inputs given current state x.
+
+ Structural part (R1): name, boundary_block, depends_on
+ Behavioral part (R3): constraint callable (lossy in serialization)
+
+ Keyed by ``name`` (not ``boundary_block``) to allow multiple
+ constraints per BoundaryAction — e.g., balance limit + regulatory cap.
+ """
+
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
+
+ name: str
+ boundary_block: str
+ depends_on: list[tuple[str, str]] = Field(default_factory=list)
+ constraint: Callable[[dict, Any], bool] | None = None
+ description: str = ""
+
+
+class TransitionSignature(BaseModel):
+ """Declares the structural read signature of a mechanism's transition.
+
+ Paper Definition 2.7: f|_x : U_x -> X, where
+ Image(f|_x) = Image(f(x, .)).
+
+ Writes are NOT included — ``Mechanism.updates`` already tracks those.
+ One signature per mechanism (intentional simplification; a mechanism
+ that updates multiple variables may have different read deps per
+ variable, but this level of granularity is deferred).
+
+ Structural part (R1): mechanism, reads, depends_on_blocks
+ Behavioral part: the actual transition function (R3, not stored)
+ """
+
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
+
+ mechanism: str
+ reads: list[tuple[str, str]] = Field(default_factory=list)
+ depends_on_blocks: list[str] = Field(default_factory=list)
+ preserves_invariant: str = ""
diff --git a/packages/gds-framework/gds/query.py b/packages/gds-framework/gds/query.py
index e7965cb..1b20078 100644
--- a/packages/gds-framework/gds/query.py
+++ b/packages/gds-framework/gds/query.py
@@ -103,6 +103,29 @@ def blocks_affecting(self, entity: str, variable: str) -> list[str]:
return sorted(all_affecting)
+ def admissibility_dependency_map(self) -> dict[str, list[tuple[str, str]]]:
+ """Map boundary block -> state variables constraining its inputs."""
+ result: dict[str, list[tuple[str, str]]] = {}
+ for ac in self.spec.admissibility_constraints.values():
+ result.setdefault(ac.boundary_block, []).extend(ac.depends_on)
+ return result
+
+ def mechanism_read_map(self) -> dict[str, list[tuple[str, str]]]:
+ """Map mechanism -> state variables it reads."""
+ return {
+ mname: list(ts.reads)
+ for mname, ts in self.spec.transition_signatures.items()
+ }
+
+ def variable_readers(self, entity: str, variable: str) -> list[str]:
+ """Which mechanisms declare reading this state variable?"""
+ ref = (entity, variable)
+ return [
+ mname
+ for mname, ts in self.spec.transition_signatures.items()
+ if ref in ts.reads
+ ]
+
@staticmethod
def _can_reach(adj: dict[str, set[str]], source: str, target: str) -> bool:
"""BFS reachability check."""
diff --git a/packages/gds-framework/gds/serialize.py b/packages/gds-framework/gds/serialize.py
index af6ec33..47939ad 100644
--- a/packages/gds-framework/gds/serialize.py
+++ b/packages/gds-framework/gds/serialize.py
@@ -82,6 +82,25 @@ def spec_to_dict(spec: GDSSpec) -> dict[str, Any]:
}
for name, p in spec.parameter_schema.parameters.items()
},
+ "admissibility_constraints": {
+ name: {
+ "name": ac.name,
+ "boundary_block": ac.boundary_block,
+ "depends_on": [list(pair) for pair in ac.depends_on],
+ "has_constraint": ac.constraint is not None,
+ "description": ac.description,
+ }
+ for name, ac in spec.admissibility_constraints.items()
+ },
+ "transition_signatures": {
+ name: {
+ "mechanism": ts.mechanism,
+ "reads": [list(pair) for pair in ts.reads],
+ "depends_on_blocks": list(ts.depends_on_blocks),
+ "preserves_invariant": ts.preserves_invariant,
+ }
+ for name, ts in spec.transition_signatures.items()
+ },
}
diff --git a/packages/gds-framework/gds/spec.py b/packages/gds-framework/gds/spec.py
index c1e1cd6..4139913 100644
--- a/packages/gds-framework/gds/spec.py
+++ b/packages/gds-framework/gds/spec.py
@@ -15,7 +15,11 @@
from pydantic import BaseModel, ConfigDict, Field
from gds.blocks.base import Block
-from gds.blocks.roles import HasParams, Mechanism
+from gds.blocks.roles import BoundaryAction, HasParams, Mechanism
+from gds.constraints import ( # noqa: TC001
+ AdmissibleInputConstraint,
+ TransitionSignature,
+)
from gds.parameters import ParameterDef, ParameterSchema
from gds.spaces import Space
from gds.state import Entity
@@ -62,6 +66,10 @@ class GDSSpec(Tagged):
blocks: dict[str, Block] = Field(default_factory=dict)
wirings: dict[str, SpecWiring] = Field(default_factory=dict)
parameter_schema: ParameterSchema = Field(default_factory=ParameterSchema)
+ admissibility_constraints: dict[str, AdmissibleInputConstraint] = Field(
+ default_factory=dict
+ )
+ transition_signatures: dict[str, TransitionSignature] = Field(default_factory=dict)
# ── Registration ────────────────────────────────────────
@@ -118,6 +126,25 @@ def register_parameter(
self.parameter_schema = self.parameter_schema.add(param)
return self
+ def register_admissibility(self, ac: AdmissibleInputConstraint) -> GDSSpec:
+ """Register an admissible input constraint.
+
+ Raises if name already registered.
+ """
+ if ac.name in self.admissibility_constraints:
+ raise ValueError(f"Admissibility constraint '{ac.name}' already registered")
+ self.admissibility_constraints[ac.name] = ac
+ return self
+
+ def register_transition_signature(self, ts: TransitionSignature) -> GDSSpec:
+ """Register a transition signature. Raises if mechanism already has one."""
+ if ts.mechanism in self.transition_signatures:
+ raise ValueError(
+ f"Transition signature for '{ts.mechanism}' already registered"
+ )
+ self.transition_signatures[ts.mechanism] = ts
+ return self
+
@property
def parameters(self) -> dict[str, TypeDef]:
"""Legacy access: parameter name → TypeDef mapping."""
@@ -131,9 +158,10 @@ def collect(
"""Register multiple objects by type-dispatching each.
Accepts any mix of TypeDef, Space, Entity, Block, and
- ParameterDef instances. Does not handle SpecWiring or
+ ParameterDef instances. Does not handle SpecWiring,
+ AdmissibleInputConstraint, TransitionSignature, or
(name, typedef) parameter shorthand --- those stay explicit
- via ``register_wiring()`` and ``register_parameter()``.
+ via their respective ``register_*()`` methods.
Raises TypeError for unrecognized types.
"""
@@ -164,6 +192,8 @@ def validate_spec(self) -> list[str]:
errors += self._validate_wiring_blocks()
errors += self._validate_mechanism_updates()
errors += self._validate_param_references()
+ errors += self._validate_admissibility_constraints()
+ errors += self._validate_transition_signatures()
return errors
def _validate_space_types(self) -> list[str]:
@@ -237,3 +267,63 @@ def _validate_param_references(self) -> list[str]:
f"unregistered parameter '{param}'"
)
return errors
+
+ def _validate_admissibility_constraints(self) -> list[str]:
+ """Admissibility constraints reference existing blocks and variables."""
+ errors: list[str] = []
+ for ac in self.admissibility_constraints.values():
+ if ac.boundary_block not in self.blocks:
+ errors.append(
+ f"Admissibility constraint '{ac.name}' references "
+ f"unregistered block '{ac.boundary_block}'"
+ )
+ elif not isinstance(self.blocks[ac.boundary_block], BoundaryAction):
+ errors.append(
+ f"Admissibility constraint '{ac.name}': "
+ f"block '{ac.boundary_block}' is not a BoundaryAction"
+ )
+ for entity_name, var_name in ac.depends_on:
+ if entity_name not in self.entities:
+ errors.append(
+ f"Admissibility constraint '{ac.name}' depends on "
+ f"unknown entity '{entity_name}'"
+ )
+ elif var_name not in self.entities[entity_name].variables:
+ errors.append(
+ f"Admissibility constraint '{ac.name}' depends on "
+ f"unknown variable '{entity_name}.{var_name}'"
+ )
+ return errors
+
+ def _validate_transition_signatures(self) -> list[str]:
+ """Transition signatures reference existing Mechanisms and variables."""
+ errors: list[str] = []
+ for ts in self.transition_signatures.values():
+ if ts.mechanism not in self.blocks:
+ errors.append(
+ f"Transition signature references "
+ f"unregistered block '{ts.mechanism}'"
+ )
+ elif not isinstance(self.blocks[ts.mechanism], Mechanism):
+ errors.append(
+ f"Transition signature for '{ts.mechanism}': "
+ f"block is not a Mechanism"
+ )
+ for entity_name, var_name in ts.reads:
+ if entity_name not in self.entities:
+ errors.append(
+ f"Transition signature for '{ts.mechanism}' reads "
+ f"unknown entity '{entity_name}'"
+ )
+ elif var_name not in self.entities[entity_name].variables:
+ errors.append(
+ f"Transition signature for '{ts.mechanism}' reads "
+ f"unknown variable '{entity_name}.{var_name}'"
+ )
+ for bname in ts.depends_on_blocks:
+ if bname not in self.blocks:
+ errors.append(
+ f"Transition signature for '{ts.mechanism}' "
+ f"depends on unregistered block '{bname}'"
+ )
+ return errors
diff --git a/packages/gds-framework/gds/verification/__init__.py b/packages/gds-framework/gds/verification/__init__.py
index a85d728..a19ff9f 100644
--- a/packages/gds-framework/gds/verification/__init__.py
+++ b/packages/gds-framework/gds/verification/__init__.py
@@ -3,11 +3,13 @@
from gds.verification.engine import verify
from gds.verification.findings import Finding, Severity, VerificationReport
from gds.verification.spec_checks import (
+ check_admissibility_references,
check_canonical_wellformedness,
check_completeness,
check_determinism,
check_parameter_references,
check_reachability,
+ check_transition_reads,
check_type_safety,
)
@@ -15,11 +17,13 @@
"Finding",
"Severity",
"VerificationReport",
+ "check_admissibility_references",
"check_canonical_wellformedness",
"check_completeness",
"check_determinism",
"check_parameter_references",
"check_reachability",
+ "check_transition_reads",
"check_type_safety",
"verify",
]
diff --git a/packages/gds-framework/gds/verification/spec_checks.py b/packages/gds-framework/gds/verification/spec_checks.py
index bd9393f..348148e 100644
--- a/packages/gds-framework/gds/verification/spec_checks.py
+++ b/packages/gds-framework/gds/verification/spec_checks.py
@@ -10,7 +10,7 @@
from collections import defaultdict
from typing import TYPE_CHECKING
-from gds.blocks.roles import HasParams, Mechanism
+from gds.blocks.roles import BoundaryAction, HasParams, Mechanism
from gds.canonical import project_canonical
from gds.verification.findings import Finding, Severity
@@ -281,3 +281,146 @@ def check_canonical_wellformedness(spec: GDSSpec) -> list[Finding]:
)
return findings
+
+
+def check_admissibility_references(spec: GDSSpec) -> list[Finding]:
+ """Admissibility constraints reference valid BoundaryActions and variables.
+
+ SC-008: Every registered AdmissibleInputConstraint references an
+ existing BoundaryAction and valid (entity, variable) pairs.
+ """
+ findings: list[Finding] = []
+
+ if not spec.admissibility_constraints:
+ findings.append(
+ Finding(
+ check_id="SC-008",
+ severity=Severity.INFO,
+ message="No admissibility constraints registered",
+ passed=True,
+ )
+ )
+ return findings
+
+ issues: list[str] = []
+ bad_names: list[str] = []
+ for ac in spec.admissibility_constraints.values():
+ before = len(issues)
+ block = spec.blocks.get(ac.boundary_block)
+ if block is None:
+ issues.append(f"'{ac.name}': block '{ac.boundary_block}' not registered")
+ elif not isinstance(block, BoundaryAction):
+ issues.append(
+ f"'{ac.name}': '{ac.boundary_block}' is not a BoundaryAction "
+ f"(is {type(block).__name__})"
+ )
+
+ for entity_name, var_name in ac.depends_on:
+ if entity_name not in spec.entities:
+ issues.append(f"'{ac.name}': unknown entity '{entity_name}'")
+ elif var_name not in spec.entities[entity_name].variables:
+ issues.append(
+ f"'{ac.name}': unknown variable '{entity_name}.{var_name}'"
+ )
+ if len(issues) > before:
+ bad_names.append(ac.name)
+
+ if issues:
+ findings.append(
+ Finding(
+ check_id="SC-008",
+ severity=Severity.ERROR,
+ message=f"Admissibility constraint issues: {issues}",
+ source_elements=bad_names,
+ passed=False,
+ )
+ )
+ else:
+ findings.append(
+ Finding(
+ check_id="SC-008",
+ severity=Severity.INFO,
+ message=(
+ f"All {len(spec.admissibility_constraints)} admissibility "
+ f"constraint(s) are well-formed"
+ ),
+ passed=True,
+ )
+ )
+
+ return findings
+
+
+def check_transition_reads(spec: GDSSpec) -> list[Finding]:
+ """Transition signatures reference valid Mechanisms and variables.
+
+ SC-009: Every TransitionSignature references an existing Mechanism,
+ reads valid (entity, variable) pairs, and depends_on_blocks are
+ registered blocks.
+ """
+ findings: list[Finding] = []
+
+ if not spec.transition_signatures:
+ findings.append(
+ Finding(
+ check_id="SC-009",
+ severity=Severity.INFO,
+ message="No transition signatures registered",
+ passed=True,
+ )
+ )
+ return findings
+
+ issues: list[str] = []
+ bad_names: list[str] = []
+ for ts in spec.transition_signatures.values():
+ before = len(issues)
+ block = spec.blocks.get(ts.mechanism)
+ if block is None:
+ issues.append(f"'{ts.mechanism}': block not registered")
+ elif not isinstance(block, Mechanism):
+ issues.append(
+ f"'{ts.mechanism}': not a Mechanism (is {type(block).__name__})"
+ )
+
+ for entity_name, var_name in ts.reads:
+ if entity_name not in spec.entities:
+ issues.append(f"'{ts.mechanism}': reads unknown entity '{entity_name}'")
+ elif var_name not in spec.entities[entity_name].variables:
+ issues.append(
+ f"'{ts.mechanism}': reads unknown variable "
+ f"'{entity_name}.{var_name}'"
+ )
+
+ for bname in ts.depends_on_blocks:
+ if bname not in spec.blocks:
+ issues.append(
+ f"'{ts.mechanism}': depends on unregistered block '{bname}'"
+ )
+ if len(issues) > before:
+ bad_names.append(ts.mechanism)
+
+ if issues:
+ findings.append(
+ Finding(
+ check_id="SC-009",
+ severity=Severity.ERROR,
+ message=f"Transition signature issues: {issues}",
+ source_elements=bad_names,
+ passed=False,
+ )
+ )
+ else:
+ findings.append(
+ Finding(
+ check_id="SC-009",
+ severity=Severity.INFO,
+ message=(
+ f"All {len(spec.transition_signatures)} transition "
+ f"signature(s) are consistent"
+ ),
+ passed=True,
+ )
+ )
+
+ return findings
diff --git a/packages/gds-framework/tests/test_constraints.py b/packages/gds-framework/tests/test_constraints.py
new file mode 100644
index 0000000..4bed909
--- /dev/null
+++ b/packages/gds-framework/tests/test_constraints.py
@@ -0,0 +1,528 @@
+"""Tests for AdmissibleInputConstraint, TransitionSignature, and SC-008/SC-009."""
+
+import pytest
+from pydantic import ValidationError
+
+from gds.blocks.roles import BoundaryAction, Mechanism, Policy
+from gds.constraints import AdmissibleInputConstraint, TransitionSignature
+from gds.spec import GDSSpec
+from gds.state import Entity, StateVariable
+from gds.types.interface import Interface, port
+from gds.types.typedef import TypeDef
+from gds.verification.findings import Severity
+from gds.verification.spec_checks import (
+ check_admissibility_references,
+ check_transition_reads,
+)
+
+
+@pytest.fixture
+def temp_type():
+ return TypeDef(name="Temperature", python_type=float)
+
+
+@pytest.fixture
+def room_entity(temp_type):
+ return Entity(
+ name="Room",
+ variables={
+ "temperature": StateVariable(name="temperature", typedef=temp_type),
+ },
+ )
+
+
+@pytest.fixture
+def sensor():
+ return BoundaryAction(
+ name="Sensor",
+ interface=Interface(forward_out=(port("Temperature"),)),
+ )
+
+
+@pytest.fixture
+def controller():
+ return Policy(
+ name="Controller",
+ interface=Interface(
+ forward_in=(port("Temperature"),),
+ forward_out=(port("Heater Command"),),
+ ),
+ )
+
+
+@pytest.fixture
+def heater():
+ return Mechanism(
+ name="Heater",
+ interface=Interface(forward_in=(port("Heater Command"),)),
+ updates=[("Room", "temperature")],
+ )
+
+
+@pytest.fixture
+def thermostat_spec(temp_type, room_entity, sensor, controller, heater):
+ spec = GDSSpec(name="thermostat")
+ spec.collect(temp_type, room_entity, sensor, controller, heater)
+ return spec
+
+
+# ── Model construction ───────────────────────────────────────
+
+
+class TestAdmissibleInputConstraintModel:
+ def test_creates_frozen(self):
+ ac = AdmissibleInputConstraint(
+ name="balance_limit",
+ boundary_block="market_order",
+ depends_on=[("Agent", "balance")],
+ description="Cannot sell more than owned",
+ )
+ assert ac.name == "balance_limit"
+ assert ac.boundary_block == "market_order"
+ assert ac.depends_on == [("Agent", "balance")]
+ assert ac.constraint is None
+
+ with pytest.raises(ValidationError):
+ ac.name = "changed" # type: ignore[misc]
+
+ def test_defaults(self):
+ ac = AdmissibleInputConstraint(name="test", boundary_block="b")
+ assert ac.depends_on == []
+ assert ac.constraint is None
+ assert ac.description == ""
+
+ def test_with_constraint(self):
+ fn = lambda state, u: u <= state["balance"] # noqa: E731
+ ac = AdmissibleInputConstraint(
+ name="test",
+ boundary_block="b",
+ constraint=fn,
+ )
+ assert ac.constraint is fn
+
+
+class TestTransitionSignatureModel:
+ def test_creates_frozen(self):
+ ts = TransitionSignature(
+ mechanism="Heater",
+ reads=[("Room", "temperature")],
+ depends_on_blocks=["Controller"],
+ )
+ assert ts.mechanism == "Heater"
+ assert ts.reads == [("Room", "temperature")]
+ assert ts.depends_on_blocks == ["Controller"]
+
+ with pytest.raises(ValidationError):
+ ts.mechanism = "changed" # type: ignore[misc]
+
+ def test_defaults(self):
+ ts = TransitionSignature(mechanism="M")
+ assert ts.reads == []
+ assert ts.depends_on_blocks == []
+ assert ts.preserves_invariant == ""
+
+ def test_with_invariant(self):
+ ts = TransitionSignature(
+ mechanism="M",
+ preserves_invariant="temperature >= 0",
+ )
+ assert ts.preserves_invariant == "temperature >= 0"
+
+
+# ── Registration ──────────────────────────────────────────────
+
+
+class TestRegistration:
+ def test_register_admissibility_chainable(self, thermostat_spec):
+ ac = AdmissibleInputConstraint(
+ name="sensor_dep",
+ boundary_block="Sensor",
+ depends_on=[("Room", "temperature")],
+ )
+ result = thermostat_spec.register_admissibility(ac)
+ assert result is thermostat_spec
+ assert "sensor_dep" in thermostat_spec.admissibility_constraints
+
+ def test_register_admissibility_duplicate_raises(self, thermostat_spec):
+ ac = AdmissibleInputConstraint(name="dup", boundary_block="Sensor")
+ thermostat_spec.register_admissibility(ac)
+ with pytest.raises(ValueError, match="already registered"):
+ thermostat_spec.register_admissibility(ac)
+
+ def test_multiple_constraints_per_boundary(self, thermostat_spec):
+ ac1 = AdmissibleInputConstraint(name="limit_a", boundary_block="Sensor")
+ ac2 = AdmissibleInputConstraint(name="limit_b", boundary_block="Sensor")
+ thermostat_spec.register_admissibility(ac1)
+ thermostat_spec.register_admissibility(ac2)
+ assert len(thermostat_spec.admissibility_constraints) == 2
+
+ def test_register_transition_signature_chainable(self, thermostat_spec):
+ ts = TransitionSignature(
+ mechanism="Heater",
+ reads=[("Room", "temperature")],
+ )
+ result = thermostat_spec.register_transition_signature(ts)
+ assert result is thermostat_spec
+ assert "Heater" in thermostat_spec.transition_signatures
+
+ def test_register_transition_signature_duplicate_raises(self, thermostat_spec):
+ ts = TransitionSignature(mechanism="Heater")
+ thermostat_spec.register_transition_signature(ts)
+ with pytest.raises(ValueError, match="already registered"):
+ thermostat_spec.register_transition_signature(ts)
+
+
+# ── Validation (validate_spec) ────────────────────────────────
+
+
+class TestValidation:
+ def test_valid_admissibility_no_errors(self, thermostat_spec):
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="sensor_dep",
+ boundary_block="Sensor",
+ depends_on=[("Room", "temperature")],
+ )
+ )
+ errors = thermostat_spec.validate_spec()
+ assert not errors
+
+ def test_admissibility_unknown_block(self, thermostat_spec):
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(name="bad", boundary_block="NonExistent")
+ )
+ errors = thermostat_spec.validate_spec()
+ assert any("unregistered block" in e for e in errors)
+
+ def test_admissibility_wrong_role(self, thermostat_spec):
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="bad",
+ boundary_block="Controller", # Policy, not BoundaryAction
+ )
+ )
+ errors = thermostat_spec.validate_spec()
+ assert any("not a BoundaryAction" in e for e in errors)
+
+ def test_admissibility_unknown_entity(self, thermostat_spec):
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="bad",
+ boundary_block="Sensor",
+ depends_on=[("NoSuchEntity", "var")],
+ )
+ )
+ errors = thermostat_spec.validate_spec()
+ assert any("unknown entity" in e for e in errors)
+
+ def test_admissibility_unknown_variable(self, thermostat_spec):
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="bad",
+ boundary_block="Sensor",
+ depends_on=[("Room", "nonexistent")],
+ )
+ )
+ errors = thermostat_spec.validate_spec()
+ assert any("unknown variable" in e for e in errors)
+
+ def test_valid_transition_signature_no_errors(self, thermostat_spec):
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ reads=[("Room", "temperature")],
+ depends_on_blocks=["Controller"],
+ )
+ )
+ errors = thermostat_spec.validate_spec()
+ assert not errors
+
+ def test_transition_unknown_block(self, thermostat_spec):
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(mechanism="NonExistent")
+ )
+ errors = thermostat_spec.validate_spec()
+ assert any("unregistered block" in e for e in errors)
+
+ def test_transition_wrong_role(self, thermostat_spec):
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(mechanism="Controller")
+ )
+ errors = thermostat_spec.validate_spec()
+ assert any("not a Mechanism" in e for e in errors)
+
+ def test_transition_unknown_read_entity(self, thermostat_spec):
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ reads=[("NoSuch", "var")],
+ )
+ )
+ errors = thermostat_spec.validate_spec()
+ assert any("unknown entity" in e for e in errors)
+
+ def test_transition_unknown_read_variable(self, thermostat_spec):
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ reads=[("Room", "nonexistent")],
+ )
+ )
+ errors = thermostat_spec.validate_spec()
+ assert any("unknown variable" in e for e in errors)
+
+ def test_transition_unknown_depends_on_block(self, thermostat_spec):
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ depends_on_blocks=["GhostBlock"],
+ )
+ )
+ errors = thermostat_spec.validate_spec()
+ assert any("unregistered block" in e for e in errors)
+
+
+# ── SC-008: check_admissibility_references ────────────────────
+
+
+class TestSC008:
+ def test_no_constraints_passes(self):
+ spec = GDSSpec(name="empty")
+ findings = check_admissibility_references(spec)
+ assert all(f.passed for f in findings)
+ assert findings[0].check_id == "SC-008"
+
+ def test_valid_constraint_passes(self, thermostat_spec):
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="sensor_dep",
+ boundary_block="Sensor",
+ depends_on=[("Room", "temperature")],
+ )
+ )
+ findings = check_admissibility_references(thermostat_spec)
+ assert all(f.passed for f in findings)
+
+ def test_invalid_block_fails(self, thermostat_spec):
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(name="bad", boundary_block="NonExistent")
+ )
+ findings = check_admissibility_references(thermostat_spec)
+ failed = [f for f in findings if not f.passed]
+ assert len(failed) == 1
+ assert failed[0].severity == Severity.ERROR
+
+ def test_wrong_role_fails(self, thermostat_spec):
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(name="bad", boundary_block="Heater")
+ )
+ findings = check_admissibility_references(thermostat_spec)
+ failed = [f for f in findings if not f.passed]
+ assert len(failed) == 1
+
+ def test_invalid_depends_on_fails(self, thermostat_spec):
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="bad",
+ boundary_block="Sensor",
+ depends_on=[("Room", "nonexistent")],
+ )
+ )
+ findings = check_admissibility_references(thermostat_spec)
+ failed = [f for f in findings if not f.passed]
+ assert len(failed) == 1
+
+
+# ── SC-009: check_transition_reads ─────────────────────────────
+
+
+class TestSC009:
+ def test_no_signatures_passes(self):
+ spec = GDSSpec(name="empty")
+ findings = check_transition_reads(spec)
+ assert all(f.passed for f in findings)
+ assert findings[0].check_id == "SC-009"
+
+ def test_valid_signature_passes(self, thermostat_spec):
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ reads=[("Room", "temperature")],
+ depends_on_blocks=["Controller"],
+ )
+ )
+ findings = check_transition_reads(thermostat_spec)
+ assert all(f.passed for f in findings)
+
+ def test_invalid_mechanism_fails(self, thermostat_spec):
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(mechanism="NonExistent")
+ )
+ findings = check_transition_reads(thermostat_spec)
+ failed = [f for f in findings if not f.passed]
+ assert len(failed) == 1
+ assert failed[0].severity == Severity.ERROR
+
+ def test_wrong_role_fails(self, thermostat_spec):
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(mechanism="Controller")
+ )
+ findings = check_transition_reads(thermostat_spec)
+ failed = [f for f in findings if not f.passed]
+ assert len(failed) == 1
+
+ def test_invalid_reads_fails(self, thermostat_spec):
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ reads=[("Room", "nonexistent")],
+ )
+ )
+ findings = check_transition_reads(thermostat_spec)
+ failed = [f for f in findings if not f.passed]
+ assert len(failed) == 1
+
+ def test_invalid_depends_on_block_fails(self, thermostat_spec):
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ depends_on_blocks=["GhostBlock"],
+ )
+ )
+ findings = check_transition_reads(thermostat_spec)
+ failed = [f for f in findings if not f.passed]
+ assert len(failed) == 1
+
+
+# ── Canonical projection ──────────────────────────────────────
+
+
+class TestCanonicalProjection:
+ def test_admissibility_map_populated(self, thermostat_spec):
+ from gds.canonical import project_canonical
+
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="sensor_dep",
+ boundary_block="Sensor",
+ depends_on=[("Room", "temperature")],
+ )
+ )
+ canonical = project_canonical(thermostat_spec)
+ assert len(canonical.admissibility_map) == 1
+ name, deps = canonical.admissibility_map[0]
+ assert name == "sensor_dep"
+ assert ("Room", "temperature") in deps
+
+ def test_read_map_populated(self, thermostat_spec):
+ from gds.canonical import project_canonical
+
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ reads=[("Room", "temperature")],
+ )
+ )
+ canonical = project_canonical(thermostat_spec)
+ assert len(canonical.read_map) == 1
+ mech, reads = canonical.read_map[0]
+ assert mech == "Heater"
+ assert ("Room", "temperature") in reads
+
+ def test_empty_maps_by_default(self, thermostat_spec):
+ from gds.canonical import project_canonical
+
+ canonical = project_canonical(thermostat_spec)
+ assert canonical.admissibility_map == ()
+ assert canonical.read_map == ()
+
+
+# ── Serialization ──────────────────────────────────────────────
+
+
+class TestSerialization:
+ def test_spec_to_dict_includes_constraints(self, thermostat_spec):
+ from gds.serialize import spec_to_dict
+
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="sensor_dep",
+ boundary_block="Sensor",
+ depends_on=[("Room", "temperature")],
+ description="reads temp",
+ )
+ )
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ reads=[("Room", "temperature")],
+ depends_on_blocks=["Controller"],
+ )
+ )
+ d = spec_to_dict(thermostat_spec)
+ assert "admissibility_constraints" in d
+ assert "sensor_dep" in d["admissibility_constraints"]
+ ac_d = d["admissibility_constraints"]["sensor_dep"]
+ assert ac_d["boundary_block"] == "Sensor"
+ assert ac_d["depends_on"] == [["Room", "temperature"]]
+ assert ac_d["has_constraint"] is False
+
+ assert "transition_signatures" in d
+ assert "Heater" in d["transition_signatures"]
+ ts_d = d["transition_signatures"]["Heater"]
+ assert ts_d["reads"] == [["Room", "temperature"]]
+ assert ts_d["depends_on_blocks"] == ["Controller"]
+
+
+# ── Query engine ───────────────────────────────────────────────
+
+
+class TestQueryEngine:
+ def test_admissibility_dependency_map(self, thermostat_spec):
+ from gds.query import SpecQuery
+
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="a",
+ boundary_block="Sensor",
+ depends_on=[("Room", "temperature")],
+ )
+ )
+ q = SpecQuery(thermostat_spec)
+ dep_map = q.admissibility_dependency_map()
+ assert "Sensor" in dep_map
+ assert ("Room", "temperature") in dep_map["Sensor"]
+
+ def test_mechanism_read_map(self, thermostat_spec):
+ from gds.query import SpecQuery
+
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ reads=[("Room", "temperature")],
+ )
+ )
+ q = SpecQuery(thermostat_spec)
+ read_map = q.mechanism_read_map()
+ assert "Heater" in read_map
+ assert ("Room", "temperature") in read_map["Heater"]
+
+ def test_variable_readers(self, thermostat_spec):
+ from gds.query import SpecQuery
+
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ reads=[("Room", "temperature")],
+ )
+ )
+ q = SpecQuery(thermostat_spec)
+ readers = q.variable_readers("Room", "temperature")
+ assert "Heater" in readers
+
+ def test_variable_readers_empty(self, thermostat_spec):
+ from gds.query import SpecQuery
+
+ q = SpecQuery(thermostat_spec)
+ readers = q.variable_readers("Room", "temperature")
+ assert readers == []
diff --git a/packages/gds-owl/README.md b/packages/gds-owl/README.md
new file mode 100644
index 0000000..645692c
--- /dev/null
+++ b/packages/gds-owl/README.md
@@ -0,0 +1,27 @@
+# gds-owl
+
+OWL/Turtle, SHACL, and SPARQL for [gds-framework](https://github.com/BlockScience/gds-core) specifications.
+
+Exports GDS models (GDSSpec, SystemIR, CanonicalGDS, VerificationReport) to RDF/OWL and provides bidirectional round-trip with Pydantic models.
+
+## Install
+
+```bash
+pip install gds-owl
+
+# With SHACL validation support
+pip install gds-owl[shacl]
+```
+
+## Quick Start
+
+```python
+import gds
+from gds_owl import spec_to_turtle, build_core_ontology
+
+# Export a GDSSpec to Turtle
+ttl = spec_to_turtle(my_spec)
+
+# Get the GDS ontology (TBox)
+ontology = build_core_ontology()
+```
diff --git a/packages/gds-owl/docs/formal-representability.md b/packages/gds-owl/docs/formal-representability.md
new file mode 100644
index 0000000..4057d1b
--- /dev/null
+++ b/packages/gds-owl/docs/formal-representability.md
@@ -0,0 +1,808 @@
+# Representability Analysis: GDS in OWL/SHACL/SPARQL
+
+A design rationale document classifying which GDS concepts can and cannot
+be represented in semantic web formalisms, grounded in the
+compositionality-temporality boundary and the canonical decomposition
+h = f ∘ g.
+
+---
+
+## Overview
+
+### The representation boundary is h = f ∘ g
+
+The GDS canonical decomposition h = f ∘ g is not just mathematical
+notation — it is the exact line where formal representation changes
+character:
+
+- **g** (policy mapping): which blocks connect to what, in what roles,
+ through what wires. Fully representable — by design, GDSSpec stores no
+ behavioral content here.
+- **f_struct** (update map): "Mechanism M updates Entity E variable V."
+ Fully representable — a finite relation.
+- **f_behav** (transition function): "Given state x and decisions d,
+ compute new state x'." Not representable — arbitrary computation.
+
+Everything to the left of f_behav is topology. Everything at f_behav and
+beyond is computation. OWL/SHACL/SPARQL live on the topology side. Python
+lives on both sides.
+
+### Composition: structure preserved, process lost
+
+The five composition operators (>>, |, feedback, loop) and their resulting
+trees survive OWL round-trip perfectly. You can reconstruct exactly how a
+system was assembled.
+
+The *process* of composition — auto-wiring via token overlap, port
+matching, construction-time validation — requires Python string computation
+that SPARQL cannot replicate. But this gap is **moot in practice**: gds-owl
+materializes both the tokens (as `typeToken` literals) and the wired
+connections (as explicit `WiringIR` edges) during export. The RDF consumer
+never needs to recompute what Python already computed.
+
+This reveals a general design principle: **materialize computation results
+as data before export, and the representation gap closes for practical
+purposes.**
+
+### Temporality: structure preserved, semantics lost
+
+A `TemporalLoop` (physical state at t feeds sensors at t+1) and a
+`CorecursiveLoop` (decisions at round t feed observations at round t+1)
+have identical RDF representation: covariant wiring from inner.forward_out
+to inner.forward_in with an exit_condition string. OWL captures "there is
+a loop" but not "what kind of time this loop means." The interpretation
+requires knowing which DSL compiled the system — that is metadata
+(preserved via `gds-ir:sourceLabel`), not topology.
+
+State evolution itself — computing x_{t+1} = f(x_t, g(x_t, u_t)) — is
+fundamentally not representable. You need a runtime, period.
+
+### The data/computation duality
+
+The same pattern recurs at every level of GDS:
+
+| Data (representable) | Computation (not representable) |
+|---|---|
+| Token sets on ports | The `tokenize()` function that produces them |
+| Wired connections | The auto-wiring process that discovers them |
+| Constraint bounds (0 <= x <= 1) | Arbitrary `Callable[[Any], bool]` constraints |
+| Update map (M updates E.V) | Transition function (how V changes) |
+| Read map (M reads E.V) | Actual data flow at runtime |
+| Admissibility deps (B depends on E.V) | Admissibility predicate (is input legal given state?) |
+| Equilibrium structure (which games compose how) | Equilibrium computation (finding Nash equilibria) |
+| Wiring graph topology (can A reach B?) | Signal propagation (does A's output actually affect B?) |
+
+If you materialize computation results as data before crossing the
+boundary, the gap shrinks to what genuinely requires a runtime: simulation,
+constraint evaluation, and equilibrium solving.
+
+### The validation stack
+
+The three semantic web formalisms serve architecturally distinct roles —
+a validation stack, not a containment chain:
+
+| Layer | Formalism | Role | Example |
+|---|---|---|---|
+| Vocabulary | OWL | Defines what things *are* | "A Mechanism is a kind of AtomicBlock" |
+| Local constraints | SHACL-core | Validates individual nodes | "Every Mechanism must update >= 1 state variable" |
+| Graph patterns | SPARQL | Validates cross-node relationships | "No two mechanisms update the same (entity, variable) pair" |
+| Computation | Python | Evaluates functions, evolves state | "Given x=0.5, f(x) = 0.7" |
+
+Each step adds expressiveness and loses decidability guarantees. The
+R1/R2/R3 tier system in this document maps directly onto this stack.
+
+### Architectural consequences
+
+1. **RDF is a viable structural interchange format.** Of 15 verification
+ checks, 6 are SHACL-expressible, 6 more with SPARQL, only 2 genuinely
+ need Python. The structural skeleton carries the vast majority of system
+ information.
+
+2. **Games are naturally ontological.** When h = g (no state, no f), the
+ GDSSpec projection is lossless. Games are morphisms between spaces, not
+ state machines. Game composition maps cleanly to OWL because it is all
+ structure.
+
+3. **Dynamical systems degrade gracefully.** Each mechanism contributes one
+ representable fact (what it updates) and one non-representable fact (how
+ it computes). The structural skeleton is always complete; what degrades
+ is the fraction of total content it represents.
+
+4. **The canonical form is architecturally load-bearing.** By separating
+ "what connects to what" (g) from "what the connections compute"
+ (f_behav), GDS provides a clean cut point for partial representation,
+ cross-tool interop, and formal reasoning.
+
+The representability boundary is Rice's theorem applied to system
+specifications: you can represent everything about a system except what
+its programs actually do. The canonical decomposition h = f ∘ g makes this
+boundary explicit and exploitable.
+
+---
+
+## 1. Preliminaries
+
+### 1.1 GDS Formal Objects
+
+**Definition 1.1 (Composition Algebra).** The GDS composition algebra is
+a tuple (Block, >>, |, fb, loop) with operations inspired by symmetric
+monoidal categories with feedback. The operations satisfy the expected
+algebraic properties (associativity of >> and |, commutativity of |) by
+construction, but the full categorical axioms (interchange law, coherence
+conditions, traced monoidal structure for feedback) have not been formally
+verified.
+
+The components are:
+
+- **Objects** are Interfaces: I = (F_in, F_out, B_in, B_out), each a tuple
+ of Ports
+- **Morphisms** are Blocks: typed components with bidirectional interfaces
+- **>>** (sequential): first ; second with token-overlap validation
+- **|** (parallel): left tensor right, no shared wires
+- **fb** (feedback): contravariant backward flow within timestep
+- **loop** (temporal): covariant forward flow across timesteps
+
+**Definition 1.2 (Token System).** Port names carry structural type
+information via tokenization:
+
+```
+tokenize : PortName -> P(Token)
+
+Split on {' + ', ', '}, then lowercase each part.
+"Temperature + Setpoint" |-> {"temperature", "setpoint"}
+"Heater Command" |-> {"heater command"}
+```
+
+Token overlap is the auto-wiring predicate:
+
+```
+compatible(p1, p2) := tokenize(p1.name) ∩ tokenize(p2.name) != empty
+```
+
+**Definition 1.3 (GDSSpec).** A specification is an 8-tuple:
+
+```
+S = (T, Sp, E, B, W, Theta, A, Sig)
+
+T : Name -> TypeDef (type registry)
+Sp : Name -> Space (typed product spaces)
+E : Name -> Entity (state holders with typed variables)
+B : Name -> Block (typed compositional blocks)
+W : Name -> SpecWiring (named compositions with explicit wires)
+Theta : Name -> ParameterDef (configuration space)
+A : Name -> AdmissibleInputConstraint (state-dependent input constraints)
+Sig : MechName -> TransitionSignature (mechanism read dependencies)
+```
+
+While presented as an 8-tuple, these components are cross-referencing:
+blocks reference types, wirings reference blocks, entities reference types,
+admissibility constraints reference boundary blocks and entity variables,
+transition signatures reference mechanisms and entity variables.
+GDSSpec is more precisely a labeled graph of registries with typed edges.
+
+**Definition 1.4 (Canonical Decomposition).** The projection
+pi : GDSSpec -> CanonicalGDS yields:
+
+```
+C = (X, U, D, Theta, g, f, h, A_deps, R_deps)
+
+X = product_{(e,v) in E} TypeDef(e.variables[v]) state space
+U = {(b, p) : b in B_boundary, p in b.forward_out} input space
+D = {(b, p) : b in B_policy, p in b.forward_out} decision space
+g : X x U -> D policy mapping
+f : X x D -> X state transition
+h_theta: X -> X where h = f ∘ g composed transition
+A_deps = {(name, {(e,v)}) : ac in A} admissibility dependencies
+R_deps = {(mech, {(e,v)}) : sig in Sig} mechanism read dependencies
+```
+
+**Definition 1.5 (Role Partition).** Blocks partition into disjoint roles:
+
+```
+B = B_boundary disjoint-union B_control disjoint-union B_policy disjoint-union B_mechanism
+
+B_boundary : forward_in = empty (exogenous input)
+B_mechanism : backward_in = backward_out = empty (state update)
+B_policy : no structural constraints (decision logic)
+B_control : no structural constraints (endogenous feedback)
+```
+
+**Definition 1.6 (TypeDef).** A type definition carries two levels:
+
+```
+TypeDef = (name, python_type, constraint, units)
+
+python_type : type (language-level type object)
+constraint : Optional[Any -> bool] (runtime validation predicate)
+```
+
+The constraint field admits arbitrary Callable — this is Turing-complete.
+
+### 1.2 Semantic Web Formal Objects
+
+**Definition 1.7 (OWL DL).** OWL DL is based on the description logic
+SROIQ(D). It provides class-level entailment under the **open-world
+assumption** (OWA): absence of a statement does not imply its negation.
+
+- **Class declarations**: C, with subsumption C1 sqsubseteq C2
+- **Object properties**: R : C1 -> C2 (binary relations between individuals)
+- **Datatype properties**: R : C -> Literal (attributes with XSD types)
+- **Restrictions**: cardinality (min/max), value constraints, disjointness
+
+Key property: **every entailment query terminates** (decidable).
+
+**Definition 1.8 (SHACL).** The Shapes Constraint Language validates RDF
+graphs against declared shapes under the **closed-world assumption** (CWA):
+the graph is taken as complete, and missing data counts as a violation.
+
+- **Node shapes**: target a class, constrain its properties
+- **Property shapes**: cardinality (sh:minCount, sh:maxCount), datatype,
+ class membership
+- **SPARQL-based constraints**: sh:sparql embeds SELECT queries as validators
+
+SHACL is not a reasoning system — it validates data, not entailment.
+
+**Definition 1.9 (SPARQL 1.1).** A query language for pattern matching
+and aggregation over RDF graphs:
+
+- **Property paths**: transitive closure (p+), alternatives (p1|p2)
+- **Negation**: FILTER NOT EXISTS { pattern }
+- **Aggregation**: GROUP BY, HAVING, COUNT
+- **Graph patterns**: triple patterns with variables, OPTIONAL, UNION
+
+Key limitation: **no mutable state, no unbounded recursion, no string
+computation** beyond regex matching.
+
+**Remark 1.10 (Complementary formalisms).** OWL, SHACL, and SPARQL solve
+different problems under different assumptions:
+
+- OWL DL: class-level entailment (OWA, monotonic)
+- SHACL: graph shape validation (CWA, non-monotonic)
+- SPARQL: graph pattern queries with aggregation and negation
+
+They do not form a simple containment chain. However, for the specific
+purpose of **enforcing constraints on GDS-exported RDF graphs**, we
+distinguish three tiers of validation expressiveness:
+
+```
+SHACL-core (node/property shapes) < SPARQL graph patterns < Turing-complete
+```
+
+OWL defines the vocabulary (classes, properties, subsumption). SHACL-core
+— node shapes, property shapes with cardinality/datatype/class constraints,
+but *without* sh:sparql — validates individual nodes against local
+constraints. SPARQL graph patterns (standalone or embedded in SHACL via
+sh:sparql) can express cross-node patterns: negation-as-failure, transitive
+closure, aggregation. None can express arbitrary computation.
+
+This three-level ordering directly motivates the R1/R2/R3 tiers in
+Definition 2.2: R1 maps to OWL + SHACL-core, R2 maps to SPARQL, R3
+exceeds all three formalisms.
+
+---
+
+## 2. Representability Classification
+
+**Definition 2.1 (Representation Function).** Let rho be the mapping from
+GDS concepts to RDF graphs implemented by gds-owl's export functions:
+
+```
+rho_spec : GDSSpec -> Graph (spec_to_graph)
+rho_ir : SystemIR -> Graph (system_ir_to_graph)
+rho_can : CanonicalGDS -> Graph (canonical_to_graph)
+rho_ver : VerificationReport -> Graph (report_to_graph)
+```
+
+And rho^{-1} the inverse mapping (import functions):
+
+```
+rho^{-1}_spec : Graph -> GDSSpec (graph_to_spec)
+rho^{-1}_ir : Graph -> SystemIR (graph_to_system_ir)
+rho^{-1}_can : Graph -> CanonicalGDS (graph_to_canonical)
+rho^{-1}_ver : Graph -> VerificationReport (graph_to_report)
+```
+
+**Remark 2.1 (Bijectivity caveats).** rho is injective on structural
+fields but not surjective onto all possible RDF graphs (only well-formed
+GDS graphs are in the image). rho^{-1} is a left inverse on structural
+fields: rho^{-1}(rho(c)) =_struct c. Three edge cases weaken strict
+bijectivity:
+
+1. **Ordering**: RDF multi-valued properties are unordered. Port lists and
+ wire lists may return in different order after round-trip. Equality is
+ set-based, not sequence-based.
+2. **Blank nodes**: Space fields and update map entries use RDF blank nodes.
+ These have no stable identity across serializations. Structural equality
+ compares by content (field name + type), not by node identity.
+3. **Lossy fields**: TypeDef.constraint and
+ AdmissibleInputConstraint.constraint are always None after import.
+ TypeDef.python_type falls back to `str` for types not in the built-in
+ map. These are documented R3 losses, not bijectivity failures.
+
+The round-trip suite (test_roundtrip.py: TestSpecRoundTrip,
+TestSystemIRRoundTrip, TestCanonicalRoundTrip, TestReportRoundTrip)
+verifies structural equality under these conventions for all four
+rho/rho^{-1} pairs.
+
+**Definition 2.2 (Representability Tiers).** A GDS concept c belongs to:
+
+**R1 (Fully representable)** if rho^{-1}(rho(c)) is structurally equal
+to c. The round-trip preserves all fields. Invariants on c are expressible
+as OWL class/property structure or SHACL cardinality/class shapes.
+
+**R2 (Structurally representable)** if rho preserves identity, topology,
+and classification, but loses behavioral content. Validation requires
+SPARQL graph pattern queries (negation-as-failure, transitive closure,
+aggregation) that exceed SHACL's node/property shape expressiveness. The
+behavioral projection, if any, is not representable.
+
+**R3 (Not representable)** if no finite OWL/SHACL/SPARQL expression can
+capture the concept. The gap is fundamental — it follows from:
+- **Rice's theorem**: any non-trivial semantic property of programs is
+ undecidable
+- **The halting problem**: arbitrary Callable may not terminate
+- **Computational class separation**: string parsing and temporal execution
+ exceed the expressiveness of all three formalisms
+
+---
+
+## 3. Layer 0 Representability: Composition Algebra
+
+**Property 3.1 (Composition Tree is R1).** The block composition tree —
+including all 5 block types (AtomicBlock, StackComposition,
+ParallelComposition, FeedbackLoop, TemporalLoop) with their structural
+fields — is fully representable in OWL.
+
+*Argument.* The representation function rho maps:
+
+```
+AtomicBlock |-> gds-core:AtomicBlock (owl:Class)
+StackComposition |-> gds-core:StackComposition + first, second (owl:ObjectProperty)
+ParallelComposition |-> gds-core:ParallelComposition + left, right
+FeedbackLoop |-> gds-core:FeedbackLoop + inner
+TemporalLoop |-> gds-core:TemporalLoop + inner
+```
+
+The OWL class hierarchy mirrors the Python class hierarchy. The
+`first`, `second`, `left`, `right`, `inner` object properties capture the
+tree structure. The round-trip test `test_roundtrip.py::
+TestSystemIRRoundTrip` verifies structural equality after
+Graph -> Turtle -> Graph -> Pydantic.
+
+The interface (F_in, F_out, B_in, B_out) is represented via four
+object properties (hasForwardIn, hasForwardOut, hasBackwardIn,
+hasBackwardOut) each pointing to Port individuals with portName and
+typeToken datatype properties. Port ordering within a direction may differ
+(RDF is unordered), but the *set* of ports is preserved.
+
+**Property 3.2 (Token Data R1, Auto-Wiring Process R3).** The materialized
+token data (the frozenset of strings on each Port) is R1. The auto-wiring
+process that uses tokenize() to discover connections is R3.
+
+*Argument.* Each Port stores `type_tokens: frozenset[str]`. These are
+exported as multiple `gds-core:typeToken` literals per Port individual. The
+round-trip preserves the token set exactly (unordered collection ->
+multi-valued RDF property -> unordered collection).
+
+Since gds-owl already materializes tokens during export, the RDF consumer
+never needs to run tokenize(). The tokens are data, not computation. The
+R3 classification applies specifically to **auto-wiring as a process**:
+discovering which ports should connect by computing token overlap from port
+name strings. This requires the tokenize() function (string splitting +
+lowercasing). While SPARQL CONSTRUCT can generate new triples from pattern
+matches, it cannot generate an unbounded number of new nodes from a single
+string value — the split points must be known at query-write time. Since
+GDS port names use variable numbers of delimiters, a fixed SPARQL query
+cannot handle all cases.
+
+In practice this is a moot point: the *wired connections* are exported as
+explicit WiringIR edges (R1). Only the *process of discovering them* is not
+replicable.
+
+**Property 3.3 (Block Roles are R1).** The role partition
+B = B_boundary disjoint-union B_control disjoint-union B_policy disjoint-union B_mechanism
+is fully representable as OWL disjoint union classes (owl:disjointUnionOf).
+
+*Argument.* Each role maps to an OWL class (gds-core:BoundaryAction,
+gds-core:Policy, gds-core:Mechanism, gds-core:ControlAction), all declared
+as subclasses of gds-core:AtomicBlock. Role-specific structural constraints
+are SHACL-expressible:
+
+- BoundaryAction: `sh:maxCount 0` on `hasForwardIn` (no forward inputs)
+- Mechanism: `sh:maxCount 0` on `hasBackwardIn` and `hasBackwardOut`
+- Mechanism: `sh:minCount 1` on `updatesEntry` (must update state)
+
+**Proposition 3.4 (Operators: Structure R1, Validation R3).** The
+composition operators `>>`, `|`, `.feedback()`, `.loop()` are R1 as
+*structure* (the resulting tree is preserved in RDF) but R3 as *process*
+(the validation logic run during construction cannot be replicated).
+
+Specifically:
+- `>>` validates token overlap between first.forward_out and
+ second.forward_in — requires tokenize() (R3)
+- `.loop()` enforces COVARIANT-only on temporal_wiring — the flag check
+ is R1 (SHACL on direction property), but port matching uses tokens (R3)
+
+---
+
+## 4. Layer 1 Representability: Specification Framework
+
+**Property 4.1 (GDSSpec Structure is R1).** The 8-tuple
+S = (T, Sp, E, B, W, Theta, A, Sig) round-trips through OWL losslessly
+for all structural fields.
+
+*Argument.* Each component maps to an OWL class with named properties:
+
+```
+TypeDef |-> gds-core:TypeDef + name, pythonType, units, hasConstraint
+Space |-> gds-core:Space + name, description, hasField -> SpaceField
+Entity |-> gds-core:Entity + name, description, hasVariable -> StateVariable
+Block |-> gds-core:{role class} + name, kind, hasInterface, usesParameter, ...
+SpecWiring |-> gds-core:SpecWiring + name, wiringBlock, hasWire -> Wire
+ParameterDef |-> gds-core:ParameterDef + name, paramType, lowerBound, upperBound
+AdmissibleInputConstraint |-> gds-core:AdmissibleInputConstraint + name, constrainsBoundary,
+ hasDependency -> AdmissibilityDep
+TransitionSignature |-> gds-core:TransitionSignature + signatureForMechanism,
+ hasReadEntry -> TransitionReadEntry
+```
+
+The `test_roundtrip.py::TestSpecRoundTrip` suite verifies: types, spaces,
+entities, blocks (with role, params, updates), parameters, wirings,
+admissibility constraints, and transition signatures all survive the
+round-trip. Documented exceptions: TypeDef.constraint (Property 4.2) and
+AdmissibleInputConstraint.constraint (Property 4.5) — both lossy for the
+same reason (arbitrary Callable).
+
+**Property 4.2 (Constraint Predicates).** The constraints used in practice
+across all GDS DSLs (numeric bounds, non-negativity, probability ranges)
+are expressible in SHACL (`sh:minInclusive`, `sh:maxInclusive`). This
+covers Probability, NonNegativeFloat, PositiveInt, and most GDS built-in
+types. These specific constraints are R2.
+
+The general case — TypeDef.constraint : Optional[Callable[[Any], bool]] —
+is R3. By Rice's theorem, any non-trivial semantic property of such
+functions is undecidable:
+
+- Given two constraints c1, c2, the question "do c1 and c2 accept the
+ same values?" is undecidable (equivalence of arbitrary programs)
+- Given a constraint c, the question "does c accept any value?" is
+ undecidable (non-emptiness of the accepted set)
+
+OWL DL is decidable (SROIQ). SHACL with SPARQL constraints is decidable
+on finite graphs. Neither can embed an undecidable problem. This
+theoretical limit rarely applies to real GDS specifications, where
+constraints are simple numeric bounds.
+
+**Observation 4.3 (Policy Mapping g is R1 by Design).** By design, GDSSpec
+stores no behavioral content for policy blocks. A Policy block is defined
+by what it connects to (interface, wiring position, parameter dependencies),
+not by what it computes. Consequently, the policy mapping g in the
+canonical form h = f ∘ g is fully characterized by structural fields, all
+of which are R1 (Property 3.1, 3.3, 4.1).
+
+This is a design decision, not a mathematical necessity — one could
+imagine a framework that attaches executable policy functions to blocks.
+GDS deliberately does not, keeping the specification layer structural.
+
+**Property 4.4 (State Transition f Decomposes).** The state transition f
+decomposes as a tuple f = ⟨f_struct, f_read, f_behav⟩ where:
+
+```
+f_struct : B_mechanism -> P(E x V)
+ The explicit write mapping from mechanisms to state variables.
+ "Mechanism M updates Entity E variable V."
+ This is a finite relation — R1. (Stored in Mechanism.updates.)
+
+f_read : B_mechanism -> P(E x V)
+ The explicit read mapping from mechanisms to state variables.
+ "Mechanism M reads Entity E variable V to compute its update."
+ This is a finite relation — R1. (Stored in TransitionSignature.reads.)
+
+f_behav : X x D -> X
+ The endomorphism on the state space parameterized by decisions.
+ "Given current state x and decisions d, compute next state x'."
+ This is an arbitrary computable function — R3.
+```
+
+Together, f_struct and f_read provide a complete structural data-flow
+picture of each mechanism: what it reads and what it writes. Only
+f_behav — the function that transforms reads into writes — remains R3.
+
+The composed system h = f ∘ g inherits: the structural decomposition
+(which blocks compose into h, via what wirings) is R1. The execution
+semantics (what h actually computes given inputs) is R3.
+
+**Property 4.5 (Admissible Input Constraints follow the f_struct/f_behav
+pattern).** An AdmissibleInputConstraint (Paper Def 2.5: U_x) decomposes
+as:
+
+```
+U_x_struct : A -> P(E x V)
+ The dependency relation: "BoundaryAction B's admissible outputs
+ depend on Entity E variable V."
+ This is a finite relation — R1.
+
+U_x_behav : (state, input) -> bool
+ The actual admissibility predicate: "is this input admissible
+ given this state?"
+ This is an arbitrary Callable — R3.
+```
+
+The structural part (name, boundary_block, depends_on) round-trips
+through OWL. The constraint callable is exported as a boolean
+`admissibilityHasConstraint` flag (present/absent) and imported as
+None — the same pattern as TypeDef.constraint. SC-008 validates that
+the structural references are well-formed (boundary block exists and
+is a BoundaryAction, depends_on references valid entity.variable pairs).
+
+**Property 4.6 (Transition Signatures follow the same pattern).**
+A TransitionSignature (Paper Def 2.7: f|_x) provides:
+
+```
+f_read : Sig -> P(E x V)
+ The read dependency relation: "Mechanism M reads Entity E variable V."
+ This is a finite relation — R1.
+
+f_block_deps : Sig -> P(B)
+ Which upstream blocks feed this mechanism.
+ This is a finite relation — R1.
+```
+
+Combined with the existing update_map (f_struct: which variables a
+mechanism *writes*), TransitionSignature completes the structural
+picture: now both reads and writes of every mechanism are declared.
+SC-009 validates that the structural references are well-formed.
+
+The actual transition function (what M computes from its reads to
+produce new values for its writes) remains R3 — it is an arbitrary
+computable function, never stored in GDSSpec.
+
+---
+
+## 5. Verification Check Classification
+
+Each of the 15 GDS verification checks is classified by whether
+SHACL/SPARQL can express it on the exported RDF graph, with practical
+impact noted.
+
+### 5.1 Generic Checks (on SystemIR)
+
+| Check | Property | Tier | Justification | Practical Impact |
+|---|---|---|---|---|
+| **G-001** | Domain/codomain matching | **R3** | Requires tokenize() — string splitting computation | Low: wired connections already exported as explicit edges |
+| **G-002** | Signature completeness | **R1** | Cardinality check on signature fields. SHACL sh:minCount. | Covered by SHACL |
+| **G-003** | Direction consistency | **R1** (flags) / **R3** (ports) | Flag contradiction is boolean — SHACL expressible. Port matching uses tokens (R3). | Flags covered; port check deferred to Python |
+| **G-004** | Dangling wirings | **R2** | WiringIR source/target are string literals (datatype properties), not object property references. Checking that a string name appears in the set of BlockIR names requires SPARQL negation-as-failure on string matching. Unlike SC-005 where `usesParameter` is an object property. | Expressible via SPARQL |
+| **G-005** | Sequential type compatibility | **R3** | Same tokenize() requirement as G-001 | Low: same mitigation as G-001 |
+| **G-006** | Covariant acyclicity (DAG) | **R2** | Cycle detection = self-reachability under transitive closure on materialized covariant edges. SPARQL: `ASK { ?v gds-ir:covariantSuccessor+ ?v }`. Requires materializing the filtered edge relation (direction="covariant" and is_temporal=false) first. | Expressible with preprocessing |
+
+### 5.2 Semantic Checks (on GDSSpec)
+
+| Check | Property | Tier | Justification | Practical Impact |
+|---|---|---|---|---|
+| **SC-001** | Completeness | **R2** | SPARQL: LEFT JOIN Entity.variables with Mechanism.updatesEntry, FILTER NOT EXISTS for orphans. | Expressible |
+| **SC-002** | Determinism | **R2** | SPARQL: GROUP BY (entity, variable) within wiring, HAVING COUNT(mechanism) > 1. | Expressible |
+| **SC-003** | Reachability | **R2** | SPARQL property paths on the wiring graph. Note: follows directed wiring edges (wireSource -> wireTarget), respecting flow direction. | Expressible |
+| **SC-004** | Type safety | **R2** | Wire.space is a string literal; checking membership in the set of registered Space names requires SPARQL, not SHACL sh:class (which works on object properties, as in SC-005). | Expressible via SPARQL |
+| **SC-005** | Parameter references | **R1** | SHACL sh:class on usesParameter targets. Already implemented in gds-owl shacl.py. | Covered by SHACL |
+| **SC-006** | f non-empty | **R1** | Equivalent to SHACL `sh:qualifiedMinCount 1` with `sh:qualifiedValueShape [sh:class gds-core:Mechanism]` on the spec node. (SPARQL illustration: `ASK { ?m a gds-core:Mechanism }`) | Covered by SHACL-core |
+| **SC-007** | X non-empty | **R1** | Same pattern: SHACL `sh:qualifiedMinCount 1` for StateVariable. (SPARQL illustration: `ASK { ?sv a gds-core:StateVariable }`) | Covered by SHACL-core |
+| **SC-008** | Admissibility references | **R1** | SHACL: `constrainsBoundary` must target a `BoundaryAction` (sh:class). Dependency entries (AdmissibilityDep) validated structurally. | Covered by SHACL |
+| **SC-009** | Transition read consistency | **R1** | SHACL: `signatureForMechanism` must target a `Mechanism` (sh:class). Read entries (TransitionReadEntry) validated structurally. | Covered by SHACL |
+
+### 5.3 Summary
+
+```
+R1 (SHACL-core): G-002, SC-005, SC-006, SC-007, SC-008, SC-009 = 6
+R2 (SPARQL): G-004, G-006, SC-001, SC-002, SC-003, SC-004 = 6
+R3 (Python-only): G-001, G-005 = 2
+Mixed (R1 + R3): G-003 (flag check R1, port matching R3) = 1
+```
+
+The R1/R2 boundary is mechanically determined: R1 = expressible in
+SHACL-core (no sh:sparql), R2 = requires SPARQL graph patterns.
+
+The R3 checks share a single root cause: **token-based port name matching
+requires string computation that exceeds SPARQL's value space operations**.
+In practice, this is mitigated by materializing tokens during export — the
+connections themselves are always R1 as explicit wiring edges.
+
+---
+
+## 6. Classification Summary
+
+**Definition 6.1 (Structural/Behavioral Partition).** We define:
+
+```
+G_struct = { composition tree, block interfaces, role partition,
+ wiring topology, update targets, parameter schema,
+ space/entity structure, canonical form metadata,
+ admissibility dependency graph (U_x_struct),
+ transition read dependencies (f_read) }
+
+G_behav = { transition functions (f_behav), constraint predicates,
+ admissibility predicates (U_x_behav),
+ auto-wiring process, construction-time validation,
+ scheduling/execution semantics }
+```
+
+**Consistency Check 6.1.** The structural/behavioral partition we define
+aligns exactly with the R1+R2 / R3 classification. This is a consistency
+property of our taxonomy, not an independent mathematical result — we
+defined G_struct and G_behav to capture what is and isn't representable.
+
+By exhaustive classification in Sections 3-5:
+
+G_struct concepts and their tiers:
+- Composition tree: R1 (Property 3.1)
+- Block interfaces: R1 (Property 3.1)
+- Role partition: R1 (Property 3.3)
+- Wiring topology: R1 (Property 4.1)
+- Update targets: R1 (Property 4.4, f_struct)
+- Parameter schema: R1 (Property 4.1)
+- Space/entity structure: R1 (Property 4.1)
+- Admissibility dependency graph (U_x_struct): R1 (Property 4.5)
+- Transition read dependencies (f_read): R1 (Property 4.6)
+- Acyclicity: R2 (Section 5.1, G-006)
+- Completeness/determinism: R2 (Section 5.2, SC-001, SC-002)
+- Reference validation (dangling wirings): R2 (Section 5.1, G-004)
+
+G_behav concepts and their tiers:
+- Transition functions: R3 (Property 4.4, f_behav)
+- Constraint predicates: R3 (Property 4.2, general case)
+- Admissibility predicates (U_x_behav): R3 (Property 4.5)
+- Auto-wiring process: R3 (Property 3.2)
+- Construction validation: R3 (Proposition 3.4)
+- Scheduling semantics: R3 (not stored in GDSSpec — external)
+
+No G_struct concept is R3. No G_behav concept is R1 or R2.
+
+**Property 6.2 (Canonical Form as Representability Boundary).** In the
+decomposition h = f ∘ g:
+
+```
+g is entirely in G_struct (R1, by Observation 4.3)
+f = ⟨f_struct, f_behav⟩ (R1 + R3, by Property 4.4)
+h = structural skeleton + behavioral core
+```
+
+The canonical form cleanly separates what ontological formalisms can
+express (g, f_struct) from what requires a runtime (f_behav).
+
+**Corollary 6.3 (GDSSpec Projection of Games is Fully Representable).**
+When h = g (the OGS case: X = empty, f = empty), the GDSSpec-level
+structure is fully representable. The OGS canonical bridge
+(spec_bridge.py) maps all atomic games to Policy blocks, producing h = g
+with no behavioral f component. By Observation 4.3, g is entirely R1.
+
+Note: game-theoretic behavioral content — payoff functions, utility
+computation, equilibrium strategies — resides in OpenGame subclass methods
+and external solvers, outside GDSSpec scope, and is therefore R3. The
+corollary applies to the specification-level projection, not to full game
+analysis.
+
+**Corollary 6.4 (Dynamical Systems Degrade Gracefully).** For systems
+with h = f ∘ g where f != empty, the structural skeleton (g + f_struct)
+is always complete in OWL. Each mechanism adds one update target to
+f_struct (R1) and one transition function to f_behav (R3). The "what" is
+never lost — only the "how."
+
+**Remark 6.5 (TemporalLoop vs CorecursiveLoop in OWL).** OWL cannot
+distinguish a temporal loop (physical state persistence, e.g., control
+systems) from a corecursive loop (strategic message threading, e.g.,
+repeated games). CorecursiveLoop (defined in gds-games as
+`ogs.dsl.composition.CorecursiveLoop`, a TemporalLoop subclass for
+repeated game semantics) shares identical structural representation: both
+use covariant wiring from inner.forward_out to inner.forward_in with an
+exit_condition string. The semantic difference — "state at t feeds sensors
+at t+1" vs "decisions at round t feed observations at round t+1" — is an
+interpretation, not topology.
+
+In practice this is benign: gds-owl preserves the DSL source label
+(gds-ir:sourceLabel on SystemIR), so consumers can recover which DSL
+compiled the system and interpret temporal wirings accordingly.
+
+---
+
+## 7. Analysis Domain Classification
+
+Each type of analysis on a GDS specification maps to a representability
+tier based on what it requires:
+
+### 7.1 R1: Fully Expressible (OWL Classes + Properties)
+
+| Analysis | Nature | GDS Implementation | Why R1 |
+|---|---|---|---|
+| What connects to what | Static topology | SpecQuery.dependency_graph() | Wiring graph is R1 |
+| How blocks compose | Static structure | HierarchyNodeIR tree | Composition tree is R1 |
+| Which blocks are which roles | Static classification | project_canonical() partition | Role partition is R1 |
+| Which params affect which blocks | Static dependency | SpecQuery.param_to_blocks() | usesParameter relation is R1 |
+| Which state variables constrain which inputs | Static dependency | SpecQuery.admissibility_dependency_map() | U_x_struct is R1 |
+| Which state variables does a mechanism read | Static dependency | SpecQuery.mechanism_read_map() | f_read is R1 |
+| Game classification | Static strategic | PatternIR game_type field | Metadata on blocks, R1 |
+
+### 7.2 R2: SPARQL-Expressible (Graph Queries + Aggregation)
+
+| Analysis | Nature | GDS Implementation | Why R2 |
+|---|---|---|---|
+| Is the wiring graph acyclic? | Structural invariant | G-006 (DFS) | Transitive self-reachability on finite graph |
+| Does every state variable have an updater? | Structural invariant | SC-001 | Left-join with negation |
+| Are there write conflicts? | Structural invariant | SC-002 | Group-by with count > 1 |
+| Are all references valid? | Structural invariant | G-004, SC-004 | Reference validation |
+| Can block A reach block B? | Structural reachability | SC-003 | Property path on wiring graph |
+
+### 7.3 R3: Python-Only (Requires Runtime)
+
+| Analysis | Nature | GDS Implementation | Why R3 |
+|---|---|---|---|
+| State evolution over time | Dynamic temporal | gds-sim execution | Requires evaluating f repeatedly |
+| Constraint satisfaction | Dynamic behavioral | TypeDef.constraint() | General case: Rice's theorem |
+| Auto-wiring computation | Dynamic structural | tokenize() + overlap | String parsing exceeds SPARQL |
+| Actual signal propagation | Dynamic behavioral | simulation with concrete values | Requires computing g(x, u) |
+| Scheduling/delay semantics | Dynamic temporal | execution model | Not stored in GDS — external |
+| Equilibrium computation | Dynamic strategic | game solvers | Computing Nash equilibria is PPAD-complete |
+
+Note the distinction: **equilibrium structure** (which games exist, how
+they compose) is R1. **Equilibrium computation** (finding the actual
+equilibrium strategies) is R3. This parallels the f_struct / f_behav
+split: the structure of the analysis is representable; the computation
+of the analysis is not.
+
+---
+
+## 8. Five Formal Correspondences
+
+### Correspondence 1: Static Topology <-> OWL Class/Property Hierarchy
+
+```
+rho : (blocks, wirings, interfaces, ports) <-> OWL individuals + object properties
+```
+
+R1. The composition tree, wiring graph, and port structure map to OWL
+individuals connected by named object properties.
+
+### Correspondence 2: Structural Invariants <-> SHACL Shapes + SPARQL Queries
+
+```
+{G-002, G-004, G-006, SC-001..SC-009} <-> SHACL + SPARQL
+```
+
+R1 or R2 depending on the check. SHACL-core captures cardinality and
+class-membership constraints (6 checks: G-002, SC-005..SC-009). SPARQL
+captures graph-pattern queries requiring negation, transitivity,
+aggregation, or cross-node string matching (6 checks). The 2 remaining
+checks (G-001, G-005) require tokenization. G-003 splits: flag check R1,
+port matching R3.
+
+### Correspondence 3: Dynamic Behavior <-> Python Runtime Only
+
+```
+{TypeDef.constraint (general), f_behav, auto-wiring, scheduling} <-> Python
+```
+
+R3. Fundamental. These require Turing-complete computation. The boundary
+is Rice's theorem (for predicates) and computational class separation
+(for string parsing and temporal execution).
+
+### Correspondence 4: Equilibrium Structure <-> Naturally Structural
+
+```
+h = g (OGS canonical form) <-> GDSSpec projection is lossless
+```
+
+R1 for the specification-level projection. When a system has no state
+(X = empty, f = empty), its GDSSpec is purely compositional. Game-theoretic
+behavioral content (payoff functions, equilibrium solvers) is outside
+GDSSpec and therefore R3.
+
+### Correspondence 5: Reachability <-> Structural Part R2, Dynamical Part R3
+
+```
+Structural reachability : "can signals reach from A to B?" -> R2 (SPARQL property paths)
+Dynamical reachability : "does signal actually propagate?" -> R3 (requires evaluating g and f)
+```
+
+The structural question asks about the *topology* of the wiring graph.
+SPARQL property paths (`?a successor+ ?b`) answer this on finite graphs.
+The dynamical question asks about *actual propagation* given concrete
+state values and policy functions — this requires executing the system.
diff --git a/packages/gds-owl/docs/representation-gap.md b/packages/gds-owl/docs/representation-gap.md
new file mode 100644
index 0000000..ece0423
--- /dev/null
+++ b/packages/gds-owl/docs/representation-gap.md
@@ -0,0 +1,350 @@
+# Representation Gap: Pydantic vs OWL/RDF
+
+## The Core Insight
+
+Python (Pydantic) and OWL/RDF are not in a hierarchy — they are **complementary representation systems** with different strengths. The bidirectional round-trip in `gds-owl` proves they overlap almost completely, but the small gap between them is revealing.
+
+## What Each Representation Captures
+
+### What OWL/RDF captures that Python doesn't
+
+| Capability | OWL/RDF | Python/Pydantic |
+|---|---|---|
+| **Cross-system linking** | Native. A GDSSpec in one graph can reference entities in another via URIs. | Requires custom serialization + shared registries. |
+| **Open-world reasoning** | OWL reasoners can infer facts not explicitly stated (e.g., "if X is a Mechanism and X updatesEntry Y, then X affects Entity Z"). | Closed-world only. You must write the inference logic yourself. |
+| **Schema evolution** | Add new properties without breaking existing consumers. Unknown triples are simply ignored. | Adding a field to a frozen Pydantic model is a breaking change. |
+| **Federated queries** | SPARQL can query across multiple GDS specs in a single query, even from different sources. | Requires loading all specs into memory and writing custom join logic. |
+| **Provenance** | PROV-O gives audit trails for free (who created this spec, when, derived from what). | Must be implemented manually. |
+| **Self-describing data** | A Turtle file contains its own schema context via prefixes and class declarations. | A JSON file requires external schema knowledge to interpret. |
+
+### What Python captures that OWL/RDF doesn't
+
+| Capability | Python/Pydantic | OWL/RDF |
+|---|---|---|
+| **Constraint functions** | `TypeDef.constraint = lambda x: 0 <= x <= 1` — a runtime predicate that validates actual data values. | Cannot represent arbitrary predicates. Can document them as annotations, but cannot execute them. |
+| **Composition operators** | `sensor >> controller >> heater` — the `>>`, `|`, `.feedback()`, `.loop()` DSL is Python syntax. | Can represent the *result* of composition (the tree structure), but not the *act* of composing. |
+| **Construction-time validation** | `@model_validator(mode="after")` enforces invariants the instant a model is created. | SHACL validates after the fact, not during construction. Invalid data can exist in a graph. |
+| **Type-level computation** | Token-based auto-wiring: `"Temperature + Setpoint"` splits on ` + `, lowercases, and checks set overlap. This is a runtime computation. | Can store the resulting tokens as RDF lists, but cannot compute the tokenization. |
+| **IDE ergonomics** | Autocomplete, type checking, refactoring, debugging. The Python type system is a development tool. | Protege exists, but the tooling ecosystem is smaller and less integrated with modern dev workflows. |
+| **Performance** | Pydantic model construction: microseconds. | rdflib graph construction: 10-100x slower. SHACL validation via pyshacl: significantly slower than `@model_validator`. |
+
+## The Lossy Fields (Documented)
+
+These are the specific fields lost during round-trip. Each reveals a category boundary:
+
+### 1. `TypeDef.constraint` — Runtime Predicate
+
+```python
+# Python: executable constraint
+Temperature = TypeDef(
+ name="Temperature",
+ python_type=float,
+ constraint=lambda x: -273.15 <= x <= 1000.0, # physically meaningful range
+ units="celsius",
+)
+
+# RDF: can only record that a constraint exists
+# gds-core:hasConstraint "true"^^xsd:boolean
+```
+
+**Why it's lossy**: A Python `Callable[[Any], bool]` is Turing-complete. OWL DL is decidable. You cannot embed an arbitrary program in an ontology and have it remain decidable.
+
+**Workaround**: Export the constraint as a human-readable annotation (`rdfs:comment`), or as a SHACL `sh:pattern` / `sh:minInclusive` / `sh:maxInclusive` for simple numeric bounds. Complex predicates require linking to the source code via `rdfs:seeAlso`.
+
+### 2. `TypeDef.python_type` — Language-Specific Type
+
+```python
+# Python: actual runtime type
+TypeDef(name="Temperature", python_type=float)
+
+# RDF: string representation
+# gds-core:pythonType "float"^^xsd:string
+```
+
+**Why it's lossy**: `float` is a Python concept. OWL has `xsd:float`, `xsd:double`, etc., but the mapping isn't 1:1 (Python `float` is IEEE 754 double-precision, which maps to `xsd:double`, not `xsd:float`). For round-trip, we map common type names back via a lookup table, but custom types (e.g., `numpy.float64`) would need a registry.
+
+**Impact**: Low. The built-in type map covers `float`, `int`, `str`, `bool` — which account for all current GDS usage.
+
+### 3. Composition Tree — Structural vs Behavioral
+
+```python
+# Python: live composition with operators
+system = (sensor | observer) >> controller >> heater
+system = system.feedback(wiring=[...])
+
+# RDF: can represent the resulting tree
+# :system gds-core:first :sensor_observer_parallel .
+# :system gds-core:second :controller_heater_stack .
+```
+
+**Why it's partially lossy**: The RDF graph captures the *structure* of the composition tree (what blocks are composed how), but not the *process* of building it. The `>>` operator includes validation logic (token overlap checking) that runs at construction time. This validation is captured in SHACL shapes, but the dynamic dispatch and error messages are Python-specific.
+
+**Impact**: None for GDSSpec export (blocks are already composed). Only relevant if you wanted to *construct* a composition from RDF, which would require a builder that re-applies the validation logic.
+
+## Why OWL/SHACL Can't Store "What Things Do"
+
+Your intuition — circuit diagrams vs circuit simulations — is almost exactly right. But the deeper reason is worth understanding, because it's not an engineering limitation. It's a mathematical one.
+
+### The Decidability Trade-off
+
+OWL is based on **Description Logic** (specifically OWL DL uses SROIQ). Description Logics are fragments of first-order logic that are deliberately restricted so that:
+
+1. **Every query terminates.** Ask "is X a subclass of Y?" and you are guaranteed an answer in finite time.
+2. **Consistency is checkable.** Ask "can this ontology ever contain a contradiction?" and you get a definitive yes/no.
+3. **Classification is automatic.** The reasoner can infer the complete class hierarchy without human guidance.
+
+These guarantees come at a cost: you cannot express arbitrary computation. The moment you allow unrestricted recursion, loops, or Turing-complete predicates, you lose decidability — some queries would run forever.
+
+A Python `lambda x: 0 <= x <= 1` is trivial, but the type signature `Callable[[Any], bool]` admits *any* computable function, including ones that don't halt. OWL cannot embed that and remain OWL.
+
+### The Circuit Analogy (Refined)
+
+| | Circuit Diagram | Circuit Simulation |
+|---|---|---|
+| **Analog in GDS** | OWL ontology + RDF instance data | Python Pydantic models + runtime |
+| **What it captures** | Components, connections, topology, constraints | Voltage, current, timing, behavior over time |
+| **Can answer** | "Is this resistor connected to ground?" | "What voltage appears at node 3 at t=5ms?" |
+| **Cannot answer** | "What happens when I flip this switch?" | "Is this the only valid topology?" (needs the diagram) |
+
+This is correct, but the analogy goes deeper:
+
+**A circuit diagram is a specification. A simulation is an execution.** You can derive a simulation from a diagram (given initial conditions and a solver), but you cannot derive the diagram from a simulation (infinitely many circuits could produce the same waveform).
+
+Similarly:
+
+- **OWL/RDF is specification.** It says what types exist, how blocks connect, what constraints hold.
+- **Python is execution.** It actually validates data, composes blocks, runs the token-overlap algorithm.
+
+You can derive the RDF from the Python (that's what `spec_to_graph()` does). You can mostly derive the Python from the RDF (that's what `graph_to_spec()` does). But the execution semantics — the `lambda`, the `>>` operator's validation logic, the `@model_validator` — live only in the runtime.
+
+### Three Levels of "Knowing"
+
+This maps to a well-known hierarchy in formal systems:
+
+| Level | What it captures | GDS example | Formalism |
+|---|---|---|---|
+| **Syntax** | Structure, names, connections | Block names, port names, wiring topology | RDF triples |
+| **Semantics** | Meaning, types, constraints | "Temperature is a float in celsius", "Mechanism must update state" | OWL classes + SHACL shapes |
+| **Pragmatics** | Behavior, computation, execution | `constraint=lambda x: x >= 0`, `>>` auto-wiring by token overlap | Python runtime |
+
+OWL lives at levels 1 and 2. Python lives at all three. The gap is level 3 — and it's the same gap that separates every declarative specification language from every imperative programming language. It's not a bug in OWL. It's the price of decidability.
+
+### SHACL Narrows the Gap (But Doesn't Close It)
+
+SHACL pushes closer to behavior than OWL alone:
+
+```turtle
+# SHACL can express: "temperature must be between -273.15 and 1000"
+:TemperatureConstraint a sh:NodeShape ;
+ sh:property [
+ sh:path :value ;
+ sh:minInclusive -273.15 ;
+ sh:maxInclusive 1000.0 ;
+ ] .
+```
+
+This covers many real GDS constraints. But SHACL's `sh:sparql` constraints, while powerful, are still not Turing-complete — SPARQL queries always terminate on finite graphs. You cannot write a SHACL shape that says "validate this value by running an arbitrary Python function."
+
+SWRL (Semantic Web Rule Language) gets even closer — it can express Horn-clause rules. But it still can't express negation-as-failure, higher-order functions, or stateful computation.
+
+The boundary is fundamental: **decidable formalisms cannot embed undecidable computation**. This is not a limitation of OWL's design. It's a consequence of the halting problem.
+
+### What This Means in Practice
+
+For GDS specifically, the practical impact is small:
+
+- **95% of GDS structure** round-trips perfectly through RDF
+- **Most constraints** are simple numeric bounds expressible in SHACL
+- **The composition tree** is fully captured as structure
+- **Only `Callable` predicates** and language-specific types are truly lost
+
+The circuit analogy holds: you design the circuit (OWL), you simulate it (Python), and the design document captures everything except the electrons moving through the wires.
+
+## The GDS Compositionality-Temporality Boundary Is the Same Boundary
+
+GDS already discovered this gap internally — between game-theoretic composition
+and dynamical systems composition. The OWL representation gap is the same
+boundary, seen from the outside.
+
+### What GDS Found
+
+The canonical spectrum across five domains revealed a structural divide:
+
+| Domain | |X| | |f| | Form | Character |
+|---|---|---|---|---|
+| OGS (games) | 0 | 0 | h = g | Stateless — pure maps |
+| Control | n | n | h = f . g | Stateful — observation + state update |
+| StockFlow | n | n | h = f . g | Stateful — accumulation dynamics |
+
+Games compute equilibria. They don't write to persistent state. Even corecursive
+loops (repeated games) carry information forward as *observations*, not as
+*entity mutations*. In category-theoretic terms: open games are morphisms in
+a symmetric monoidal category with feedback. They are maps, not machines.
+
+Control and stock-flow systems are the opposite. They have state variables (X),
+state update functions (f), and the temporal loop carries physical state forward
+across timesteps.
+
+Both use the **same structural composition operators** (`>>`, `|`, `.feedback()`,
+`.loop()`). The algebra is identical. The semantics are orthogonal.
+
+### OWL Lives on the Game-Theory Side of This Boundary
+
+This is the key insight: **OWL/RDF is inherently atemporal**. An RDF graph is a
+set of (subject, predicate, object) triples — relations between things. There is
+no built-in notion of "before and after," "state at time t," or "update."
+
+This means OWL naturally represents the compositional/structural side of GDS
+(the `g` in `h = f . g`) far better than the temporal/behavioral side (the `f`):
+
+| GDS Component | Nature | OWL Fit |
+|---|---|---|
+| **g** (policy, observation, decision) | Structural mapping — signals in, signals out | Excellent. Object properties capture flow topology. |
+| **f** (state update, mechanism) | Temporal mutation — state at t becomes state at t+1 | Partial. Can describe *what* f updates, but not *how*. |
+| **Composition tree** (>>, \|) | Structural nesting | Excellent. `first`, `second`, `left`, `right` properties. |
+| **FeedbackLoop** (.feedback()) | Within-timestep backward flow | Good. Structural — just backward edges. |
+| **TemporalLoop** (.loop()) | Across-timestep forward recurrence | Structural part captured, temporal semantics lost. |
+| **CorecursiveLoop** (OGS) | Across-round strategic iteration | Same structure as TemporalLoop — OWL can't distinguish them. |
+
+The last row is the critical one: **OWL cannot distinguish a corecursive game loop
+from a temporal state loop**, because the distinction is semantic (what does iteration
+*mean*?), not structural (how are the wires connected?).
+
+This is exactly the same problem GDS faced at Layer 0. The composition algebra
+treats `TemporalLoop` and `CorecursiveLoop` identically — same wiring pattern,
+same structural validation. The difference is domain semantics, which lives in
+the DSL layer (Layer 1+), not in the algebra.
+
+### The Three-Way Isomorphism
+
+```
+Game-theoretic composition ←→ OWL/RDF representation
+ (atemporal, structural, (atemporal, structural,
+ maps between spaces) relations between entities)
+
+Dynamical systems execution ←→ Python runtime
+ (temporal, behavioral, (temporal, behavioral,
+ state evolving over time) computation producing results)
+```
+
+Games and ontologies are both **declarative**: they describe what things are and how
+they relate. Dynamical systems and programs are both **imperative**: they describe
+what happens over time.
+
+GDS bridges these two worlds with the canonical form `h = f . g`:
+- `g` is the declarative part (composable, structural, OWL-friendly)
+- `f` is the imperative part (state-updating, temporal, Python-native)
+- `h` is the complete system (both sides unified)
+
+The round-trip gap in gds-owl is precisely the `f` side leaking through.
+
+### Why OGS Round-Trips Better Than Control
+
+This predicts something testable: **OGS specifications should round-trip through
+OWL with less information loss than control or stock-flow specifications**, because
+OGS is `h = g` (purely structural/compositional, no state update semantics to lose).
+
+And indeed:
+- OGS blocks are all Policy — no `Mechanism.updates` to reify
+- OGS has no Entity/StateVariable — no state space to encode
+- The corecursive loop is structurally identical to a temporal loop — no semantic
+ distinction is lost because there was no temporal semantics to begin with
+- The canonical form `h = g` maps directly to "all blocks are related by composition" —
+ which is exactly what OWL expresses
+
+Control and stock-flow systems lose the `f` semantics:
+- `Mechanism.updates = [("Room", "temperature")]` becomes a reified triple that
+ says *what* gets updated, but not *how* (the state transition function itself is a
+ Python callable)
+- The temporal loop says "state feeds back" structurally, but not "with what delay"
+ or "under what scheduling semantics"
+
+### What This Means for the Research Questions
+
+The GDS research boundaries document (docs/guides/research-boundaries.md) identified
+three key open questions. Each maps directly to the OWL representation gap:
+
+**RQ1 (MIMO semantics)**: Should vector-valued spaces become first-class?
+- OWL impact: Vector spaces are harder to represent than scalar ports. RDF naturally
+ represents named relations, not ordered tuples. This is a structural limitation
+ shared by both the composition algebra and OWL.
+
+**RQ2 (What does a timestep mean?)**: Different domains interpret `.loop()` differently.
+- OWL impact: This is *exactly* the gap. OWL captures the loop *structure* but not
+ the loop *semantics*. A temporal loop in control (physical state persistence) and a
+ corecursive loop in OGS (strategic message threading) are the same OWL triples.
+ The distinction requires domain-specific annotation — which is what the dual IR
+ stack (PatternIR + SystemIR) already provides in Python.
+
+**RQ3 (OGS as degenerate dynamical system)**: Is X=0, f=0, h=g a valid GDS?
+- OWL impact: Yes, and it's the *best-represented* case. A system with no state
+ variables and no mechanisms is purely compositional — which is the part of GDS
+ that OWL captures perfectly. The "degenerate" case is actually the one where
+ OWL and Pydantic representations are isomorphic.
+
+### The Circuit Analogy (Revisited)
+
+The earlier analogy — circuit diagrams vs circuit simulations — now sharpens:
+
+| | Circuit Diagram | Schematic + Netlist | SPICE Simulation |
+|---|---|---|---|
+| GDS analog | OWL ontology | Composition algebra (Layer 0) | Python runtime (gds-sim) |
+| Games analog | Strategy profile description | Game tree | Equilibrium solver |
+| Dynamics analog | Block diagram | State-space model | ODE integrator |
+| Captures | Topology + component types | Topology + port typing + composition rules | Behavior over time |
+| Misses | Behavior, timing | Execution semantics | Often loses global structure |
+
+OWL is the diagram. The composition algebra is the netlist. Python is the simulator.
+Games live naturally in the diagram/netlist. Dynamics need the simulator.
+
+## What This Means for the "Ontology-First" Future
+
+The gap analysis suggests a three-tier architecture:
+
+```
+Tier 1: OWL Ontology (schema)
+ - Class hierarchy, property definitions
+ - SHACL shapes for structural validation
+ - SPARQL queries for analysis
+ → Source of truth for: what things ARE
+
+Tier 2: Python DSL (behavior)
+ - Composition operators (>>, |, .feedback(), .loop())
+ - Runtime constraint predicates
+ - Construction-time validation
+ → Source of truth for: what things DO
+
+Tier 3: Instance Data (both)
+ - Pydantic models ↔ RDF graphs (round-trip proven)
+ - Either format can be the serialization layer
+ → The overlap zone where both representations agree
+```
+
+The key insight: **you don't have to choose one**. The ontology defines the vocabulary and structural rules. Python defines the computational behavior. Instance data lives in both and can be translated freely.
+
+This is analogous to how SQL databases work: the schema (DDL) defines structure, application code defines behavior, and data lives in both the database and application memory. Nobody argues that SQL "stores more" than Python or vice versa — they serve different roles.
+
+## Practical Implications
+
+### When to use OWL/RDF
+
+- Publishing a GDS specification for external consumption
+- Querying across multiple specifications simultaneously
+- Linking GDS specs to external ontologies (FIBO, ArchiMate, PROV-O)
+- Archiving specifications with self-describing metadata
+- Running structural validation without Python installed
+
+### When to use Pydantic
+
+- Building and composing specifications interactively
+- Running constraint validation on actual data values
+- Leveraging IDE tooling (autocomplete, type checking)
+- Performance-sensitive operations (construction, validation)
+- Anything involving the composition DSL (`>>`, `|`, `.feedback()`, `.loop()`)
+
+### When to use both
+
+- Development workflow: build in Python, export to RDF for publication
+- Verification: SHACL for structural checks, Python for runtime checks
+- Cross-system analysis: export multiple specs to RDF, query with SPARQL
+- Round-trip: start from RDF (e.g., Protege-edited), import to Python for computation
diff --git a/packages/gds-owl/gds_owl/__init__.py b/packages/gds-owl/gds_owl/__init__.py
new file mode 100644
index 0000000..3f9166e
--- /dev/null
+++ b/packages/gds-owl/gds_owl/__init__.py
@@ -0,0 +1,72 @@
+"""gds-owl — OWL/Turtle, SHACL, and SPARQL for gds-framework specifications."""
+
+__version__ = "0.1.0"
+
+from gds_owl._namespace import (
+ GDS,
+ GDS_CORE,
+ GDS_IR,
+ GDS_VERIF,
+ PREFIXES,
+)
+from gds_owl.export import (
+ canonical_to_graph,
+ report_to_graph,
+ spec_to_graph,
+ system_ir_to_graph,
+)
+from gds_owl.import_ import (
+ graph_to_canonical,
+ graph_to_report,
+ graph_to_spec,
+ graph_to_system_ir,
+)
+from gds_owl.ontology import build_core_ontology
+from gds_owl.serialize import (
+ canonical_to_turtle,
+ report_to_turtle,
+ spec_to_turtle,
+ system_ir_to_turtle,
+ to_jsonld,
+ to_ntriples,
+ to_turtle,
+)
+from gds_owl.shacl import (
+ build_all_shapes,
+ build_generic_shapes,
+ build_semantic_shapes,
+ build_structural_shapes,
+ validate_graph,
+)
+from gds_owl.sparql import TEMPLATES, run_query
+
+__all__ = [
+ "GDS",
+ "GDS_CORE",
+ "GDS_IR",
+ "GDS_VERIF",
+ "PREFIXES",
+ "TEMPLATES",
+ "build_all_shapes",
+ "build_core_ontology",
+ "build_generic_shapes",
+ "build_semantic_shapes",
+ "build_structural_shapes",
+ "canonical_to_graph",
+ "canonical_to_turtle",
+ "graph_to_canonical",
+ "graph_to_report",
+ "graph_to_spec",
+ "graph_to_system_ir",
+ "report_to_graph",
+ "report_to_turtle",
+ "run_query",
+ "spec_to_graph",
+ "spec_to_turtle",
+ "system_ir_to_graph",
+ "system_ir_to_turtle",
+ "to_jsonld",
+ "to_ntriples",
+ "to_turtle",
+ "validate_graph",
+]
diff --git a/packages/gds-owl/gds_owl/_namespace.py b/packages/gds-owl/gds_owl/_namespace.py
new file mode 100644
index 0000000..a8cf6c4
--- /dev/null
+++ b/packages/gds-owl/gds_owl/_namespace.py
@@ -0,0 +1,22 @@
+"""OWL namespace constants and prefix bindings for the GDS ontology."""
+
+from rdflib import Namespace
+
+# Base namespace
+GDS = Namespace("https://gds.block.science/ontology/")
+
+# Sub-namespaces
+GDS_CORE = Namespace("https://gds.block.science/ontology/core/")
+GDS_IR = Namespace("https://gds.block.science/ontology/ir/")
+GDS_VERIF = Namespace("https://gds.block.science/ontology/verification/")
+
+# Standard prefix bindings for Turtle output
+PREFIXES: dict[str, Namespace] = {
+ "gds": GDS,
+ "gds-core": GDS_CORE,
+ "gds-ir": GDS_IR,
+ "gds-verif": GDS_VERIF,
+}
+
+# Default base URI for instance data
+DEFAULT_BASE_URI = "https://gds.block.science/instance/"
diff --git a/packages/gds-owl/gds_owl/export.py b/packages/gds-owl/gds_owl/export.py
new file mode 100644
index 0000000..ca96eb1
--- /dev/null
+++ b/packages/gds-owl/gds_owl/export.py
@@ -0,0 +1,592 @@
+"""Export GDS Pydantic models to RDF graphs (ABox instance data).
+
+Mirrors the pattern in ``gds.serialize.spec_to_dict()`` but targets
+``rdflib.Graph`` instead of plain dicts.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from urllib.parse import quote
+
+from rdflib import RDF, RDFS, XSD, BNode, Graph, Literal, Namespace, URIRef
+
+from gds_owl._namespace import (
+ DEFAULT_BASE_URI,
+ GDS_CORE,
+ GDS_IR,
+ GDS_VERIF,
+ PREFIXES,
+)
+
+if TYPE_CHECKING:
+ from gds.blocks.base import Block
+ from gds.canonical import CanonicalGDS
+ from gds.ir.models import BlockIR, HierarchyNodeIR, SystemIR, WiringIR
+ from gds.parameters import ParameterDef
+ from gds.spaces import Space
+ from gds.spec import GDSSpec, SpecWiring
+ from gds.state import Entity
+ from gds.types.typedef import TypeDef
+ from gds.verification.findings import VerificationReport
+
+
+def _bind(g: Graph) -> None:
+ for prefix, ns in PREFIXES.items():
+ g.bind(prefix, ns)
+
+
+def _ns(base_uri: str, spec_name: str) -> Namespace:
+ """Build an instance namespace for a spec."""
+ safe = quote(spec_name, safe="")
+ uri = f"{base_uri}{safe}/"
+ return Namespace(uri)
+
+
+def _uri(ns: Namespace, category: str, name: str) -> URIRef:
+ """Build a deterministic instance URI."""
+ safe = quote(name, safe="")
+ return ns[f"{category}/{safe}"]
+
+
+# ── TypeDef ──────────────────────────────────────────────────────────
+
+
+def _typedef_to_rdf(g: Graph, ns: Namespace, t: TypeDef) -> URIRef:
+ uri = _uri(ns, "type", t.name)
+ g.add((uri, RDF.type, GDS_CORE["TypeDef"]))
+ g.add((uri, GDS_CORE["name"], Literal(t.name)))
+ g.add((uri, GDS_CORE["description"], Literal(t.description)))
+ g.add((uri, GDS_CORE["pythonType"], Literal(t.python_type.__name__)))
+ g.add(
+ (
+ uri,
+ GDS_CORE["hasConstraint"],
+ Literal(t.constraint is not None, datatype=XSD.boolean),
+ )
+ )
+ if t.units:
+ g.add((uri, GDS_CORE["units"], Literal(t.units)))
+ return uri
+
+
+# ── Space ────────────────────────────────────────────────────────────
+
+
+def _space_to_rdf(
+ g: Graph, ns: Namespace, s: Space, type_uris: dict[str, URIRef]
+) -> URIRef:
+ uri = _uri(ns, "space", s.name)
+ g.add((uri, RDF.type, GDS_CORE["Space"]))
+ g.add((uri, GDS_CORE["name"], Literal(s.name)))
+ g.add((uri, GDS_CORE["description"], Literal(s.description)))
+ for field_name, typedef in s.fields.items():
+ field_node = BNode()
+ g.add((field_node, RDF.type, GDS_CORE["SpaceField"]))
+ g.add((field_node, GDS_CORE["fieldName"], Literal(field_name)))
+ if typedef.name in type_uris:
+ g.add((field_node, GDS_CORE["fieldType"], type_uris[typedef.name]))
+ g.add((uri, GDS_CORE["hasField"], field_node))
+ return uri
+
+
+# ── Entity ───────────────────────────────────────────────────────────
+
+
+def _entity_to_rdf(
+ g: Graph, ns: Namespace, e: Entity, type_uris: dict[str, URIRef]
+) -> URIRef:
+ uri = _uri(ns, "entity", e.name)
+ g.add((uri, RDF.type, GDS_CORE["Entity"]))
+ g.add((uri, GDS_CORE["name"], Literal(e.name)))
+ g.add((uri, GDS_CORE["description"], Literal(e.description)))
+ for var_name, sv in e.variables.items():
+ sv_uri = _uri(ns, f"entity/{quote(e.name, safe='')}/var", var_name)
+ g.add((sv_uri, RDF.type, GDS_CORE["StateVariable"]))
+ g.add((sv_uri, GDS_CORE["name"], Literal(sv.name)))
+ g.add((sv_uri, GDS_CORE["description"], Literal(sv.description)))
+ g.add((sv_uri, GDS_CORE["symbol"], Literal(sv.symbol)))
+ if sv.typedef.name in type_uris:
+ g.add((sv_uri, GDS_CORE["usesType"], type_uris[sv.typedef.name]))
+ g.add((uri, GDS_CORE["hasVariable"], sv_uri))
+ return uri
+
+
+# ── Block ────────────────────────────────────────────────────────────
+
+
+def _block_to_rdf(
+ g: Graph,
+ ns: Namespace,
+ b: Block,
+ param_uris: dict[str, URIRef],
+ entity_uris: dict[str, URIRef],
+) -> URIRef:
+ from gds.blocks.roles import (
+ BoundaryAction,
+ ControlAction,
+ HasConstraints,
+ HasOptions,
+ HasParams,
+ Mechanism,
+ Policy,
+ )
+
+ uri = _uri(ns, "block", b.name)
+
+ # Determine OWL class from role
+ if isinstance(b, BoundaryAction):
+ owl_cls = GDS_CORE["BoundaryAction"]
+ elif isinstance(b, Mechanism):
+ owl_cls = GDS_CORE["Mechanism"]
+ elif isinstance(b, Policy):
+ owl_cls = GDS_CORE["Policy"]
+ elif isinstance(b, ControlAction):
+ owl_cls = GDS_CORE["ControlAction"]
+ else:
+ owl_cls = GDS_CORE["AtomicBlock"]
+
+ g.add((uri, RDF.type, owl_cls))
+ g.add((uri, GDS_CORE["name"], Literal(b.name)))
+
+ kind = getattr(b, "kind", "generic")
+ g.add((uri, GDS_CORE["kind"], Literal(kind)))
+
+ # Interface
+ iface_uri = _uri(ns, f"block/{quote(b.name, safe='')}", "interface")
+ g.add((iface_uri, RDF.type, GDS_CORE["Interface"]))
+ g.add((uri, GDS_CORE["hasInterface"], iface_uri))
+
+ for port in b.interface.forward_in:
+ p_uri = BNode()
+ g.add((p_uri, RDF.type, GDS_CORE["Port"]))
+ g.add((p_uri, GDS_CORE["portName"], Literal(port.name)))
+ for token in sorted(port.type_tokens):
+ g.add((p_uri, GDS_CORE["typeToken"], Literal(token)))
+ g.add((iface_uri, GDS_CORE["hasForwardIn"], p_uri))
+
+ for port in b.interface.forward_out:
+ p_uri = BNode()
+ g.add((p_uri, RDF.type, GDS_CORE["Port"]))
+ g.add((p_uri, GDS_CORE["portName"], Literal(port.name)))
+ for token in sorted(port.type_tokens):
+ g.add((p_uri, GDS_CORE["typeToken"], Literal(token)))
+ g.add((iface_uri, GDS_CORE["hasForwardOut"], p_uri))
+
+ for port in b.interface.backward_in:
+ p_uri = BNode()
+ g.add((p_uri, RDF.type, GDS_CORE["Port"]))
+ g.add((p_uri, GDS_CORE["portName"], Literal(port.name)))
+ g.add((iface_uri, GDS_CORE["hasBackwardIn"], p_uri))
+
+ for port in b.interface.backward_out:
+ p_uri = BNode()
+ g.add((p_uri, RDF.type, GDS_CORE["Port"]))
+ g.add((p_uri, GDS_CORE["portName"], Literal(port.name)))
+ g.add((iface_uri, GDS_CORE["hasBackwardOut"], p_uri))
+
+ # Role-specific properties
+ if isinstance(b, HasParams):
+ for param_name in b.params_used:
+ if param_name in param_uris:
+ g.add((uri, GDS_CORE["usesParameter"], param_uris[param_name]))
+
+ if isinstance(b, HasConstraints):
+ for c in b.constraints:
+ g.add((uri, GDS_CORE["constraint"], Literal(c)))
+
+ if isinstance(b, HasOptions):
+ for opt in b.options:
+ g.add((uri, GDS_CORE["option"], Literal(opt)))
+
+ if isinstance(b, Mechanism):
+ for entity_name, var_name in b.updates:
+ entry = BNode()
+ g.add((entry, RDF.type, GDS_CORE["UpdateMapEntry"]))
+ g.add(
+ (
+ entry,
+ GDS_CORE["updatesEntity"],
+ Literal(entity_name),
+ )
+ )
+ g.add(
+ (
+ entry,
+ GDS_CORE["updatesVariable"],
+ Literal(var_name),
+ )
+ )
+ g.add((uri, GDS_CORE["updatesEntry"], entry))
+
+ return uri
+
+
+# ── SpecWiring ───────────────────────────────────────────────────────
+
+
+def _wiring_to_rdf(
+ g: Graph,
+ ns: Namespace,
+ w: SpecWiring,
+ block_uris: dict[str, URIRef],
+ space_uris: dict[str, URIRef],
+) -> URIRef:
+ uri = _uri(ns, "wiring", w.name)
+ g.add((uri, RDF.type, GDS_CORE["SpecWiring"]))
+ g.add((uri, GDS_CORE["name"], Literal(w.name)))
+ g.add((uri, GDS_CORE["description"], Literal(w.description)))
+
+ for bname in w.block_names:
+ if bname in block_uris:
+ g.add((uri, GDS_CORE["wiringBlock"], block_uris[bname]))
+
+ for wire in w.wires:
+ wire_node = BNode()
+ g.add((wire_node, RDF.type, GDS_CORE["Wire"]))
+ g.add((wire_node, GDS_CORE["wireSource"], Literal(wire.source)))
+ g.add((wire_node, GDS_CORE["wireTarget"], Literal(wire.target)))
+ if wire.space:
+ g.add((wire_node, GDS_CORE["wireSpace"], Literal(wire.space)))
+ g.add(
+ (
+ wire_node,
+ GDS_CORE["wireOptional"],
+ Literal(wire.optional, datatype=XSD.boolean),
+ )
+ )
+ g.add((uri, GDS_CORE["hasWire"], wire_node))
+
+ return uri
+
+
+# ── ParameterDef ─────────────────────────────────────────────────────
+
+
+def _parameter_to_rdf(
+ g: Graph, ns: Namespace, p: ParameterDef, type_uris: dict[str, URIRef]
+) -> URIRef:
+ uri = _uri(ns, "parameter", p.name)
+ g.add((uri, RDF.type, GDS_CORE["ParameterDef"]))
+ g.add((uri, GDS_CORE["name"], Literal(p.name)))
+ g.add((uri, GDS_CORE["description"], Literal(p.description)))
+ if p.typedef.name in type_uris:
+ g.add((uri, GDS_CORE["paramType"], type_uris[p.typedef.name]))
+ if p.bounds is not None:
+ g.add((uri, GDS_CORE["lowerBound"], Literal(str(p.bounds[0]))))
+ g.add((uri, GDS_CORE["upperBound"], Literal(str(p.bounds[1]))))
+ return uri
+
+
+# ── GDSSpec (top-level) ─────────────────────────────────────────────
+
+
+def spec_to_graph(
+ spec: GDSSpec,
+ *,
+ base_uri: str = DEFAULT_BASE_URI,
+) -> Graph:
+ """Export a GDSSpec to an RDF graph (ABox instance data)."""
+ g = Graph()
+ _bind(g)
+ ns = _ns(base_uri, spec.name)
+ g.bind("inst", ns)
+
+ spec_uri = ns["spec"]
+ g.add((spec_uri, RDF.type, GDS_CORE["GDSSpec"]))
+ g.add((spec_uri, GDS_CORE["name"], Literal(spec.name)))
+ g.add((spec_uri, GDS_CORE["description"], Literal(spec.description)))
+
+ # Types
+ type_uris: dict[str, URIRef] = {}
+ for name, t in spec.types.items():
+ type_uris[name] = _typedef_to_rdf(g, ns, t)
+ g.add((spec_uri, GDS_CORE["hasType"], type_uris[name]))
+
+ # Also export parameter typedefs that may not be in spec.types
+ for p in spec.parameter_schema.parameters.values():
+ if p.typedef.name not in type_uris:
+ type_uris[p.typedef.name] = _typedef_to_rdf(g, ns, p.typedef)
+
+ # Spaces
+ space_uris: dict[str, URIRef] = {}
+ for name, s in spec.spaces.items():
+ space_uris[name] = _space_to_rdf(g, ns, s, type_uris)
+ g.add((spec_uri, GDS_CORE["hasSpace"], space_uris[name]))
+
+ # Entities
+ entity_uris: dict[str, URIRef] = {}
+ for name, e in spec.entities.items():
+ entity_uris[name] = _entity_to_rdf(g, ns, e, type_uris)
+ g.add((spec_uri, GDS_CORE["hasEntity"], entity_uris[name]))
+
+ # Parameters
+ param_uris: dict[str, URIRef] = {}
+ for name, p in spec.parameter_schema.parameters.items():
+ param_uris[name] = _parameter_to_rdf(g, ns, p, type_uris)
+ g.add((spec_uri, GDS_CORE["hasParameter"], param_uris[name]))
+
+ # Blocks
+ block_uris: dict[str, URIRef] = {}
+ for name, b in spec.blocks.items():
+ block_uris[name] = _block_to_rdf(g, ns, b, param_uris, entity_uris)
+ g.add((spec_uri, GDS_CORE["hasBlock"], block_uris[name]))
+
+ # Wirings
+ for _name, w in spec.wirings.items():
+ w_uri = _wiring_to_rdf(g, ns, w, block_uris, space_uris)
+ g.add((spec_uri, GDS_CORE["hasWiring"], w_uri))
+
+ # Admissibility constraints
+ for ac_name, ac in spec.admissibility_constraints.items():
+ ac_uri = _uri(ns, "admissibility", ac_name)
+ g.add((ac_uri, RDF.type, GDS_CORE["AdmissibleInputConstraint"]))
+ g.add((ac_uri, GDS_CORE["name"], Literal(ac_name)))
+ g.add(
+ (
+ ac_uri,
+ GDS_CORE["constraintBoundaryBlock"],
+ Literal(ac.boundary_block),
+ )
+ )
+ if ac.boundary_block in block_uris:
+ g.add(
+ (
+ ac_uri,
+ GDS_CORE["constrainsBoundary"],
+ block_uris[ac.boundary_block],
+ )
+ )
+ g.add(
+ (
+ ac_uri,
+ GDS_CORE["admissibilityHasConstraint"],
+ Literal(ac.constraint is not None, datatype=XSD.boolean),
+ )
+ )
+ g.add((ac_uri, GDS_CORE["description"], Literal(ac.description)))
+ for entity_name, var_name in ac.depends_on:
+ dep = BNode()
+ g.add((dep, RDF.type, GDS_CORE["AdmissibilityDep"]))
+ g.add((dep, GDS_CORE["depEntity"], Literal(entity_name)))
+ g.add((dep, GDS_CORE["depVariable"], Literal(var_name)))
+ g.add((ac_uri, GDS_CORE["hasDependency"], dep))
+ g.add((spec_uri, GDS_CORE["hasAdmissibilityConstraint"], ac_uri))
+
+ # Transition signatures
+ for mname, ts in spec.transition_signatures.items():
+ ts_uri = _uri(ns, "transition_sig", mname)
+ g.add((ts_uri, RDF.type, GDS_CORE["TransitionSignature"]))
+ g.add((ts_uri, GDS_CORE["name"], Literal(mname)))
+ g.add((ts_uri, GDS_CORE["signatureMechanism"], Literal(ts.mechanism)))
+ if ts.mechanism in block_uris:
+ g.add(
+ (
+ ts_uri,
+ GDS_CORE["signatureForMechanism"],
+ block_uris[ts.mechanism],
+ )
+ )
+ for bname in ts.depends_on_blocks:
+ g.add((ts_uri, GDS_CORE["dependsOnBlock"], Literal(bname)))
+ if ts.preserves_invariant:
+ g.add(
+ (
+ ts_uri,
+ GDS_CORE["preservesInvariant"],
+ Literal(ts.preserves_invariant),
+ )
+ )
+ for entity_name, var_name in ts.reads:
+ entry = BNode()
+ g.add((entry, RDF.type, GDS_CORE["TransitionReadEntry"]))
+ g.add((entry, GDS_CORE["readEntity"], Literal(entity_name)))
+ g.add((entry, GDS_CORE["readVariable"], Literal(var_name)))
+ g.add((ts_uri, GDS_CORE["hasReadEntry"], entry))
+ g.add((spec_uri, GDS_CORE["hasTransitionSignature"], ts_uri))
+
+ return g
+
+
+# ── SystemIR ─────────────────────────────────────────────────────────
+
+
+def _block_ir_to_rdf(g: Graph, ns: Namespace, b: BlockIR) -> URIRef:
+ uri = _uri(ns, "block", b.name)
+ g.add((uri, RDF.type, GDS_IR["BlockIR"]))
+ g.add((GDS_CORE["name"], RDFS.label, Literal("name"))) # property hint
+ g.add((uri, GDS_CORE["name"], Literal(b.name)))
+ g.add((uri, GDS_IR["blockType"], Literal(b.block_type)))
+ fwd_in, fwd_out, bwd_in, bwd_out = b.signature
+ g.add((uri, GDS_IR["signatureForwardIn"], Literal(fwd_in)))
+ g.add((uri, GDS_IR["signatureForwardOut"], Literal(fwd_out)))
+ g.add((uri, GDS_IR["signatureBackwardIn"], Literal(bwd_in)))
+ g.add((uri, GDS_IR["signatureBackwardOut"], Literal(bwd_out)))
+ g.add((uri, GDS_IR["logic"], Literal(b.logic)))
+ g.add((uri, GDS_IR["colorCode"], Literal(b.color_code, datatype=XSD.integer)))
+ return uri
+
+
+def _wiring_ir_to_rdf(g: Graph, ns: Namespace, w: WiringIR, idx: int) -> URIRef:
+ uri = _uri(ns, "wiring", f"{w.source}-{w.target}-{idx}")
+ g.add((uri, RDF.type, GDS_IR["WiringIR"]))
+ g.add((uri, GDS_IR["source"], Literal(w.source)))
+ g.add((uri, GDS_IR["target"], Literal(w.target)))
+ g.add((uri, GDS_IR["label"], Literal(w.label)))
+ g.add((uri, GDS_IR["wiringType"], Literal(w.wiring_type)))
+ g.add((uri, GDS_IR["direction"], Literal(w.direction.value)))
+ g.add((uri, GDS_IR["isFeedback"], Literal(w.is_feedback, datatype=XSD.boolean)))
+ g.add((uri, GDS_IR["isTemporal"], Literal(w.is_temporal, datatype=XSD.boolean)))
+ g.add((uri, GDS_IR["category"], Literal(w.category)))
+ return uri
+
+
+def _hierarchy_to_rdf(g: Graph, ns: Namespace, node: HierarchyNodeIR) -> URIRef:
+ uri = _uri(ns, "hierarchy", node.id)
+ g.add((uri, RDF.type, GDS_IR["HierarchyNodeIR"]))
+ g.add((uri, GDS_CORE["name"], Literal(node.name)))
+ if node.composition_type:
+ g.add((uri, GDS_IR["compositionType"], Literal(node.composition_type.value)))
+ if node.block_name:
+ g.add((uri, GDS_IR["blockName"], Literal(node.block_name)))
+ if node.exit_condition:
+ g.add((uri, GDS_IR["exitCondition"], Literal(node.exit_condition)))
+ for child in node.children:
+ child_uri = _hierarchy_to_rdf(g, ns, child)
+ g.add((uri, GDS_IR["hasChild"], child_uri))
+ return uri
+
+
+def system_ir_to_graph(
+ system: SystemIR,
+ *,
+ base_uri: str = DEFAULT_BASE_URI,
+) -> Graph:
+ """Export a SystemIR to an RDF graph."""
+ g = Graph()
+ _bind(g)
+ ns = _ns(base_uri, system.name)
+ g.bind("inst", ns)
+
+ sys_uri = ns["system"]
+ g.add((sys_uri, RDF.type, GDS_IR["SystemIR"]))
+ g.add((sys_uri, GDS_CORE["name"], Literal(system.name)))
+ g.add(
+ (
+ sys_uri,
+ GDS_IR["compositionTypeSystem"],
+ Literal(system.composition_type.value),
+ )
+ )
+ if system.source:
+ g.add((sys_uri, GDS_IR["sourceLabel"], Literal(system.source)))
+
+ for b in system.blocks:
+ b_uri = _block_ir_to_rdf(g, ns, b)
+ g.add((sys_uri, GDS_IR["hasBlockIR"], b_uri))
+
+ for idx, w in enumerate(system.wirings):
+ w_uri = _wiring_ir_to_rdf(g, ns, w, idx)
+ g.add((sys_uri, GDS_IR["hasWiringIR"], w_uri))
+
+ for inp in system.inputs:
+ inp_uri = _uri(ns, "input", inp.name)
+ g.add((inp_uri, RDF.type, GDS_IR["InputIR"]))
+ g.add((inp_uri, GDS_CORE["name"], Literal(inp.name)))
+ g.add((sys_uri, GDS_IR["hasInputIR"], inp_uri))
+
+ if system.hierarchy:
+ h_uri = _hierarchy_to_rdf(g, ns, system.hierarchy)
+ g.add((sys_uri, GDS_IR["hasHierarchy"], h_uri))
+
+ return g
+
+
+# ── CanonicalGDS ─────────────────────────────────────────────────────
+
+
+def canonical_to_graph(
+ canonical: CanonicalGDS,
+ *,
+ base_uri: str = DEFAULT_BASE_URI,
+ name: str = "canonical",
+) -> Graph:
+ """Export a CanonicalGDS to an RDF graph."""
+ g = Graph()
+ _bind(g)
+ ns = _ns(base_uri, name)
+ g.bind("inst", ns)
+
+ can_uri = ns["canonical"]
+ g.add((can_uri, RDF.type, GDS_CORE["CanonicalGDS"]))
+ g.add((can_uri, GDS_CORE["formula"], Literal(canonical.formula())))
+
+ # State variables
+ for entity_name, var_name in canonical.state_variables:
+ sv_uri = _uri(ns, "state_var", f"{entity_name}.{var_name}")
+ g.add((sv_uri, RDF.type, GDS_CORE["StateVariable"]))
+ g.add((sv_uri, GDS_CORE["name"], Literal(var_name)))
+ g.add((sv_uri, GDS_CORE["description"], Literal(f"{entity_name}.{var_name}")))
+ g.add((can_uri, GDS_CORE["hasVariable"], sv_uri))
+
+ # Block role partitions
+ for bname in canonical.boundary_blocks:
+ g.add((can_uri, GDS_CORE["boundaryBlock"], Literal(bname)))
+ for bname in canonical.control_blocks:
+ g.add((can_uri, GDS_CORE["controlBlock"], Literal(bname)))
+ for bname in canonical.policy_blocks:
+ g.add((can_uri, GDS_CORE["policyBlock"], Literal(bname)))
+ for bname in canonical.mechanism_blocks:
+ g.add((can_uri, GDS_CORE["mechanismBlock"], Literal(bname)))
+
+ # Update map
+ for mech_name, updates in canonical.update_map:
+ for entity_name, var_name in updates:
+ entry = BNode()
+ g.add((entry, RDF.type, GDS_CORE["UpdateMapEntry"]))
+ g.add((entry, GDS_CORE["name"], Literal(mech_name)))
+ g.add((entry, GDS_CORE["updatesEntity"], Literal(entity_name)))
+ g.add((entry, GDS_CORE["updatesVariable"], Literal(var_name)))
+ g.add((can_uri, GDS_CORE["updatesEntry"], entry))
+
+ return g
+
+
+# ── VerificationReport ───────────────────────────────────────────────
+
+
+def report_to_graph(
+ report: VerificationReport,
+ *,
+ base_uri: str = DEFAULT_BASE_URI,
+) -> Graph:
+ """Export a VerificationReport to an RDF graph."""
+ g = Graph()
+ _bind(g)
+ ns = _ns(base_uri, report.system_name)
+ g.bind("inst", ns)
+
+ report_uri = ns["report"]
+ g.add((report_uri, RDF.type, GDS_VERIF["VerificationReport"]))
+ g.add((report_uri, GDS_VERIF["systemName"], Literal(report.system_name)))
+
+ for idx, f in enumerate(report.findings):
+ f_uri = _uri(ns, "finding", f"{f.check_id}-{idx}")
+ g.add((f_uri, RDF.type, GDS_VERIF["Finding"]))
+ g.add((f_uri, GDS_VERIF["checkId"], Literal(f.check_id)))
+ g.add((f_uri, GDS_VERIF["severity"], Literal(f.severity.value)))
+ g.add((f_uri, GDS_VERIF["message"], Literal(f.message)))
+ g.add((f_uri, GDS_VERIF["passed"], Literal(f.passed, datatype=XSD.boolean)))
+ for elem in f.source_elements:
+ g.add((f_uri, GDS_VERIF["sourceElement"], Literal(elem)))
+ if f.exportable_predicate:
+ g.add(
+ (
+ f_uri,
+ GDS_VERIF["exportablePredicate"],
+ Literal(f.exportable_predicate),
+ )
+ )
+ g.add((report_uri, GDS_VERIF["hasFinding"], f_uri))
+
+ return g
diff --git a/packages/gds-owl/gds_owl/import_.py b/packages/gds-owl/gds_owl/import_.py
new file mode 100644
index 0000000..78b2629
--- /dev/null
+++ b/packages/gds-owl/gds_owl/import_.py
@@ -0,0 +1,600 @@
+"""Import RDF graphs back into GDS Pydantic models (round-trip support).
+
+Reconstructs GDSSpec, SystemIR, CanonicalGDS, and VerificationReport
+from RDF graphs produced by the export functions.
+
+Known lossy fields:
+- TypeDef.constraint: Python callable, not serializable. Imported as None.
+- TypeDef.python_type: Mapped from string via _PYTHON_TYPE_MAP for builtins.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from rdflib import RDF, XSD, Graph, Literal, URIRef
+
+from gds_owl._namespace import GDS_CORE, GDS_IR, GDS_VERIF
+
+if TYPE_CHECKING:
+ from gds.canonical import CanonicalGDS
+ from gds.ir.models import HierarchyNodeIR, SystemIR
+ from gds.spec import GDSSpec
+ from gds.verification.findings import VerificationReport
+
+# Map python_type strings back to actual types
+_PYTHON_TYPE_MAP: dict[str, type] = {
+ "float": float,
+ "int": int,
+ "str": str,
+ "bool": bool,
+ "list": list,
+ "dict": dict,
+ "tuple": tuple,
+ "set": set,
+ "complex": complex,
+ "bytes": bytes,
+}
+
+
+def _str(g: Graph, subject: URIRef, predicate: URIRef) -> str:
+ """Get a single string literal value, or empty string."""
+ vals = list(g.objects(subject, predicate))
+ return str(vals[0]) if vals else ""
+
+
+def _bool(g: Graph, subject: URIRef, predicate: URIRef) -> bool:
+ """Get a single boolean literal value, or False."""
+ vals = list(g.objects(subject, predicate))
+ if vals:
+ v = vals[0]
+ if isinstance(v, Literal) and v.datatype == XSD.boolean:
+ return v.toPython()
+ return str(v).lower() in ("true", "1")
+ return False
+
+
+def _strs(g: Graph, subject: URIRef, predicate: URIRef) -> list[str]:
+ """Get all string literal values for a predicate."""
+ return [str(v) for v in g.objects(subject, predicate)]
+
+
+def _subjects_of_type(g: Graph, rdf_type: URIRef) -> list[URIRef]:
+ """Get all subjects of a given RDF type."""
+ return [s for s in g.subjects(RDF.type, rdf_type) if isinstance(s, URIRef)]
+
+
+# ── TypeDef ──────────────────────────────────────────────────────────
+
+
+def _import_typedef(g: Graph, uri: URIRef) -> dict:
+ """Extract TypeDef fields from an RDF node."""
+ name = _str(g, uri, GDS_CORE["name"])
+ py_type_str = _str(g, uri, GDS_CORE["pythonType"])
+ python_type = _PYTHON_TYPE_MAP.get(py_type_str, str)
+ description = _str(g, uri, GDS_CORE["description"])
+ units = _str(g, uri, GDS_CORE["units"]) or None
+ return {
+ "name": name,
+ "python_type": python_type,
+ "description": description,
+ "units": units,
+ "constraint": None, # not serializable
+ }
+
+
+# ── GDSSpec ──────────────────────────────────────────────────────────
+
+
+def graph_to_spec(
+ g: Graph,
+ *,
+ spec_uri: URIRef | None = None,
+) -> GDSSpec:
+ """Reconstruct a GDSSpec from an RDF graph.
+
+ If spec_uri is None, finds the first GDSSpec individual in the graph.
+ """
+ from gds import (
+ GDSSpec,
+ ParameterDef,
+ SpecWiring,
+ Wire,
+ )
+ from gds.blocks.roles import BoundaryAction, Mechanism, Policy
+ from gds.constraints import AdmissibleInputConstraint, TransitionSignature
+ from gds.spaces import Space
+ from gds.state import Entity, StateVariable
+ from gds.types.interface import Interface, port
+ from gds.types.typedef import TypeDef
+
+ if spec_uri is None:
+ specs = _subjects_of_type(g, GDS_CORE["GDSSpec"])
+ if not specs:
+ raise ValueError("No GDSSpec found in graph")
+ spec_uri = specs[0]
+
+ spec_name = _str(g, spec_uri, GDS_CORE["name"])
+ spec_desc = _str(g, spec_uri, GDS_CORE["description"])
+ spec = GDSSpec(name=spec_name, description=spec_desc)
+
+ # Import types
+ typedef_map: dict[str, TypeDef] = {}
+ type_uris = list(g.objects(spec_uri, GDS_CORE["hasType"]))
+ for t_uri in type_uris:
+ if not isinstance(t_uri, URIRef):
+ continue
+ td_fields = _import_typedef(g, t_uri)
+ td = TypeDef(**td_fields)
+ typedef_map[td.name] = td
+ spec.register_type(td)
+
+ # Also collect all TypeDef URIs for parameter types
+ all_typedef_uris = _subjects_of_type(g, GDS_CORE["TypeDef"])
+ for t_uri in all_typedef_uris:
+ td_fields = _import_typedef(g, t_uri)
+ if td_fields["name"] not in typedef_map:
+ td = TypeDef(**td_fields)
+ typedef_map[td.name] = td
+
+ # Import spaces
+ space_uris = list(g.objects(spec_uri, GDS_CORE["hasSpace"]))
+ for s_uri in space_uris:
+ if not isinstance(s_uri, URIRef):
+ continue
+ s_name = _str(g, s_uri, GDS_CORE["name"])
+ s_desc = _str(g, s_uri, GDS_CORE["description"])
+ fields: dict[str, TypeDef] = {}
+ for field_node in g.objects(s_uri, GDS_CORE["hasField"]):
+ field_name = _str(g, field_node, GDS_CORE["fieldName"])
+ field_type_uris = list(g.objects(field_node, GDS_CORE["fieldType"]))
+ if field_type_uris:
+ ft_name = _str(g, field_type_uris[0], GDS_CORE["name"])
+ if ft_name in typedef_map:
+ fields[field_name] = typedef_map[ft_name]
+ spec.register_space(Space(name=s_name, fields=fields, description=s_desc))
+
+ # Import entities
+ entity_uris = list(g.objects(spec_uri, GDS_CORE["hasEntity"]))
+ for e_uri in entity_uris:
+ if not isinstance(e_uri, URIRef):
+ continue
+ e_name = _str(g, e_uri, GDS_CORE["name"])
+ e_desc = _str(g, e_uri, GDS_CORE["description"])
+ variables: dict[str, StateVariable] = {}
+ for sv_uri in g.objects(e_uri, GDS_CORE["hasVariable"]):
+ if not isinstance(sv_uri, URIRef):
+ continue
+ sv_name = _str(g, sv_uri, GDS_CORE["name"])
+ sv_desc = _str(g, sv_uri, GDS_CORE["description"])
+ sv_symbol = _str(g, sv_uri, GDS_CORE["symbol"])
+ # Resolve typedef
+ sv_type_uris = list(g.objects(sv_uri, GDS_CORE["usesType"]))
+ if sv_type_uris:
+ sv_type_name = _str(g, sv_type_uris[0], GDS_CORE["name"])
+ sv_typedef = typedef_map.get(
+ sv_type_name,
+ TypeDef(name=sv_type_name, python_type=str),
+ )
+ else:
+ sv_typedef = TypeDef(name="unknown", python_type=str)
+ variables[sv_name] = StateVariable(
+ name=sv_name,
+ typedef=sv_typedef,
+ description=sv_desc,
+ symbol=sv_symbol,
+ )
+ spec.register_entity(
+ Entity(name=e_name, variables=variables, description=e_desc)
+ )
+
+ # Import parameters
+ param_uris = list(g.objects(spec_uri, GDS_CORE["hasParameter"]))
+ param_uri_map: dict[str, URIRef] = {}
+ for p_uri in param_uris:
+ if not isinstance(p_uri, URIRef):
+ continue
+ p_name = _str(g, p_uri, GDS_CORE["name"])
+ p_desc = _str(g, p_uri, GDS_CORE["description"])
+ param_uri_map[p_name] = p_uri
+ # Resolve typedef
+ pt_uris = list(g.objects(p_uri, GDS_CORE["paramType"]))
+ if pt_uris:
+ pt_name = _str(g, pt_uris[0], GDS_CORE["name"])
+ p_typedef = typedef_map.get(pt_name, TypeDef(name=pt_name, python_type=str))
+ else:
+ p_typedef = TypeDef(name="unknown", python_type=str)
+ spec.register_parameter(
+ ParameterDef(name=p_name, typedef=p_typedef, description=p_desc)
+ )
+
+ # Import blocks
+ block_uris = list(g.objects(spec_uri, GDS_CORE["hasBlock"]))
+ # Build reverse lookup: param URI -> param name
+ param_name_by_uri: dict[URIRef, str] = {}
+ for pname, puri in param_uri_map.items():
+ param_name_by_uri[puri] = pname
+
+ for b_uri in block_uris:
+ if not isinstance(b_uri, URIRef):
+ continue
+ b_name = _str(g, b_uri, GDS_CORE["name"])
+ b_kind = _str(g, b_uri, GDS_CORE["kind"])
+
+ # Reconstruct interface
+ iface_uris = list(g.objects(b_uri, GDS_CORE["hasInterface"]))
+ fwd_in_ports: list[str] = []
+ fwd_out_ports: list[str] = []
+ bwd_in_ports: list[str] = []
+ bwd_out_ports: list[str] = []
+
+ if iface_uris:
+ iface_uri = iface_uris[0]
+ for p in g.objects(iface_uri, GDS_CORE["hasForwardIn"]):
+ fwd_in_ports.append(_str(g, p, GDS_CORE["portName"]))
+ for p in g.objects(iface_uri, GDS_CORE["hasForwardOut"]):
+ fwd_out_ports.append(_str(g, p, GDS_CORE["portName"]))
+ for p in g.objects(iface_uri, GDS_CORE["hasBackwardIn"]):
+ bwd_in_ports.append(_str(g, p, GDS_CORE["portName"]))
+ for p in g.objects(iface_uri, GDS_CORE["hasBackwardOut"]):
+ bwd_out_ports.append(_str(g, p, GDS_CORE["portName"]))
+
+ iface = Interface(
+ forward_in=tuple(port(n) for n in sorted(fwd_in_ports)),
+ forward_out=tuple(port(n) for n in sorted(fwd_out_ports)),
+ backward_in=tuple(port(n) for n in sorted(bwd_in_ports)),
+ backward_out=tuple(port(n) for n in sorted(bwd_out_ports)),
+ )
+
+ # Params used
+ params_used = []
+ for pu in g.objects(b_uri, GDS_CORE["usesParameter"]):
+ if isinstance(pu, URIRef) and pu in param_name_by_uri:
+ params_used.append(param_name_by_uri[pu])
+
+ constraints = _strs(g, b_uri, GDS_CORE["constraint"])
+ options = _strs(g, b_uri, GDS_CORE["option"])
+
+ # Build block by kind
+ if b_kind == "boundary":
+ block = BoundaryAction(
+ name=b_name,
+ interface=iface,
+ params_used=params_used,
+ constraints=constraints,
+ options=options,
+ )
+ elif b_kind == "mechanism":
+ updates: list[tuple[str, str]] = []
+ for entry in g.objects(b_uri, GDS_CORE["updatesEntry"]):
+ ent = _str(g, entry, GDS_CORE["updatesEntity"])
+ var = _str(g, entry, GDS_CORE["updatesVariable"])
+ updates.append((ent, var))
+ block = Mechanism(
+ name=b_name,
+ interface=iface,
+ updates=updates,
+ params_used=params_used,
+ constraints=constraints,
+ )
+ elif b_kind == "policy":
+ block = Policy(
+ name=b_name,
+ interface=iface,
+ params_used=params_used,
+ constraints=constraints,
+ options=options,
+ )
+ else:
+ from gds.blocks.base import AtomicBlock
+
+ block = AtomicBlock(name=b_name, interface=iface)
+
+ spec.register_block(block)
+
+ # Import wirings
+ wiring_uris = list(g.objects(spec_uri, GDS_CORE["hasWiring"]))
+ for w_uri in wiring_uris:
+ if not isinstance(w_uri, URIRef):
+ continue
+ w_name = _str(g, w_uri, GDS_CORE["name"])
+ w_desc = _str(g, w_uri, GDS_CORE["description"])
+
+ block_names = []
+ for wb in g.objects(w_uri, GDS_CORE["wiringBlock"]):
+ if isinstance(wb, URIRef):
+ bn = _str(g, wb, GDS_CORE["name"])
+ if bn:
+ block_names.append(bn)
+
+ wires = []
+ for wire_node in g.objects(w_uri, GDS_CORE["hasWire"]):
+ ws = _str(g, wire_node, GDS_CORE["wireSource"])
+ wt = _str(g, wire_node, GDS_CORE["wireTarget"])
+ wsp = _str(g, wire_node, GDS_CORE["wireSpace"])
+ wo = _bool(g, wire_node, GDS_CORE["wireOptional"])
+ wires.append(Wire(source=ws, target=wt, space=wsp, optional=wo))
+
+ spec.register_wiring(
+ SpecWiring(
+ name=w_name,
+ block_names=block_names,
+ wires=wires,
+ description=w_desc,
+ )
+ )
+
+ # Import admissibility constraints
+ ac_uris = list(g.objects(spec_uri, GDS_CORE["hasAdmissibilityConstraint"]))
+ for ac_uri in ac_uris:
+ if not isinstance(ac_uri, URIRef):
+ continue
+ ac_name = _str(g, ac_uri, GDS_CORE["name"])
+ ac_boundary = _str(g, ac_uri, GDS_CORE["constraintBoundaryBlock"])
+ ac_desc = _str(g, ac_uri, GDS_CORE["description"])
+ depends_on: list[tuple[str, str]] = []
+ for dep in g.objects(ac_uri, GDS_CORE["hasDependency"]):
+ ent = _str(g, dep, GDS_CORE["depEntity"])
+ var = _str(g, dep, GDS_CORE["depVariable"])
+ depends_on.append((ent, var))
+ spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name=ac_name,
+ boundary_block=ac_boundary,
+ depends_on=depends_on,
+ constraint=None,
+ description=ac_desc,
+ )
+ )
+
+ # Import transition signatures
+ ts_uris = list(g.objects(spec_uri, GDS_CORE["hasTransitionSignature"]))
+ for ts_uri in ts_uris:
+ if not isinstance(ts_uri, URIRef):
+ continue
+ ts_mech = _str(g, ts_uri, GDS_CORE["signatureMechanism"])
+ reads: list[tuple[str, str]] = []
+ for entry in g.objects(ts_uri, GDS_CORE["hasReadEntry"]):
+ ent = _str(g, entry, GDS_CORE["readEntity"])
+ var = _str(g, entry, GDS_CORE["readVariable"])
+ reads.append((ent, var))
+ depends_on_blocks = _strs(g, ts_uri, GDS_CORE["dependsOnBlock"])
+ invariant = _str(g, ts_uri, GDS_CORE["preservesInvariant"])
+ spec.register_transition_signature(
+ TransitionSignature(
+ mechanism=ts_mech,
+ reads=reads,
+ depends_on_blocks=depends_on_blocks,
+ preserves_invariant=invariant,
+ )
+ )
+
+ return spec
+
+
+# ── SystemIR ─────────────────────────────────────────────────────────
+
+
+def graph_to_system_ir(
+ g: Graph,
+ *,
+ system_uri: URIRef | None = None,
+) -> SystemIR:
+ """Reconstruct a SystemIR from an RDF graph."""
+ from gds.ir.models import (
+ BlockIR,
+ CompositionType,
+ FlowDirection,
+ InputIR,
+ SystemIR,
+ WiringIR,
+ )
+
+ if system_uri is None:
+ systems = _subjects_of_type(g, GDS_IR["SystemIR"])
+ if not systems:
+ raise ValueError("No SystemIR found in graph")
+ system_uri = systems[0]
+
+ name = _str(g, system_uri, GDS_CORE["name"])
+ comp_type_str = _str(g, system_uri, GDS_IR["compositionTypeSystem"])
+ comp_type = (
+ CompositionType(comp_type_str) if comp_type_str else CompositionType.SEQUENTIAL
+ )
+ source = _str(g, system_uri, GDS_IR["sourceLabel"])
+
+ # Blocks
+ blocks = []
+ for b_uri in g.objects(system_uri, GDS_IR["hasBlockIR"]):
+ if not isinstance(b_uri, URIRef):
+ continue
+ b_name = _str(g, b_uri, GDS_CORE["name"])
+ block_type = _str(g, b_uri, GDS_IR["blockType"])
+ fwd_in = _str(g, b_uri, GDS_IR["signatureForwardIn"])
+ fwd_out = _str(g, b_uri, GDS_IR["signatureForwardOut"])
+ bwd_in = _str(g, b_uri, GDS_IR["signatureBackwardIn"])
+ bwd_out = _str(g, b_uri, GDS_IR["signatureBackwardOut"])
+ logic = _str(g, b_uri, GDS_IR["logic"])
+ color_code_vals = list(g.objects(b_uri, GDS_IR["colorCode"]))
+ color_code = int(color_code_vals[0].toPython()) if color_code_vals else 1
+ blocks.append(
+ BlockIR(
+ name=b_name,
+ block_type=block_type,
+ signature=(fwd_in, fwd_out, bwd_in, bwd_out),
+ logic=logic,
+ color_code=color_code,
+ )
+ )
+
+ # Wirings
+ wirings = []
+ for w_uri in g.objects(system_uri, GDS_IR["hasWiringIR"]):
+ if not isinstance(w_uri, URIRef):
+ continue
+ w_source = _str(g, w_uri, GDS_IR["source"])
+ w_target = _str(g, w_uri, GDS_IR["target"])
+ w_label = _str(g, w_uri, GDS_IR["label"])
+ w_type = _str(g, w_uri, GDS_IR["wiringType"])
+ w_dir_str = _str(g, w_uri, GDS_IR["direction"])
+ w_dir = FlowDirection(w_dir_str) if w_dir_str else FlowDirection.COVARIANT
+ w_fb = _bool(g, w_uri, GDS_IR["isFeedback"])
+ w_temp = _bool(g, w_uri, GDS_IR["isTemporal"])
+ w_cat = _str(g, w_uri, GDS_IR["category"])
+ wirings.append(
+ WiringIR(
+ source=w_source,
+ target=w_target,
+ label=w_label,
+ wiring_type=w_type,
+ direction=w_dir,
+ is_feedback=w_fb,
+ is_temporal=w_temp,
+ category=w_cat or "dataflow",
+ )
+ )
+
+ # Inputs
+ inputs = []
+ for inp_uri in g.objects(system_uri, GDS_IR["hasInputIR"]):
+ if not isinstance(inp_uri, URIRef):
+ continue
+ inp_name = _str(g, inp_uri, GDS_CORE["name"])
+ inputs.append(InputIR(name=inp_name))
+
+ # Hierarchy
+ hierarchy = None
+ h_uris = list(g.objects(system_uri, GDS_IR["hasHierarchy"]))
+ if h_uris and isinstance(h_uris[0], URIRef):
+ hierarchy = _import_hierarchy(g, h_uris[0])
+
+ return SystemIR(
+ name=name,
+ blocks=blocks,
+ wirings=wirings,
+ inputs=inputs,
+ composition_type=comp_type,
+ hierarchy=hierarchy,
+ source=source,
+ )
+
+
+def _import_hierarchy(g: Graph, uri: URIRef) -> HierarchyNodeIR:
+ """Recursively import a HierarchyNodeIR tree."""
+ from gds.ir.models import CompositionType, HierarchyNodeIR
+
+ node_id = str(uri).split("/")[-1]
+ name = _str(g, uri, GDS_CORE["name"])
+ comp_type_str = _str(g, uri, GDS_IR["compositionType"])
+ comp_type = CompositionType(comp_type_str) if comp_type_str else None
+ block_name = _str(g, uri, GDS_IR["blockName"]) or None
+ exit_condition = _str(g, uri, GDS_IR["exitCondition"])
+
+ children = []
+ for child_uri in g.objects(uri, GDS_IR["hasChild"]):
+ if isinstance(child_uri, URIRef):
+ children.append(_import_hierarchy(g, child_uri))
+
+ return HierarchyNodeIR(
+ id=node_id,
+ name=name,
+ composition_type=comp_type,
+ children=children,
+ block_name=block_name,
+ exit_condition=exit_condition,
+ )
+
+
+# ── CanonicalGDS ─────────────────────────────────────────────────────
+
+
+def graph_to_canonical(
+ g: Graph,
+ *,
+ canonical_uri: URIRef | None = None,
+) -> CanonicalGDS:
+ """Reconstruct a CanonicalGDS from an RDF graph."""
+ from gds.canonical import CanonicalGDS
+
+ if canonical_uri is None:
+ canons = _subjects_of_type(g, GDS_CORE["CanonicalGDS"])
+ if not canons:
+ raise ValueError("No CanonicalGDS found in graph")
+ canonical_uri = canons[0]
+
+ # State variables
+ state_variables = []
+ for sv_uri in g.objects(canonical_uri, GDS_CORE["hasVariable"]):
+ desc = _str(g, sv_uri, GDS_CORE["description"])
+ if "." in desc:
+ entity_name, var_name = desc.split(".", 1)
+ state_variables.append((entity_name, var_name))
+
+ # Role blocks
+ boundary_blocks = tuple(_strs(g, canonical_uri, GDS_CORE["boundaryBlock"]))
+ control_blocks = tuple(_strs(g, canonical_uri, GDS_CORE["controlBlock"]))
+ policy_blocks = tuple(_strs(g, canonical_uri, GDS_CORE["policyBlock"]))
+ mechanism_blocks = tuple(_strs(g, canonical_uri, GDS_CORE["mechanismBlock"]))
+
+ # Update map
+ update_entries: dict[str, list[tuple[str, str]]] = {}
+ for entry in g.objects(canonical_uri, GDS_CORE["updatesEntry"]):
+ mech_name = _str(g, entry, GDS_CORE["name"])
+ entity_name = _str(g, entry, GDS_CORE["updatesEntity"])
+ var_name = _str(g, entry, GDS_CORE["updatesVariable"])
+ update_entries.setdefault(mech_name, []).append((entity_name, var_name))
+
+ update_map = tuple(
+ (mech, tuple(updates)) for mech, updates in update_entries.items()
+ )
+
+ return CanonicalGDS(
+ state_variables=tuple(state_variables),
+ boundary_blocks=boundary_blocks,
+ control_blocks=control_blocks,
+ policy_blocks=policy_blocks,
+ mechanism_blocks=mechanism_blocks,
+ update_map=update_map,
+ )
+
+
+# ── VerificationReport ───────────────────────────────────────────────
+
+
+def graph_to_report(
+ g: Graph,
+ *,
+ report_uri: URIRef | None = None,
+) -> VerificationReport:
+ """Reconstruct a VerificationReport from an RDF graph."""
+ from gds.verification.findings import Finding, Severity, VerificationReport
+
+ if report_uri is None:
+ reports = _subjects_of_type(g, GDS_VERIF["VerificationReport"])
+ if not reports:
+ raise ValueError("No VerificationReport found in graph")
+ report_uri = reports[0]
+
+ system_name = _str(g, report_uri, GDS_VERIF["systemName"])
+ findings = []
+
+ for f_uri in g.objects(report_uri, GDS_VERIF["hasFinding"]):
+ check_id = _str(g, f_uri, GDS_VERIF["checkId"])
+ severity_str = _str(g, f_uri, GDS_VERIF["severity"])
+ severity = Severity(severity_str) if severity_str else Severity.INFO
+ message = _str(g, f_uri, GDS_VERIF["message"])
+ passed = _bool(g, f_uri, GDS_VERIF["passed"])
+ source_elements = _strs(g, f_uri, GDS_VERIF["sourceElement"])
+ exportable = _str(g, f_uri, GDS_VERIF["exportablePredicate"])
+ findings.append(
+ Finding(
+ check_id=check_id,
+ severity=severity,
+ message=message,
+ passed=passed,
+ source_elements=source_elements,
+ exportable_predicate=exportable,
+ )
+ )
+
+ return VerificationReport(system_name=system_name, findings=findings)
diff --git a/packages/gds-owl/gds_owl/ontology.py b/packages/gds-owl/gds_owl/ontology.py
new file mode 100644
index 0000000..b040a2e
--- /dev/null
+++ b/packages/gds-owl/gds_owl/ontology.py
@@ -0,0 +1,545 @@
+"""GDS core ontology — OWL class hierarchy and property definitions (TBox).
+
+Builds the GDS ontology programmatically as an rdflib Graph. This defines
+the *schema* (classes, properties, domain/range) — not instance data.
+"""
+
+from rdflib import OWL, RDF, RDFS, XSD, Graph, Literal
+
+from gds_owl._namespace import GDS, GDS_CORE, GDS_IR, GDS_VERIF, PREFIXES
+
+
+def _bind_prefixes(g: Graph) -> None:
+ """Bind all GDS prefixes to a graph."""
+ for prefix, ns in PREFIXES.items():
+ g.bind(prefix, ns)
+ g.bind("owl", OWL)
+ g.bind("rdfs", RDFS)
+ g.bind("xsd", XSD)
+
+
+def _add_class(
+ g: Graph,
+ cls: str,
+ ns: type = GDS_CORE,
+ *,
+ parent: str | None = None,
+ parent_ns: type | None = None,
+ label: str = "",
+ comment: str = "",
+) -> None:
+ """Declare an OWL class with optional subclass relation."""
+ uri = ns[cls]
+ g.add((uri, RDF.type, OWL.Class))
+ if label:
+ g.add((uri, RDFS.label, Literal(label)))
+ if comment:
+ g.add((uri, RDFS.comment, Literal(comment)))
+ if parent:
+ p_ns = parent_ns or ns
+ g.add((uri, RDFS.subClassOf, p_ns[parent]))
+
+
+def _add_object_property(
+ g: Graph,
+ name: str,
+ ns: type = GDS_CORE,
+ *,
+ domain: str | None = None,
+ domain_ns: type | None = None,
+ range_: str | None = None,
+ range_ns: type | None = None,
+ label: str = "",
+) -> None:
+ """Declare an OWL object property."""
+ uri = ns[name]
+ g.add((uri, RDF.type, OWL.ObjectProperty))
+ if label:
+ g.add((uri, RDFS.label, Literal(label)))
+ if domain:
+ d_ns = domain_ns or ns
+ g.add((uri, RDFS.domain, d_ns[domain]))
+ if range_:
+ r_ns = range_ns or ns
+ g.add((uri, RDFS.range, r_ns[range_]))
+
+
+def _add_datatype_property(
+ g: Graph,
+ name: str,
+ ns: type = GDS_CORE,
+ *,
+ domain: str | None = None,
+ domain_ns: type | None = None,
+ range_: str = "string",
+ label: str = "",
+) -> None:
+ """Declare an OWL datatype property."""
+ uri = ns[name]
+ g.add((uri, RDF.type, OWL.DatatypeProperty))
+ if label:
+ g.add((uri, RDFS.label, Literal(label)))
+ if domain:
+ d_ns = domain_ns or ns
+ g.add((uri, RDFS.domain, d_ns[domain]))
+ xsd_type = getattr(XSD, range_, XSD.string)
+ g.add((uri, RDFS.range, xsd_type))
+
+
+def _build_composition_algebra(g: Graph) -> None:
+ """Layer 0: Block hierarchy and composition operators."""
+ # Block hierarchy
+ _add_class(g, "Block", label="Block", comment="Abstract base for all GDS blocks")
+ _add_class(
+ g,
+ "AtomicBlock",
+ parent="Block",
+ label="Atomic Block",
+ comment="Leaf node — non-decomposable block",
+ )
+ _add_class(
+ g,
+ "StackComposition",
+ parent="Block",
+ label="Stack Composition",
+ comment="Sequential composition (>> operator)",
+ )
+ _add_class(
+ g,
+ "ParallelComposition",
+ parent="Block",
+ label="Parallel Composition",
+ comment="Side-by-side composition (| operator)",
+ )
+ _add_class(
+ g,
+ "FeedbackLoop",
+ parent="Block",
+ label="Feedback Loop",
+ comment="Backward feedback within a timestep (.feedback())",
+ )
+ _add_class(
+ g,
+ "TemporalLoop",
+ parent="Block",
+ label="Temporal Loop",
+ comment="Forward iteration across timesteps (.loop())",
+ )
+
+ # Block roles
+ _add_class(
+ g,
+ "BoundaryAction",
+ parent="AtomicBlock",
+ label="Boundary Action",
+ comment="Exogenous input (admissible input set U)",
+ )
+ _add_class(
+ g,
+ "Policy",
+ parent="AtomicBlock",
+ label="Policy",
+ comment="Decision logic (maps signals to mechanism inputs)",
+ )
+ _add_class(
+ g,
+ "Mechanism",
+ parent="AtomicBlock",
+ label="Mechanism",
+ comment="State update (only block that writes state)",
+ )
+ _add_class(
+ g,
+ "ControlAction",
+ parent="AtomicBlock",
+ label="Control Action",
+ comment="Endogenous control (reads state, emits signals)",
+ )
+
+ # Interface and Port
+ _add_class(
+ g, "Interface", label="Interface", comment="Bidirectional typed interface"
+ )
+ _add_class(g, "Port", label="Port", comment="Named typed port on an interface")
+
+ # Composition properties
+ _add_object_property(g, "first", domain="StackComposition", range_="Block")
+ _add_object_property(g, "second", domain="StackComposition", range_="Block")
+ _add_object_property(g, "left", domain="ParallelComposition", range_="Block")
+ _add_object_property(g, "right", domain="ParallelComposition", range_="Block")
+ _add_object_property(g, "inner", domain="FeedbackLoop", range_="Block")
+
+ # Block -> Interface -> Port
+ _add_object_property(g, "hasInterface", domain="Block", range_="Interface")
+ _add_object_property(g, "hasForwardIn", domain="Interface", range_="Port")
+ _add_object_property(g, "hasForwardOut", domain="Interface", range_="Port")
+ _add_object_property(g, "hasBackwardIn", domain="Interface", range_="Port")
+ _add_object_property(g, "hasBackwardOut", domain="Interface", range_="Port")
+
+ # Port datatype properties
+ _add_datatype_property(g, "portName", domain="Port")
+ _add_datatype_property(g, "typeToken", domain="Port")
+
+
+def _build_spec_framework(g: Graph) -> None:
+ """Layer 1: GDSSpec, types, spaces, entities, wirings, parameters."""
+ # Spec registry
+ _add_class(
+ g,
+ "GDSSpec",
+ label="GDS Specification",
+ comment="Central registry for a GDS system specification",
+ )
+
+ # Type system
+ _add_class(
+ g, "TypeDef", label="Type Definition", comment="Runtime-constrained type"
+ )
+ _add_class(g, "Space", label="Space", comment="Typed product space for data flow")
+ _add_class(
+ g,
+ "SpaceField",
+ label="Space Field",
+ comment="Named field within a Space (reified field-name + TypeDef)",
+ )
+
+ # State model
+ _add_class(
+ g, "Entity", label="Entity", comment="Named state holder (actor/resource)"
+ )
+ _add_class(
+ g,
+ "StateVariable",
+ label="State Variable",
+ comment="Single typed state variable within an entity",
+ )
+
+ # Wiring model
+ _add_class(
+ g,
+ "SpecWiring",
+ label="Spec Wiring",
+ comment="Named composition of blocks connected by wires",
+ )
+ _add_class(
+ g,
+ "Wire",
+ label="Wire",
+ comment="Connection within a wiring (source -> target through space)",
+ )
+
+ # Parameters
+ _add_class(
+ g,
+ "ParameterDef",
+ label="Parameter Definition",
+ comment="Parameter in the configuration space Theta",
+ )
+
+ # Canonical decomposition
+ _add_class(
+ g,
+ "CanonicalGDS",
+ label="Canonical GDS",
+ comment="Formal h = f . g decomposition of a GDS specification",
+ )
+
+ # Update map entry (reified: mechanism -> entity + variable)
+ _add_class(
+ g,
+ "UpdateMapEntry",
+ label="Update Map Entry",
+ comment="Reified (mechanism, entity, variable) update relationship",
+ )
+
+ # Structural annotations (Paper Defs 2.5, 2.7)
+ _add_class(
+ g,
+ "AdmissibleInputConstraint",
+ label="Admissible Input Constraint",
+ comment="State-dependent constraint on BoundaryAction outputs (U_x)",
+ )
+ _add_class(
+ g,
+ "AdmissibilityDep",
+ label="Admissibility Dependency",
+ comment="Reified (entity, variable) dependency for admissibility",
+ )
+ _add_class(
+ g,
+ "TransitionSignature",
+ label="Transition Signature",
+ comment="Structural read signature of a mechanism transition (f|_x)",
+ )
+ _add_class(
+ g,
+ "TransitionReadEntry",
+ label="Transition Read Entry",
+ comment="Reified (entity, variable) read dependency",
+ )
+
+ # GDSSpec -> children
+ _add_object_property(g, "hasBlock", domain="GDSSpec", range_="Block")
+ _add_object_property(g, "hasType", domain="GDSSpec", range_="TypeDef")
+ _add_object_property(g, "hasSpace", domain="GDSSpec", range_="Space")
+ _add_object_property(g, "hasEntity", domain="GDSSpec", range_="Entity")
+ _add_object_property(g, "hasWiring", domain="GDSSpec", range_="SpecWiring")
+ _add_object_property(g, "hasParameter", domain="GDSSpec", range_="ParameterDef")
+ _add_object_property(g, "hasCanonical", domain="GDSSpec", range_="CanonicalGDS")
+ _add_object_property(
+ g,
+ "hasAdmissibilityConstraint",
+ domain="GDSSpec",
+ range_="AdmissibleInputConstraint",
+ )
+ _add_object_property(
+ g,
+ "hasTransitionSignature",
+ domain="GDSSpec",
+ range_="TransitionSignature",
+ )
+
+ # Entity -> StateVariable
+ _add_object_property(g, "hasVariable", domain="Entity", range_="StateVariable")
+
+ # Space -> SpaceField -> TypeDef
+ _add_object_property(g, "hasField", domain="Space", range_="SpaceField")
+ _add_object_property(g, "fieldType", domain="SpaceField", range_="TypeDef")
+ _add_datatype_property(g, "fieldName", domain="SpaceField")
+
+ # StateVariable -> TypeDef
+ _add_object_property(g, "usesType", domain="StateVariable", range_="TypeDef")
+
+ # Block -> ParameterDef
+ _add_object_property(g, "usesParameter", domain="Block", range_="ParameterDef")
+
+ # Mechanism updates (via reified UpdateMapEntry)
+ _add_object_property(g, "updatesEntry", domain="Mechanism", range_="UpdateMapEntry")
+ _add_object_property(g, "updatesEntity", domain="UpdateMapEntry", range_="Entity")
+ _add_object_property(
+ g, "updatesVariable", domain="UpdateMapEntry", range_="StateVariable"
+ )
+
+ # SpecWiring -> blocks and wires
+ _add_object_property(g, "wiringBlock", domain="SpecWiring", range_="Block")
+ _add_object_property(g, "hasWire", domain="SpecWiring", range_="Wire")
+
+ # Wire properties
+ _add_datatype_property(g, "wireSource", domain="Wire")
+ _add_datatype_property(g, "wireTarget", domain="Wire")
+ _add_object_property(g, "wireSpace", domain="Wire", range_="Space")
+ _add_datatype_property(g, "wireOptional", domain="Wire", range_="boolean")
+
+ # ParameterDef -> TypeDef
+ _add_object_property(g, "paramType", domain="ParameterDef", range_="TypeDef")
+ _add_datatype_property(g, "lowerBound", domain="ParameterDef")
+ _add_datatype_property(g, "upperBound", domain="ParameterDef")
+
+ # TypeDef datatype properties
+ _add_datatype_property(g, "pythonType", domain="TypeDef")
+ _add_datatype_property(g, "units", domain="TypeDef")
+ _add_datatype_property(g, "hasConstraint", domain="TypeDef", range_="boolean")
+
+ # StateVariable datatype properties
+ _add_datatype_property(g, "symbol", domain="StateVariable")
+
+ # Canonical decomposition properties
+ _add_object_property(g, "boundaryBlock", domain="CanonicalGDS", range_="Block")
+ _add_object_property(g, "controlBlock", domain="CanonicalGDS", range_="Block")
+ _add_object_property(g, "policyBlock", domain="CanonicalGDS", range_="Block")
+ _add_object_property(g, "mechanismBlock", domain="CanonicalGDS", range_="Block")
+ _add_datatype_property(g, "formula", domain="CanonicalGDS")
+
+ # AdmissibleInputConstraint properties
+ _add_object_property(
+ g,
+ "constrainsBoundary",
+ domain="AdmissibleInputConstraint",
+ range_="BoundaryAction",
+ )
+ _add_datatype_property(
+ g, "constraintBoundaryBlock", domain="AdmissibleInputConstraint"
+ )
+ _add_datatype_property(
+ g,
+ "admissibilityHasConstraint",
+ domain="AdmissibleInputConstraint",
+ range_="boolean",
+ )
+ _add_object_property(
+ g,
+ "hasDependency",
+ domain="AdmissibleInputConstraint",
+ range_="AdmissibilityDep",
+ )
+ _add_datatype_property(g, "depEntity", domain="AdmissibilityDep")
+ _add_datatype_property(g, "depVariable", domain="AdmissibilityDep")
+
+ # TransitionSignature properties
+ _add_object_property(
+ g,
+ "signatureForMechanism",
+ domain="TransitionSignature",
+ range_="Mechanism",
+ )
+ _add_datatype_property(g, "signatureMechanism", domain="TransitionSignature")
+ _add_datatype_property(g, "dependsOnBlock", domain="TransitionSignature")
+ _add_datatype_property(g, "preservesInvariant", domain="TransitionSignature")
+ _add_object_property(
+ g,
+ "hasReadEntry",
+ domain="TransitionSignature",
+ range_="TransitionReadEntry",
+ )
+ _add_datatype_property(g, "readEntity", domain="TransitionReadEntry")
+ _add_datatype_property(g, "readVariable", domain="TransitionReadEntry")
+
+ # Shared datatype properties
+ _add_datatype_property(g, "name")
+ _add_datatype_property(g, "description")
+ _add_datatype_property(g, "kind", domain="AtomicBlock")
+ _add_datatype_property(g, "constraint", domain="Block")
+ _add_datatype_property(g, "option", domain="Block")
+
+
+def _build_ir_classes(g: Graph) -> None:
+ """IR layer: SystemIR, BlockIR, WiringIR, HierarchyNodeIR, InputIR."""
+ _add_class(g, "SystemIR", GDS_IR, label="System IR", comment="Top-level flat IR")
+ _add_class(
+ g, "BlockIR", GDS_IR, label="Block IR", comment="Flat atomic block in IR"
+ )
+ _add_class(
+ g,
+ "WiringIR",
+ GDS_IR,
+ label="Wiring IR",
+ comment="Directed edge between blocks in IR",
+ )
+ _add_class(
+ g,
+ "HierarchyNodeIR",
+ GDS_IR,
+ label="Hierarchy Node IR",
+ comment="Composition tree node",
+ )
+ _add_class(g, "InputIR", GDS_IR, label="Input IR", comment="External system input")
+
+ # SystemIR -> children
+ _add_object_property(g, "hasBlockIR", GDS_IR, domain="SystemIR", range_="BlockIR")
+ _add_object_property(g, "hasWiringIR", GDS_IR, domain="SystemIR", range_="WiringIR")
+ _add_object_property(g, "hasInputIR", GDS_IR, domain="SystemIR", range_="InputIR")
+ _add_object_property(
+ g, "hasHierarchy", GDS_IR, domain="SystemIR", range_="HierarchyNodeIR"
+ )
+
+ # BlockIR properties
+ _add_datatype_property(g, "blockType", GDS_IR, domain="BlockIR")
+ _add_datatype_property(g, "signatureForwardIn", GDS_IR, domain="BlockIR")
+ _add_datatype_property(g, "signatureForwardOut", GDS_IR, domain="BlockIR")
+ _add_datatype_property(g, "signatureBackwardIn", GDS_IR, domain="BlockIR")
+ _add_datatype_property(g, "signatureBackwardOut", GDS_IR, domain="BlockIR")
+ _add_datatype_property(g, "logic", GDS_IR, domain="BlockIR")
+ _add_datatype_property(g, "colorCode", GDS_IR, domain="BlockIR", range_="integer")
+
+ # WiringIR properties
+ _add_datatype_property(g, "source", GDS_IR, domain="WiringIR")
+ _add_datatype_property(g, "target", GDS_IR, domain="WiringIR")
+ _add_datatype_property(g, "label", GDS_IR, domain="WiringIR")
+ _add_datatype_property(g, "wiringType", GDS_IR, domain="WiringIR")
+ _add_datatype_property(g, "direction", GDS_IR, domain="WiringIR")
+ _add_datatype_property(g, "isFeedback", GDS_IR, domain="WiringIR", range_="boolean")
+ _add_datatype_property(g, "isTemporal", GDS_IR, domain="WiringIR", range_="boolean")
+ _add_datatype_property(g, "category", GDS_IR, domain="WiringIR")
+
+ # HierarchyNodeIR properties
+ _add_datatype_property(g, "compositionType", GDS_IR, domain="HierarchyNodeIR")
+ _add_datatype_property(g, "blockName", GDS_IR, domain="HierarchyNodeIR")
+ _add_datatype_property(g, "exitCondition", GDS_IR, domain="HierarchyNodeIR")
+ _add_object_property(
+ g,
+ "hasChild",
+ GDS_IR,
+ domain="HierarchyNodeIR",
+ range_="HierarchyNodeIR",
+ )
+
+ # SystemIR datatype properties
+ _add_datatype_property(g, "compositionTypeSystem", GDS_IR, domain="SystemIR")
+ _add_datatype_property(g, "sourceLabel", GDS_IR, domain="SystemIR")
+
+
+def _build_verification_classes(g: Graph) -> None:
+ """Verification layer: Finding and VerificationReport."""
+ _add_class(
+ g,
+ "Finding",
+ GDS_VERIF,
+ label="Finding",
+ comment="A single verification check result",
+ )
+ _add_class(
+ g,
+ "VerificationReport",
+ GDS_VERIF,
+ label="Verification Report",
+ comment="Aggregated verification results for a system",
+ )
+
+ # Report -> Finding
+ _add_object_property(
+ g,
+ "hasFinding",
+ GDS_VERIF,
+ domain="VerificationReport",
+ range_="Finding",
+ )
+
+ # Finding properties
+ _add_datatype_property(g, "checkId", GDS_VERIF, domain="Finding")
+ _add_datatype_property(g, "severity", GDS_VERIF, domain="Finding")
+ _add_datatype_property(g, "message", GDS_VERIF, domain="Finding")
+ _add_datatype_property(g, "passed", GDS_VERIF, domain="Finding", range_="boolean")
+ _add_datatype_property(g, "sourceElement", GDS_VERIF, domain="Finding")
+ _add_datatype_property(g, "exportablePredicate", GDS_VERIF, domain="Finding")
+
+ # Report properties
+ _add_datatype_property(g, "systemName", GDS_VERIF, domain="VerificationReport")
+
+
+def build_core_ontology() -> Graph:
+ """Build the complete GDS core ontology as an OWL graph (TBox).
+
+ Returns an rdflib Graph containing all OWL class declarations,
+ object properties, and datatype properties for the GDS ecosystem.
+
+ This is the schema — use the export functions to produce instance data (ABox).
+ """
+ g = Graph()
+ _bind_prefixes(g)
+
+ # Ontology metadata
+ g.add((GDS["ontology"], RDF.type, OWL.Ontology))
+ g.add(
+ (
+ GDS["ontology"],
+ RDFS.label,
+ Literal("Generalized Dynamical Systems Ontology"),
+ )
+ )
+ g.add(
+ (
+ GDS["ontology"],
+ RDFS.comment,
+ Literal(
+ "OWL ontology for typed compositional specifications "
+ "of complex systems, grounded in GDS theory."
+ ),
+ )
+ )
+
+ _build_composition_algebra(g)
+ _build_spec_framework(g)
+ _build_ir_classes(g)
+ _build_verification_classes(g)
+
+ return g
diff --git a/packages/gds-owl/gds_owl/serialize.py b/packages/gds-owl/gds_owl/serialize.py
new file mode 100644
index 0000000..17355c6
--- /dev/null
+++ b/packages/gds-owl/gds_owl/serialize.py
@@ -0,0 +1,61 @@
+"""Serialization convenience functions — Graph to Turtle/JSON-LD/N-Triples.
+
+Also provides high-level shortcuts that combine export + serialization.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from gds_owl.export import (
+ canonical_to_graph,
+ report_to_graph,
+ spec_to_graph,
+ system_ir_to_graph,
+)
+
+if TYPE_CHECKING:
+ from rdflib import Graph
+
+ from gds.canonical import CanonicalGDS
+ from gds.ir.models import SystemIR
+ from gds.spec import GDSSpec
+ from gds.verification.findings import VerificationReport
+
+
+def to_turtle(graph: Graph) -> str:
+ """Serialize an RDF graph to Turtle format."""
+ return graph.serialize(format="turtle")
+
+
+def to_jsonld(graph: Graph) -> str:
+ """Serialize an RDF graph to JSON-LD format."""
+ return graph.serialize(format="json-ld")
+
+
+def to_ntriples(graph: Graph) -> str:
+ """Serialize an RDF graph to N-Triples format."""
+ return graph.serialize(format="nt")
+
+
+# ── High-level convenience ───────────────────────────────────────────
+
+
+def spec_to_turtle(spec: GDSSpec, **kwargs: Any) -> str:
+ """Export a GDSSpec directly to Turtle string."""
+ return to_turtle(spec_to_graph(spec, **kwargs))
+
+
+def system_ir_to_turtle(system: SystemIR, **kwargs: Any) -> str:
+ """Export a SystemIR directly to Turtle string."""
+ return to_turtle(system_ir_to_graph(system, **kwargs))
+
+
+def canonical_to_turtle(canonical: CanonicalGDS, **kwargs: Any) -> str:
+ """Export a CanonicalGDS directly to Turtle string."""
+ return to_turtle(canonical_to_graph(canonical, **kwargs))
+
+
+def report_to_turtle(report: VerificationReport, **kwargs: Any) -> str:
+ """Export a VerificationReport directly to Turtle string."""
+ return to_turtle(report_to_graph(report, **kwargs))
diff --git a/packages/gds-owl/gds_owl/shacl.py b/packages/gds-owl/gds_owl/shacl.py
new file mode 100644
index 0000000..2a285ad
--- /dev/null
+++ b/packages/gds-owl/gds_owl/shacl.py
@@ -0,0 +1,436 @@
+"""SHACL shape library for validating GDS RDF graphs.
+
+Three shape sets:
+- Structural: Pydantic model constraints (cardinality, required fields)
+- Generic: G-001..G-006 verification checks on SystemIR
+- Semantic: SC-001..SC-007 verification checks on GDSSpec
+
+Requires pyshacl (optional dependency: pip install gds-owl[shacl]).
+"""
+
+from __future__ import annotations
+
+from rdflib import RDF, SH, XSD, Graph, Literal, Namespace, URIRef
+
+from gds_owl._namespace import GDS_CORE, GDS_IR, GDS_VERIF, PREFIXES
+
+SH_NS = Namespace("http://www.w3.org/ns/shacl#")
+GDS_SHAPE = Namespace("https://gds.block.science/shapes/")
+
+
+def _bind(g: Graph) -> None:
+ for prefix, ns in PREFIXES.items():
+ g.bind(prefix, ns)
+ g.bind("sh", SH)
+ g.bind("gds-shape", GDS_SHAPE)
+ g.bind("xsd", XSD)
+
+
+def _add_property_shape(
+ g: Graph,
+ node_shape: URIRef,
+ path: URIRef,
+ *,
+ min_count: int | None = None,
+ max_count: int | None = None,
+ datatype: URIRef | None = None,
+ class_: URIRef | None = None,
+ message: str = "",
+) -> None:
+ """Add a property constraint to a node shape."""
+ from rdflib import BNode
+
+ prop = BNode()
+ g.add((node_shape, SH.property, prop))
+ g.add((prop, SH.path, path))
+ if min_count is not None:
+ g.add((prop, SH.minCount, Literal(min_count)))
+ if max_count is not None:
+ g.add((prop, SH.maxCount, Literal(max_count)))
+ if datatype is not None:
+ g.add((prop, SH.datatype, datatype))
+ if class_ is not None:
+ g.add((prop, SH["class"], class_))
+ if message:
+ g.add((prop, SH.message, Literal(message)))
+
+
+# ── Structural Shapes ────────────────────────────────────────────────
+
+
+def build_structural_shapes() -> Graph:
+ """Build SHACL shapes for GDS structural constraints.
+
+ These mirror the Pydantic model validators: required fields,
+ cardinality, and role-specific invariants.
+ """
+ g = Graph()
+ _bind(g)
+
+ # GDSSpec: must have exactly 1 name
+ spec_shape = GDS_SHAPE["GDSSpecShape"]
+ g.add((spec_shape, RDF.type, SH.NodeShape))
+ g.add((spec_shape, SH.targetClass, GDS_CORE["GDSSpec"]))
+ _add_property_shape(
+ g,
+ spec_shape,
+ GDS_CORE["name"],
+ min_count=1,
+ max_count=1,
+ datatype=XSD.string,
+ message="GDSSpec must have exactly one name",
+ )
+
+ # BoundaryAction: must have 0 hasForwardIn ports
+ ba_shape = GDS_SHAPE["BoundaryActionShape"]
+ g.add((ba_shape, RDF.type, SH.NodeShape))
+ g.add((ba_shape, SH.targetClass, GDS_CORE["BoundaryAction"]))
+ _add_property_shape(
+ g,
+ ba_shape,
+ GDS_CORE["name"],
+ min_count=1,
+ max_count=1,
+ message="BoundaryAction must have a name",
+ )
+ # BoundaryAction interface must have no forward_in (checked via interface)
+ _add_property_shape(
+ g,
+ ba_shape,
+ GDS_CORE["hasInterface"],
+ min_count=1,
+ max_count=1,
+ message="BoundaryAction must have exactly one interface",
+ )
+
+ # Mechanism: must have 0 backward ports, >= 1 updatesEntry
+ mech_shape = GDS_SHAPE["MechanismShape"]
+ g.add((mech_shape, RDF.type, SH.NodeShape))
+ g.add((mech_shape, SH.targetClass, GDS_CORE["Mechanism"]))
+ _add_property_shape(
+ g,
+ mech_shape,
+ GDS_CORE["name"],
+ min_count=1,
+ max_count=1,
+ message="Mechanism must have a name",
+ )
+ _add_property_shape(
+ g,
+ mech_shape,
+ GDS_CORE["updatesEntry"],
+ min_count=1,
+ message="Mechanism must update at least one state variable",
+ )
+
+ # Policy: must have name and interface
+ pol_shape = GDS_SHAPE["PolicyShape"]
+ g.add((pol_shape, RDF.type, SH.NodeShape))
+ g.add((pol_shape, SH.targetClass, GDS_CORE["Policy"]))
+ _add_property_shape(
+ g,
+ pol_shape,
+ GDS_CORE["name"],
+ min_count=1,
+ max_count=1,
+ message="Policy must have a name",
+ )
+
+ # Entity: must have name, >= 0 variables
+ ent_shape = GDS_SHAPE["EntityShape"]
+ g.add((ent_shape, RDF.type, SH.NodeShape))
+ g.add((ent_shape, SH.targetClass, GDS_CORE["Entity"]))
+ _add_property_shape(
+ g,
+ ent_shape,
+ GDS_CORE["name"],
+ min_count=1,
+ max_count=1,
+ message="Entity must have a name",
+ )
+
+ # TypeDef: must have name and pythonType
+ td_shape = GDS_SHAPE["TypeDefShape"]
+ g.add((td_shape, RDF.type, SH.NodeShape))
+ g.add((td_shape, SH.targetClass, GDS_CORE["TypeDef"]))
+ _add_property_shape(
+ g,
+ td_shape,
+ GDS_CORE["name"],
+ min_count=1,
+ max_count=1,
+ message="TypeDef must have a name",
+ )
+ _add_property_shape(
+ g,
+ td_shape,
+ GDS_CORE["pythonType"],
+ min_count=1,
+ max_count=1,
+ message="TypeDef must have a pythonType",
+ )
+
+ # Space: must have name
+ space_shape = GDS_SHAPE["SpaceShape"]
+ g.add((space_shape, RDF.type, SH.NodeShape))
+ g.add((space_shape, SH.targetClass, GDS_CORE["Space"]))
+ _add_property_shape(
+ g,
+ space_shape,
+ GDS_CORE["name"],
+ min_count=1,
+ max_count=1,
+ message="Space must have a name",
+ )
+
+ # AdmissibleInputConstraint: must have name and boundaryBlock
+ aic_shape = GDS_SHAPE["AdmissibleInputConstraintShape"]
+ g.add((aic_shape, RDF.type, SH.NodeShape))
+ g.add((aic_shape, SH.targetClass, GDS_CORE["AdmissibleInputConstraint"]))
+ _add_property_shape(
+ g,
+ aic_shape,
+ GDS_CORE["name"],
+ min_count=1,
+ max_count=1,
+ datatype=XSD.string,
+ message="AdmissibleInputConstraint must have a name",
+ )
+ _add_property_shape(
+ g,
+ aic_shape,
+ GDS_CORE["constraintBoundaryBlock"],
+ min_count=1,
+ max_count=1,
+ datatype=XSD.string,
+ message="AdmissibleInputConstraint must have a boundaryBlock",
+ )
+
+ # TransitionSignature: must have name and mechanismName
+ ts_shape = GDS_SHAPE["TransitionSignatureShape"]
+ g.add((ts_shape, RDF.type, SH.NodeShape))
+ g.add((ts_shape, SH.targetClass, GDS_CORE["TransitionSignature"]))
+ _add_property_shape(
+ g,
+ ts_shape,
+ GDS_CORE["name"],
+ min_count=1,
+ max_count=1,
+ datatype=XSD.string,
+ message="TransitionSignature must have a name",
+ )
+ _add_property_shape(
+ g,
+ ts_shape,
+ GDS_CORE["signatureMechanism"],
+ min_count=1,
+ max_count=1,
+ datatype=XSD.string,
+ message="TransitionSignature must have a mechanismName",
+ )
+
+ # BlockIR: must have name
+ bir_shape = GDS_SHAPE["BlockIRShape"]
+ g.add((bir_shape, RDF.type, SH.NodeShape))
+ g.add((bir_shape, SH.targetClass, GDS_IR["BlockIR"]))
+ _add_property_shape(
+ g,
+ bir_shape,
+ GDS_CORE["name"],
+ min_count=1,
+ max_count=1,
+ message="BlockIR must have a name",
+ )
+
+ # SystemIR: must have name
+ sir_shape = GDS_SHAPE["SystemIRShape"]
+ g.add((sir_shape, RDF.type, SH.NodeShape))
+ g.add((sir_shape, SH.targetClass, GDS_IR["SystemIR"]))
+ _add_property_shape(
+ g,
+ sir_shape,
+ GDS_CORE["name"],
+ min_count=1,
+ max_count=1,
+ message="SystemIR must have a name",
+ )
+
+ # WiringIR: must have source and target
+ wir_shape = GDS_SHAPE["WiringIRShape"]
+ g.add((wir_shape, RDF.type, SH.NodeShape))
+ g.add((wir_shape, SH.targetClass, GDS_IR["WiringIR"]))
+ _add_property_shape(
+ g,
+ wir_shape,
+ GDS_IR["source"],
+ min_count=1,
+ max_count=1,
+ message="WiringIR must have a source",
+ )
+ _add_property_shape(
+ g,
+ wir_shape,
+ GDS_IR["target"],
+ min_count=1,
+ max_count=1,
+ message="WiringIR must have a target",
+ )
+
+ # Finding: must have checkId, severity, passed
+ finding_shape = GDS_SHAPE["FindingShape"]
+ g.add((finding_shape, RDF.type, SH.NodeShape))
+ g.add((finding_shape, SH.targetClass, GDS_VERIF["Finding"]))
+ _add_property_shape(
+ g,
+ finding_shape,
+ GDS_VERIF["checkId"],
+ min_count=1,
+ max_count=1,
+ message="Finding must have a checkId",
+ )
+ _add_property_shape(
+ g,
+ finding_shape,
+ GDS_VERIF["severity"],
+ min_count=1,
+ max_count=1,
+ message="Finding must have a severity",
+ )
+ _add_property_shape(
+ g,
+ finding_shape,
+ GDS_VERIF["passed"],
+ min_count=1,
+ max_count=1,
+ message="Finding must have a passed status",
+ )
+
+ return g
+
+
+# ── Generic Check Shapes (G-001..G-006) ─────────────────────────────
+
+
+def build_generic_shapes() -> Graph:
+ """Build SHACL shapes mirroring G-001..G-006 generic checks.
+
+ These operate on SystemIR RDF graphs.
+ G-006 (covariant acyclicity) is not expressible in SHACL and
+ is documented as a SPARQL query instead.
+ """
+ g = Graph()
+ _bind(g)
+
+ # G-004: Dangling wirings — every WiringIR source/target must reference
+ # a BlockIR name that exists in the same SystemIR.
+ # This is expressed as a SPARQL-based constraint.
+ g004_shape = GDS_SHAPE["G004DanglingWiringShape"]
+ g.add((g004_shape, RDF.type, SH.NodeShape))
+ g.add((g004_shape, SH.targetClass, GDS_IR["WiringIR"]))
+ g.add(
+ (
+ g004_shape,
+ SH.message,
+ Literal("G-004: Wiring references a block not in the system"),
+ )
+ )
+
+ return g
+
+
+# ── Semantic Check Shapes (SC-001..SC-007) ───────────────────────────
+
+
+def build_semantic_shapes() -> Graph:
+ """Build SHACL shapes mirroring SC-001..SC-007 semantic checks.
+
+ These operate on GDSSpec RDF graphs.
+ """
+ g = Graph()
+ _bind(g)
+
+ # SC-001: Completeness — every Entity StateVariable should have
+ # at least one Mechanism that updatesEntry referencing it.
+ # This is advisory (not all specs require full coverage).
+
+ # SC-005: Parameter references — blocks using parameters must
+ # reference registered ParameterDef instances.
+ # Expressed as: every usesParameter target must be of type ParameterDef.
+ sc005_shape = GDS_SHAPE["SC005ParamRefShape"]
+ g.add((sc005_shape, RDF.type, SH.NodeShape))
+ g.add((sc005_shape, SH.targetClass, GDS_CORE["AtomicBlock"]))
+ _add_property_shape(
+ g,
+ sc005_shape,
+ GDS_CORE["usesParameter"],
+ class_=GDS_CORE["ParameterDef"],
+ message=(
+ "SC-005: Block references a parameter that is not a registered ParameterDef"
+ ),
+ )
+
+ # SC-008: Admissibility constraint must reference a BoundaryAction
+ sc008_shape = GDS_SHAPE["SC008AdmissibilityShape"]
+ g.add((sc008_shape, RDF.type, SH.NodeShape))
+ g.add((sc008_shape, SH.targetClass, GDS_CORE["AdmissibleInputConstraint"]))
+ _add_property_shape(
+ g,
+ sc008_shape,
+ GDS_CORE["constrainsBoundary"],
+ class_=GDS_CORE["BoundaryAction"],
+ message=("SC-008: Admissibility constraint must reference a BoundaryAction"),
+ )
+
+ # SC-009: Transition signature must reference a Mechanism
+ sc009_shape = GDS_SHAPE["SC009TransitionSigShape"]
+ g.add((sc009_shape, RDF.type, SH.NodeShape))
+ g.add((sc009_shape, SH.targetClass, GDS_CORE["TransitionSignature"]))
+ _add_property_shape(
+ g,
+ sc009_shape,
+ GDS_CORE["signatureForMechanism"],
+ class_=GDS_CORE["Mechanism"],
+ message="SC-009: Transition signature must reference a Mechanism",
+ )
+
+ return g
+
+
+# ── Combined ─────────────────────────────────────────────────────────
+
+
+def build_all_shapes() -> Graph:
+ """Build all SHACL shapes (structural + generic + semantic)."""
+ g = build_structural_shapes()
+ g += build_generic_shapes()
+ g += build_semantic_shapes()
+ return g
+
+
+# ── Validation ───────────────────────────────────────────────────────
+
+
+def validate_graph(
+ data_graph: Graph,
+ shapes_graph: Graph | None = None,
+) -> tuple[bool, Graph, str]:
+ """Validate an RDF graph against SHACL shapes.
+
+ Requires pyshacl (optional dependency).
+ Returns (conforms, results_graph, results_text).
+ """
+ try:
+ from pyshacl import validate
+ except ImportError as e:
+ raise ImportError(
+ "pyshacl is required for SHACL validation. "
+ "Install with: pip install gds-owl[shacl]"
+ ) from e
+
+ if shapes_graph is None:
+ shapes_graph = build_all_shapes()
+
+ conforms, results_graph, results_text = validate(
+ data_graph, shacl_graph=shapes_graph
+ )
+ return conforms, results_graph, results_text
diff --git a/packages/gds-owl/gds_owl/sparql.py b/packages/gds-owl/gds_owl/sparql.py
new file mode 100644
index 0000000..3d5743b
--- /dev/null
+++ b/packages/gds-owl/gds_owl/sparql.py
@@ -0,0 +1,200 @@
+"""SPARQL query templates for GDS RDF graphs.
+
+Pre-built queries for common analyses: dependency paths, reachability,
+loop detection, parameter impact, block grouping, and entity update maps.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from rdflib import Graph
+
+
+@dataclass(frozen=True)
+class SPARQLTemplate:
+ """A named, parameterized SPARQL query template."""
+
+ name: str
+ description: str
+ query: str
+
+
+# ── Template Registry ────────────────────────────────────────────────
+
+TEMPLATES: dict[str, SPARQLTemplate] = {}
+
+
+def _register(t: SPARQLTemplate) -> SPARQLTemplate:
+ TEMPLATES[t.name] = t
+ return t
+
+
+# ── Pre-built Queries ────────────────────────────────────────────────
+
+_register(
+ SPARQLTemplate(
+ name="blocks_by_role",
+ description="Group all blocks by their role (kind).",
+ query="""\
+PREFIX gds-core:
+
+SELECT ?block_name ?kind
+WHERE {
+ ?block gds-core:kind ?kind .
+ ?block gds-core:name ?block_name .
+}
+ORDER BY ?kind ?block_name
+""",
+ )
+)
+
+_register(
+ SPARQLTemplate(
+ name="dependency_path",
+ description="All wired connections in a GDSSpec.",
+ query="""\
+PREFIX gds-core:
+
+SELECT ?wiring_name ?source ?target ?space ?optional
+WHERE {
+ ?wiring a gds-core:SpecWiring ;
+ gds-core:name ?wiring_name ;
+ gds-core:hasWire ?wire .
+ ?wire gds-core:wireSource ?source ;
+ gds-core:wireTarget ?target .
+ OPTIONAL { ?wire gds-core:wireSpace ?space }
+ OPTIONAL { ?wire gds-core:wireOptional ?optional }
+}
+ORDER BY ?wiring_name ?source ?target
+""",
+ )
+)
+
+_register(
+ SPARQLTemplate(
+ name="entity_update_map",
+ description="Which mechanisms update which entity variables.",
+ query="""\
+PREFIX gds-core:
+
+SELECT ?block_name ?entity ?variable
+WHERE {
+ ?block a gds-core:Mechanism ;
+ gds-core:name ?block_name ;
+ gds-core:updatesEntry ?entry .
+ ?entry gds-core:updatesEntity ?entity ;
+ gds-core:updatesVariable ?variable .
+}
+ORDER BY ?block_name ?entity ?variable
+""",
+ )
+)
+
+_register(
+ SPARQLTemplate(
+ name="param_impact",
+ description="Which parameters are used by which blocks.",
+ query="""\
+PREFIX gds-core:
+
+SELECT ?param_name ?block_name ?kind
+WHERE {
+ ?block gds-core:usesParameter ?param .
+ ?param gds-core:name ?param_name .
+ ?block gds-core:name ?block_name .
+ ?block gds-core:kind ?kind .
+}
+ORDER BY ?param_name ?block_name
+""",
+ )
+)
+
+_register(
+ SPARQLTemplate(
+ name="ir_block_list",
+ description="List all BlockIR nodes in a SystemIR with their types.",
+ query="""\
+PREFIX gds-core:
+PREFIX gds-ir:
+
+SELECT ?block_name ?block_type ?logic
+WHERE {
+ ?block a gds-ir:BlockIR ;
+ gds-core:name ?block_name .
+ OPTIONAL { ?block gds-ir:blockType ?block_type }
+ OPTIONAL { ?block gds-ir:logic ?logic }
+}
+ORDER BY ?block_name
+""",
+ )
+)
+
+_register(
+ SPARQLTemplate(
+ name="ir_wiring_list",
+ description="List all WiringIR edges in a SystemIR.",
+ query="""\
+PREFIX gds-ir:
+
+SELECT ?source ?target ?label ?direction ?is_feedback ?is_temporal
+WHERE {
+ ?wiring a gds-ir:WiringIR ;
+ gds-ir:source ?source ;
+ gds-ir:target ?target .
+ OPTIONAL { ?wiring gds-ir:label ?label }
+ OPTIONAL { ?wiring gds-ir:direction ?direction }
+ OPTIONAL { ?wiring gds-ir:isFeedback ?is_feedback }
+ OPTIONAL { ?wiring gds-ir:isTemporal ?is_temporal }
+}
+ORDER BY ?source ?target
+""",
+ )
+)
+
+_register(
+ SPARQLTemplate(
+ name="verification_summary",
+ description="Summary of verification findings by check ID and severity.",
+ query="""\
+PREFIX gds-verif:
+
+SELECT ?check_id ?severity ?passed ?message
+WHERE {
+ ?finding a gds-verif:Finding ;
+ gds-verif:checkId ?check_id ;
+ gds-verif:severity ?severity ;
+ gds-verif:passed ?passed .
+ OPTIONAL { ?finding gds-verif:message ?message }
+}
+ORDER BY ?check_id
+""",
+ )
+)
+
+
+# ── Query Execution ──────────────────────────────────────────────────
+
+
+def run_query(
+ graph: Graph,
+ template_name: str,
+ **params: str,
+) -> list[dict[str, Any]]:
+ """Run a registered SPARQL template against a graph.
+
+ Parameters can be substituted into the query using Python string
+ formatting ({param_name} placeholders).
+
+ Returns a list of dicts, one per result row, with variable names as keys.
+ """
+ if template_name not in TEMPLATES:
+ raise KeyError(
+ f"Unknown template '{template_name}'. Available: {sorted(TEMPLATES.keys())}"
+ )
+ template = TEMPLATES[template_name]
+ query = template.query.format(**params) if params else template.query
+ results = graph.query(query)
+ return [{str(var): row[i] for i, var in enumerate(results.vars)} for row in results]
diff --git a/packages/gds-owl/pyproject.toml b/packages/gds-owl/pyproject.toml
new file mode 100644
index 0000000..fb008bc
--- /dev/null
+++ b/packages/gds-owl/pyproject.toml
@@ -0,0 +1,84 @@
+[project]
+name = "gds-owl"
+dynamic = ["version"]
+description = "OWL/Turtle, SHACL, and SPARQL for gds-framework specifications"
+readme = "README.md"
+license = "Apache-2.0"
+requires-python = ">=3.12"
+authors = [
+ { name = "Rohan Mehta", email = "rohan@block.science" },
+]
+keywords = [
+ "generalized-dynamical-systems",
+ "owl",
+ "rdf",
+ "turtle",
+ "shacl",
+ "sparql",
+ "ontology",
+ "gds-framework",
+]
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Intended Audience :: Developers",
+ "Intended Audience :: Science/Research",
+ "License :: OSI Approved :: Apache Software License",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Scientific/Engineering",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Typing :: Typed",
+]
+dependencies = [
+ "gds-framework>=0.2.3",
+ "rdflib>=7.0",
+]
+
+[project.optional-dependencies]
+shacl = [
+ "pyshacl>=0.27",
+]
+
+[project.urls]
+Homepage = "https://github.com/BlockScience/gds-core"
+Repository = "https://github.com/BlockScience/gds-core"
+Documentation = "https://blockscience.github.io/gds-core"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.version]
+path = "gds_owl/__init__.py"
+
+[tool.hatch.build.targets.wheel]
+packages = ["gds_owl"]
+
+[tool.uv.sources]
+gds-framework = { workspace = true }
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "--import-mode=importlib --cov=gds_owl --cov-report=term-missing --no-header -q"
+
+[tool.coverage.run]
+source = ["gds_owl"]
+omit = ["gds_owl/__init__.py"]
+
+[tool.coverage.report]
+fail_under = 80
+show_missing = true
+exclude_lines = [
+ "if TYPE_CHECKING:",
+ "pragma: no cover",
+]
+
+[dependency-groups]
+dev = [
+ "mypy>=1.19.1",
+ "pyright>=1.1.408",
+ "pytest>=8.0",
+ "pytest-cov>=6.0",
+ "ruff>=0.8",
+]
diff --git a/packages/gds-owl/tests/__init__.py b/packages/gds-owl/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/gds-owl/tests/conftest.py b/packages/gds-owl/tests/conftest.py
new file mode 100644
index 0000000..8da077c
--- /dev/null
+++ b/packages/gds-owl/tests/conftest.py
@@ -0,0 +1,127 @@
+"""Shared fixtures for gds-owl tests."""
+
+import pytest
+
+import gds
+from gds import (
+ BoundaryAction,
+ CanonicalGDS,
+ GDSSpec,
+ Mechanism,
+ Policy,
+ SystemIR,
+ VerificationReport,
+ compile_system,
+ interface,
+ project_canonical,
+ verify,
+)
+from gds.types.typedef import TypeDef
+
+
+@pytest.fixture()
+def temperature_type() -> TypeDef:
+ return gds.typedef("Temperature", float, units="celsius")
+
+
+@pytest.fixture()
+def heater_command_type() -> TypeDef:
+ return gds.typedef("HeaterCommand", float)
+
+
+@pytest.fixture()
+def temp_entity(temperature_type: TypeDef) -> gds.Entity:
+ return gds.entity("Room", temperature=gds.state_var(temperature_type, symbol="T"))
+
+
+@pytest.fixture()
+def sensor() -> BoundaryAction:
+ return BoundaryAction(
+ name="Sensor",
+ interface=interface(forward_out=["Temperature"]),
+ )
+
+
+@pytest.fixture()
+def controller() -> Policy:
+ return Policy(
+ name="Controller",
+ interface=interface(forward_in=["Temperature"], forward_out=["Heater Command"]),
+ params_used=["gain"],
+ )
+
+
+@pytest.fixture()
+def heater() -> Mechanism:
+ return Mechanism(
+ name="Heater",
+ interface=interface(forward_in=["Heater Command"]),
+ updates=[("Room", "temperature")],
+ )
+
+
+@pytest.fixture()
+def thermostat_spec(
+ temperature_type: TypeDef,
+ heater_command_type: TypeDef,
+ temp_entity: gds.Entity,
+ sensor: BoundaryAction,
+ controller: Policy,
+ heater: Mechanism,
+) -> GDSSpec:
+ temp_space = gds.space("TemperatureSpace", temperature=temperature_type)
+ cmd_space = gds.space("CommandSpace", command=heater_command_type)
+ gain_param = gds.ParameterDef(
+ name="gain",
+ typedef=gds.typedef("GainType", float),
+ description="Controller gain",
+ )
+
+ spec = GDSSpec(name="thermostat", description="Simple thermostat system")
+ spec.collect(
+ temperature_type,
+ heater_command_type,
+ temp_space,
+ cmd_space,
+ temp_entity,
+ sensor,
+ controller,
+ heater,
+ gain_param,
+ )
+ spec.register_wiring(
+ gds.SpecWiring(
+ name="main",
+ block_names=["Sensor", "Controller", "Heater"],
+ wires=[
+ gds.Wire(
+ source="Sensor",
+ target="Controller",
+ space="TemperatureSpace",
+ ),
+ gds.Wire(source="Controller", target="Heater", space="CommandSpace"),
+ ],
+ description="Main thermostat wiring",
+ )
+ )
+ return spec
+
+
+@pytest.fixture()
+def thermostat_ir(
+ sensor: BoundaryAction,
+ controller: Policy,
+ heater: Mechanism,
+) -> SystemIR:
+ system = sensor >> controller >> heater
+ return compile_system("thermostat", system)
+
+
+@pytest.fixture()
+def thermostat_canonical(thermostat_spec: GDSSpec) -> CanonicalGDS:
+ return project_canonical(thermostat_spec)
+
+
+@pytest.fixture()
+def thermostat_report(thermostat_ir: SystemIR) -> VerificationReport:
+ return verify(thermostat_ir)
diff --git a/packages/gds-owl/tests/test_export.py b/packages/gds-owl/tests/test_export.py
new file mode 100644
index 0000000..c5b1129
--- /dev/null
+++ b/packages/gds-owl/tests/test_export.py
@@ -0,0 +1,267 @@
+"""Tests for GDS -> RDF export functions."""
+
+from rdflib import RDF, Graph, Literal
+
+from gds import CanonicalGDS, GDSSpec, SystemIR
+from gds.verification.findings import VerificationReport
+from gds_owl._namespace import GDS_CORE, GDS_IR, GDS_VERIF
+from gds_owl.export import (
+ canonical_to_graph,
+ report_to_graph,
+ spec_to_graph,
+ system_ir_to_graph,
+)
+from gds_owl.serialize import (
+ spec_to_turtle,
+ system_ir_to_turtle,
+ to_jsonld,
+ to_ntriples,
+ to_turtle,
+)
+
+
+class TestSpecToGraph:
+ def test_returns_graph(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ assert isinstance(g, Graph)
+ assert len(g) > 0
+
+ def test_spec_individual_exists(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ specs = list(g.subjects(RDF.type, GDS_CORE["GDSSpec"]))
+ assert len(specs) == 1
+
+ def test_spec_name(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ specs = list(g.subjects(RDF.type, GDS_CORE["GDSSpec"]))
+ names = list(g.objects(specs[0], GDS_CORE["name"]))
+ assert Literal("thermostat") in names
+
+ def test_types_exported(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ typedefs = list(g.subjects(RDF.type, GDS_CORE["TypeDef"]))
+ assert len(typedefs) >= 2 # Temperature, HeaterCommand (+ GainType)
+
+ def test_typedef_has_python_type(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ typedefs = list(g.subjects(RDF.type, GDS_CORE["TypeDef"]))
+ for td in typedefs:
+ py_types = list(g.objects(td, GDS_CORE["pythonType"]))
+ assert len(py_types) == 1
+
+ def test_typedef_has_units(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ # Temperature has units="celsius"
+ found_units = False
+ for td in g.subjects(RDF.type, GDS_CORE["TypeDef"]):
+ units = list(g.objects(td, GDS_CORE["units"]))
+ if units and str(units[0]) == "celsius":
+ found_units = True
+ assert found_units
+
+ def test_spaces_exported(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spaces = list(g.subjects(RDF.type, GDS_CORE["Space"]))
+ assert len(spaces) == 2 # TemperatureSpace, CommandSpace
+
+ def test_space_has_fields(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ fields = list(g.subjects(RDF.type, GDS_CORE["SpaceField"]))
+ assert len(fields) >= 2
+
+ def test_entities_exported(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ entities = list(g.subjects(RDF.type, GDS_CORE["Entity"]))
+ assert len(entities) == 1 # Room
+
+ def test_entity_has_variables(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ state_vars = list(g.subjects(RDF.type, GDS_CORE["StateVariable"]))
+ assert len(state_vars) == 1 # temperature
+
+ def test_state_variable_has_symbol(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ svs = list(g.subjects(RDF.type, GDS_CORE["StateVariable"]))
+ for sv in svs:
+ symbols = list(g.objects(sv, GDS_CORE["symbol"]))
+ assert len(symbols) == 1
+
+ def test_blocks_exported(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ # Check role classes
+ boundaries = list(g.subjects(RDF.type, GDS_CORE["BoundaryAction"]))
+ policies = list(g.subjects(RDF.type, GDS_CORE["Policy"]))
+ mechanisms = list(g.subjects(RDF.type, GDS_CORE["Mechanism"]))
+ assert len(boundaries) == 1 # Sensor
+ assert len(policies) == 1 # Controller
+ assert len(mechanisms) == 1 # Heater
+
+ def test_block_has_interface(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ interfaces = list(g.subjects(RDF.type, GDS_CORE["Interface"]))
+ assert len(interfaces) == 3
+
+ def test_block_has_ports(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ ports = list(g.subjects(RDF.type, GDS_CORE["Port"]))
+ # Sensor fwd_out, Controller fwd_in+fwd_out, Heater fwd_in
+ assert len(ports) >= 3
+
+ def test_mechanism_has_updates(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ entries = list(g.subjects(RDF.type, GDS_CORE["UpdateMapEntry"]))
+ assert len(entries) == 1
+
+ def test_policy_uses_parameter(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ policies = list(g.subjects(RDF.type, GDS_CORE["Policy"]))
+ for p in policies:
+ params = list(g.objects(p, GDS_CORE["usesParameter"]))
+ assert len(params) == 1 # gain
+
+ def test_parameters_exported(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ params = list(g.subjects(RDF.type, GDS_CORE["ParameterDef"]))
+ assert len(params) == 1 # gain
+
+ def test_wirings_exported(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ wirings = list(g.subjects(RDF.type, GDS_CORE["SpecWiring"]))
+ assert len(wirings) == 1 # main
+
+ def test_wires_exported(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ wires = list(g.subjects(RDF.type, GDS_CORE["Wire"]))
+ assert len(wires) == 2 # Sensor->Controller, Controller->Heater
+
+ def test_custom_base_uri(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec, base_uri="https://example.com/")
+ ttl = g.serialize(format="turtle")
+ assert "example.com" in ttl
+
+
+class TestSystemIRToGraph:
+ def test_returns_graph(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ assert isinstance(g, Graph)
+
+ def test_system_ir_exists(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ systems = list(g.subjects(RDF.type, GDS_IR["SystemIR"]))
+ assert len(systems) == 1
+
+ def test_block_irs_exported(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ blocks = list(g.subjects(RDF.type, GDS_IR["BlockIR"]))
+ assert len(blocks) == 3
+
+ def test_wiring_irs_exported(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ wirings = list(g.subjects(RDF.type, GDS_IR["WiringIR"]))
+ assert len(wirings) >= 2
+
+ def test_hierarchy_exported(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ nodes = list(g.subjects(RDF.type, GDS_IR["HierarchyNodeIR"]))
+ assert len(nodes) >= 1
+
+ def test_block_ir_has_signature(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ blocks = list(g.subjects(RDF.type, GDS_IR["BlockIR"]))
+ for b in blocks:
+ fwd_in = list(g.objects(b, GDS_IR["signatureForwardIn"]))
+ assert len(fwd_in) == 1
+
+
+class TestCanonicalToGraph:
+ def test_returns_graph(self, thermostat_canonical: CanonicalGDS) -> None:
+ g = canonical_to_graph(thermostat_canonical)
+ assert isinstance(g, Graph)
+
+ def test_canonical_exists(self, thermostat_canonical: CanonicalGDS) -> None:
+ g = canonical_to_graph(thermostat_canonical)
+ canons = list(g.subjects(RDF.type, GDS_CORE["CanonicalGDS"]))
+ assert len(canons) == 1
+
+ def test_formula_exported(self, thermostat_canonical: CanonicalGDS) -> None:
+ g = canonical_to_graph(thermostat_canonical)
+ canons = list(g.subjects(RDF.type, GDS_CORE["CanonicalGDS"]))
+ formulas = list(g.objects(canons[0], GDS_CORE["formula"]))
+ assert len(formulas) == 1
+ assert "h" in str(formulas[0])
+
+ def test_role_blocks_exported(self, thermostat_canonical: CanonicalGDS) -> None:
+ g = canonical_to_graph(thermostat_canonical)
+ canons = list(g.subjects(RDF.type, GDS_CORE["CanonicalGDS"]))
+ can = canons[0]
+ boundaries = list(g.objects(can, GDS_CORE["boundaryBlock"]))
+ policies = list(g.objects(can, GDS_CORE["policyBlock"]))
+ mechanisms = list(g.objects(can, GDS_CORE["mechanismBlock"]))
+ assert len(boundaries) == 1
+ assert len(policies) == 1
+ assert len(mechanisms) == 1
+
+ def test_update_map_exported(self, thermostat_canonical: CanonicalGDS) -> None:
+ g = canonical_to_graph(thermostat_canonical)
+ entries = list(g.subjects(RDF.type, GDS_CORE["UpdateMapEntry"]))
+ assert len(entries) == 1
+
+
+class TestReportToGraph:
+ def test_returns_graph(self, thermostat_report: VerificationReport) -> None:
+ g = report_to_graph(thermostat_report)
+ assert isinstance(g, Graph)
+
+ def test_report_exists(self, thermostat_report: VerificationReport) -> None:
+ g = report_to_graph(thermostat_report)
+ reports = list(g.subjects(RDF.type, GDS_VERIF["VerificationReport"]))
+ assert len(reports) == 1
+
+ def test_findings_exported(self, thermostat_report: VerificationReport) -> None:
+ g = report_to_graph(thermostat_report)
+ findings = list(g.subjects(RDF.type, GDS_VERIF["Finding"]))
+ assert len(findings) == len(thermostat_report.findings)
+
+ def test_finding_has_check_id(self, thermostat_report: VerificationReport) -> None:
+ g = report_to_graph(thermostat_report)
+ findings = list(g.subjects(RDF.type, GDS_VERIF["Finding"]))
+ for f in findings:
+ check_ids = list(g.objects(f, GDS_VERIF["checkId"]))
+ assert len(check_ids) == 1
+
+
+class TestSerializationFormats:
+ def test_to_turtle(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ ttl = to_turtle(g)
+ assert isinstance(ttl, str)
+ assert "gds-core:GDSSpec" in ttl
+
+ def test_to_jsonld(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ jld = to_jsonld(g)
+ assert isinstance(jld, str)
+ assert "thermostat" in jld
+
+ def test_to_ntriples(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ nt = to_ntriples(g)
+ assert isinstance(nt, str)
+ assert "gds.block.science" in nt
+
+ def test_spec_to_turtle_convenience(self, thermostat_spec: GDSSpec) -> None:
+ ttl = spec_to_turtle(thermostat_spec)
+ assert "thermostat" in ttl
+ assert "gds-core:BoundaryAction" in ttl
+
+ def test_system_ir_to_turtle_convenience(self, thermostat_ir: SystemIR) -> None:
+ ttl = system_ir_to_turtle(thermostat_ir)
+ assert "thermostat" in ttl
+ assert "gds-ir:BlockIR" in ttl
+
+ def test_turtle_parses_back(self, thermostat_spec: GDSSpec) -> None:
+ g1 = spec_to_graph(thermostat_spec)
+ ttl = to_turtle(g1)
+ g2 = Graph()
+ g2.parse(data=ttl, format="turtle")
+ assert len(g1) == len(g2)
diff --git a/packages/gds-owl/tests/test_import.py b/packages/gds-owl/tests/test_import.py
new file mode 100644
index 0000000..b515dc8
--- /dev/null
+++ b/packages/gds-owl/tests/test_import.py
@@ -0,0 +1,222 @@
+"""Tests for RDF -> Pydantic import functions."""
+
+import pytest
+from rdflib import Graph
+
+from gds import CanonicalGDS, GDSSpec, SystemIR
+from gds.verification.findings import VerificationReport
+from gds_owl.export import (
+ canonical_to_graph,
+ report_to_graph,
+ spec_to_graph,
+ system_ir_to_graph,
+)
+from gds_owl.import_ import (
+ graph_to_canonical,
+ graph_to_report,
+ graph_to_spec,
+ graph_to_system_ir,
+)
+
+
+class TestGraphToSpec:
+ def test_reconstructs_spec_name(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ assert spec2.name == "thermostat"
+
+ def test_reconstructs_description(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ assert spec2.description == "Simple thermostat system"
+
+ def test_reconstructs_types(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ assert set(spec2.types.keys()) == set(thermostat_spec.types.keys())
+
+ def test_type_python_type_preserved(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ for name, td in spec2.types.items():
+ assert td.python_type == thermostat_spec.types[name].python_type
+
+ def test_type_units_preserved(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ assert spec2.types["Temperature"].units == "celsius"
+
+ def test_reconstructs_spaces(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ assert set(spec2.spaces.keys()) == set(thermostat_spec.spaces.keys())
+
+ def test_space_fields_preserved(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ for name, space in spec2.spaces.items():
+ orig = thermostat_spec.spaces[name]
+ assert set(space.fields.keys()) == set(orig.fields.keys())
+
+ def test_reconstructs_entities(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ assert set(spec2.entities.keys()) == set(thermostat_spec.entities.keys())
+
+ def test_entity_variables_preserved(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ for name, entity in spec2.entities.items():
+ orig = thermostat_spec.entities[name]
+ assert set(entity.variables.keys()) == set(orig.variables.keys())
+
+ def test_reconstructs_blocks(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ assert set(spec2.blocks.keys()) == set(thermostat_spec.blocks.keys())
+
+ def test_block_kinds_preserved(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ for name, block in spec2.blocks.items():
+ orig = thermostat_spec.blocks[name]
+ assert getattr(block, "kind", "generic") == getattr(orig, "kind", "generic")
+
+ def test_block_params_preserved(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ from gds.blocks.roles import HasParams
+
+ for name, block in spec2.blocks.items():
+ orig = thermostat_spec.blocks[name]
+ if isinstance(orig, HasParams):
+ assert isinstance(block, HasParams)
+ assert set(block.params_used) == set(orig.params_used)
+
+ def test_mechanism_updates_preserved(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ from gds.blocks.roles import Mechanism
+
+ for name, block in spec2.blocks.items():
+ orig = thermostat_spec.blocks[name]
+ if isinstance(orig, Mechanism):
+ assert isinstance(block, Mechanism)
+ assert set(block.updates) == set(orig.updates)
+
+ def test_reconstructs_parameters(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ assert (
+ spec2.parameter_schema.names() == thermostat_spec.parameter_schema.names()
+ )
+
+ def test_reconstructs_wirings(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ assert set(spec2.wirings.keys()) == set(thermostat_spec.wirings.keys())
+
+ def test_wiring_wires_preserved(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ spec2 = graph_to_spec(g)
+ for name, wiring in spec2.wirings.items():
+ orig = thermostat_spec.wirings[name]
+ assert len(wiring.wires) == len(orig.wires)
+
+ def test_raises_on_empty_graph(self) -> None:
+ g = Graph()
+ with pytest.raises(ValueError, match="No GDSSpec found"):
+ graph_to_spec(g)
+
+
+class TestGraphToSystemIR:
+ def test_reconstructs_name(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ ir2 = graph_to_system_ir(g)
+ assert ir2.name == "thermostat"
+
+ def test_reconstructs_blocks(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ ir2 = graph_to_system_ir(g)
+ assert len(ir2.blocks) == len(thermostat_ir.blocks)
+
+ def test_block_names_match(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ ir2 = graph_to_system_ir(g)
+ orig_names = {b.name for b in thermostat_ir.blocks}
+ new_names = {b.name for b in ir2.blocks}
+ assert new_names == orig_names
+
+ def test_reconstructs_wirings(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ ir2 = graph_to_system_ir(g)
+ assert len(ir2.wirings) == len(thermostat_ir.wirings)
+
+ def test_composition_type_preserved(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ ir2 = graph_to_system_ir(g)
+ assert ir2.composition_type == thermostat_ir.composition_type
+
+ def test_raises_on_empty_graph(self) -> None:
+ g = Graph()
+ with pytest.raises(ValueError, match="No SystemIR found"):
+ graph_to_system_ir(g)
+
+
+class TestGraphToCanonical:
+ def test_reconstructs_blocks(self, thermostat_canonical: CanonicalGDS) -> None:
+ g = canonical_to_graph(thermostat_canonical)
+ can2 = graph_to_canonical(g)
+ assert set(can2.boundary_blocks) == set(thermostat_canonical.boundary_blocks)
+ assert set(can2.policy_blocks) == set(thermostat_canonical.policy_blocks)
+ assert set(can2.mechanism_blocks) == set(thermostat_canonical.mechanism_blocks)
+
+ def test_reconstructs_state_variables(
+ self, thermostat_canonical: CanonicalGDS
+ ) -> None:
+ g = canonical_to_graph(thermostat_canonical)
+ can2 = graph_to_canonical(g)
+ assert set(can2.state_variables) == set(thermostat_canonical.state_variables)
+
+ def test_reconstructs_update_map(self, thermostat_canonical: CanonicalGDS) -> None:
+ g = canonical_to_graph(thermostat_canonical)
+ can2 = graph_to_canonical(g)
+ # Compare as sets of (mech_name, set_of_updates)
+ orig = {(m, frozenset(u)) for m, u in thermostat_canonical.update_map}
+ new = {(m, frozenset(u)) for m, u in can2.update_map}
+ assert new == orig
+
+ def test_raises_on_empty_graph(self) -> None:
+ g = Graph()
+ with pytest.raises(ValueError, match="No CanonicalGDS found"):
+ graph_to_canonical(g)
+
+
+class TestGraphToReport:
+ def test_reconstructs_system_name(
+ self, thermostat_report: VerificationReport
+ ) -> None:
+ g = report_to_graph(thermostat_report)
+ r2 = graph_to_report(g)
+ assert r2.system_name == thermostat_report.system_name
+
+ def test_reconstructs_findings_count(
+ self, thermostat_report: VerificationReport
+ ) -> None:
+ g = report_to_graph(thermostat_report)
+ r2 = graph_to_report(g)
+ assert len(r2.findings) == len(thermostat_report.findings)
+
+ def test_finding_check_ids_preserved(
+ self, thermostat_report: VerificationReport
+ ) -> None:
+ g = report_to_graph(thermostat_report)
+ r2 = graph_to_report(g)
+ orig_ids = {f.check_id for f in thermostat_report.findings}
+ new_ids = {f.check_id for f in r2.findings}
+ assert new_ids == orig_ids
+
+ def test_raises_on_empty_graph(self) -> None:
+ g = Graph()
+ with pytest.raises(ValueError, match="No VerificationReport found"):
+ graph_to_report(g)
diff --git a/packages/gds-owl/tests/test_namespace.py b/packages/gds-owl/tests/test_namespace.py
new file mode 100644
index 0000000..9b274de
--- /dev/null
+++ b/packages/gds-owl/tests/test_namespace.py
@@ -0,0 +1,46 @@
+"""Tests for GDS OWL namespace constants."""
+
+from rdflib import Namespace
+
+from gds_owl._namespace import (
+ DEFAULT_BASE_URI,
+ GDS,
+ GDS_CORE,
+ GDS_IR,
+ GDS_VERIF,
+ PREFIXES,
+)
+
+
+class TestNamespaceURIs:
+ def test_base_namespace_is_well_formed(self) -> None:
+ assert str(GDS).startswith("https://")
+ assert str(GDS).endswith("/")
+
+ def test_sub_namespaces_extend_base(self) -> None:
+ base = str(GDS)
+ assert str(GDS_CORE).startswith(base)
+ assert str(GDS_IR).startswith(base)
+ assert str(GDS_VERIF).startswith(base)
+
+ def test_sub_namespaces_are_distinct(self) -> None:
+ uris = {str(GDS_CORE), str(GDS_IR), str(GDS_VERIF)}
+ assert len(uris) == 3
+
+ def test_prefixes_cover_all_namespaces(self) -> None:
+ assert "gds" in PREFIXES
+ assert "gds-core" in PREFIXES
+ assert "gds-ir" in PREFIXES
+ assert "gds-verif" in PREFIXES
+
+ def test_prefixes_are_namespace_instances(self) -> None:
+ for ns in PREFIXES.values():
+ assert isinstance(ns, Namespace)
+
+ def test_uriref_generation(self) -> None:
+ block_uri = GDS_CORE["Block"]
+ assert str(block_uri) == "https://gds.block.science/ontology/core/Block"
+
+ def test_default_base_uri(self) -> None:
+ assert DEFAULT_BASE_URI.startswith("https://")
+ assert DEFAULT_BASE_URI.endswith("/")
diff --git a/packages/gds-owl/tests/test_ontology.py b/packages/gds-owl/tests/test_ontology.py
new file mode 100644
index 0000000..e81e794
--- /dev/null
+++ b/packages/gds-owl/tests/test_ontology.py
@@ -0,0 +1,201 @@
+"""Tests for the GDS core ontology (TBox)."""
+
+from rdflib import OWL, RDF, RDFS, Graph
+
+from gds_owl._namespace import GDS, GDS_CORE, GDS_IR, GDS_VERIF
+from gds_owl.ontology import build_core_ontology
+
+
+class TestOntologyStructure:
+ def test_returns_graph(self) -> None:
+ g = build_core_ontology()
+ assert isinstance(g, Graph)
+
+ def test_has_ontology_declaration(self) -> None:
+ g = build_core_ontology()
+ assert (GDS["ontology"], RDF.type, OWL.Ontology) in g
+
+ def test_has_ontology_label(self) -> None:
+ g = build_core_ontology()
+ labels = list(g.objects(GDS["ontology"], RDFS.label))
+ assert len(labels) == 1
+ assert "Generalized Dynamical Systems" in str(labels[0])
+
+ def test_nonempty(self) -> None:
+ g = build_core_ontology()
+ assert len(g) > 50
+
+
+class TestBlockHierarchy:
+ def test_block_is_owl_class(self) -> None:
+ g = build_core_ontology()
+ assert (GDS_CORE["Block"], RDF.type, OWL.Class) in g
+
+ def test_atomic_block_subclasses_block(self) -> None:
+ g = build_core_ontology()
+ assert (GDS_CORE["AtomicBlock"], RDFS.subClassOf, GDS_CORE["Block"]) in g
+
+ def test_composition_operators_subclass_block(self) -> None:
+ g = build_core_ontology()
+ for cls in [
+ "StackComposition",
+ "ParallelComposition",
+ "FeedbackLoop",
+ "TemporalLoop",
+ ]:
+ assert (GDS_CORE[cls], RDFS.subClassOf, GDS_CORE["Block"]) in g
+
+ def test_roles_subclass_atomic_block(self) -> None:
+ g = build_core_ontology()
+ for role in ["BoundaryAction", "Policy", "Mechanism", "ControlAction"]:
+ assert (GDS_CORE[role], RDFS.subClassOf, GDS_CORE["AtomicBlock"]) in g
+
+ def test_all_block_types_are_owl_classes(self) -> None:
+ g = build_core_ontology()
+ block_types = [
+ "Block",
+ "AtomicBlock",
+ "StackComposition",
+ "ParallelComposition",
+ "FeedbackLoop",
+ "TemporalLoop",
+ "BoundaryAction",
+ "Policy",
+ "Mechanism",
+ "ControlAction",
+ ]
+ for bt in block_types:
+ assert (GDS_CORE[bt], RDF.type, OWL.Class) in g
+
+
+class TestSpecFramework:
+ def test_spec_classes_exist(self) -> None:
+ g = build_core_ontology()
+ for cls in [
+ "GDSSpec",
+ "TypeDef",
+ "Space",
+ "Entity",
+ "StateVariable",
+ "SpecWiring",
+ "Wire",
+ "ParameterDef",
+ "CanonicalGDS",
+ ]:
+ assert (GDS_CORE[cls], RDF.type, OWL.Class) in g
+
+ def test_spec_object_properties(self) -> None:
+ g = build_core_ontology()
+ for prop in [
+ "hasBlock",
+ "hasType",
+ "hasSpace",
+ "hasEntity",
+ "hasWiring",
+ "hasParameter",
+ ]:
+ assert (GDS_CORE[prop], RDF.type, OWL.ObjectProperty) in g
+
+ def test_entity_has_variable_property(self) -> None:
+ g = build_core_ontology()
+ assert (GDS_CORE["hasVariable"], RDF.type, OWL.ObjectProperty) in g
+ assert (GDS_CORE["hasVariable"], RDFS.domain, GDS_CORE["Entity"]) in g
+ assert (GDS_CORE["hasVariable"], RDFS.range, GDS_CORE["StateVariable"]) in g
+
+ def test_state_variable_uses_type(self) -> None:
+ g = build_core_ontology()
+ assert (GDS_CORE["usesType"], RDF.type, OWL.ObjectProperty) in g
+
+ def test_mechanism_updates_entry(self) -> None:
+ g = build_core_ontology()
+ assert (GDS_CORE["updatesEntry"], RDF.type, OWL.ObjectProperty) in g
+ assert (GDS_CORE["UpdateMapEntry"], RDF.type, OWL.Class) in g
+
+ def test_canonical_properties(self) -> None:
+ g = build_core_ontology()
+ for prop in ["boundaryBlock", "controlBlock", "policyBlock", "mechanismBlock"]:
+ assert (GDS_CORE[prop], RDF.type, OWL.ObjectProperty) in g
+
+
+class TestIRClasses:
+ def test_ir_classes_exist(self) -> None:
+ g = build_core_ontology()
+ for cls in ["SystemIR", "BlockIR", "WiringIR", "HierarchyNodeIR", "InputIR"]:
+ assert (GDS_IR[cls], RDF.type, OWL.Class) in g
+
+ def test_system_ir_properties(self) -> None:
+ g = build_core_ontology()
+ for prop in ["hasBlockIR", "hasWiringIR", "hasInputIR", "hasHierarchy"]:
+ assert (GDS_IR[prop], RDF.type, OWL.ObjectProperty) in g
+
+ def test_block_ir_datatype_properties(self) -> None:
+ g = build_core_ontology()
+ for prop in [
+ "blockType",
+ "signatureForwardIn",
+ "signatureForwardOut",
+ "logic",
+ "colorCode",
+ ]:
+ assert (GDS_IR[prop], RDF.type, OWL.DatatypeProperty) in g
+
+ def test_wiring_ir_properties(self) -> None:
+ g = build_core_ontology()
+ for prop in [
+ "source",
+ "target",
+ "label",
+ "direction",
+ "isFeedback",
+ "isTemporal",
+ ]:
+ assert (GDS_IR[prop], RDF.type, OWL.DatatypeProperty) in g
+
+ def test_hierarchy_has_child(self) -> None:
+ g = build_core_ontology()
+ assert (GDS_IR["hasChild"], RDF.type, OWL.ObjectProperty) in g
+
+
+class TestVerificationClasses:
+ def test_finding_class(self) -> None:
+ g = build_core_ontology()
+ assert (GDS_VERIF["Finding"], RDF.type, OWL.Class) in g
+
+ def test_report_class(self) -> None:
+ g = build_core_ontology()
+ assert (GDS_VERIF["VerificationReport"], RDF.type, OWL.Class) in g
+
+ def test_report_has_finding(self) -> None:
+ g = build_core_ontology()
+ assert (GDS_VERIF["hasFinding"], RDF.type, OWL.ObjectProperty) in g
+
+ def test_finding_properties(self) -> None:
+ g = build_core_ontology()
+ for prop in [
+ "checkId",
+ "severity",
+ "message",
+ "passed",
+ "exportablePredicate",
+ ]:
+ assert (GDS_VERIF[prop], RDF.type, OWL.DatatypeProperty) in g
+
+
+class TestOntologySerialization:
+ def test_serializes_to_turtle(self) -> None:
+ g = build_core_ontology()
+ ttl = g.serialize(format="turtle")
+ assert "gds-core:Block" in ttl
+ assert "owl:Class" in ttl
+
+ def test_serializes_to_xml(self) -> None:
+ g = build_core_ontology()
+ xml = g.serialize(format="xml")
+ assert "RDF" in xml
+
+ def test_round_trips_through_turtle(self) -> None:
+ g1 = build_core_ontology()
+ ttl = g1.serialize(format="turtle")
+ g2 = Graph()
+ g2.parse(data=ttl, format="turtle")
+ assert len(g1) == len(g2)
diff --git a/packages/gds-owl/tests/test_roundtrip.py b/packages/gds-owl/tests/test_roundtrip.py
new file mode 100644
index 0000000..b9db3c3
--- /dev/null
+++ b/packages/gds-owl/tests/test_roundtrip.py
@@ -0,0 +1,201 @@
+"""Round-trip tests: Pydantic -> Turtle -> Pydantic.
+
+These prove the RDF representation is structurally lossless
+(except for known lossy fields like TypeDef.constraint).
+"""
+
+from rdflib import Graph
+
+from gds import CanonicalGDS, GDSSpec, SystemIR
+from gds.blocks.roles import HasParams, Mechanism
+from gds.verification.findings import VerificationReport
+from gds_owl.import_ import (
+ graph_to_canonical,
+ graph_to_report,
+ graph_to_spec,
+ graph_to_system_ir,
+)
+from gds_owl.serialize import to_turtle
+
+
+class TestSpecRoundTrip:
+ def _round_trip(self, spec: GDSSpec) -> GDSSpec:
+ from gds_owl.export import spec_to_graph
+
+ g = spec_to_graph(spec)
+ ttl = to_turtle(g)
+ g2 = Graph()
+ g2.parse(data=ttl, format="turtle")
+ return graph_to_spec(g2)
+
+ def test_name_survives(self, thermostat_spec: GDSSpec) -> None:
+ spec2 = self._round_trip(thermostat_spec)
+ assert spec2.name == thermostat_spec.name
+
+ def test_types_survive(self, thermostat_spec: GDSSpec) -> None:
+ spec2 = self._round_trip(thermostat_spec)
+ assert set(spec2.types.keys()) == set(thermostat_spec.types.keys())
+ for name in thermostat_spec.types:
+ orig_type = thermostat_spec.types[name].python_type
+ assert spec2.types[name].python_type == orig_type
+ assert spec2.types[name].units == thermostat_spec.types[name].units
+
+ def test_spaces_survive(self, thermostat_spec: GDSSpec) -> None:
+ spec2 = self._round_trip(thermostat_spec)
+ assert set(spec2.spaces.keys()) == set(thermostat_spec.spaces.keys())
+ for name in thermostat_spec.spaces:
+ assert set(spec2.spaces[name].fields.keys()) == set(
+ thermostat_spec.spaces[name].fields.keys()
+ )
+
+ def test_entities_survive(self, thermostat_spec: GDSSpec) -> None:
+ spec2 = self._round_trip(thermostat_spec)
+ assert set(spec2.entities.keys()) == set(thermostat_spec.entities.keys())
+ for name in thermostat_spec.entities:
+ assert set(spec2.entities[name].variables.keys()) == set(
+ thermostat_spec.entities[name].variables.keys()
+ )
+
+ def test_blocks_survive(self, thermostat_spec: GDSSpec) -> None:
+ spec2 = self._round_trip(thermostat_spec)
+ assert set(spec2.blocks.keys()) == set(thermostat_spec.blocks.keys())
+ for name in thermostat_spec.blocks:
+ orig = thermostat_spec.blocks[name]
+ new = spec2.blocks[name]
+ assert getattr(new, "kind", "generic") == getattr(orig, "kind", "generic")
+ if isinstance(orig, HasParams):
+ assert isinstance(new, HasParams)
+ assert set(new.params_used) == set(orig.params_used)
+ if isinstance(orig, Mechanism):
+ assert isinstance(new, Mechanism)
+ assert set(new.updates) == set(orig.updates)
+
+ def test_parameters_survive(self, thermostat_spec: GDSSpec) -> None:
+ spec2 = self._round_trip(thermostat_spec)
+ assert (
+ spec2.parameter_schema.names() == thermostat_spec.parameter_schema.names()
+ )
+
+ def test_wirings_survive(self, thermostat_spec: GDSSpec) -> None:
+ spec2 = self._round_trip(thermostat_spec)
+ assert set(spec2.wirings.keys()) == set(thermostat_spec.wirings.keys())
+ for name in thermostat_spec.wirings:
+ assert len(spec2.wirings[name].wires) == len(
+ thermostat_spec.wirings[name].wires
+ )
+
+ def test_constraint_is_lossy(self, thermostat_spec: GDSSpec) -> None:
+ """TypeDef.constraint is not serializable; imported as None."""
+ spec2 = self._round_trip(thermostat_spec)
+ for td in spec2.types.values():
+ assert td.constraint is None
+
+ def test_admissibility_constraints_survive(self, thermostat_spec: GDSSpec) -> None:
+ from gds.constraints import AdmissibleInputConstraint
+
+ thermostat_spec.register_admissibility(
+ AdmissibleInputConstraint(
+ name="sensor_dep",
+ boundary_block="Sensor",
+ depends_on=[("Room", "temperature")],
+ constraint=lambda s, u: True,
+ description="Sensor reads room temp",
+ )
+ )
+ spec2 = self._round_trip(thermostat_spec)
+ assert "sensor_dep" in spec2.admissibility_constraints
+ ac = spec2.admissibility_constraints["sensor_dep"]
+ assert ac.boundary_block == "Sensor"
+ assert set(ac.depends_on) == {("Room", "temperature")}
+ assert ac.description == "Sensor reads room temp"
+ assert ac.constraint is None # lossy
+
+ def test_transition_signatures_survive(self, thermostat_spec: GDSSpec) -> None:
+ from gds.constraints import TransitionSignature
+
+ thermostat_spec.register_transition_signature(
+ TransitionSignature(
+ mechanism="Heater",
+ reads=[("Room", "temperature")],
+ depends_on_blocks=["Controller"],
+ preserves_invariant="temp >= 0",
+ )
+ )
+ spec2 = self._round_trip(thermostat_spec)
+ assert "Heater" in spec2.transition_signatures
+ ts = spec2.transition_signatures["Heater"]
+ assert set(ts.reads) == {("Room", "temperature")}
+ assert set(ts.depends_on_blocks) == {"Controller"}
+ assert ts.preserves_invariant == "temp >= 0"
+
+
+class TestSystemIRRoundTrip:
+ def _round_trip(self, ir: SystemIR) -> SystemIR:
+ from gds_owl.export import system_ir_to_graph
+
+ g = system_ir_to_graph(ir)
+ ttl = to_turtle(g)
+ g2 = Graph()
+ g2.parse(data=ttl, format="turtle")
+ return graph_to_system_ir(g2)
+
+ def test_name_survives(self, thermostat_ir: SystemIR) -> None:
+ ir2 = self._round_trip(thermostat_ir)
+ assert ir2.name == thermostat_ir.name
+
+ def test_blocks_survive(self, thermostat_ir: SystemIR) -> None:
+ ir2 = self._round_trip(thermostat_ir)
+ orig_names = {b.name for b in thermostat_ir.blocks}
+ new_names = {b.name for b in ir2.blocks}
+ assert new_names == orig_names
+
+ def test_wirings_survive(self, thermostat_ir: SystemIR) -> None:
+ ir2 = self._round_trip(thermostat_ir)
+ assert len(ir2.wirings) == len(thermostat_ir.wirings)
+
+ def test_composition_type_survives(self, thermostat_ir: SystemIR) -> None:
+ ir2 = self._round_trip(thermostat_ir)
+ assert ir2.composition_type == thermostat_ir.composition_type
+
+
+class TestCanonicalRoundTrip:
+ def _round_trip(self, can: CanonicalGDS) -> CanonicalGDS:
+ from gds_owl.export import canonical_to_graph
+
+ g = canonical_to_graph(can)
+ ttl = to_turtle(g)
+ g2 = Graph()
+ g2.parse(data=ttl, format="turtle")
+ return graph_to_canonical(g2)
+
+ def test_blocks_survive(self, thermostat_canonical: CanonicalGDS) -> None:
+ can2 = self._round_trip(thermostat_canonical)
+ assert set(can2.boundary_blocks) == set(thermostat_canonical.boundary_blocks)
+ assert set(can2.policy_blocks) == set(thermostat_canonical.policy_blocks)
+ assert set(can2.mechanism_blocks) == set(thermostat_canonical.mechanism_blocks)
+
+ def test_state_variables_survive(self, thermostat_canonical: CanonicalGDS) -> None:
+ can2 = self._round_trip(thermostat_canonical)
+ assert set(can2.state_variables) == set(thermostat_canonical.state_variables)
+
+
+class TestReportRoundTrip:
+ def _round_trip(self, report: VerificationReport) -> VerificationReport:
+ from gds_owl.export import report_to_graph
+
+ g = report_to_graph(report)
+ ttl = to_turtle(g)
+ g2 = Graph()
+ g2.parse(data=ttl, format="turtle")
+ return graph_to_report(g2)
+
+ def test_system_name_survives(self, thermostat_report: VerificationReport) -> None:
+ r2 = self._round_trip(thermostat_report)
+ assert r2.system_name == thermostat_report.system_name
+
+ def test_findings_survive(self, thermostat_report: VerificationReport) -> None:
+ r2 = self._round_trip(thermostat_report)
+ assert len(r2.findings) == len(thermostat_report.findings)
+ orig_ids = {f.check_id for f in thermostat_report.findings}
+ new_ids = {f.check_id for f in r2.findings}
+ assert new_ids == orig_ids
diff --git a/packages/gds-owl/tests/test_shacl.py b/packages/gds-owl/tests/test_shacl.py
new file mode 100644
index 0000000..1ae4190
--- /dev/null
+++ b/packages/gds-owl/tests/test_shacl.py
@@ -0,0 +1,144 @@
+"""Tests for SHACL shape library."""
+
+import importlib.util
+
+import pytest
+from rdflib import RDF, SH, BNode, Graph, Literal
+
+from gds import GDSSpec, SystemIR
+from gds_owl._namespace import GDS_CORE
+from gds_owl.export import report_to_graph, spec_to_graph, system_ir_to_graph
+from gds_owl.shacl import (
+ build_all_shapes,
+ build_generic_shapes,
+ build_semantic_shapes,
+ build_structural_shapes,
+)
+
+HAS_PYSHACL = importlib.util.find_spec("pyshacl") is not None
+
+
+class TestStructuralShapes:
+ def test_returns_graph(self) -> None:
+ g = build_structural_shapes()
+ assert isinstance(g, Graph)
+ assert len(g) > 0
+
+ def test_has_node_shapes(self) -> None:
+ g = build_structural_shapes()
+ shapes = list(g.subjects(RDF.type, SH.NodeShape))
+ # GDSSpec, BA, Mech, Policy, Entity, TypeDef, Space, etc.
+ assert len(shapes) >= 8
+
+ def test_spec_shape_targets_gds_spec(self) -> None:
+ g = build_structural_shapes()
+ from gds_owl.shacl import GDS_SHAPE
+
+ spec_shape = GDS_SHAPE["GDSSpecShape"]
+ targets = list(g.objects(spec_shape, SH.targetClass))
+ assert GDS_CORE["GDSSpec"] in targets
+
+ def test_mechanism_shape_requires_updates(self) -> None:
+ g = build_structural_shapes()
+ from gds_owl.shacl import GDS_SHAPE
+
+ mech_shape = GDS_SHAPE["MechanismShape"]
+ props = list(g.objects(mech_shape, SH.property))
+ # One of the property shapes should have path = updatesEntry
+ paths = []
+ for p in props:
+ path_vals = list(g.objects(p, SH.path))
+ paths.extend(path_vals)
+ assert GDS_CORE["updatesEntry"] in paths
+
+
+class TestGenericShapes:
+ def test_returns_graph(self) -> None:
+ g = build_generic_shapes()
+ assert isinstance(g, Graph)
+
+ def test_has_g004_shape(self) -> None:
+ g = build_generic_shapes()
+ from gds_owl.shacl import GDS_SHAPE
+
+ assert (GDS_SHAPE["G004DanglingWiringShape"], RDF.type, SH.NodeShape) in g
+
+
+class TestSemanticShapes:
+ def test_returns_graph(self) -> None:
+ g = build_semantic_shapes()
+ assert isinstance(g, Graph)
+
+ def test_has_sc005_shape(self) -> None:
+ g = build_semantic_shapes()
+ from gds_owl.shacl import GDS_SHAPE
+
+ assert (GDS_SHAPE["SC005ParamRefShape"], RDF.type, SH.NodeShape) in g
+
+
+class TestAllShapes:
+ def test_combines_all(self) -> None:
+ g = build_all_shapes()
+ assert isinstance(g, Graph)
+ structural = build_structural_shapes()
+ generic = build_generic_shapes()
+ semantic = build_semantic_shapes()
+ # Combined should have at least as many triples
+ assert len(g) >= max(len(structural), len(generic), len(semantic))
+
+
+@pytest.mark.skipif(not HAS_PYSHACL, reason="pyshacl not installed")
+class TestValidation:
+ def test_valid_spec_conforms(self, thermostat_spec: GDSSpec) -> None:
+ from gds_owl.shacl import validate_graph
+
+ data = spec_to_graph(thermostat_spec)
+ conforms, _, _ = validate_graph(data)
+ assert conforms is True
+
+ def test_valid_system_ir_conforms(self, thermostat_ir: SystemIR) -> None:
+ from gds_owl.shacl import validate_graph
+
+ data = system_ir_to_graph(thermostat_ir)
+ conforms, _, _ = validate_graph(data)
+ assert conforms is True
+
+ def test_invalid_spec_fails(self) -> None:
+ """A GDSSpec with no name should fail validation."""
+ from gds_owl.shacl import validate_graph
+
+ g = Graph()
+ # Create a GDSSpec individual with no name
+ spec_uri = BNode()
+ g.add((spec_uri, RDF.type, GDS_CORE["GDSSpec"]))
+ conforms, _, _text = validate_graph(g)
+ assert conforms is False
+ assert "name" in _text.lower()
+
+ def test_invalid_mechanism_fails(self) -> None:
+ """A Mechanism with no updatesEntry should fail."""
+ from gds_owl.shacl import validate_graph
+
+ g = Graph()
+ mech_uri = BNode()
+ g.add((mech_uri, RDF.type, GDS_CORE["Mechanism"]))
+ g.add((mech_uri, GDS_CORE["name"], Literal("BadMech")))
+ conforms, _, _text = validate_graph(g)
+ assert conforms is False
+
+ def test_report_conforms(self, thermostat_report) -> None:
+ from gds_owl.shacl import validate_graph
+
+ data = report_to_graph(thermostat_report)
+ conforms, _, _ = validate_graph(data)
+ assert conforms is True
+
+
+@pytest.mark.skipif(HAS_PYSHACL, reason="pyshacl is installed")
+class TestValidationWithoutPyshacl:
+ def test_raises_import_error(self, thermostat_spec: GDSSpec) -> None:
+ from gds_owl.shacl import validate_graph
+
+ data = spec_to_graph(thermostat_spec)
+ with pytest.raises(ImportError, match="pyshacl"):
+ validate_graph(data)
diff --git a/packages/gds-owl/tests/test_sparql.py b/packages/gds-owl/tests/test_sparql.py
new file mode 100644
index 0000000..f93bc52
--- /dev/null
+++ b/packages/gds-owl/tests/test_sparql.py
@@ -0,0 +1,146 @@
+"""Tests for SPARQL query templates."""
+
+import pytest
+
+from gds import GDSSpec, SystemIR
+from gds.verification.findings import VerificationReport
+from gds_owl.export import report_to_graph, spec_to_graph, system_ir_to_graph
+from gds_owl.sparql import TEMPLATES, run_query
+
+
+class TestTemplateRegistry:
+ def test_templates_registered(self) -> None:
+ assert len(TEMPLATES) >= 7
+
+ def test_required_templates_exist(self) -> None:
+ expected = [
+ "blocks_by_role",
+ "dependency_path",
+ "entity_update_map",
+ "param_impact",
+ "ir_block_list",
+ "ir_wiring_list",
+ "verification_summary",
+ ]
+ for name in expected:
+ assert name in TEMPLATES, f"Missing template: {name}"
+
+ def test_templates_have_descriptions(self) -> None:
+ for name, t in TEMPLATES.items():
+ assert t.description, f"Template {name} has no description"
+
+ def test_templates_have_queries(self) -> None:
+ for name, t in TEMPLATES.items():
+ assert "SELECT" in t.query or "CONSTRUCT" in t.query, (
+ f"Template {name} has no SELECT/CONSTRUCT"
+ )
+
+
+class TestBlocksByRole:
+ def test_returns_results(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ results = run_query(g, "blocks_by_role")
+ assert len(results) == 3 # Sensor, Controller, Heater
+
+ def test_results_have_kind(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ results = run_query(g, "blocks_by_role")
+ kinds = {str(r["kind"]) for r in results}
+ assert "boundary" in kinds
+ assert "policy" in kinds
+ assert "mechanism" in kinds
+
+ def test_results_have_block_name(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ results = run_query(g, "blocks_by_role")
+ names = {str(r["block_name"]) for r in results}
+ assert "Sensor" in names
+ assert "Controller" in names
+ assert "Heater" in names
+
+
+class TestDependencyPath:
+ def test_returns_wire_connections(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ results = run_query(g, "dependency_path")
+ assert len(results) == 2 # Sensor->Controller, Controller->Heater
+
+ def test_wire_source_target(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ results = run_query(g, "dependency_path")
+ pairs = {(str(r["source"]), str(r["target"])) for r in results}
+ assert ("Sensor", "Controller") in pairs
+ assert ("Controller", "Heater") in pairs
+
+
+class TestEntityUpdateMap:
+ def test_returns_mechanism_updates(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ results = run_query(g, "entity_update_map")
+ assert len(results) == 1
+
+ def test_heater_updates_room_temperature(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ results = run_query(g, "entity_update_map")
+ r = results[0]
+ assert str(r["block_name"]) == "Heater"
+ assert str(r["entity"]) == "Room"
+ assert str(r["variable"]) == "temperature"
+
+
+class TestParamImpact:
+ def test_returns_param_usage(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ results = run_query(g, "param_impact")
+ assert len(results) == 1
+
+ def test_gain_used_by_controller(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ results = run_query(g, "param_impact")
+ r = results[0]
+ assert str(r["param_name"]) == "gain"
+ assert str(r["block_name"]) == "Controller"
+
+
+class TestIRBlockList:
+ def test_returns_blocks(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ results = run_query(g, "ir_block_list")
+ assert len(results) == 3
+
+ def test_block_names(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ results = run_query(g, "ir_block_list")
+ names = {str(r["block_name"]) for r in results}
+ assert "Sensor" in names
+ assert "Controller" in names
+ assert "Heater" in names
+
+
+class TestIRWiringList:
+ def test_returns_wirings(self, thermostat_ir: SystemIR) -> None:
+ g = system_ir_to_graph(thermostat_ir)
+ results = run_query(g, "ir_wiring_list")
+ assert len(results) >= 2
+
+
+class TestVerificationSummary:
+ def test_returns_findings(self, thermostat_report: VerificationReport) -> None:
+ g = report_to_graph(thermostat_report)
+ results = run_query(g, "verification_summary")
+ assert len(results) == len(thermostat_report.findings)
+
+ def test_findings_have_check_ids(
+ self, thermostat_report: VerificationReport
+ ) -> None:
+ g = report_to_graph(thermostat_report)
+ results = run_query(g, "verification_summary")
+ check_ids = {str(r["check_id"]) for r in results}
+ assert len(check_ids) > 0
+
+
+class TestRunQueryErrors:
+ def test_unknown_template_raises(self, thermostat_spec: GDSSpec) -> None:
+ g = spec_to_graph(thermostat_spec)
+ with pytest.raises(KeyError, match="Unknown template"):
+ run_query(g, "nonexistent_template")
diff --git a/packages/gds-stockflow/stockflow/dsl/compile.py b/packages/gds-stockflow/stockflow/dsl/compile.py
index afbf156..8bfd2bd 100644
--- a/packages/gds-stockflow/stockflow/dsl/compile.py
+++ b/packages/gds-stockflow/stockflow/dsl/compile.py
@@ -391,6 +391,23 @@ def compile_model(model: StockFlowModel) -> GDSSpec:
)
)
+ # 7. Register transition signatures (mechanism read dependencies)
+ from gds.constraints import TransitionSignature
+
+ for stock in model.stocks:
+ connected_flows = [
+ flow.name
+ for flow in model.flows
+ if flow.target == stock.name or flow.source == stock.name
+ ]
+ spec.register_transition_signature(
+ TransitionSignature(
+ mechanism=_accumulation_block_name(stock.name),
+ reads=[(stock.name, "level")],
+ depends_on_blocks=connected_flows,
+ )
+ )
+
return spec
diff --git a/packages/gds-stockflow/tests/test_integration.py b/packages/gds-stockflow/tests/test_integration.py
index 4235e86..8373622 100644
--- a/packages/gds-stockflow/tests/test_integration.py
+++ b/packages/gds-stockflow/tests/test_integration.py
@@ -81,6 +81,14 @@ def test_spec_validates(self, model):
errors = spec.validate_spec()
assert len(errors) == 0, f"Spec validation errors: {errors}"
+ def test_transition_signatures_emitted(self, model):
+ spec = compile_model(model)
+ assert len(spec.transition_signatures) == 1
+ ts = spec.transition_signatures["Population Accumulation"]
+ assert ("Population", "level") in ts.reads
+ assert "Births" in ts.depends_on_blocks
+ assert "Deaths" in ts.depends_on_blocks
+
class TestPredatorPreyEndToEnd:
"""Two-stock predator-prey model."""
diff --git a/pyproject.toml b/pyproject.toml
index 6be9865..c3e3632 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,7 @@ dependencies = [
"gds-examples>=0.1.0",
"gds-sim>=0.1.0",
"gds-psuu>=0.1.0",
+ "gds-owl>=0.1.0",
]
[project.urls]
@@ -60,6 +61,7 @@ gds-business = { workspace = true }
gds-examples = { workspace = true }
gds-sim = { workspace = true }
gds-psuu = { workspace = true }
+gds-owl = { workspace = true }
[tool.uv.workspace]
members = ["packages/*"]
@@ -76,7 +78,7 @@ select = ["E", "W", "F", "I", "UP", "B", "SIM", "TCH", "RUF"]
"packages/gds-examples/prisoners_dilemma/visualize.py" = ["E501"]
[tool.ruff.lint.isort]
-known-first-party = ["gds", "gds_viz", "ogs", "stockflow", "gds_control", "gds_software", "gds_business", "gds_sim", "gds_psuu"]
+known-first-party = ["gds", "gds_viz", "ogs", "stockflow", "gds_control", "gds_software", "gds_business", "gds_sim", "gds_psuu", "gds_owl"]
[dependency-groups]
docs = [