Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 136 additions & 1 deletion docs/research/journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,141 @@ coverage is **39 tests generating ~4,660 random examples**.
| Issue | Phase | Status |
|---|---|---|
| #135 | 1b-c: Coq mechanized proofs | Open (long-term, needs Coq expertise) |
| #138 | 4: Dynamical invariants | Open (blocked on gds-analysis package) |
| #138 | 4: Dynamical invariants | Closed (superseded by #140-#142) |

---

## Entry 006 — 2026-03-28

**Subject:** StateMetric (bridge Step 3) + gds-analysis package (Steps 4-5)

### Motivation

The bridge proposal (paper-implementation-gap.md) maps paper definitions to
code in 7 incremental steps. Steps 1-2 were done prior. Steps 3-5 were
identified as the next actionable work — Step 3 is structural (same pattern
as Steps 1-2), and Steps 4-5 require runtime but are now unblocked by
gds-sim's existence.

### Actions

#### Step 3: StateMetric (Paper Assumption 3.2)

Added `StateMetric` to gds-framework following the exact
AdmissibleInputConstraint / TransitionSignature pattern:

- `constraints.py`: frozen Pydantic model with `name`, `variables`
(entity-variable pairs), `metric_type` (annotation), `distance`
(R3 lossy callable), `description`
- `spec.py`: `GDSSpec.register_state_metric()` + `_validate_state_metrics()`
(checks entity/variable references exist, rejects empty variables)
- `__init__.py`: exported as public API
- `export.py`: RDF export as `StateMetric` class + `MetricVariableEntry`
blank nodes
- `import_.py`: round-trip import with `distance=None` (R3 lossy)
- `shacl.py`: `StateMetricShape` (name required, xsd:string)
- 9 new framework tests + 1 OWL round-trip test

Commit: `f9168ee`

#### gds-analysis Package (#140)

New package bridging gds-framework structural annotations to gds-sim
runtime. Dependency graph:

```
gds-framework <-- gds-sim <-- gds-analysis
^ |
+----------------------------------+
```

Three modules:

- **`adapter.py`**: `spec_to_model(spec, policies, sufs, ...)` maps
GDSSpec blocks to `gds_sim.Model`. BoundaryAction + Policy → policies,
Mechanism.updates → SUFs keyed by state variable. Auto-generates initial
state from entities. Optionally wraps BoundaryAction policies with
constraint guards.

- **`constraints.py`**: `guarded_policy(fn, constraints)` wraps a policy
with AdmissibleInputConstraint enforcement. Three violation modes:
warn (log + continue), raise (ConstraintViolation), zero (empty signal).

- **`metrics.py`**: `trajectory_distances(spec, trajectory)` computes
StateMetric distances between successive states. Extracts relevant
variables by `EntityName.VariableName` key, applies distance callable.

21 tests, 93% coverage, including end-to-end thermostat integration
(spec → model → simulate → measure distances).

Commit: `447fc62`

#### Reachable Set R(x) and Configuration Space X_C (#141)

Added `reachability.py` to gds-analysis:

- **`reachable_set(spec, model, state, input_samples)`**: Paper Def 4.1.
For each input sample, runs one timestep with overridden policy outputs,
collects distinct reached states. Deduplicates by state fingerprint.

- **`reachable_graph(spec, model, initial_states, input_samples, max_depth)`**:
BFS expansion from initial states, applying `reachable_set()` at each
node. Returns adjacency dict of state fingerprints.

- **`configuration_space(graph)`**: Paper Def 4.2. Tarjan's algorithm for
strongly connected components. Returns SCCs sorted by size — the largest
is X_C.

11 new tests covering single/multiple/duplicate inputs, empty inputs,
BFS depth expansion, SCC cases (self-loop, cycle, DAG, disconnected),
and end-to-end thermostat integration.

Commit: `081cb9c`

### Bridge Status

