Skip to content

Commit 55011a0

Browse files
committed
feat(spo): v0.3.0 — Petri net FSM, SNN bridge, event-driven transitions
Petri net regime FSM with guard-gated transitions and ProtocolNetSpec binding. SNN controller bridge (LIF rate model + Nengo/Lava optional backends). EventBus pub/sub, force_transition(), hysteresis_hold_steps, transition_history. Rust force_transition + transition_log for FFI parity. 777 tests passing, 0 regressions. Co-Authored-By: Arcane Sapience <protoscience@anulum.li>
1 parent 0892360 commit 55011a0

File tree

24 files changed

+1465
-40
lines changed

24 files changed

+1465
-40
lines changed

CHANGELOG.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.3.0] - 2026-03-04
11+
12+
### Added
13+
14+
- **Petri net regime FSM**`PetriNet`, `Place`, `Arc`, `Transition`, `Marking`, `Guard` for multi-phase protocol sequencing
15+
- **`PetriNetAdapter`** — maps Petri net markings to `Regime` values with highest-severity-wins priority
16+
- **`ProtocolNetSpec`** — binding spec `protocol_net:` key for declarative protocol sequencing in YAML
17+
- **Event-driven transitions**`EventBus` + `RegimeEvent` pub/sub system with bounded history
18+
- **`RegimeManager.force_transition()`** — bypasses cooldown and hysteresis hold
19+
- **`RegimeManager.transition_history`** — deque of (step, prev, new) tuples (maxlen=100)
20+
- **`hysteresis_hold_steps`** — consecutive-step requirement for soft downward transitions
21+
- **`BoundaryObserver` event wiring** — posts `boundary_breach` events to EventBus
22+
- **SNN controller bridge** (`SNNControllerBridge`) — pure-numpy LIF rate model + Nengo/Lava optional backends
23+
- `nengo` and `lava` optional dependency groups
24+
- Event kinds: `boundary_breach`, `r_threshold`, `regime_transition`, `manual`, `petri_transition`
25+
- CLI wires EventBus, BoundaryObserver events, and Petri net when binding spec declares `protocol_net:`
26+
- Rust `RegimeManager.force_transition()` and `transition_log` for FFI contract parity
27+
- ~90 new tests across 5 new test files
28+
29+
### Changed
30+
31+
- `SupervisorPolicy` accepts optional `petri_adapter` argument; when present, `decide()` delegates regime to Petri net
32+
- `BoundaryObserver.observe()` accepts optional `step` kwarg for event attribution
33+
- `RegimeManager` constructor accepts `event_bus` and `hysteresis_hold_steps` params
34+
- `adapters/__init__.py` exports `SNNControllerBridge`
35+
1036
## [0.2.0] - 2026-03-04
1137

1238
### Added
@@ -150,7 +176,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
150176
- Module linkage guard (`tools/check_test_module_linkage.py`) requiring test files for all source modules
151177
- Rust kernel (`spo-kernel/`) with PyO3 bindings for UPDEEngine, RegimeManager, CoherenceMonitor
152178

153-
[Unreleased]: https://github.com/anulum/scpn-phase-orchestrator/compare/v0.2.0...HEAD
179+
[Unreleased]: https://github.com/anulum/scpn-phase-orchestrator/compare/v0.3.0...HEAD
180+
[0.3.0]: https://github.com/anulum/scpn-phase-orchestrator/compare/v0.2.0...v0.3.0
154181
[0.2.0]: https://github.com/anulum/scpn-phase-orchestrator/compare/v0.1.1...v0.2.0
155182
[0.1.1]: https://github.com/anulum/scpn-phase-orchestrator/compare/v0.1.0...v0.1.1
156183
[0.1.0]: https://github.com/anulum/scpn-phase-orchestrator/releases/tag/v0.1.0

CITATION.cff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ authors:
77
given-names: Miroslav
88
orcid: "https://orcid.org/0009-0009-3560-0851"
99
email: protoscience@anulum.li
10-
version: 0.2.0
10+
version: 0.3.0
1111
date-released: "2026-03-04"
1212
license: AGPL-3.0-or-later
1313
url: "https://github.com/anulum/scpn-phase-orchestrator"

