Files
DECNET/decnet/topology/status.py
anti 33f139ecfa feat(mazenet): topology package — config, status machine, generator, persistence
Adds decnet/topology/ with:

- config.TopologyConfig: pydantic model driving generation (depth,
  branching_factor, deckies_per_lan_min/max, bridge_forward_probability,
  cross_edge_probability, subnet_base_prefix, service selection, seed).
  Emits GeneratedTopology dataclass (lans, deckies, edges).

- status.TopologyStatus + assert_transition: seven-state machine with
  an explicit legal-transition table.  torn_down is terminal; degraded
  is schema-reserved for future Healer use.

- generator.generate: deterministic DAG generation under config.seed.
  Builds a tree of LANs (DMZ at root), plants deckies in each LAN,
  promotes one decky per non-DMZ LAN to a parent bridge, and rolls
  cross-edges per cross_edge_probability for DAG shape.

- persistence: persist() writes a plan to the repo as pending;
  transition_status() enforces state-machine legality; hydrate() loads
  topology + children into a single dict.

Covered by tests/topology/{test_status,test_generator,test_persistence}.
2026-04-20 16:48:20 -04:00

73 lines
2.4 KiB
Python

"""MazeNET topology status state machine.
Seven states — six active in v1. ``degraded`` is schema-reserved for the
future Healer worker and has no transitions into it from v1 code paths.
"""
from __future__ import annotations
class TopologyStatus:
PENDING = "pending"
DEPLOYING = "deploying"
ACTIVE = "active"
DEGRADED = "degraded"
FAILED = "failed"
TEARING_DOWN = "tearing_down"
TORN_DOWN = "torn_down"
ALL: frozenset[str] = frozenset(
{PENDING, DEPLOYING, ACTIVE, DEGRADED, FAILED, TEARING_DOWN, TORN_DOWN}
)
# Directed transitions. torn_down is terminal. degraded is unreachable
# in v1 (Healer would be the only writer), but its outbound edges stay
# defined so when Healer lands the state machine already accepts them.
_LEGAL: dict[str, frozenset[str]] = {
TopologyStatus.PENDING: frozenset(
{TopologyStatus.DEPLOYING, TopologyStatus.TORN_DOWN}
),
TopologyStatus.DEPLOYING: frozenset(
{
TopologyStatus.ACTIVE,
TopologyStatus.FAILED,
TopologyStatus.DEGRADED,
TopologyStatus.TEARING_DOWN,
}
),
TopologyStatus.ACTIVE: frozenset(
{TopologyStatus.DEGRADED, TopologyStatus.TEARING_DOWN}
),
TopologyStatus.DEGRADED: frozenset(
{TopologyStatus.ACTIVE, TopologyStatus.TEARING_DOWN}
),
TopologyStatus.FAILED: frozenset({TopologyStatus.TEARING_DOWN}),
TopologyStatus.TEARING_DOWN: frozenset(
{TopologyStatus.TORN_DOWN, TopologyStatus.DEGRADED}
),
TopologyStatus.TORN_DOWN: frozenset(),
}
class TopologyStatusError(ValueError):
"""Raised when an illegal topology status transition is attempted."""
def assert_transition(current: str, new: str) -> None:
"""Validate ``current → new`` or raise :class:`TopologyStatusError`."""
if current not in TopologyStatus.ALL:
raise TopologyStatusError(f"unknown current status: {current!r}")
if new not in TopologyStatus.ALL:
raise TopologyStatusError(f"unknown new status: {new!r}")
if new not in _LEGAL[current]:
raise TopologyStatusError(
f"illegal transition: {current!r}{new!r}"
)
def legal_next(current: str) -> frozenset[str]:
"""Return the set of legal successor statuses from ``current``."""
if current not in _LEGAL:
raise TopologyStatusError(f"unknown status: {current!r}")
return _LEGAL[current]