| Step | Paper | Annotation / Function | Status |
|---|---|---|---|
| 1 | Def 2.5 | AdmissibleInputConstraint | Done (prior) |
| 2 | Def 2.7 | TransitionSignature | Done (prior) |
| 3 | Assumption 3.2 | StateMetric | **Done** |
| 4 | Def 4.1 | `reachable_set()` | **Done** |
| 5 | Def 4.2 | `configuration_space()` | **Done** |
| 6 | Def 3.3 | Contingent derivative D'F | Open (#142, research) |
| 7 | Theorem 4.4 | Local controllability | Open (#142, research) |

### Issue Tracker

| Issue | Status |
|---|---|
| #134 Phase 1a | Closed |
| #135 Phase 1b-c (Coq) | Open |
| #136 Phase 2 | Closed |
| #137 Phase 3 | Closed |
| #138 Phase 4 (original) | Closed (superseded) |
| #140 gds-analysis | **Closed** |
| #141 R(x) + X_C | **Closed** |
| #142 D'F + controllability | Open (research frontier) |

### Observations

1. gds-sim has zero dependency on gds-framework. This is correct
architecture — gds-sim is a generic trajectory executor, gds-analysis
is the GDS-specific bridge. The adapter pattern keeps both packages
clean.

2. The `_step_once()` implementation creates a temporary Model per input
sample, which is simple but not performant for large input spaces.
A future optimization would batch inputs or use gds-sim's parameter
sweep directly.

3. `reachable_set()` is trajectory-based (Monte Carlo), not symbolic.
It cannot prove that a state is *unreachable* — only that it wasn't
reached in the sampled inputs. For formal reachability guarantees,
symbolic tools (Z3, JuliaReach) would be needed.

4. Steps 6-7 (contingent derivative, controllability) are genuinely
research-level. They require convergence analysis and Lipschitz
conditions that go beyond trajectory sampling.

---
31 changes: 31 additions & 0 deletions packages/gds-analysis/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# gds-analysis

Dynamical analysis for GDS specifications. Bridges `gds-framework` structural annotations to `gds-sim` runtime.

## Installation

```bash
uv add gds-analysis
```

## Quick Start

```python
from gds_analysis import spec_to_model, trajectory_distances
from gds_sim import Simulation

# Build a runnable model from a GDSSpec
model = spec_to_model(
spec,
policies={"Sensor": sensor_fn, "Controller": controller_fn},
sufs={"Heater": heater_fn},
initial_state={"Room.temperature": 18.0},
)

# Run simulation
sim = Simulation(model=model, timesteps=100)
results = sim.run()

# Compute distances using StateMetric annotations
distances = trajectory_distances(spec, results.to_list())
```
26 changes: 26 additions & 0 deletions packages/gds-analysis/gds_analysis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Dynamical analysis for GDS specifications.

Bridges gds-framework structural annotations to gds-sim runtime,
enabling constraint enforcement, metric computation, and reachability
analysis on concrete trajectories.
"""

__version__ = "0.1.0"

from gds_analysis.adapter import spec_to_model
from gds_analysis.constraints import guarded_policy
from gds_analysis.metrics import trajectory_distances
from gds_analysis.reachability import (
configuration_space,
reachable_graph,
reachable_set,
)

__all__ = [
"configuration_space",
"guarded_policy",
"reachable_graph",
"reachable_set",
"spec_to_model",
"trajectory_distances",
]
176 changes: 176 additions & 0 deletions packages/gds-analysis/gds_analysis/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""Adapt GDSSpec structural annotations to gds-sim execution primitives.

The adapter reads the block composition, wiring topology, and structural
annotations from a GDSSpec and produces a gds_sim.Model that can be run.

Users must supply the behavioral functions (policies, SUFs) that
gds-framework deliberately leaves as R3. The adapter wires them together
using the structural skeleton.
"""

from __future__ import annotations

import warnings
from typing import TYPE_CHECKING, Any

from gds.blocks.roles import BoundaryAction, ControlAction, Mechanism, Policy
from gds_sim import Model
from gds_sim.types import StateUpdateBlock

from gds_analysis.constraints import guarded_policy

if TYPE_CHECKING:
from gds import GDSSpec


def spec_to_model(
spec: GDSSpec,
*,
policies: dict[str, Any] | None = None,
sufs: dict[str, Any] | None = None,
initial_state: dict[str, Any] | None = None,
params: dict[str, list[Any]] | None = None,
enforce_constraints: bool = True,
) -> Model:
"""Build a gds_sim.Model from a GDSSpec and user-supplied functions.

Parameters
----------
spec
The GDS specification with registered blocks, wirings, and
structural annotations.
policies
Mapping of block name -> policy callable. Required for every
BoundaryAction, Policy, and ControlAction block.
sufs
Mapping of block name -> state update callable. Required for
every Mechanism block.
initial_state
Initial state dict. If None, builds a zero-valued state from
the spec's entities and variables.
params
Parameter sweep dict (passed through to gds_sim.Model).
enforce_constraints
If True, wrap BoundaryAction policies with
AdmissibleInputConstraint guards.

Returns
-------
gds_sim.Model
A runnable simulation model.

Raises
------
ValueError
If required policies or SUFs are missing.
"""
policies = policies or {}
sufs = sufs or {}

if initial_state is None:
initial_state = _default_initial_state(spec)

blocks = _build_state_update_blocks(spec, policies, sufs, enforce_constraints)

return Model(
initial_state=initial_state,
state_update_blocks=blocks,
params=params or {},
)


def _default_initial_state(spec: GDSSpec) -> dict[str, Any]:
"""Build a zero-valued initial state from spec entities."""
state: dict[str, Any] = {}
for entity in spec.entities.values():
for var_name, sv in entity.variables.items():
key = f"{entity.name}.{var_name}"
python_type = sv.typedef.python_type
if python_type is float:
state[key] = 0.0
elif python_type is int:
state[key] = 0
elif python_type is bool:
state[key] = False
else:
state[key] = ""
return state


def _build_state_update_blocks(
spec: GDSSpec,
policies: dict[str, Any],
sufs: dict[str, Any],
enforce_constraints: bool,
) -> list[StateUpdateBlock]:
"""Map spec blocks to gds-sim StateUpdateBlocks.

All blocks are packed into a single StateUpdateBlock. All policies
run in parallel (signal aggregation via dict.update), then all SUFs
run. Multi-wiring topologies with sequential tier dependencies are
not yet modeled — this is a known simplification.
"""
block_policies: dict[str, Any] = {}
block_sufs: dict[str, Any] = {}

for name, block in spec.blocks.items():
if isinstance(block, (BoundaryAction, Policy, ControlAction)):
if name not in policies:
raise ValueError(
f"Missing policy function for block '{name}' "
f"({type(block).__name__})"
)
fn = policies[name]
if enforce_constraints and isinstance(block, BoundaryAction):
fn = _apply_constraint_guard(spec, name, fn)
elif enforce_constraints and not isinstance(block, BoundaryAction):
# Warn if constraints were registered for non-BoundaryAction
mismatched = [
ac
for ac in spec.admissibility_constraints.values()
if ac.boundary_block == name
]
if mismatched:
warnings.warn(
f"AdmissibleInputConstraint targets block "
f"'{name}' ({type(block).__name__}), but "
f"constraints are only enforced on "
f"BoundaryAction blocks.",
stacklevel=3,
)
block_policies[name] = fn

elif isinstance(block, Mechanism):
if name not in sufs:
raise ValueError(
f"Missing state update function for block '{name}' (Mechanism)"
)
# Key by target state variable, not block name.
# gds-sim validates that SUF dict keys exist in initial_state.
for entity_name, var_name in block.updates:
state_key = f"{entity_name}.{var_name}"
block_sufs[state_key] = sufs[name]

return [
StateUpdateBlock(
policies=block_policies,
variables=block_sufs,
)
]


def _apply_constraint_guard(
spec: GDSSpec,
block_name: str,
policy_fn: Any,
) -> Any:
"""Wrap a policy with AdmissibleInputConstraint guards."""
constraints = [
ac
for ac in spec.admissibility_constraints.values()
if ac.boundary_block == block_name and ac.constraint is not None
]
if not constraints:
return policy_fn

return guarded_policy(policy_fn, constraints)
Loading
Loading