ROADMAP.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@
2727
- OpenTelemetry trace/metric export for production observability (`OTelExporter`)
2828
- Pre-commit hook for version consistency
2929

30-
## v0.3
30+
## v0.3 (released)
3131

32-
- Petri net regime FSM for multi-phase protocol sequencing
33-
- SNN controller bridge (Nengo/Lava backends)
34-
- Event-driven mode transitions with hysteresis
32+
- Petri net regime FSM for multi-phase protocol sequencing (`PetriNet`, `PetriNetAdapter`, `ProtocolNetSpec`)
33+
- SNN controller bridge (`SNNControllerBridge`) with Nengo/Lava optional backends
34+
- Event-driven mode transitions with `EventBus`, `RegimeEvent`, `force_transition()`, `hysteresis_hold_steps`
35+
- Rust `force_transition()` + `transition_log` for FFI parity
36+
- ~90 new tests (total ~800)
3537

3638
## v0.4
3739

docs/specs/policy_dsl.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,54 @@ policy_engine = PolicyEngine(rules)
137137
actions = policy_engine.evaluate(regime, upde_state, good_layers, bad_layers)
138138
```
139139

140+
## Protocol Net (v0.3)
141+
142+
The binding spec supports an optional `protocol_net:` key for Petri net
143+
regime sequencing. When present, `SupervisorPolicy.decide()` delegates
144+
regime evaluation to `PetriNetAdapter` instead of `RegimeManager.evaluate()`.
145+
146+
```yaml
147+
protocol_net:
148+
places: [warmup, nominal, cooldown, done]
149+
initial: {warmup: 1}
150+
place_regime: {warmup: NOMINAL, nominal: NOMINAL, cooldown: RECOVERY, done: NOMINAL}
151+
transitions:
152+
- name: start
153+
inputs: [{place: warmup, weight: 1}]
154+
outputs: [{place: nominal, weight: 1}]
155+
guard: "stability_proxy > 0.6"
156+
- name: wind_down
157+
inputs: [{place: nominal, weight: 1}]
158+
outputs: [{place: cooldown, weight: 1}]
159+
guard: "R_0 < 0.3"
160+
- name: finish
161+
inputs: [{place: cooldown, weight: 1}]
162+
outputs: [{place: done, weight: 1}]
163+
```
164+
165+
### Fields
166+
167+
| Field | Type | Description |
168+
|-------|------|-------------|
169+
| `places` | list[str] | Place names in the net |
170+
| `initial` | dict[str, int] | Initial token marking |
171+
| `place_regime` | dict[str, str] | Maps each place to a Regime |
172+
| `transitions[].name` | str | Transition identifier |
173+
| `transitions[].inputs` | list[{place, weight}] | Input arcs |
174+
| `transitions[].outputs` | list[{place, weight}] | Output arcs |
175+
| `transitions[].guard` | str \| null | Guard expression: `"metric op threshold"` |
176+
177+
Guard syntax uses the same operators as policy conditions: `>`, `>=`, `<`,
178+
`<=`, `==`. Metrics are the same flat dict keys passed to policy rules
179+
(`R`, `R_0`..`R_n`, `stability_proxy`).
180+
181+
When multiple places are marked, the highest-severity regime wins
182+
(CRITICAL > RECOVERY > DEGRADED > NOMINAL).
183+
140184
## References
141185

142186
Implementation: `src/scpn_phase_orchestrator/supervisor/policy_rules.py`.
143187
The hardcoded default policy: `src/scpn_phase_orchestrator/supervisor/policy.py`.
188+
Petri net: `src/scpn_phase_orchestrator/supervisor/petri_net.py`.
189+
Petri adapter: `src/scpn_phase_orchestrator/supervisor/petri_adapter.py`.
144190
Threshold values used in example rules are empirical — see [ASSUMPTIONS.md](../ASSUMPTIONS.md).

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta"
1111

1212
[project]
1313
name = "scpn-phase-orchestrator"
14-
version = "0.2.0"
14+
version = "0.3.0"
1515
description = "Domain-agnostic coherence control compiler built on SCPN's Kuramoto/UPDE framework"
1616
readme = "README.md"
1717
license = "AGPL-3.0-or-later"
@@ -55,6 +55,8 @@ queuewaves = [
5555
"httpx>=0.27",
5656
"websockets>=12.0",
5757
]
58+
nengo = ["nengo>=4.0"]
59+
lava = ["lava-nc>=0.9"]
5860
otel = ["opentelemetry-api>=1.20", "opentelemetry-sdk>=1.20"]
5961
plot = ["matplotlib>=3.7"]
6062
notebook = ["jupyter>=1.0", "nbconvert>=7.0", "matplotlib>=3.7"]

spo-kernel/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ members = [
1313
resolver = "2"
1414

1515
[workspace.package]
16-
version = "0.2.0"
16+
version = "0.3.0"
1717
edition = "2021"
1818
rust-version = "1.75.0"
1919
authors = ["Miroslav Sotek <protoscience@anulum.li>"]

spo-kernel/crates/spo-ffi/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "maturin"
44

55
[project]
66
name = "spo-kernel"
7-
version = "0.2.0"
7+
version = "0.3.0"
88
description = "SCPN Phase Orchestrator — Rust-accelerated UPDE kernel"
99
authors = [{name = "Miroslav Sotek", email = "protoscience@anulum.li"}]
1010
requires-python = ">=3.10"

spo-kernel/crates/spo-supervisor/src/regime.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub struct RegimeManager {
1414
cooldown_steps: u64,
1515
step_counter: u64,
1616
last_transition: u64,
17+
pub transition_log: Vec<(u64, Regime, Regime)>,
1718
}
1819

1920
impl RegimeManager {
@@ -25,6 +26,7 @@ impl RegimeManager {
2526
cooldown_steps,
2627
step_counter: 0,
2728
last_transition: 0,
29+
transition_log: Vec::new(),
2830
}
2931
}
3032

@@ -77,11 +79,26 @@ impl RegimeManager {
7779
return self.current;
7880
}
7981

82+
let prev = self.current;
8083
self.last_transition = self.step_counter;
8184
self.current = proposed;
85+
self.transition_log.push((self.step_counter, prev, proposed));
8286
proposed
8387
}
8488

89+
/// Bypass cooldown and hysteresis — used by event-driven triggers.
90+
pub fn force_transition(&mut self, regime: Regime) -> Regime {
91+
self.step_counter += 1;
92+
if regime == self.current {
93+
return self.current;
94+
}
95+
let prev = self.current;
96+
self.last_transition = self.step_counter;
97+
self.current = regime;
98+
self.transition_log.push((self.step_counter, prev, regime));
99+
regime
100+
}
101+
85102
#[must_use]
86103
pub fn step_counter(&self) -> u64 {
87104
self.step_counter

src/scpn_phase_orchestrator/adapters/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
from scpn_phase_orchestrator.adapters.plasma_control_bridge import PlasmaControlBridge
1313
from scpn_phase_orchestrator.adapters.quantum_control_bridge import QuantumControlBridge
1414
from scpn_phase_orchestrator.adapters.scpn_control_bridge import SCPNControlBridge
15+
from scpn_phase_orchestrator.adapters.snn_bridge import SNNControllerBridge
1516

1617
__all__ = [
1718
"FusionCoreBridge",
1819
"OTelExporter",
1920
"PlasmaControlBridge",
2021
"QuantumControlBridge",
2122
"SCPNControlBridge",
23+
"SNNControllerBridge",
2224
]
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# SCPN Phase Orchestrator
2+
# Copyright concepts (c) 1996-2026 Miroslav Sotek. All rights reserved.
3+
# Copyright code (c) 2026 Miroslav Sotek. All rights reserved.
4+
# ORCID: https://orcid.org/0009-0009-3560-0851
5+
# Contact: www.anulum.li | protoscience@anulum.li
6+
# License: GNU AGPL v3 | Commercial licensing available
7+
8+
from __future__ import annotations
9+
10+
import numpy as np
11+
from numpy.typing import NDArray
12+
13+
from scpn_phase_orchestrator.actuation.mapper import ControlAction
14+
from scpn_phase_orchestrator.upde.metrics import UPDEState
15+
16+
__all__ = ["SNNControllerBridge"]
17+
18+
# Abbott 1999, Eq. 1 — LIF time constants
19+
TAU_RC = 0.02 # s, membrane time constant
20+
TAU_REF = 0.002 # s, refractory period
21+
22+
23+
class SNNControllerBridge:
24+
"""Bridge between UPDE state and spiking neural network controllers.
25+
26+
Pure-numpy methods work without SNN libraries. Nengo/Lava methods
27+
require their respective packages.
28+
"""
29+
30+
def __init__(
31+
self,
32+
n_neurons: int = 100,
33+
tau_rc: float = TAU_RC,
34+
tau_ref: float = TAU_REF,
35+
) -> None:
36+
self.n_neurons = n_neurons
37+
self.tau_rc = tau_rc
38+
self.tau_ref = tau_ref
39+
40+
def upde_state_to_input_current(
41+
self, state: UPDEState, i_scale: float = 1.0
42+
) -> NDArray:
43+
"""Map R values from each layer to LIF input currents."""
44+
r_values = np.array([ls.R for ls in state.layers], dtype=np.float64)
45+
return r_values * i_scale
46+
47+
def spike_rates_to_actions(
48+
self,
49+
rates: NDArray,
50+
layer_assignments: list[int],
51+
threshold_hz: float = 50.0,
52+
) -> list[ControlAction]:
53+
"""Convert spike rates to control actions.
54+
55+
*rates*: 1-D array of mean firing rates (Hz) per neuron group.
56+
*layer_assignments*: maps each rate index to a layer.
57+
*threshold_hz*: rates above this trigger coupling boost.
58+
"""
59+
actions: list[ControlAction] = []
60+
for idx, (rate, layer) in enumerate(
61+
zip(rates, layer_assignments, strict=False)
62+
):
63+
if rate > threshold_hz:
64+
excess = (rate - threshold_hz) / threshold_hz
65+
actions.append(
66+
ControlAction(
67+
knob="K",
68+
scope=f"layer_{layer}",
69+
value=0.05 * excess,
70+
ttl_s=5.0,
71+
justification=f"SNN group {idx}: {rate:.1f} Hz",
72+
)
73+
)
74+
return actions
75+
76+
def lif_rate_estimate(self, currents: NDArray) -> NDArray:
77+
"""Analytic LIF steady-state firing rate (Abbott 1999, Eq. 1).
78+
79+
rate = 1 / (tau_ref - tau_rc * ln(1 - 1/J)) for J > 1
80+
"""
81+
rates = np.zeros_like(currents, dtype=np.float64)
82+
above = currents > 1.0
83+
if above.any():
84+
j = currents[above]
85+
rates[above] = 1.0 / (self.tau_ref - self.tau_rc * np.log(1.0 - 1.0 / j))
86+
return rates
87+
88+
def build_nengo_network(
89+
self, n_layers: int, seed: int = 0, synapse: float = 0.01
90+
) -> object:
91+
"""Build a Nengo network for UPDE-SNN coupling.
92+
93+
Raises ImportError if nengo is not installed.
94+
"""
95+
import nengo
96+
97+
with nengo.Network(seed=seed) as model:
98+
model.input_node = nengo.Node(size_in=n_layers)
99+
model.ensemble = nengo.Ensemble(
100+
n_neurons=self.n_neurons,
101+
dimensions=n_layers,
102+
neuron_type=nengo.LIF(tau_rc=self.tau_rc, tau_ref=self.tau_ref),
103+
)
104+
model.output_node = nengo.Node(size_in=n_layers)
105+
nengo.Connection(model.input_node, model.ensemble, synapse=synapse)
106+
nengo.Connection(model.ensemble, model.output_node, synapse=synapse)
107+
return model
108+
109+
def build_lava_process(self, n_layers: int) -> object:
110+
"""Build a Lava LIF process for UPDE-SNN coupling.
111+
112+
Raises ImportError if lava-nc is not installed.
113+
"""
114+
from lava.proc.lif.process import LIF
115+
116+
return LIF(
117+
shape=(self.n_neurons,),
118+
du=1.0 / self.tau_rc,
119+
dv=1.0 / self.tau_ref,
120+
vth=1.0,
121+
)

0 commit comments

Comments
 (0)