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 + +[![PyPI](https://img.shields.io/pypi/v/gds-owl)](https://pypi.org/project/gds-owl/) +[![Python](https://img.shields.io/pypi/pyversions/gds-owl)](https://pypi.org/project/gds-owl/) +[![License](https://img.shields.io/github/license/BlockScience/gds-core)](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 = [