From db043fd4a4b0d0870effd02e8cd2306ad6366e2d Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Tue, 28 Jul 2026 18:00:48 +0900 Subject: [PATCH 1/3] feat: implement Determa State format 1 core --- .github/workflows/test.yml | 28 +- AGENTS.md | 112 +- CONTRIBUTING.md | 110 +- Makefile | 11 +- README.md | 275 ++- conformance/conftest.py | 91 +- conformance/harness.py | 669 +++-- conformance/pins.py | 12 + conformance/test_conformance.py | 103 +- examples/format-1.yaml | 26 + pyproject.toml | 2 +- scripts/sync_schema.py | 35 +- src/determa/state/__init__.py | 46 +- src/determa/state/cel.py | 244 +- src/determa/state/cli.py | 662 +---- src/determa/state/contracts.py | 98 - src/determa/state/data/machine.schema.json | 1182 ++++++++- src/determa/state/definition.py | 246 +- src/determa/state/engine.py | 2601 +++++++++++++++++--- src/determa/state/errors.py | 63 +- src/determa/state/export.py | 115 - src/determa/state/instance.py | 729 ------ src/determa/state/model.py | 396 ++- src/determa/state/observer.py | 46 - src/determa/state/store.py | 175 -- src/determa/state/validator.py | 983 ++++++-- src/determa/state/values.py | 44 - src/determa/state/yaml12.py | 261 +- tests/test_cel.py | 25 + tests/test_choice.py | 150 -- tests/test_cli.py | 49 + tests/test_cli_stream.py | 126 - tests/test_engine.py | 207 ++ tests/test_export.py | 52 - tests/test_library_api.py | 212 -- tests/test_loading.py | 161 ++ tests/test_logging.py | 82 - tests/test_model.py | 101 - tests/test_native_values.py | 99 - tests/test_observer.py | 93 - tests/test_static_validation.py | 126 - tests/test_stepping.py | 291 --- tests/test_stores.py | 193 -- tests/test_submachine.py | 104 - tests/test_validator.py | 119 - tests/test_yaml12.py | 90 - 46 files changed, 6007 insertions(+), 5638 deletions(-) create mode 100644 conformance/pins.py create mode 100644 examples/format-1.yaml delete mode 100644 src/determa/state/contracts.py delete mode 100644 src/determa/state/export.py delete mode 100644 src/determa/state/instance.py delete mode 100644 src/determa/state/observer.py delete mode 100644 src/determa/state/store.py delete mode 100644 src/determa/state/values.py create mode 100644 tests/test_cel.py delete mode 100644 tests/test_choice.py create mode 100644 tests/test_cli.py delete mode 100644 tests/test_cli_stream.py create mode 100644 tests/test_engine.py delete mode 100644 tests/test_export.py delete mode 100644 tests/test_library_api.py create mode 100644 tests/test_loading.py delete mode 100644 tests/test_logging.py delete mode 100644 tests/test_model.py delete mode 100644 tests/test_native_values.py delete mode 100644 tests/test_observer.py delete mode 100644 tests/test_static_validation.py delete mode 100644 tests/test_stepping.py delete mode 100644 tests/test_stores.py delete mode 100644 tests/test_submachine.py delete mode 100644 tests/test_validator.py delete mode 100644 tests/test_yaml12.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index edd76c3..0fb7b8c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,8 +7,6 @@ on: branches: [main] jobs: - # Unit tests: the implementation's own suite — hermetic, offline, fast. - # This is the blocking gate (`test (ubuntu-24.04)`). test: runs-on: ${{ matrix.os }} strategy: @@ -16,8 +14,8 @@ jobs: matrix: os: [ubuntu-24.04] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" cache: pip @@ -30,18 +28,30 @@ jobs: - name: Unit tests run: pytest -q - # Conformance: the language-agnostic suite from fruwehq/determa-state-conformance, run - # black-box against this implementation. Separate job: it downloads an external - # repo, so a failure here means "diverges from the spec suite", not "our code broke". conformance: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - name: Check out pinned conformance suite + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + repository: fruwehq/determa-state-conformance + ref: 409bbdc6c2d4a4e9d50ddb1d994c5f5cd7d97762 + path: .pinned/determa-state-conformance + - name: Check out pinned specification + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + repository: fruwehq/determa-state-spec + ref: 03771fac569a47b82f27891cd3700d4d1d876f8b + path: .pinned/determa-state-spec + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" cache: pip - name: Install run: pip install -e '.[dev]' - name: Conformance suite + env: + DETERMA_CONFORMANCE_DIR: ${{ github.workspace }}/.pinned/determa-state-conformance + DETERMA_SPEC_DIR: ${{ github.workspace }}/.pinned/determa-state-spec run: pytest conformance -q diff --git a/AGENTS.md b/AGENTS.md index eb9ec7b..ff5a6ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,63 +1,71 @@ # AGENTS.md — determa-state-python -Guidance for AI/coding agents working in this repository. (Tool-agnostic; not specific to any one assistant.) +Guidance for coding agents working in this repository. -## What this repo is -The **Python reference implementation** of Determa State. Distribution name -**`determa-state`**; import name **`determa.state`** (a PEP 420 namespace package — there is -**no** `src/determa/__init__.py`, so it coexists with the `determa` launcher package). It is -correct **iff** it passes the conformance suite. +## Repository + +This is the Python implementation of Determa State. The distribution is +`determa-state`; the import is `determa.state`. `src/determa` is a PEP 420 namespace +package so it can coexist with the umbrella `determa` launcher. + +The implementation is conformant only when it passes the language-neutral suite. +Format-1 work currently uses these immutable pre-release inputs: + +- specification: `03771fac569a47b82f27891cd3700d4d1d876f8b`; +- conformance: `409bbdc6c2d4a4e9d50ddb1d994c5f5cd7d97762` (75 core cases). + +The package version is still `0.0.6`; the specification, conformance suite, Python +engine, and Rust engine version together. + +## Boundaries + +The implemented public API is `load_bundle`, `create`, and `dispatch`, plus validation +and error types exported by `determa.state`. It implements the exact `format: 1` +grammar. Do not restore abandoned draft field names or compatibility aliases. + +The core is a pure foreground transform over one root ownership aggregate. It has no +hidden queues, timers, stores, snapshots, migration, enabled-event inspection, or +standardized execution CLI. Snapshot portability, machine definition migration or +hot-swap, package imports, and living tutorials are separate initiatives. Layout: -- `src/determa/state/` — the engine package (`__about__.py` is the single version source). -- `tests/` — the implementation's own **unit tests** (hermetic, offline). -- `conformance/` — a **black-box harness** that fetches `determa-state-conformance` at the - tag matching this package's version into `.cache/` (override with `DETERMA_CONFORMANCE_DIR`; - spec schema override `DETERMA_SPEC_DIR`). -- `.github/workflows/` — `test.yml` (CI gate) and `release.yml` (tag → PyPI). - -## Determa in one paragraph -**Determa** is a family for defining/running well-specified, verifiable behavior. **Determa -State** is a language-agnostic **statechart engine** (Harel/UML lineage, PSiCC RTC): one -YAML/JSON machine runs identically under any implementation, validated against a shared -conformance suite. Guards/action values are **CEL** (via the `cel-python`/`celpy` package, -lazily imported so the CLI starts fast). An umbrella `determa` launcher dispatches -`determa …` → `determa-` on PATH; this package also installs a -`determa-state-python` alias for explicit implementation selection. - -## Repositories (org `fruwehq`, local folders `~/src/personal/`) -| Repo | Role | -|---|---| -| determa-state-spec | normative prose spec + schema. No CI. | -| determa-state-conformance | the conformance suite (arbiter). No CI. | -| **determa-state-python** (this) | Python impl — `determa-state` / `determa.state`. | -| determa-state-rust | Rust impl — crate `determa-state`. | -| determa | umbrella launcher (`python/`, `rust/`, `node/`). | - -## Working rules (every Determa repo) -- **One issue → one PR**, branch → PR → **squash-merge**, linear history, resolve threads; `main` is protected (**and requires branches be up-to-date** — after a merge moves `main`, update other open PRs, which re-runs CI, before merging). -- **No AI/assistant attribution** anywhere (commits, PRs, comments, docs). -- **Conformance-first:** spec text → conformance case → this impl. Don't diverge from the pinned suite. -- **Synchronized SemVer** with spec + rust (currently **0.0.6**); bump `src/determa/state/__about__.py`. -- **No abbreviations** in JSON output / public identifiers (`definition` not `def`). Kept for now: `config`, machine-keywords (`esvs`, …), snapshot `def_id`/`def_version`, `spawn.def`. - -## Gates (run before requesting review — this is the CI gate) + +- `src/determa/state/` — loader, validator, CEL profile, model, and engine; +- `src/determa/state/data/machine.schema.json` — exact pinned normative schema; +- `tests/` — hermetic implementation tests; +- `conformance/` — black-box format-1 harness and immutable pins; +- `.github/workflows/test.yml` — unit and pinned conformance gates; +- `.github/workflows/release.yml` — tag-triggered PyPI publication. + +## Working Rules + +- One issue to one PR, squash merge, linear history, and resolved review threads. +- Never put assistant attribution in commits, PRs, comments, or documentation. +- Behavioral work is specification, then conformance, then implementations. The + conformance suite is the arbiter. +- Do not change the package version, tag, publish, or merge unless explicitly + authorized as release work. +- Keep JSON/public identifiers unabbreviated and use only exact normative grammar. +- Unit tests remain hermetic and offline. Conformance may use its immutable checkouts. +- Preserve lazy CEL and JSON Schema imports where practical. + +## Gates + ```sh -pip install -e '.[dev]' +python -m pip install -e '.[dev]' ruff check . mypy src/determa -pytest -q # unit tests (hermetic, offline) — CI job "test (ubuntu-24.04)" -pytest conformance # conformance suite (network, or DETERMA_CONFORMANCE_DIR) — CI job "conformance" -# convenience: `make check` (gate) and `make conformance` +pytest -q +DETERMA_CONFORMANCE_DIR=/path/to/conformance \ +DETERMA_SPEC_DIR=/path/to/spec \ +pytest conformance -q ``` -Keep CEL/`jsonschema` imports lazy (they dominate startup); unit tests must not touch the network. -## Releasing -Tag `vX.Y.Z` → `release.yml` builds sdist+wheel and publishes to **PyPI via Trusted -Publishing (OIDC)** — **gated on the `pypi` GitHub Environment (manual approval)**. The -PyPI project name is `determa-state`. **A tag publishes**, so only tag when you intend to -release. After a spec release, the conformance fetch auto-targets the new `v{version}` tag. +`make check` runs lint, type checking, and unit tests. `make conformance` fetches or +reuses the immutable inputs recorded in `conformance/pins.py`. + +## Release -## Pointers -- Library API (SPEC §2): `Host`, `Instance`, `load_definitions` (accepts YAML **or** a dict/mapping), `validate`, etc. — see `README.md` and `tests/test_library_api.py`. -- CLI (SPEC §13/§14): `src/determa/state/cli.py`. Spec: `determa-state-spec/SPEC.md`. +A `vX.Y.Z` tag triggers `release.yml`, builds the distribution, and publishes to PyPI +using Trusted Publishing through the manually approved `pypi` environment. A tag +publishes, so do not create one during ordinary implementation work. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56cce5c..a6882e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,95 +1,67 @@ # Contributing to Determa State (Python) -**determa-state** is the Python reference implementation of the -[Determa State](https://github.com/fruwehq/determa-state-spec) statechart engine. It is correct **iff** it -passes the language-agnostic [conformance suite](https://github.com/fruwehq/determa-state-conformance). -The prose specification lives in [`fruwehq/determa-state-spec`](https://github.com/fruwehq/determa-state-spec) -(`SPEC.md`, the JSON Schema, and examples); the executable correctness target lives in -[`fruwehq/determa-state-conformance`](https://github.com/fruwehq/determa-state-conformance). +`determa-state` is the Python implementation of the +[Determa State specification](https://github.com/fruwehq/determa-state-spec). The +[language-neutral conformance suite](https://github.com/fruwehq/determa-state-conformance) +is the executable arbiter of behavior. -## Dev setup +## Development Setup ```sh python -m venv .venv -source .venv/bin/activate # or `.venv\Scripts\activate` on Windows -pip install -e '.[dev]' +. .venv/bin/activate +python -m pip install -e '.[dev]' ``` -Python ≥ 3.11. The package is import-named `determa.state`, distribution-named `determa-state`. +Python 3.11 or newer is supported. The distribution is `determa-state`; the import is +`determa.state`. -## The gate +## Gates -Before pushing, run all three and keep them clean (`make check` does exactly this): +Run the implementation gates and the full format-1 conformance suite before review: ```sh ruff check . -mypy src -pytest # unit tests only — hermetic, offline +mypy src/determa +pytest -q +pytest conformance -q ``` -CI runs `test (ubuntu-24.04)` on every PR and is **required** — a PR merges only once it -is green. This job runs the **unit tests only**. +`tests/` is hermetic and offline. `conformance/` uses the approved immutable +specification and suite commits recorded in `conformance/pins.py`. The harness caches +those checkouts under `.cache/`. For offline or local cross-repository work: -## Unit tests vs. conformance — kept separate +```sh +DETERMA_CONFORMANCE_DIR=/path/to/determa-state-conformance \ +DETERMA_SPEC_DIR=/path/to/determa-state-spec \ +pytest conformance -q +``` -This implementation has its **own unit tests** (`tests/`), which are hermetic and offline -— `pytest` (or `make test`) runs only these, and they never touch the network. That is the -required PR gate. +CI checks out both immutable inputs directly and also verifies that the packaged schema +is byte-for-value equivalent to the pinned specification schema. -**Conformance is separate.** The language-agnostic suite is downloaded and run black-box -against the built CLI/engine, in its own directory (`conformance/`) and its own CI job -(`conformance`, non-blocking by default). Run it locally with: +## Workflow -```sh -make conformance # == pytest conformance -``` +1. Read `AGENTS.md` and the linked specification/conformance changes first. +2. Create one branch and one pull request for one issue. +3. Never push directly to protected `main`. +4. Resolve every review thread and keep the branch current before squash-merging. +5. Do not add assistant attribution to commits, PRs, comments, or documentation. +6. Specify and add conformance behavior before changing an engine. -The suite is **not** a submodule. `conformance/conftest.py` clones -`fruwehq/determa-state-conformance` at the release tag matching this package's version (falling -back to `main` while the tag does not yet exist) into a gitignored `.cache/` directory and -reuses it. To force a refresh, delete `.cache/`. +Do not reintroduce compatibility aliases for abandoned pre-format-1 grammar or public +behavior. -- **Offline / local edits:** point the tests at a local checkout with - `DETERMA_CONFORMANCE_DIR=/path/to/determa-state-conformance` (and `DETERMA_SPEC_DIR=/path/to/determa-state-spec` - for the schema-parity test). If the suite cannot be obtained and no override is set, the - conformance tests **skip** rather than error. -- **Black-box CLI conformance** runs the implementation's `determa-state` (via `python -m determa.state`) - as a **subprocess** against `conformance/run_cli.py`, so packaging/entry-point regressions - are caught (SPEC §13.6). +## Versioning And Release -## Workflow +`src/determa/state/__about__.py` is the single package version source. Determa State +specification, conformance, Python, and Rust versions are synchronized. The current +package remains `0.0.6` while format 1 is pre-release. -1. Branch from `main`, open a Pull Request, and **squash-merge** — `main` stays linear. -2. Resolve all review threads before merging. -3. **Never push to `main` directly.** -4. **No AI/assistant attribution anywhere** — not in commits, PR bodies, comments, or - docs (no `Co-Authored-By:`, no "Generated with…"). Commits and PRs read as the - author's own work. -5. One issue → one PR. A behavior change usually pairs with a `determa-state-spec` edit and a - `determa-state-conformance` case; link them from the PR. - -## Versioning - -The version source of truth is **`pyproject.toml`** (`version = …`); the package derives -`determa.state.__version__` from the installed distribution metadata (no second copy to keep in -sync). The package version **is** the implemented Determa State spec version. - -> determa-state-spec, determa-state-conformance, and determa-state share one synchronized SemVer version -> (currently pre-1.0 `0.0.x`). A release tags all three `vX.Y.Z` in lockstep; an -> implementation declares "implements Determa State spec vX.Y.Z" and pins the conformance suite -> at that tag. - -### Releasing `vX.Y.Z` (lockstep) -1. Land all spec / conformance / implementation changes on the three `main` branches. -2. Bump the version in **`pyproject.toml`** (here) and the `VERSION` files in `determa-state-spec` and - `determa-state-conformance`. -3. Tag `vX.Y.Z` on **determa-state-spec** and **determa-state-conformance** (`gh api -X POST - repos/fruwehq//git/refs -f ref=refs/tags/vX.Y.Z -f sha=$(gh api - repos/fruwehq//commits/main --jq .sha)`), so this package pins the matching - conformance tag instead of falling back to `main`. -4. Tag **determa-state** `vX.Y.Z` only to publish to PyPI — it triggers `release.yml` - (Trusted Publishing). +A `vX.Y.Z` tag triggers `release.yml` and publishes to PyPI through Trusted Publishing, +gated by the manually approved `pypi` environment. Version bumps, tags, and publication +are separate release work and require explicit authorization. ## License -Contributions are made under the project's [MIT license](LICENSE). +Contributions are made under the [MIT license](LICENSE). diff --git a/Makefile b/Makefile index 1b1b278..c9b694c 100644 --- a/Makefile +++ b/Makefile @@ -4,15 +4,14 @@ test: pytest -q -# Conformance — the language-agnostic suite from fruwehq/determa-state-conformance, run -# black-box against this implementation. Downloads the suite (pinned to the release -# tag matching this package's version) into .cache/ on first run. +# Conformance — the language-agnostic format-1 core suite, pinned to the approved +# immutable pre-release commits in conformance/pins.py. # Offline / against a local checkout: DETERMA_CONFORMANCE_DIR=/path/to/determa-state-conformance make conformance conformance: pytest conformance -q -# Refresh the bundled JSON Schema from fruwehq/determa-state-spec at the matching version tag -# (or DETERMA_SPEC_DIR=/path/to/determa-state-spec). The schema-drift test guards that they match. +# Refresh the bundled JSON Schema from the immutable format-1 specification pin +# (or DETERMA_SPEC_DIR=/path/to/determa-state-spec). sync-schema: python scripts/sync_schema.py @@ -20,7 +19,7 @@ lint: ruff check . typecheck: - mypy src + mypy src/determa # Everything a PR needs to pass locally (unit gate), plus conformance. check: lint typecheck test diff --git a/README.md b/README.md index 9bed56d..a9835a3 100644 --- a/README.md +++ b/README.md @@ -1,159 +1,170 @@ # determa-state -Reference implementation (**Python**) of the [**Determa State**](https://github.com/fruwehq/determa-state-spec) -statechart engine. - -The normative `SPEC.md`, the JSON Schema for machine YAML, and the cross-language -**conformance suite** live in the spec repo. This repository implements that spec in -Python and is correct **iff it passes the conformance suite**. - -Implements the **Determa State spec v0.0.6** (early alpha; all Determa State repos share one -[synchronized version](https://github.com/fruwehq/determa-state-spec)). - -Status: **passing the full conformance suite** — all 31 engine cases -(`conformance/01`–`31`) plus `conformance/cli/01`–`03`. Implements YAML 1.2 loading -+ validation, the full statechart semantics (RTC dispatch, hierarchy, orthogonal -regions + `done`, shallow/deep history, choice pseudostates, submachine states, esvs, CEL guards, -structured actions, -active objects + bus, defer, timers, faults), static contracts, snapshot -round-trip + safe-point migration, Mermaid `export`, and the §13 CLI. Built up -the build order in [issue #3][issue]. - -[issue]: https://github.com/fruwehq/determa-state-python/issues/3 - -## Conformance suite - -The cross-language **conformance suite** is the single source of truth for correctness; -this repository is correct **iff it passes it**. The suite lives in -[`fruwehq/determa-state-conformance`](https://github.com/fruwehq/determa-state-conformance); the test -harness **fetches it at the matching release tag** (`v0.0.6`) into a gitignored -`.cache/` — no git submodule. The normative `SPEC.md` and JSON Schema live in -[`fruwehq/determa-state-spec`](https://github.com/fruwehq/determa-state-spec); the schema-drift test fetches the -schema at the same tag. - -For **offline** work, point the harness at a local checkout: -``` -export DETERMA_CONFORMANCE_DIR=/path/to/determa-state-conformance # the suite -export DETERMA_SPEC_DIR=/path/to/determa-state-spec # the schema (optional) -``` +Python implementation of [Determa State](https://github.com/fruwehq/determa-state-spec), +a language-agnostic statechart engine with a shared normative conformance suite. -## Scope (per the spec) -- Load and validate machine YAML against `schema/machine.schema.json`, parsed under - the **YAML 1.2 core schema** (only `true`/`false` are booleans). -- Execute statecharts per `SPEC.md`: run-to-completion; hierarchy; orthogonal regions - (+ `done`); shallow/deep history; `initial` transitions; `esvs` (extended-state - variables declared in states, hierarchical) including `external` esvs + the `env` - event and `refresh`; `defer` (deferred-set, edge-triggered); timers via an injected - clock; active-object spawning; `publish` (directed / by subscription / scoped); and - faults (the `error` event). -- **Guards in CEL** (e.g. [`cel-python`](https://pypi.org/project/cel-python/)); - **structured actions** (`assign`/`publish`/`refresh`/`spawn`/`stop`) with CEL values. -- **Adapters** — bus / queue / clock / store / observer (SPEC §8), each with a simple - in-memory default for tests. -- An **`export`** command that renders a machine (and an instance's current - `state_config`) to **Mermaid** `stateDiagram-v2` (SPEC §12), behind a pluggable - exporter interface so more formats (PlantUML, SCXML, …) can be added later. -- A test harness that runs the upstream conformance cases against this engine. - -## Use as a library -The CLI (`determa-state …`) is a thin wrapper over a programmatic API; an engine can be -embedded in a host program **without** the CLI or the file-backed store (SPEC §2): +This pre-release implements Determa State `format: 1` at the approved specification +commit `03771fac569a47b82f27891cd3700d4d1d876f8b`. Correctness is determined by the +75-case core suite at conformance commit +`409bbdc6c2d4a4e9d50ddb1d994c5f5cd7d97762`. -```python -import determa.state as ds +The package version remains `0.0.6` until the specification, conformance suite, Python +engine, and Rust engine are released together. -defs = ds.load_definitions(open("gate.yaml").read()) -ds.validate(defs[0].raw) # raises ValidationError if invalid +## Install -host = ds.Host() -host.register_all(defs) -inst = host.create_root(host.machines["gate"], "g1", external={"fare": 50}) -host.run_to_quiescence() +The published `0.0.6` distribution predates format 1. Until the next synchronized +Determa State release, install this pre-release implementation from a checkout: -host.deliver("g1", "coin", {"amount": 100}) # typed event; False if rejected -host.run_to_quiescence() -assert inst.active_leaf_names() == ["unlocked"] -assert inst.resolved_esvs()["fare"] == 50 -assert inst.status is ds.Status.ACTIVE +```sh +git clone https://github.com/fruwehq/determa-state-python.git +cd determa-state-python +python -m pip install -e . +``` -host.advance("30s") # virtual clock -snaps = host.snapshot_all() # persist / round-trip (§8) -host.restore_all(snaps) +The distribution is `determa-state`; the import is `determa.state`. It also installs +`determa-state` and `determa-state-python` commands. + +## Define A Bundle + +Format 1 uses one self-contained bundle containing one or more machines: + +```yaml +format: 1 +namespace: example.counter +events: + increment: + direction: input + payload: + amount: { type: int, required: true } + reset: + direction: input +machines: + - machine_id: counter + version: 1 + root: + type: composite + variables: + count: { type: int, init: 0 } + initial: { transition_to: running } + states: + running: + on_events: + increment: + action: + - assign: { count: "count + event.payload.amount" } + reset: + action: + - assign: { count: "0" } ``` -`load_definitions` also accepts a **native mapping** (or a list of them for a -multi-document machine) instead of YAML text, so a host can build machines in code -without serializing — through the same `validate()` path: +The same bundle is available at [`examples/format-1.yaml`](examples/format-1.yaml). +Documents are parsed using the portable YAML 1.2 scalar rules, then checked against the +bundled normative JSON Schema and semantic validation rules. Abandoned draft grammar +names are not accepted. + +## Use The Library + +`create` and `dispatch` are pure foreground operations. They do not retain hidden +machine state or call queues, timers, databases, or remote services. ```python +from pathlib import Path + import determa.state as ds -gate = { - "id": "gate", - "events": {"coin": {"payload": {"amount": {"type": "int", "required": True}}}}, - "top": { - "esvs": {"fare": {"type": "int", "external": True}}, - "initial": {"transition_to": "locked"}, - "states": { - "locked": {"on_events": {"coin": {"transition_to": "unlocked", - "guard": "event.payload.amount >= fare"}}}, - "unlocked": {"on_events": {"push": {"transition_to": "locked"}}}, - }, - }, +bundle = ds.load_bundle(Path("examples/format-1.yaml").read_text()) +created = ds.create( + bundle, + machine_id="counter", + root_instance_id="counter-42", + creation_id="create-counter-42", + bindings={}, +) +state = created["state"] + +target = { + "root": { + "root_instance_id": state["root_instance_id"], + "root_runtime_id": state["root_runtime_id"], + } } +result = ds.dispatch( + bundle, + state, + { + "input": { + "event": "increment", + "event_id": "counter-42:increment:1", + "target": target, + "payload": {"amount": 2}, + } + }, +) -defs = ds.load_definitions(gate) # dict, not a YAML string -host = ds.Host() -host.register_all(defs) -inst = host.create_root(host.machines["gate"], "g1", external={"fare": 50}) -host.run_to_quiescence() +assert result["status"] == "running" +assert result["disposition"] == "handled" +state = result["state"] +root = state["runtimes"][state["root_runtime_id"]] +assert root["scopes"]["root"]["count"] == 2 ``` -The public surface is everything exported from the `determa.state` package -(`determa.state.__all__`): `Host`, `Instance`, `Definition`, `Machine`, `Status`, `Event`, -`load_definitions` / `load_definition`, `validate` / `collect_errors`, and the -error types. See [`tests/test_library_api.py`](tests/test_library_api.py). - -### Observing transitions (SPEC §8) -Pass an **observer** — a passive callback invoked once per RTC step (automatic *or* -manual) with `{ instance, event, transition, entered, exited, published, spawned, -faulted }`. Built-ins: `JsonlObserver(stream)` (a drop-in transition log) and -`CollectingObserver` (records to a list). - -```python -import sys -import determa.state as ds -host = ds.Host(observer=ds.JsonlObserver(sys.stdout)) # one JSON line per step +Both calls return all result fields: `status`, `disposition`, `state`, `emissions`, +`fault`, and `rejection` (`create` has a null disposition). The caller owns delivery: +the core processes at most one supplied envelope and does not place it in an internal +queue. Successful processing returns a new JSON-compatible logical aggregate while +leaving the supplied prior state unchanged. Rejections and unhandled deliveries return +the exact supplied state object. + +`load_bundle` also accepts a native Python mapping through the same structural and +semantic validation path. Native values must satisfy the same portable Unicode and +numeric domain as source documents. + +## Implemented Core + +- strict format-1 loading, default materialization, bundle fingerprinting, and exact + source-level scalar handling; +- portable CEL guards and action expressions; +- hierarchical dispatch, local and unmarked transitions, choices, shallow/deep + history, entry/exit behavior, final states, and stop interruption; +- lexical typed variables, input/external bindings, `env` refresh, and typed payloads; +- explicit sends, isolated lifecycle-bound components, and deterministic routing; +- owned spawn, nominal instance references, binding, cancellation, completion, + failure propagation, and cleanup cascades; +- atomic RTC rollback, deterministic identities/counters, pure inspection, and + incompatible or malformed prior-state rejection. + +Format 1 deliberately does not define native queues, timers, deferral, dead letters, +stores, snapshot wire encoding, machine hot-swap/migration, package imports, +standardized enabled-event inspection, or a standardized execution CLI. Hosts may +persist the returned logical aggregate in their own transaction, but portable +serialization and definition migration remain separate specification work. + +The implementation-local CLI only validates a bundle: + +```sh +determa-state validate examples/format-1.yaml ``` -The Observer is *domain* observability (what the machine did). For *operational* -diagnostics the engine also emits **standard-library logging** under the `determa.state` logger -(dispatch/transition at `DEBUG`, faults/dead-letter at `WARNING`). It is silent by -default (a `NullHandler` is attached); enable it from the host app: +It prints the normalized bundle fingerprint on success. -```python -import logging -logging.basicConfig(level=logging.DEBUG) # or logging.getLogger("determa.state").setLevel(...) -``` +## Develop -## Layout -- `src/determa/state/` — the package. -- `tests/` — the implementation's own **unit tests** (hermetic, offline). -- `conformance/` — the harness that runs the external **conformance suite** black-box - against this implementation (kept separate from the unit tests). +```sh +python -m venv .venv +. .venv/bin/activate +python -m pip install -e '.[dev]' -## Develop +ruff check . +mypy src/determa +pytest -q +pytest conformance -q ``` -python -m venv .venv && . .venv/bin/activate -pip install -e '.[dev]' -make check # ruff + mypy + unit tests (hermetic, offline) — the PR gate -make conformance # download & run the language-agnostic conformance suite -``` -Equivalently: `pytest` runs the unit tests only; `pytest conformance` runs the -conformance suite (it fetches `determa-state-conformance` into `.cache/` on first run — set -`DETERMA_CONFORMANCE_DIR` to use a local checkout offline). The two are **separate**: -unit tests never touch the network; conformance is opt-in. +Unit tests are hermetic and offline. The conformance harness uses the immutable commits +listed above, cached under `.cache/`; local checkouts can be supplied with +`DETERMA_CONFORMANCE_DIR` and `DETERMA_SPEC_DIR`. ## License -MIT — see [LICENSE](LICENSE). + +MIT. See [LICENSE](LICENSE). diff --git a/conformance/conftest.py b/conformance/conftest.py index 3347f85..0d32151 100644 --- a/conformance/conftest.py +++ b/conformance/conftest.py @@ -1,11 +1,4 @@ -"""Fetch the language-agnostic conformance suite before test collection. - -The suite lives in ``fruwehq/determa-state-conformance`` (no git submodule). It is cloned at the -release tag matching this package's version (falling back to ``main`` while the tag does -not yet exist) into a gitignored ``.cache/`` directory and reused. Override with a local -checkout via ``DETERMA_CONFORMANCE_DIR`` for offline work. If the suite cannot be obtained -(offline, no override), the conformance tests skip rather than error. -""" +"""Fetch the immutable pre-release conformance and specification inputs.""" from __future__ import annotations @@ -13,31 +6,57 @@ import subprocess from pathlib import Path -import determa.state as ds - -_ROOT = Path(__file__).resolve().parent.parent -_CACHE = _ROOT / ".cache" / "determa-state-conformance" -_REPO = "https://github.com/fruwehq/determa-state-conformance.git" - - -def _ensure_conformance() -> None: - if os.environ.get("DETERMA_CONFORMANCE_DIR"): - return # caller provides a local checkout - if (_CACHE / ".git").exists(): - return # already fetched; reuse (force a refresh by deleting .cache/) - _CACHE.parent.mkdir(parents=True, exist_ok=True) - # Prefer the release tag matching our version; fall back to main (tags may not exist - # yet pre-release). Network/tooling failure leaves the suite absent -> tests skip. - for ref in (f"v{ds.__version__}", "main"): - try: - subprocess.run( - ["git", "clone", "--depth", "1", "--branch", ref, _REPO, str(_CACHE)], - check=True, - capture_output=True, - ) - return - except (subprocess.CalledProcessError, OSError): - continue - - -_ensure_conformance() +from .pins import CONFORMANCE_CACHE, CONFORMANCE_COMMIT, SPEC_CACHE, SPEC_COMMIT + + +def _head(path: Path) -> str | None: + try: + result = subprocess.run( + ["git", "-C", str(path), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + except (subprocess.CalledProcessError, OSError): + return None + return result.stdout.strip() + + +def _ensure_checkout(path: Path, repository: str, commit: str) -> None: + if _head(path) == commit: + return + path.mkdir(parents=True, exist_ok=True) + try: + if not (path / ".git").exists(): + subprocess.run(["git", "init", str(path)], check=True, capture_output=True) + subprocess.run( + ["git", "-C", str(path), "fetch", "--depth", "1", repository, commit], + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "-C", str(path), "checkout", "--detach", "FETCH_HEAD"], + check=True, + capture_output=True, + ) + except (subprocess.CalledProcessError, OSError): + return + + +if "DETERMA_CONFORMANCE_DIR" not in os.environ: + _ensure_checkout( + CONFORMANCE_CACHE, + "https://github.com/fruwehq/determa-state-conformance.git", + CONFORMANCE_COMMIT, + ) + if _head(CONFORMANCE_CACHE) == CONFORMANCE_COMMIT: + os.environ["DETERMA_CONFORMANCE_DIR"] = str(CONFORMANCE_CACHE) + +if "DETERMA_SPEC_DIR" not in os.environ: + _ensure_checkout( + SPEC_CACHE, + "https://github.com/fruwehq/determa-state-spec.git", + SPEC_COMMIT, + ) + if _head(SPEC_CACHE) == SPEC_COMMIT: + os.environ["DETERMA_SPEC_DIR"] = str(SPEC_CACHE) diff --git a/conformance/harness.py b/conformance/harness.py index b66da74..d6c0694 100644 --- a/conformance/harness.py +++ b/conformance/harness.py @@ -1,312 +1,439 @@ -"""Conformance-suite harness helpers. - -The language-agnostic suite (SPEC §9) lives in ``fruwehq/determa-state-conformance`` and is -fetched at the matching release tag by ``conftest.py`` (no git submodule). These helpers -locate the fetched suite, enumerate cases, and run engine cases against this -implementation (create the root as id ``root``, per step ``send``/``advance``, run all -instances to quiescence, then check ``expect``). -""" +"""Driver for the language-neutral format-1 core conformance cases.""" from __future__ import annotations -import importlib.util +import copy import os -import sys from dataclasses import dataclass from pathlib import Path -from types import ModuleType from typing import Any -from determa.state import Host +import yaml + +from determa.state import ValidationError, create, dispatch, load_bundle -REPO_ROOT = Path(__file__).resolve().parent.parent +from .pins import CONFORMANCE_CACHE def conformance_root() -> Path: - """Root of the fetched ``determa-state-conformance`` checkout. - - ``DETERMA_CONFORMANCE_DIR`` overrides with a local checkout (offline/dev); otherwise - the cache populated by ``conftest.py`` is used. - """ - env = os.environ.get("DETERMA_CONFORMANCE_DIR") - return Path(env) if env else REPO_ROOT / ".cache" / "determa-state-conformance" - - -CONFORMANCE_DIR = conformance_root() / "conformance" - -# Cases the engine is known to pass. Others are skipped until their features -# land; extend this set as build-order steps are completed. -SUPPORTED = frozenset( - { - "01-guarded-leaf", - "02-hierarchy-bubbling", - "03-initial-action", - "04-defer", - "05-esvs-scope", - "06-payload-typing", - "07-internal-external", - "08-local-vs-external", - "09-orthogonal", - "10-history-deep", - "11-history-shallow", - "12-guarded-list", - "13-spawn-publish", - "14-subscription", - "15-external-env-refresh", - "16-timer", - "17-fault-handled", - "18-fault-unhandled", - "19-contract-pass", - "20-contract-fail", - "21-snapshot-roundtrip", - "22-migration", - "23-choice", - "24-choice-chain", - "25-choice-invalid", - "26-unreachable", - "27-dead-branch", - "28-reachable-ok", - "29-submachine", - "30-submachine-interrupt", - "31-enabled-events", - } -) + override = os.environ.get("DETERMA_CONFORMANCE_DIR") + return Path(override) if override else CONFORMANCE_CACHE + + +CORE_DIR = conformance_root() / "conformance" / "core" @dataclass(frozen=True) -class EngineCase: +class CoreCase: name: str path: Path - machine_files: list[Path] - test_file: Path - - -def _machine_files(case_dir: Path) -> list[Path]: - """The machine-definition file(s) for a case. - Most cases have ``machine.yaml``; migration cases have versioned - ``v1.yaml``/``v2.yaml``/… files instead (SPEC §9). - """ - single = case_dir / "machine.yaml" - if single.exists(): - return [single] - versioned = sorted(case_dir.glob("v*.yaml")) - if versioned: - return versioned - return [] + @property + def machine_file(self) -> Path | None: + path = self.path / "machine.yaml" + return path if path.exists() else None - -def engine_cases() -> list[EngineCase]: - """All engine conformance cases (``conformance/01``–``22``), sorted.""" - if not CONFORMANCE_DIR.exists(): - return [] - cases: list[EngineCase] = [] - for case_dir in sorted(p for p in CONFORMANCE_DIR.iterdir() if p.is_dir()): - machine_files = _machine_files(case_dir) - if not machine_files: - continue - test_file = case_dir / "test.yaml" - cases.append( - EngineCase( - name=case_dir.name, - path=case_dir, - machine_files=machine_files, - test_file=test_file, - ) - ) - return cases + @property + def test_file(self) -> Path: + return self.path / "test.yaml" -def cli_cases() -> list[Path]: - """All CLI conformance case directories (``conformance/cli/*``).""" - cli_dir = CONFORMANCE_DIR / "cli" - if not cli_dir.exists(): +def core_cases() -> list[CoreCase]: + if not CORE_DIR.exists(): return [] - return sorted(p for p in cli_dir.iterdir() if p.is_dir()) - - -# --- CLI case runner (SPEC §13.6): true black box via the spec repo's runner -- -def run_cli_case(case_dir: Path) -> None: - """Run a CLI case **black-box** via the spec repo's reference runner (§13.6). - - Invokes this package as a subprocess (``python -m determa.state``), so packaging and - entry-point regressions are caught — not an in-process import. Delegating to the - suite's ``conformance/run_cli.py`` also avoids harness drift. - """ - runner = _load_cli_runner() - rc = runner.main( - [ - "--cmd", - f"{sys.executable} -m determa.state", - "--conformance-dir", - str(CONFORMANCE_DIR / "cli"), - case_dir.name, - ] + return [ + CoreCase(path.name, path) + for path in sorted(CORE_DIR.iterdir()) + if path.is_dir() and (path / "test.yaml").exists() + ] + + +def _load_test(path: Path) -> dict[str, Any]: + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + + +def _materialize_driver_value(value: Any) -> Any: + if isinstance(value, dict) and set(value) == {"invalid_unicode_scalar"}: + return chr(int(value["invalid_unicode_scalar"], 16)) + if isinstance(value, dict): + return {key: _materialize_driver_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_materialize_driver_value(item) for item in value] + return value + + +def run_case(case: CoreCase) -> None: + test = _load_test(case.test_file) + _run_static_documents(case, test) + machine_file = case.machine_file + static = test.get("static") or {} + primary_invalid = ( + isinstance(static, dict) and "documents" not in static and static.get("valid") is False ) - assert rc == 0, f"cli/{case_dir.name}: black-box CLI runner reported failure" - - -def _load_cli_runner() -> ModuleType: - path = CONFORMANCE_DIR / "run_cli.py" - spec = importlib.util.spec_from_file_location("determa_cli_runner", path) - assert spec is not None and spec.loader is not None, f"runner not found: {path}" - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -# --- engine case runner ----------------------------------------------------- -def run_engine_case(case: EngineCase) -> None: - """Execute one engine conformance case, asserting every ``expect`` (SPEC §9).""" - from determa.state import collect_errors, load_definition, load_definitions - from determa.state.contracts import load_contract, validate_contracts - - test = _load_yaml(case.test_file) - assert case.machine_files, f"{case.name}: no machine files" - - if "static" in test: - from determa.state import ValidationError - - expected = bool(test["static"]["valid"]) - try: - root_raw = load_definitions(case.machine_files[0].read_text(encoding="utf-8"))[0].raw - except ValidationError: - # invalid at load time (schema / structural / choice rules) - assert expected is False, f"{case.name}: expected valid but load failed" - return - errors = list(collect_errors(root_raw)) - contracts: dict[str, dict[str, Any]] = {} - cdir = case.path / "contracts" - if cdir.exists(): - for cf in sorted(cdir.glob("*.yaml")): - c = load_contract(cf.read_text(encoding="utf-8")) - contracts[c["id"]] = c - errors.extend(validate_contracts(root_raw, contracts)) - valid = not errors - assert valid is expected, ( - f"{case.name}: static valid={valid} != {expected} ({errors})" - ) + if machine_file is None or primary_invalid: return - - external = test.get("external") or {} - host = Host() - files = case.machine_files - versioned = bool(files) and all( - f.name[:1] == "v" and f.stem[1:].isdigit() for f in files + source = machine_file.read_text(encoding="utf-8") + try: + bundle = load_bundle(source) + except ValidationError: + documents = static.get("documents") if isinstance(static, dict) else None + primary_declared_invalid = isinstance(documents, list) and any( + document.get("file") == "machine.yaml" and document.get("valid") is False + for document in documents + ) + if primary_declared_invalid and not ( + test.get("steps") or test.get("create") or test.get("load") + ): + return + raise + if "load" in test: + assert test["load"].get("valid", True) is True + create_spec = test.get("create") or {} + machine_id = bundle.raw["machines"][0]["machine_id"] + root_instance_id = _materialize_driver_value( + create_spec.get("root_instance_id", f"conformance:{case.name}:root") ) - if versioned: - ordered = sorted(files) - for f in ordered: - host.register(load_definition(f.read_text(encoding="utf-8"))) - root_id = load_definition(ordered[0].read_text(encoding="utf-8")).id - lowest = min(v for (iid, v) in host.versions if iid == root_id) - root_machine = host.versions[(root_id, lowest)] - else: - defs = load_definitions(files[0].read_text(encoding="utf-8")) - host.register_all(defs) - root_machine = host.machines[defs[0].id] - host.create_root(root_machine, "root", external=external) - host.run_to_quiescence() - - roundtrip = bool(test.get("roundtrip")) - for i, step in enumerate(test.get("steps", [])): - step_label = f"{case.name} step {i}" - before_pub, before_sp = len(host.published), len(host.spawned) + creation_id = _materialize_driver_value( + create_spec.get("creation_id", f"conformance:{case.name}:create") + ) + bindings = _materialize_driver_value(create_spec.get("bindings") or {}) + result = create( + bundle, + machine_id, + root_instance_id, + creation_id, + bindings, + ) + _assert_result(result, create_spec.get("expect") or {}, None, {}) + if result["state"] is None: + assert not test.get("steps") + return + state = result["state"] + captures: dict[str, list[dict[str, Any]]] = {} + for index, step in enumerate(test.get("steps") or []): + prior_state = state + target_runtime_id = state["root_runtime_id"] + dispatch_bundle = bundle if "send" in step: - delivered = _do_send(host, step["send"], step_label) - host.run_to_quiescence() - elif "advance" in step: - host.advance(step["advance"]) - delivered = True - host.run_to_quiescence() - elif "upgrade" in step: - host.upgrade(int(step["upgrade"]), root_machine.id) - delivered = True - host.run_to_quiescence() + send = _materialize_driver_value(step["send"]) + if "bundle" in send: + dispatch_bundle = load_bundle( + (case.path / send["bundle"]).read_text(encoding="utf-8") + ) + target = _root_target(state) + if "bound_instance" in send: + reference = _visible_variables(state, state["runtimes"][state["root_runtime_id"]])[ + send["bound_instance"] + ] + target = {"spawned_instance": copy.deepcopy(reference)} + target_runtime_id = reference["instance_id"] + envelope = { + "event": send["event"], + "event_id": send.get("event_id", f"conformance:{case.name}:step:{index}:input"), + "target": target, + "payload": copy.deepcopy(send.get("payload") or {}), + } + if "correlation_id" in send: + envelope["correlation_id"] = send["correlation_id"] + result = dispatch(dispatch_bundle, state, {"input": envelope}) + elif "deliver" in step: + delivery = step["deliver"] + envelope = copy.deepcopy(captures[delivery["captured"]][delivery["index"]]) + target_runtime_id = _target_runtime_id(envelope["target"]) + result = dispatch(dispatch_bundle, state, {"internal": envelope}) else: - raise AssertionError(f"{step_label}: unsupported step {list(step)}") - _check_expect( - host, + raise AssertionError(f"{case.name} step {index}: unsupported driver step") + _assert_result( + result, step.get("expect") or {}, - step_label, - delivered=delivered, - published=host.published[before_pub:], - spawned=host.spawned[before_sp:], + target_runtime_id, + captures, + prior_state=prior_state, + ) + state = result["state"] + if "capture_emissions_as" in step: + captures[step["capture_emissions_as"]] = copy.deepcopy(result["emissions"]) + + +def _run_static_documents(case: CoreCase, test: dict[str, Any]) -> None: + documents: list[dict[str, Any]] = [] + static = test.get("static") + if isinstance(static, dict): + if "documents" in static: + documents = static["documents"] + elif case.machine_file is not None: + documents = [{"file": "machine.yaml", **static}] + for document in documents: + source = (case.path / document["file"]).read_text(encoding="utf-8") + try: + load_bundle(source) + actual = (True, None) + except ValidationError as error: + actual = (False, error.code) + assert actual == (document["valid"], document.get("error")), ( + f"{case.name}/{document['file']}: {actual}" ) - if roundtrip: - host.restore_all(host.snapshot_all()) - - -def _do_send(host: Host, send: dict[str, Any], label: str) -> bool: - instance = send.get("instance", "root") - event = send["event"] - payload = send.get("payload") - return host.deliver(instance, event, payload) -def _check_expect( - host: Host, - expect: dict[str, Any], - label: str, - delivered: bool, - published: list[str], - spawned: list[str], +def _assert_result( + result: dict[str, Any], + expected: dict[str, Any], + target_runtime_id: str | None, + captures: dict[str, list[dict[str, Any]]], + *, + prior_state: dict[str, Any] | None = None, ) -> None: - if "rejected" in expect: - rejected = bool(expect["rejected"]) - assert delivered is (not rejected), ( - f"{label}: rejected={delivered} != expected {rejected}" - ) - instance_id = expect.get("instance", "root") - inst = host.instances.get(instance_id) - if "config" in expect: - assert inst is not None, f"{label}: instance {instance_id} missing" - assert inst.active_leaf_names() == sorted(expect["config"]), ( - f"{label}: config {inst.active_leaf_names()} != {sorted(expect['config'])}" + del captures + for name in ("status", "disposition"): + if name in expected: + assert result[name] == expected[name], (name, result[name], expected[name]) + if "rejection" in expected: + _assert_partial(result["rejection"], expected["rejection"], state=result["state"]) + if "fault" in expected: + _assert_partial(result["fault"], expected["fault"], state=result["state"]) + if expected.get("caller_still_owns_input"): + assert result["state"] is not None + assert not {"queue", "timers", "dead_letters"} & set(result["state"]) + assert all( + not {"queue", "timers", "dead_letters"} & set(runtime) + for runtime in result["state"]["runtimes"].values() ) - if "esvs" in expect and inst is not None: - actual = inst.resolved_esvs() - for name, val in expect["esvs"].items(): - assert actual.get(name) == val, ( - f"{label}: esv {name}={actual.get(name)!r} != {val!r}" + if result["disposition"] == "rejected": + assert result["state"] is prior_state + if result["state"] is None: + return + state = result["state"] + runtime = state["runtimes"][state["root_runtime_id"]] + _assert_runtime(state, runtime, expected) + if "emissions" in expected: + assert len(result["emissions"]) == len(expected["emissions"]) + for actual, wanted in zip(result["emissions"], expected["emissions"], strict=True): + _assert_emission( + state, + runtime, + target_runtime_id, + prior_state, + actual, + wanted, ) - if "enabled" in expect: - assert inst is not None, f"{label}: instance {instance_id} missing" - assert host.enabled_events(inst) == sorted(expect["enabled"]), ( - f"{label}: enabled {host.enabled_events(inst)} != {sorted(expect['enabled'])}" - ) - if "status" in expect: - assert inst is not None - assert inst.status.value == expect["status"], ( - f"{label}: status {inst.status.value} != {expect['status']}" - ) - if "published" in expect: - assert published == expect["published"], ( - f"{label}: published {published} != {expect['published']}" + + +def _assert_runtime( + state: dict[str, Any], runtime: dict[str, Any], expected: dict[str, Any] +) -> None: + if "config" in expected: + actual = _config(runtime) + assert actual == expected["config"], (actual, expected["config"]) + if "variables" in expected: + _assert_partial(_visible_variables(state, runtime), expected["variables"], state=state) + if "history" in expected: + assert runtime["history"] == expected["history"], ( + runtime["history"], + expected["history"], ) - if "spawned" in expect: - assert spawned == expect["spawned"], ( - f"{label}: spawned {spawned} != {expect['spawned']}" + if "components" in expected: + wanted = expected["components"] + assert set(runtime["components"]) == set(wanted) + for component_id, component_expected in wanted.items(): + child = state["runtimes"][runtime["components"][component_id]] + if "status" in component_expected: + _assert_partial(child, {"status": component_expected["status"]}) + _assert_runtime(state, child, component_expected) + if "owned_instances" in expected: + children = sorted( + [ + child + for child in state["runtimes"].values() + if child.get("role") == "spawned" and _is_descendant_runtime(state, child, runtime) + ], + key=lambda child: ( + child["owner_runtime_id"].encode("utf-8"), + child["spawn_sequence"], + ), ) - if expect.get("dead_letter"): - assert inst is not None - assert inst.dead_letter, f"{label}: expected a dead-letter record" - if expect.get("instances"): - for iid, sub in expect["instances"].items(): - target = host.instances.get(iid) - if "status" in sub and target is not None: - assert target.status.value == sub["status"], ( - f"{label}: {iid} status {target.status.value} != {sub['status']}" + assert len(children) == len(expected["owned_instances"]) + for child, child_expected in zip(children, expected["owned_instances"], strict=True): + key = child_expected["key"] + if key.get("owner") == "root": + assert child["owner_runtime_id"] == state["root_runtime_id"] + if "spawn_sequence" in key: + assert child["spawn_sequence"] == key["spawn_sequence"] + if "machine_id" in child_expected: + assert child["machine_id"] == child_expected["machine_id"] + _assert_runtime(state, child, child_expected) + if isinstance(expected.get("fault"), dict) and "code" in expected["fault"]: + _assert_partial(runtime["fault"], expected["fault"]) + + +def _assert_emission( + state: dict[str, Any], + runtime: dict[str, Any], + processed_runtime_id: str | None, + prior_state: dict[str, Any] | None, + actual: dict[str, Any], + expected: dict[str, Any], +) -> None: + for key, value in expected.items(): + if key == "target": + if value == "external": + assert actual["target"] == "external" + elif value == "root": + assert actual["target"] == _root_target(state) + elif value == "owner": + emitter = _emitting_runtime(state, prior_state, processed_runtime_id, actual) + if emitter is not None and emitter.get("owner_runtime_id") is not None: + owner = _runtime_from_either(state, prior_state, emitter["owner_runtime_id"]) + assert owner is not None + owner_target = _runtime_target(state, owner) + else: + owner_target = _root_target(state) + assert actual["target"] == owner_target + elif isinstance(value, dict) and "component" in value: + component = next( + ( + candidate + for candidate in state["runtimes"].values() + if candidate.get("role") == "component" + and candidate.get("component_id") == value["component"] + and ( + processed_runtime_id is None + or candidate.get("owner_runtime_id") == processed_runtime_id + or candidate.get("owner_runtime_id") == runtime["runtime_id"] + ) + ), + None, ) - if "config" in sub and target is not None: - assert target.active_leaf_names() == sorted(sub["config"]), ( - f"{label}: {iid} config mismatch" + assert component is not None + assert actual["target"] == component["target"] + elif isinstance(value, dict) and "bound_instance" in value: + reference = _visible_variables(state, runtime)[value["bound_instance"]] + assert actual["target"] == {"spawned_instance": reference} + elif key == "payload": + _assert_partial(actual["payload"], value, state=state) + else: + assert actual.get(key) == value, (key, actual.get(key), value) + + +def _assert_partial(actual: Any, expected: Any, *, state: dict[str, Any] | None = None) -> None: + if isinstance(expected, dict): + assert isinstance(actual, dict), (actual, expected) + if set(expected) == {"instance_reference"}: + assertion = expected["instance_reference"] + assert _is_reference(actual) + if "machine_id" in assertion: + assert actual["machine_id"] == assertion["machine_id"] + if "targetable" in assertion: + assert state is not None + target = state["runtimes"].get(actual["instance_id"]) + targetable = ( + state["status"] == "running" + and target is not None + and target.get("role") == "spawned" + and target.get("status") == "running" + and target.get("instance_reference") == actual ) + assert targetable is assertion["targetable"] + return + for key, value in expected.items(): + assert key in actual, (key, actual) + _assert_partial(actual[key], value, state=state) + elif isinstance(expected, list): + assert isinstance(actual, list) and len(actual) == len(expected) + for left, right in zip(actual, expected, strict=True): + _assert_partial(left, right, state=state) + else: + assert type(actual) is type(expected) and actual == expected, (actual, expected) + + +def _is_reference(value: Any) -> bool: + return isinstance(value, dict) and set(value) == { + "root_instance_id", + "instance_id", + "machine_id", + "machine_version", + } + +def _config(runtime: dict[str, Any]) -> list[str]: + if not runtime["active"]: + return [] + leaf = runtime["active"][-1] + return [] if leaf == "root" else [leaf] + + +def _visible_variables(state: dict[str, Any], runtime: dict[str, Any]) -> dict[str, Any]: + del state + result: dict[str, Any] = {} + for path in runtime["active"]: + result.update(runtime["scopes"].get(path, {})) + return copy.deepcopy(result) + + +def _root_target(state: dict[str, Any]) -> dict[str, Any]: + return { + "root": { + "root_instance_id": state["root_instance_id"], + "root_runtime_id": state["root_runtime_id"], + } + } -def _load_yaml(path: Path) -> dict[str, Any]: - import yaml # conformance test.yaml is a scenario, not a machine; core schema ok - with path.open(encoding="utf-8") as fh: - data = yaml.safe_load(fh) - return data or {} +def _target_runtime_id(target: dict[str, Any]) -> str: + if "root" in target: + return target["root"]["root_runtime_id"] + if "component" in target: + return target["component"]["component_runtime_id"] + return target["spawned_instance"]["instance_id"] + + +def _runtime_from_either( + state: dict[str, Any], + prior_state: dict[str, Any] | None, + runtime_id: str, +) -> dict[str, Any] | None: + runtime = state["runtimes"].get(runtime_id) + if runtime is not None or prior_state is None: + return runtime + return prior_state["runtimes"].get(runtime_id) + + +def _emitting_runtime( + state: dict[str, Any], + prior_state: dict[str, Any] | None, + processed_runtime_id: str | None, + emission: dict[str, Any], +) -> dict[str, Any] | None: + payload = emission.get("payload") or {} + source_id = payload.get("component_runtime_id") + if source_id is None and emission.get("event") in { + "done", + "determa.spawned_instance_failed", + }: + source_id = payload.get("instance_id") + if not isinstance(source_id, str): + source_id = processed_runtime_id + if source_id is None: + return None + return _runtime_from_either(state, prior_state, source_id) + + +def _runtime_target(state: dict[str, Any], runtime: dict[str, Any]) -> dict[str, Any]: + if runtime["role"] == "root": + return _root_target(state) + if runtime["role"] == "component": + return copy.deepcopy(runtime["target"]) + return {"spawned_instance": copy.deepcopy(runtime["instance_reference"])} + + +def _is_descendant_runtime( + state: dict[str, Any], + candidate: dict[str, Any], + owner: dict[str, Any], +) -> bool: + owner_id = candidate.get("owner_runtime_id") + while owner_id is not None: + if owner_id == owner["runtime_id"]: + return True + parent = state["runtimes"].get(owner_id) + owner_id = parent.get("owner_runtime_id") if parent is not None else None + return False diff --git a/conformance/pins.py b/conformance/pins.py new file mode 100644 index 0000000..89fec02 --- /dev/null +++ b/conformance/pins.py @@ -0,0 +1,12 @@ +"""Immutable pre-release specification and conformance inputs.""" + +from __future__ import annotations + +from pathlib import Path + +CONFORMANCE_COMMIT = "409bbdc6c2d4a4e9d50ddb1d994c5f5cd7d97762" +SPEC_COMMIT = "03771fac569a47b82f27891cd3700d4d1d876f8b" + +ROOT = Path(__file__).resolve().parent.parent +CONFORMANCE_CACHE = ROOT / ".cache" / f"determa-state-conformance-{CONFORMANCE_COMMIT[:12]}" +SPEC_CACHE = ROOT / ".cache" / f"determa-state-spec-{SPEC_COMMIT[:12]}" diff --git a/conformance/test_conformance.py b/conformance/test_conformance.py index c17a429..9b05c99 100644 --- a/conformance/test_conformance.py +++ b/conformance/test_conformance.py @@ -1,110 +1,37 @@ -"""Conformance suite gates. - -Two layers: - -1. **Step-1 gate** — every machine definition in the upstream suite MUST load - and validate (SPEC §2/§9), and the bundled schema must not drift from the - spec repo's. -2. **Engine gate** — each supported case is run end-to-end (create root, - ``send`` to quiescence, check ``expect``). Unsupported cases are skipped - until their features land; see ``harness.SUPPORTED``. -""" +"""Full format-1 core conformance gate.""" from __future__ import annotations import json import os -import urllib.error -import urllib.request from pathlib import Path import pytest -import determa.state as ds -from determa.state import load_definitions from determa.state.validator import schema as bundled_schema -from .harness import ( - CONFORMANCE_DIR, - SUPPORTED, - cli_cases, - engine_cases, - run_cli_case, - run_engine_case, -) +from .harness import CORE_DIR, CoreCase, core_cases, run_case def _spec_schema() -> dict | None: - """The normative schema from fruwehq/determa-state-spec at the matching tag (or an override). - - Returns ``None`` when offline and no ``DETERMA_SPEC_DIR`` override is set, so the - drift test can skip rather than fail. - """ override = os.environ.get("DETERMA_SPEC_DIR") - if override: - p = Path(override) / "schema" / "machine.schema.json" - return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None - for ref in (f"v{ds.__version__}", "main"): - url = f"https://raw.githubusercontent.com/fruwehq/determa-state-spec/{ref}/schema/machine.schema.json" - try: - with urllib.request.urlopen(url, timeout=10) as resp: # noqa: S310 (fixed host) - return json.loads(resp.read()) - except (urllib.error.URLError, OSError, ValueError): - continue - return None - - -def _each_machine_file() -> list[pytest.Param]: - import yaml - - params: list[pytest.Param] = [] - for case in engine_cases(): - # A `static: { valid: false }` case may hold a deliberately invalid machine - # (it must NOT load cleanly), so exclude it from the "loads and validates" gate. - test = yaml.safe_load(case.test_file.read_text(encoding="utf-8")) or {} - if test.get("static", {}).get("valid") is False: - continue - for mf in case.machine_files: - params.append(pytest.param(mf, id=f"{case.name}:{mf.name}")) - for case in cli_cases(): - mf = case / "machine.yaml" - if mf.exists(): - params.append(pytest.param(mf, id=f"cli/{case.name}")) - return params - - -@pytest.mark.parametrize("path", _each_machine_file()) -def test_machine_file_loads_and_validates(path: Path) -> None: - defs = load_definitions(path.read_text(encoding="utf-8")) - assert defs, f"{path}: no definitions loaded" - for d in defs: - assert d.id == d.raw["id"] - - -def test_bundled_schema_matches_spec() -> None: - """The engine's bundled schema must equal the spec repo's schema (no drift).""" - upstream = _spec_schema() - if upstream is None: - pytest.skip( - "spec schema unavailable (offline; set DETERMA_SPEC_DIR to a local checkout)" - ) - assert upstream == bundled_schema() + if not override: + return None + path = Path(override) / "schema" / "machine.schema.json" + return json.loads(path.read_text(encoding="utf-8")) if path.exists() else None def test_suite_present() -> None: - if not CONFORMANCE_DIR.exists(): - pytest.skip("conformance suite not fetched (offline; set DETERMA_CONFORMANCE_DIR)") - assert len(engine_cases()) == 31, "expected 31 engine cases" - assert len(cli_cases()) == 3, "expected 3 CLI cases" + assert CORE_DIR.exists(), "pinned conformance suite is unavailable" + assert len(core_cases()) == 75 -@pytest.mark.parametrize("case", engine_cases(), ids=lambda c: c.name) -def test_engine_case(case) -> None: # type: ignore[no-untyped-def] - if case.name not in SUPPORTED: - pytest.skip(f"not yet supported: {case.name}") - run_engine_case(case) +def test_bundled_schema_matches_pinned_spec() -> None: + upstream = _spec_schema() + assert upstream is not None, "pinned specification is unavailable" + assert bundled_schema() == upstream -@pytest.mark.parametrize("case", cli_cases(), ids=lambda c: f"cli/{c.name}") -def test_cli_case(case) -> None: # type: ignore[no-untyped-def] - run_cli_case(case) +@pytest.mark.parametrize("case", core_cases(), ids=lambda case: case.name) +def test_core_case(case: CoreCase) -> None: + run_case(case) diff --git a/examples/format-1.yaml b/examples/format-1.yaml new file mode 100644 index 0000000..23073fe --- /dev/null +++ b/examples/format-1.yaml @@ -0,0 +1,26 @@ +format: 1 +namespace: example.counter +events: + increment: + direction: input + payload: + amount: { type: int, required: true } + reset: + direction: input +machines: + - machine_id: counter + version: 1 + root: + type: composite + variables: + count: { type: int, init: 0 } + initial: { transition_to: running } + states: + running: + on_events: + increment: + action: + - assign: { count: "count + event.payload.amount" } + reset: + action: + - assign: { count: "0" } diff --git a/pyproject.toml b/pyproject.toml index 5200acf..8d10a20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ license = { file = "LICENSE" } authors = [{ name = "Christian-Manuel Butzke" }] keywords = ["statechart", "state-machine", "determa", "fsm", "hsm", "uml", "scxml"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", diff --git a/scripts/sync_schema.py b/scripts/sync_schema.py index d1b3390..85ae99f 100644 --- a/scripts/sync_schema.py +++ b/scripts/sync_schema.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 -"""Refresh the bundled JSON Schema from the spec repo (fruwehq/determa-state-spec). +"""Refresh the bundled JSON Schema from the approved immutable specification commit. Writes ``src/determa/state/data/machine.schema.json`` from Determa State's -``schema/machine.schema.json`` at the tag matching this package's version (falling back -to ``main``), or from a local checkout via ``DETERMA_SPEC_DIR``. This removes the manual -copy step; the schema-drift test still guards that the two stay in sync. +``schema/machine.schema.json`` at the format-1 pre-release pin, or from a local checkout +via ``DETERMA_SPEC_DIR``. The schema-drift conformance test guards that they match. Usage: ``python scripts/sync_schema.py`` (or ``make sync-schema``). """ @@ -13,7 +12,6 @@ import json import os -import re import sys import urllib.error import urllib.request @@ -21,29 +19,22 @@ ROOT = Path(__file__).resolve().parent.parent DEST = ROOT / "src" / "determa" / "state" / "data" / "machine.schema.json" -ABOUT = ROOT / "src" / "determa" / "state" / "__about__.py" - - -def _version() -> str: - m = re.search(r'__version__\s*=\s*"([^"]+)"', ABOUT.read_text(encoding="utf-8")) - if m is None: - raise SystemExit(f"could not read version from {ABOUT}") - return m.group(1) +SPEC_COMMIT = "03771fac569a47b82f27891cd3700d4d1d876f8b" def _fetch() -> str: override = os.environ.get("DETERMA_SPEC_DIR") if override: return (Path(override) / "schema" / "machine.schema.json").read_text(encoding="utf-8") - last: Exception | None = None - for ref in (f"v{_version()}", "main"): - url = f"https://raw.githubusercontent.com/fruwehq/determa-state-spec/{ref}/schema/machine.schema.json" - try: - with urllib.request.urlopen(url, timeout=10) as resp: # noqa: S310 (fixed host) - return resp.read().decode("utf-8") - except urllib.error.URLError as exc: - last = exc - raise SystemExit(f"could not fetch schema from fruwehq/determa-state-spec: {last}") + url = ( + "https://raw.githubusercontent.com/fruwehq/determa-state-spec/" + f"{SPEC_COMMIT}/schema/machine.schema.json" + ) + try: + with urllib.request.urlopen(url, timeout=10) as response: # noqa: S310 (fixed host) + return response.read().decode("utf-8") + except urllib.error.URLError as exc: + raise SystemExit(f"could not fetch schema from {SPEC_COMMIT}: {exc}") from exc def main() -> int: diff --git a/src/determa/state/__init__.py b/src/determa/state/__init__.py index 3014246..eca87b8 100644 --- a/src/determa/state/__init__.py +++ b/src/determa/state/__init__.py @@ -1,49 +1,31 @@ -"""Determa State — Python reference implementation of the Determa State statechart engine. - -The normative SPEC.md, machine JSON Schema, and cross-language conformance -suite live in the spec repo (https://github.com/fruwehq/determa-state-spec). This package -implements that spec; it is correct iff it passes the conformance suite. -""" +"""Python reference implementation of Determa State format 1.""" from __future__ import annotations import logging -from . import yaml12 from .__about__ import __version__ -from .cel import CelError -from .definition import Definition, load_definition, load_definitions -from .engine import Host -from .errors import DetermaError, ErrorRecord, SchemaError, ValidationError -from .instance import Event, Instance, Status -from .model import Machine, State -from .observer import CollectingObserver, JsonlObserver, Observer +from .definition import Bundle, BundleSource, load_bundle +from .engine import Delivery, Result, create, dispatch +from .errors import CelError, DetermaError, ErrorRecord, SchemaError, ValidationError from .validator import collect_errors, validate __all__ = [ - "Definition", - "ErrorRecord", - "Event", + "Bundle", + "BundleSource", + "CelError", "DetermaError", - "Host", - "Instance", - "Machine", - "Observer", - "JsonlObserver", - "CollectingObserver", + "Delivery", + "ErrorRecord", + "Result", "SchemaError", - "State", - "Status", - "CelError", "ValidationError", + "__version__", "collect_errors", - "load_definition", - "load_definitions", + "create", + "dispatch", + "load_bundle", "validate", - "yaml12", - "__version__", ] -# Diagnostic logging under the ``determa.state`` logger; silent unless the host app -# configures logging (e.g. ``logging.basicConfig(level=logging.DEBUG)``). logging.getLogger("determa.state").addHandler(logging.NullHandler()) diff --git a/src/determa/state/cel.py b/src/determa/state/cel.py index ccf7c86..ecbd3bd 100644 --- a/src/determa/state/cel.py +++ b/src/determa/state/cel.py @@ -1,94 +1,222 @@ -"""CEL (Common Expression Language) evaluation for guards and action values. - -Guards are CEL booleans; computed action values (an ``assign`` RHS, a published -payload value) are CEL over ``(esvs, event, id, parent)`` (SPEC §6). CEL is -side-effect-free and non-Turing-complete, which is what makes guards portable. - -This module wraps ``cel-python`` (the ``celpy`` package). celpy requires its -own container types for field selection, so bindings are deep-converted on the -way in. Compiled programs are cached by expression text. -""" +"""Portable CEL profile checks and evaluation.""" from __future__ import annotations +import math +import re from functools import lru_cache from typing import TYPE_CHECKING, Any, cast +from .errors import CelError + if TYPE_CHECKING: import celpy - -class CelError(Exception): - """A CEL expression failed to compile or evaluate (e.g. division by zero).""" - - -# celpy (with its lark + pendulum transitive deps) costs ~0.1s to import, but is only -# needed to *evaluate* an expression — not to load, inspect, snapshot, or step a machine. -# Import it lazily so the CLI and library stay fast on guard-free paths (SPEC §6). _celpy: Any = None _celtypes: Any = None -_env: Any = None +_environment: Any = None +_INT_MIN = -(2**63) +_INT_MAX = 2**63 - 1 +_ALLOWED_FUNCTIONS = frozenset({"size", "has", "double", "int", "string"}) +_CEL_WORDS = frozenset({"true", "false", "null", "in"}) def _load() -> tuple[Any, Any, Any]: - global _celpy, _celtypes, _env + global _celpy, _celtypes, _environment if _celpy is None: - import celpy as celpy_mod - import celpy.celtypes as celtypes_mod + import celpy as celpy_module + import celpy.celtypes as celtypes_module - _celpy = celpy_mod - _celtypes = celtypes_mod - _env = celpy_mod.Environment() - return _celpy, _celtypes, _env + _celpy = celpy_module + _celtypes = celtypes_module + _environment = celpy_module.Environment() + return _celpy, _celtypes, _environment -@lru_cache(maxsize=2048) -def _program(expr: str) -> celpy.Runner: - celpy_mod, _, env = _load() +@lru_cache(maxsize=4096) +def _program(expression: str) -> celpy.Runner: + celpy_module, _, environment = _load() try: - return cast("celpy.Runner", env.program(env.compile(expr))) - except celpy_mod.CELEvalError as exc: - raise CelError(f"compile error: {expr!r}: {exc}") from exc + return cast("celpy.Runner", environment.program(environment.compile(expression))) + except Exception as exc: + raise CelError(f"invalid CEL expression: {expression}") from exc + + +def compile_expression(expression: str) -> None: + """Parse an expression without evaluating it.""" + _program(expression) + + +def _without_strings(expression: str) -> str: + pattern = r"""(?s)'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*\"""" + return re.sub(pattern, " ", expression) + + +def profile_error(expression: str, instance_reference_names: set[str] | None = None) -> bool: + """Return whether an expression uses a construct outside the closed profile.""" + stripped = _without_strings(expression) + if re.search(r"\.\s*[A-Za-z_][A-Za-z0-9_]*\s*\(", stripped): + return True + functions = set(re.findall(r"(?=|<|>|\+|-|\*|/|%)\s*[0-9]+\.[0-9]", stripped): + return True + if re.search(r"\b[0-9]+\.[0-9]\s*(?:==|!=|<=|>=|<|>|\+|-|\*|/|%)\s*[0-9]+\b", stripped): + return True + for name in instance_reference_names or set(): + if re.search(rf"\b{re.escape(name)}\s*\.", stripped): + return True + if re.search(rf"\bstring\s*\(\s*{re.escape(name)}\s*\)", stripped): + return True + return False + + +def referenced_names(expression: str) -> set[str]: + """Conservatively collect bare activation identifiers.""" + stripped = _without_strings(expression) + names = set(re.findall(r"(? str: + """Infer the portable type for the expression shapes used by format 1.""" + expr = expression.strip() + if expr in scope: + return scope[expr] + event_match = re.fullmatch(r"event\.payload\.([A-Za-z_][A-Za-z0-9_]*)", expr) + if event_match and event_fields is not None: + return event_fields.get(event_match.group(1), "unknown") + owner_match = re.fullmatch(r"owner\.variables\.([A-Za-z_][A-Za-z0-9_]*)", expr) + if owner_match and owner_fields is not None: + return owner_fields.get(owner_match.group(1), "unknown") + if expr in {"true", "false"}: + return "bool" + if expr == "null": + return "null" + if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", expr): + return "int" + if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", expr): + return "float" + if re.fullmatch(r"""'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*\"""", expr): + return "string" + if expr.startswith("[") and expr.endswith("]"): + return "list" + if expr.startswith("{") and expr.endswith("}"): + return "map" + if re.match(r"^(?:size|int)\s*\(", expr): + return "int" + if re.match(r"^double\s*\(", expr): + return "float" + if re.match(r"^string\s*\(", expr): + return "string" + ternary = re.match(r"^.+\?(.+):(.+)$", expr) + if ternary: + left = infer_type( + ternary.group(1).strip(), + scope, + event_fields=event_fields, + owner_fields=owner_fields, + ) + right = infer_type( + ternary.group(2).strip(), + scope, + event_fields=event_fields, + owner_fields=owner_fields, + ) + return left if left == right else "unknown" + if re.search(r"\[[^\]]+\]\s*$", expr): + return "unknown" + if ( + "==" in expr + or "!=" in expr + or re.search(r"(?:<=|>=|<|>)", expr) + or "&&" in expr + or "||" in expr + or expr.startswith("!") + or expr.startswith("has(") + or re.search(r"\bin\b", expr) + ): + return "bool" + for name, type_name in scope.items(): + if re.search(rf"\b{re.escape(name)}\b", expr): + return type_name + return "unknown" def _to_cel(value: Any) -> Any: _, celtypes, _ = _load() + if value is None: + return None + if isinstance(value, bool): + return celtypes.BoolType(value) + if isinstance(value, int): + return celtypes.IntType(value) + if isinstance(value, float): + return celtypes.DoubleType(value) + if isinstance(value, str): + return celtypes.StringType(value) if isinstance(value, dict): - return celtypes.MapType({k: _to_cel(v) for k, v in value.items()}) + return celtypes.MapType( + {celtypes.StringType(key): _to_cel(item) for key, item in value.items()} + ) if isinstance(value, list): - return celtypes.ListType([_to_cel(v) for v in value]) - return value + return celtypes.ListType([_to_cel(item) for item in value]) + raise CelError(f"unsupported CEL value: {type(value).__name__}") def _from_cel(value: Any) -> Any: - """Normalize a celpy result to a canonical native/JSON Python value (SPEC §5.1). - - No guard-language wrapper type (``celpy.celtypes.*``) may cross the engine boundary, - so every CEL result is coerced to its native equivalent here — the single choke point - for esv assignments, published payloads, and spawn args. - """ _, celtypes, _ = _load() - if isinstance(value, celtypes.BoolType): # subclasses int — check before IntType + if isinstance(value, celtypes.BoolType): return bool(value) if isinstance(value, (celtypes.IntType, celtypes.UintType)): - return int(value) + integer = int(value) + if not _INT_MIN <= integer <= _INT_MAX: + raise CelError("integer overflow") + return integer if isinstance(value, celtypes.DoubleType): - return float(value) + double = float(value) + if not math.isfinite(double): + raise CelError("non-finite double") + return 0.0 if double == 0.0 else double if isinstance(value, celtypes.StringType): return str(value) - if isinstance(value, celtypes.BytesType): - return bytes(value) - if isinstance(value, dict): # MapType (and native dict): normalize keys + values - return {_from_cel(k): _from_cel(v) for k, v in value.items()} - if isinstance(value, list): # ListType (and native list) - return [_from_cel(v) for v in value] - return value - - -def evaluate(expr: str, bindings: dict[str, Any]) -> Any: - """Evaluate a CEL expression, returning a canonical native/JSON value (§5.1).""" - celpy_mod, _, _ = _load() + if isinstance(value, dict): + result: dict[str, Any] = {} + for key, item in value.items(): + normalized_key = _from_cel(key) + if not isinstance(normalized_key, str): + raise CelError("map key is not a string") + result[normalized_key] = _from_cel(item) + return result + if isinstance(value, list): + return [_from_cel(item) for item in value] + if value is None or isinstance(value, bool | int | float | str): + return value + raise CelError(f"unsupported CEL result: {type(value).__name__}") + + +def evaluate(expression: str, bindings: dict[str, Any]) -> Any: + """Evaluate one expression using only the explicit activation.""" + celpy_module, _, _ = _load() try: - return _from_cel(_program(expr).evaluate(_to_cel(bindings))) - except celpy_mod.CELEvalError as exc: + return _from_cel(_program(expression).evaluate(_to_cel(bindings))) + except CelError: + raise + except (celpy_module.CELEvalError, ValueError, TypeError, KeyError) as exc: raise CelError(str(exc)) from exc diff --git a/src/determa/state/cli.py b/src/determa/state/cli.py index 2d3114b..61d3900 100644 --- a/src/determa/state/cli.py +++ b/src/determa/state/cli.py @@ -1,647 +1,41 @@ -"""Standard CLI (SPEC §13). - -Every implementation exposes the same command surface so operators and tests -interact with any language's engine identically. State persists in a -file-backed store; a state-changing command loads the affected instances, runs -all to quiescence, and persists. Diagnostics go to stderr; the result to stdout. -""" +"""Small implementation-local command line entry point.""" from __future__ import annotations import argparse -import io import json -import os -import sys -from contextlib import redirect_stderr, redirect_stdout +from collections.abc import Sequence from pathlib import Path -from typing import Any, cast - -from . import collect_errors, load_definitions -from . import export as export_mod -from .contracts import load_contract, validate_contracts -from .engine import Host -from .errors import DetermaError -from .instance import Instance, Status -from .model import Machine -from .store import Store, StoreState, open_store - -# Exit codes (SPEC §13.2). -EXIT_OK = 0 -EXIT_OTHER = 1 -EXIT_USAGE = 2 -EXIT_VALIDATION = 3 -EXIT_NOT_FOUND = 4 -EXIT_FAULTED = 5 - - -def main(argv: list[str] | None = None) -> int: - args = _build_parser().parse_args(argv) - store_dir = args.store or os.environ.get("DETERMA_STORE", "./.determa") - try: - return int(args.cmd(args, open_store(store_dir))) - except DetermaError as exc: - print(str(exc), file=sys.stderr) - return EXIT_OTHER - - -def _build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser( - prog="determa-state", - description="Determa State statechart engine", - formatter_class=_GroupedHelpFormatter, - epilog=( - "store specification (--store / DETERMA_STORE):\n" - " file: portable snapshot files (the default, ./.determa)\n" - " mem: in-memory, ephemeral\n" - " sqlite: a single-file database\n" - "examples:\n" - " determa-state --store mem: new t1 machine.yaml\n" - " determa-state --store sqlite:./state.db list --json" - ), - ) - p.add_argument( - "--store", - default=None, - help="store spec: file: | mem: | sqlite: (default ./.determa or $DETERMA_STORE)", - ) - p.add_argument("--version", action="version", version=f"determa-state {_pkg_version()}") - sub = p.add_subparsers(dest="command", required=True, metavar="") - - # `--json` is accepted per-subcommand (after the positionals). - common = argparse.ArgumentParser(add_help=False) - common.add_argument("--json", action="store_true", help="machine-readable output") - - def add(cmd: str, group: str, desc: str, example: str, **kw: Any) -> argparse.ArgumentParser: - sp = sub.add_parser( - cmd, - parents=[common], - help=desc, - description=desc, - epilog=f"example:\n {example}", - formatter_class=argparse.RawDescriptionHelpFormatter, - **kw, - ) - sp._cli_group = group # type: ignore[attr-defined] - sp._cli_help = desc # type: ignore[attr-defined] - return sp - - v = add("validate", "Authoring", "validate a machine definition file", - "determa-state validate machine.yaml") - v.add_argument("machine") - v.set_defaults(cmd=cmd_validate) - - e = add("export", "Authoring", "render a machine to a diagram", - "determa-state export machine.yaml --format mermaid") - e.add_argument("machine") - e.add_argument("--format", default="mermaid", choices=["mermaid"], - help="output format (currently only 'mermaid')") - e.add_argument("--state", default=None, help="instance id whose active config to highlight") - e.set_defaults(cmd=cmd_export) - - n = add("new", "Instances", "create a new instance from a machine", - "determa-state new t1 machine.yaml --external token=abc") - n.add_argument("id") - n.add_argument("machine") - n.add_argument("--external", action="append", default=[], - help="seed an external esv: k=v (repeatable)") - n.set_defaults(cmd=cmd_new) - - s = add("send", "Instances", "deliver an event to an instance", - "determa-state send t1 coin --payload amount=100") - s.add_argument("instance") - s.add_argument("event") - s.add_argument("--payload", action="append", default=[], help="event field k=v (repeatable)") - s.add_argument("--payload-json", default=None, help="whole payload as one JSON object") - s.set_defaults(cmd=cmd_send) - - a = add("advance", "Instances", "advance the virtual clock by a duration", - "determa-state advance 5s") - a.add_argument("duration", help="e.g. 500ms, 5s, 2m") - a.set_defaults(cmd=cmd_advance) - - env = add("env", "Instances", "notify an instance of environment changes", - "determa-state env t1 --changed level=high") - env.add_argument("instance") - env.add_argument("--changed", required=True, help="comma-separated k=v pairs") - env.set_defaults(cmd=cmd_env) - - st = add("state", "Instances", "print an instance's current state", - "determa-state state t1 --json") - st.add_argument("instance") - st.set_defaults(cmd=cmd_state) - - en = add("enabled", "Instances", "list events an instance can currently handle", - "determa-state enabled t1") - en.add_argument("instance") - en.set_defaults(cmd=cmd_enabled) - - ip = add("inspect", "Instances", "show full internal state for debugging", - "determa-state inspect t1 --json") - ip.add_argument("instance") - ip.set_defaults(cmd=cmd_inspect) - - md = add("mode", "Stepping", "get or set auto vs manual processing mode", - "determa-state mode manual") - md.add_argument("mode", nargs="?", choices=["auto", "manual"]) - md.set_defaults(cmd=cmd_mode) - - ij = add("inject", "Stepping", "enqueue an event without processing (manual mode)", - "determa-state inject t1 coin --payload amount=100") - ij.add_argument("instance") - ij.add_argument("event") - ij.add_argument("--payload", action="append", default=[], help="event field k=v (repeatable)") - ij.add_argument("--payload-json", default=None, help="whole payload as one JSON object") - ij.set_defaults(cmd=cmd_inject) - - sp = add("step", "Stepping", "process N RTC steps (manual mode)", - "determa-state step t1 --steps 1") - sp.add_argument("instance") - sp.add_argument("--steps", type=int, default=1, help="number of RTC steps (default 1)") - sp.set_defaults(cmd=cmd_step) - - snap = add("snapshot", "Persistence", "serialize an instance to a snapshot", - "determa-state snapshot t1 > t1.json") - snap.add_argument("instance") - snap.set_defaults(cmd=cmd_snapshot) - - r = add("restore", "Persistence", "recreate an instance from a snapshot", - "determa-state restore t1.json") - r.add_argument("snapshot") - r.set_defaults(cmd=cmd_restore) - - ls = add("list", "Persistence", "list all instances", - "determa-state list --json") - ls.set_defaults(cmd=cmd_list) - - run = add("run", "Batch", "drive many commands from NDJSON stdin (§13.7)", - "echo '[\"new\",\"t1\",\"machine.yaml\"]' | determa-state run -") - run.add_argument("source", nargs="?", default="-", help="'-' for stdin, or an NDJSON file") - run.set_defaults(cmd=cmd_run) - return p - - -# Command groups, shown gcloud-style in --help (alphabetical within each group). -_GROUP_ORDER = ["Authoring", "Instances", "Stepping", "Persistence", "Batch"] - - -class _GroupedHelpFormatter(argparse.RawDescriptionHelpFormatter): - """Renders the subcommand list grouped by category (gcloud-style) in top-level help.""" - - def _format_action(self, action: argparse.Action) -> str: - if not isinstance(action, argparse._SubParsersAction): - return super()._format_action(action) - groups: dict[str, list[tuple[str, argparse.ArgumentParser]]] = {} - for name, parser in action.choices.items(): - groups.setdefault(getattr(parser, "_cli_group", "Commands"), []).append( - (name, parser) - ) - width = max((len(n) for items in groups.values() for n, _ in items), default=0) - lines: list[str] = [] - ordered = _GROUP_ORDER + sorted(g for g in groups if g not in _GROUP_ORDER) - for group_name in ordered: - items = groups.get(group_name) - if not items: - continue - lines.append(f" {group_name}:") - for name, parser in sorted(items): - lines.append(f" {name:<{width}} {getattr(parser, '_cli_help', '')}") - return "\n".join(lines) + "\n" - - -# --- host (de)serialization ------------------------------------------------- -def _build_host(state: StoreState) -> Host: - host = Host() - host.now = state.now - host.mode = state.mode - host._spawn_counters = dict(state.spawn_counters) # noqa: SLF001 - for text in state.defs.values(): - host.register_all(load_definitions(text)) - host.restore_all(state.instances) - return host - - -def _persist(store: Store, state: StoreState, host: Host) -> None: - state.instances = host.snapshot_all() - state.now = host.now - state.mode = host.mode - state.spawn_counters = dict(host._spawn_counters) # noqa: SLF001 - store.save(state) - - -def _resolve_machine_path(arg: str) -> Path: - return Path(arg) - - -# --- commands --------------------------------------------------------------- -def cmd_validate(args: argparse.Namespace, store: Store) -> int: - raw_text = _resolve_machine_path(args.machine).read_text(encoding="utf-8") - defs = load_definitions(raw_text) - root = defs[0] - errors = list(collect_errors(root.raw)) - cdir = _resolve_machine_path(args.machine).parent / "contracts" - if cdir.exists(): - contracts = {} - for cf in sorted(cdir.glob("*.yaml")): - c = load_contract(cf.read_text(encoding="utf-8")) - contracts[c["id"]] = c - errors.extend(validate_contracts(root.raw, contracts)) - valid = not errors - if args.json: - print( - json.dumps( - { - "valid": valid, - "errors": [{"path": e["path"], "message": e["message"]} for e in errors], - } - ) - ) - elif not valid: - for e in errors: - print(f"{e['path']}: {e['message']}", file=sys.stderr) - return EXIT_OK if valid else EXIT_VALIDATION - - -def cmd_new(args: argparse.Namespace, store: Store) -> int: - state = store.load() - if any(s["id"] == args.id for s in state.instances): - print(f"instance '{args.id}' already exists", file=sys.stderr) - return EXIT_USAGE - text = _resolve_machine_path(args.machine).read_text(encoding="utf-8") - defs = load_definitions(text) - key = f"{defs[0].id}@{defs[0].version}" - state.defs[key] = text - host = _build_host(state) - external = _parse_kv(args.external, _external_types(host.machines[defs[0].id])) - host.create_root(host.machines[defs[0].id], args.id, external=external) - host.run_to_quiescence() - inst = host.instances[args.id] - _print_state(args, host, inst) - _persist(store, state, host) - return EXIT_OK - - -def cmd_send(args: argparse.Namespace, store: Store) -> int: - state = store.load() - host = _build_host(state) - inst = host.instances.get(args.instance) - if inst is None: - print(f"no such instance: {args.instance}", file=sys.stderr) - return EXIT_NOT_FOUND - payload = _build_payload(args, inst.machine) - before = len(host.published) - if not host.deliver(args.instance, args.event, payload): - print(f"rejected: {args.event}", file=sys.stderr) - return EXIT_VALIDATION - host.maybe_run() - if args.json: - obj = _state_json(host, host.instances[args.instance]) - obj["published"] = host.published[before:] - print(json.dumps(obj)) - _persist(store, state, host) - inst = host.instances.get(args.instance) - if inst is not None and inst.status is Status.FAULTED: - return EXIT_FAULTED - return EXIT_OK - - -def cmd_advance(args: argparse.Namespace, store: Store) -> int: - state = store.load() - host = _build_host(state) - host.advance(args.duration) - host.maybe_run() - if args.json: - print(json.dumps({"now": host.now})) - _persist(store, state, host) - return EXIT_OK - - -def cmd_env(args: argparse.Namespace, store: Store) -> int: - state = store.load() - host = _build_host(state) - if args.instance not in host.instances: - print(f"no such instance: {args.instance}", file=sys.stderr) - return EXIT_NOT_FOUND - changed = _parse_csv_kv(args.changed) - host.deliver(args.instance, "env", {"changed": changed}) - host.maybe_run() - _print_state(args, host, host.instances[args.instance]) - _persist(store, state, host) - return EXIT_OK +from .__about__ import __version__ +from .definition import load_bundle +from .errors import ValidationError -def cmd_state(args: argparse.Namespace, store: Store) -> int: - state = store.load() - host = _build_host(state) - inst = host.instances.get(args.instance) - if inst is None: - print(f"no such instance: {args.instance}", file=sys.stderr) - return EXIT_NOT_FOUND - _print_state(args, host, inst) - return EXIT_OK +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="determa-state") + parser.add_argument("--version", action="version", version=__version__) + subcommands = parser.add_subparsers(dest="command", required=True) + validate = subcommands.add_parser("validate", help="validate one format-1 bundle") + validate.add_argument("file", type=Path) + return parser -def cmd_list(args: argparse.Namespace, store: Store) -> int: - state = store.load() - host = _build_host(state) - if args.json: - rows = [ - { - "id": i.id, - "definition": f"{i.machine.id}@{i.machine.version}", - "parent": i.parent_id, - "status": i.status.value, - "config": i.active_leaf_names(), - } - for i in sorted(host.instances.values(), key=lambda x: x.id) - ] - print(json.dumps(rows)) - else: - for i in sorted(host.instances.values(), key=lambda x: x.id): - print(f"{i.id}\t{i.status.value}\t{i.active_leaf_names()}") - return EXIT_OK - -def cmd_snapshot(args: argparse.Namespace, store: Store) -> int: - state = store.load() - host = _build_host(state) - inst = host.instances.get(args.instance) - if inst is None: - print(f"no such instance: {args.instance}", file=sys.stderr) - return EXIT_NOT_FOUND - print(json.dumps(inst.to_snapshot())) - return EXIT_OK - - -def cmd_restore(args: argparse.Namespace, store: Store) -> int: - state = store.load() - snap = json.loads(_resolve_machine_path(args.snapshot).read_text(encoding="utf-8")) - host = _build_host(state) - machine = host.versions.get((snap["def_id"], snap["def_version"])) - if machine is None: - print(f"unknown definition: {snap['def_id']}@{snap['def_version']}", file=sys.stderr) - return EXIT_NOT_FOUND - inst = Instance(machine, snap["id"], snap["parent_id"], host, auto_enter=False) - inst.load_snapshot(snap) - host.instances[snap["id"]] = inst - _persist(store, state, host) - return EXIT_OK - - -def cmd_export(args: argparse.Namespace, store: Store) -> int: - defs = load_definitions(_resolve_machine_path(args.machine).read_text(encoding="utf-8")) - machine = Machine(defs[0]) - state_config = None - if args.state: - st = store.load() - host = _build_host(st) - inst = host.instances.get(args.state) - if inst is None: - print(f"no such instance: {args.state}", file=sys.stderr) - return EXIT_NOT_FOUND - state_config = sorted(inst.config) - print(export_mod.export(machine, format=args.format, state_config=state_config)) - return EXIT_OK - - -# --- introspection & stepping (SPEC §14) ------------------------------------ -def cmd_mode(args: argparse.Namespace, store: Store) -> int: - state = store.load() - if args.mode is not None: - state.mode = args.mode - store.save(state) - if args.json: - print(json.dumps({"mode": state.mode})) - else: - print(state.mode) - return EXIT_OK - - -def cmd_inject(args: argparse.Namespace, store: Store) -> int: - state = store.load() - host = _build_host(state) - inst = host.instances.get(args.instance) - if inst is None: - print(f"no such instance: {args.instance}", file=sys.stderr) - return EXIT_NOT_FOUND - payload = _build_payload(args, inst.machine) - if not host.inject(args.instance, args.event, payload): - print(f"rejected: {args.event}", file=sys.stderr) - return EXIT_VALIDATION - _print_state(args, host, inst) - _persist(store, state, host) - return EXIT_OK - - -def cmd_step(args: argparse.Namespace, store: Store) -> int: - state = store.load() - host = _build_host(state) - inst = host.instances.get(args.instance) - if inst is None: - print(f"no such instance: {args.instance}", file=sys.stderr) - return EXIT_NOT_FOUND - records = host.step(inst, args.steps) - if args.json: - obj = _state_json(host, inst) - obj["steps"] = records - print(json.dumps(obj)) - _persist(store, state, host) - if inst.status is Status.FAULTED: - return EXIT_FAULTED - return EXIT_OK - - -def cmd_enabled(args: argparse.Namespace, store: Store) -> int: - state = store.load() - host = _build_host(state) - inst = host.instances.get(args.instance) - if inst is None: - print(f"no such instance: {args.instance}", file=sys.stderr) - return EXIT_NOT_FOUND - enabled = host.enabled_events(inst) - if args.json: - print(json.dumps({"instance": inst.id, "enabled": enabled})) - else: - for event_type in enabled: - print(event_type) - return EXIT_OK - - -def cmd_inspect(args: argparse.Namespace, store: Store) -> int: - state = store.load() - host = _build_host(state) - inst = host.instances.get(args.instance) - if inst is None: - print(f"no such instance: {args.instance}", file=sys.stderr) - return EXIT_NOT_FOUND - if args.json: - obj = {"instance": inst.id, **host.inspect(inst)} - print(json.dumps(obj)) - else: - _print_inspect(inst, host.inspect(inst)) - return EXIT_OK - - -def _print_inspect(inst: Instance, info: dict[str, Any]) -> None: +def main(argv: Sequence[str] | None = None) -> int: + """Validate a bundle; execution remains an explicit library foreground call.""" + arguments = _parser().parse_args(argv) + if arguments.command != "validate": + return 2 + try: + bundle = load_bundle(arguments.file.read_text(encoding="utf-8")) + except (OSError, ValidationError) as error: + code = error.code if isinstance(error, ValidationError) else "source_error" + print(json.dumps({"valid": False, "code": code}, separators=(",", ":"))) + return 1 print( - f"{inst.id}\t{info['status']}\t{info['config']}\t" - f"queue={len(info['queue'])} deferred={len(info['deferred'])} " - f"timers={len(info['timers'])}" + json.dumps( + {"valid": True, "fingerprint": bundle.fingerprint}, + separators=(",", ":"), + ) ) - - -# --- batch / streaming mode (SPEC §13.7) ------------------------------------ -def cmd_run(args: argparse.Namespace, store: Store) -> int: - """Drive many commands from NDJSON stdin against one store + virtual clock. - - Each input line is a JSON array of argv tokens (one §13.3 command). For each - line, exactly one NDJSON result object is written to stdout in input order: - ``{ "ok": bool, "exit": int, "result": , "error": {"message": str}? }``. - A failing line does not abort the stream; the process exit code is the first - non-zero line exit, else 0. - """ - if args.source in (None, "-"): - lines = sys.stdin.read().splitlines() - else: - lines = Path(args.source).read_text(encoding="utf-8").splitlines() - parser = _build_parser() - first_nonzero = 0 - for raw in lines: - line = raw.strip() - if not line: - continue - exit_code, result, error = _run_one(parser, store, line) - record: dict[str, Any] = {"ok": exit_code == 0, "exit": exit_code, "result": result} - if error is not None: - record["error"] = {"message": error} - print(json.dumps(record), flush=True) - if exit_code != 0 and first_nonzero == 0: - first_nonzero = exit_code - return first_nonzero - - -def _run_one( - parser: argparse.ArgumentParser, store: Store, line: str -) -> tuple[int, Any, str | None]: - """Execute one batch line; return (exit_code, result_value, error_message).""" - try: - argv = json.loads(line) - if not isinstance(argv, list) or not all(isinstance(t, str) for t in argv): - raise ValueError("each line must be a JSON array of strings") - except (ValueError, json.JSONDecodeError) as exc: - return EXIT_USAGE, None, str(exc) - - out, err = io.StringIO(), io.StringIO() - try: - with redirect_stdout(out), redirect_stderr(err): - sub = parser.parse_args([*argv, "--json"]) - if getattr(sub, "command", None) == "run": - return EXIT_USAGE, None, "nested 'run' is not allowed in batch mode" - rc = int(sub.cmd(sub, store)) - except SystemExit as exc: # argparse usage error - code = exc.code if isinstance(exc.code, int) else EXIT_USAGE - return code, None, (err.getvalue().strip() or "usage error") - except DetermaError as exc: - return EXIT_OTHER, None, str(exc) - - result = _parse_captured(out.getvalue()) - message = (err.getvalue().strip() or None) if rc != 0 else None - return rc, result, message - - -def _parse_captured(text: str) -> Any: - """A command's captured stdout as JSON when it is JSON, else the raw string/None.""" - s = text.strip() - if not s: - return None - try: - return json.loads(s) - except json.JSONDecodeError: - return s - - -# --- output helpers --------------------------------------------------------- -def _state_json(host: Host, inst: Instance) -> dict[str, Any]: - return { - "instance": inst.id, - "definition": f"{inst.machine.id}@{inst.machine.version}", - "status": inst.status.value, - "config": inst.active_leaf_names(), - "esvs": inst.resolved_esvs(), - } - - -def _print_state(args: argparse.Namespace, host: Host, inst: Instance) -> None: - if args.json: - print(json.dumps(_state_json(host, inst))) - - -def _build_payload(args: argparse.Namespace, machine: Machine) -> dict[str, Any] | None: - if args.payload_json: - return cast(dict[str, Any], json.loads(args.payload_json)) - if not args.payload: - return None - types = _event_payload_types(machine, args.event) - return _parse_kv(args.payload, types) - - -def _event_payload_types(machine: Machine, event: str) -> dict[str, str]: - decl = (machine.definition.raw.get("events") or {}).get(event) - if not isinstance(decl, dict): - return {} - return {k: v["type"] for k, v in (decl.get("payload") or {}).items()} - - -def _external_types(machine: Machine) -> dict[str, str]: - types: dict[str, str] = {} - for var, decl in (machine.top.raw.get("esvs") or {}).items(): - if decl.get("external"): - types[var] = decl["type"] - return types - - -def _parse_kv(items: list[str], types: dict[str, str]) -> dict[str, Any]: - out: dict[str, Any] = {} - for item in items: - if "=" not in item: - continue - k, v = item.split("=", 1) - out[k] = _coerce(v, types.get(k)) - return out - - -def _parse_csv_kv(items: str) -> dict[str, Any]: - out: dict[str, Any] = {} - for part in items.split(","): - if "=" in part: - k, v = part.split("=", 1) - out[k] = _coerce(v, None) - return out - - -def _coerce(value: str, type_name: str | None) -> Any: - if type_name == "int": - return int(value) - if type_name == "float": - return float(value) - if type_name == "bool": - return value.lower() in {"true", "yes", "1"} - if type_name == "list": - return json.loads(value) - if type_name == "map": - return json.loads(value) - if type_name is None: - for caster in (int, float): - try: - return caster(value) - except ValueError: - continue - if value.lower() in {"true", "false"}: - return value.lower() == "true" - return value - - -def _pkg_version() -> str: - from . import __version__ - - return __version__ + return 0 diff --git a/src/determa/state/contracts.py b/src/determa/state/contracts.py deleted file mode 100644 index bf80720..0000000 --- a/src/determa/state/contracts.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Static contract validation (SPEC §7). - -A contract declares required events (handled somewhere), states (declared), and -spawns (defs referenced by a `spawn` action). A machine's declared contracts -(its top-level `contracts: [...]`) are checked against these requirements as -part of static validation (the §9 `static:` mode and the CLI `validate`). -""" - -from __future__ import annotations - -from typing import Any - -from . import yaml12 -from .errors import ErrorRecord - - -def load_contract(text: str) -> dict[str, Any]: - doc = yaml12.load(text) - if not isinstance(doc, dict): - raise ValueError("a contract must be a mapping") - return doc - - -def validate_contracts( - machine_raw: dict[str, Any], contracts: dict[str, dict[str, Any]] -) -> list[ErrorRecord]: - errors: list[ErrorRecord] = [] - handled, state_names, spawns = _collect(machine_raw.get("top") or {}) - for cid in machine_raw.get("contracts") or []: - contract = contracts.get(cid) - if contract is None: - errors.append( - ErrorRecord(path="/contracts", message=f"contract '{cid}' not found") - ) - continue - requires = contract.get("requires") or {} - for ev in requires.get("events") or []: - if ev not in handled: - errors.append( - ErrorRecord( - path=f"/contracts/{cid}", - message=f"required event '{ev}' has no handler", - ) - ) - for st in requires.get("states") or []: - if st not in state_names: - errors.append( - ErrorRecord( - path=f"/contracts/{cid}", - message=f"required state '{st}' is not declared", - ) - ) - for sp in requires.get("spawns") or []: - if sp not in spawns: - errors.append( - ErrorRecord( - path=f"/contracts/{cid}", - message=f"required spawn '{sp}' is never used", - ) - ) - return errors - - -def _actions(node: dict[str, Any]) -> list[list[dict[str, Any]]]: - """All action-lists attached to a state node (entry/exit/transitions/after).""" - lists: list[list[dict[str, Any]]] = [] - lists.append(node.get("entry") or []) - lists.append(node.get("exit") or []) - for spec in (node.get("on_events") or {}).values(): - transitions = spec if isinstance(spec, list) else [spec] - for t in transitions: - lists.append(t.get("action") or []) - for after in node.get("after") or []: - lists.append(after.get("action") or []) - return lists - - -def _collect(top: dict[str, Any]) -> tuple[set[str], set[str], set[str]]: - handled: set[str] = set() - state_names: set[str] = set() - spawns: set[str] = set() - - def walk(node: dict[str, Any]) -> None: - handled.update((node.get("on_events") or {}).keys()) - for action_list in _actions(node): - for action in action_list: - if "spawn" in action: - spawns.add(action["spawn"]["def"]) - for name, child in (node.get("states") or {}).items(): - state_names.add(name) - walk(child) - for region in node.get("regions") or []: - for name, child in (region.get("states") or {}).items(): - state_names.add(name) - walk(child) - - walk(top) - return handled, state_names, spawns diff --git a/src/determa/state/data/machine.schema.json b/src/determa/state/data/machine.schema.json index 502b6ec..21d00d9 100644 --- a/src/determa/state/data/machine.schema.json +++ b/src/determa/state/data/machine.schema.json @@ -1,217 +1,1149 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://determa.dev/state/schema/machine.schema.json", - "title": "Determa State machine definition", - "description": "Strict structural schema for a Determa State statechart definition (SPEC.md §4). Validates one YAML/JSON document. Semantics (RTC, scoping, defer, timers, bus) live in SPEC.md and the conformance suite.", - "type": "object", - "required": ["id", "top"], - "additionalProperties": false, - "properties": { - "format": { "type": "integer", "minimum": 1, "default": 1, "description": "Grammar version. Optional; defaults to 1." }, - "id": { "$ref": "#/$defs/identifier", "description": "Stable definition id." }, - "version": { "type": "integer", "minimum": 1, "default": 1, "description": "This definition's version; drives migration (SPEC §10)." }, - "contracts": { "type": "array", "items": { "$ref": "#/$defs/identifier" } }, - "subscribe": { "type": "array", "items": { "$ref": "#/$defs/identifier" }, "description": "External event types delivered to this machine by subscription (SPEC §5.7)." }, + "title": "Determa State format 1 bundle", + "description": "Strict structural schema for the pre-release format 1 bundle grammar defined by SPEC.md.", + "$ref": "#/$defs/bundle", + "$defs": { + "identifier": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "celVisibleIdentifier": { + "allOf": [ + { + "$ref": "#/$defs/identifier" + }, + { + "not": { + "enum": [ + "event", + "owner", + "false", + "in", + "null", + "true", + "as", + "break", + "const", + "continue", + "else", + "for", + "function", + "if", + "import", + "let", + "loop", + "package", + "namespace", + "return", + "var", + "void", + "while" + ] + } + } + ] + }, + "stateIdentifier": { + "allOf": [ + { + "$ref": "#/$defs/identifier" + }, + { + "not": { + "const": "root" + } + } + ] + }, + "dottedIdentifier": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*$" + }, + "eventHandlerName": { + "type": "string", + "pattern": "^(?:[A-Za-z_][A-Za-z0-9_]*|determa\\.(?:component_completed|component_failed|spawned_instance_failed))$" + }, + "authorEventName": { + "allOf": [ + { + "$ref": "#/$defs/identifier" + }, + { + "not": { + "enum": ["env", "done"] + } + } + ] + }, + "expression": { + "type": "string", + "description": "A CEL expression." + }, + "valueType": { + "enum": ["string", "int", "float", "bool", "map", "list"] + }, "languages": { "type": "object", "additionalProperties": false, "properties": { - "guard": { "type": "string", "default": "cel" }, - "action": { "type": "string", "default": "determa" } + "guard": { + "const": "cel", + "default": "cel" + }, + "action": { + "const": "determa", + "default": "determa" + } } }, - "events": { + "payloadField": { + "type": "object", + "required": ["type"], + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/$defs/valueType" + }, + "required": { + "type": "boolean", + "default": false + }, + "default": {} + }, + "allOf": [ + { + "not": { + "properties": { + "required": { + "const": true + } + }, + "required": ["required", "default"] + } + }, + { + "if": { + "properties": { + "type": { + "const": "string" + } + }, + "required": ["type", "default"] + }, + "then": { + "properties": { + "default": { + "type": "string" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "int" + } + }, + "required": ["type", "default"] + }, + "then": { + "properties": { + "default": { + "type": "integer", + "minimum": -9223372036854775808, + "maximum": 9223372036854775807 + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "float" + } + }, + "required": ["type", "default"] + }, + "then": { + "properties": { + "default": { + "type": "number", + "minimum": -1.7976931348623157e308, + "maximum": 1.7976931348623157e308 + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "bool" + } + }, + "required": ["type", "default"] + }, + "then": { + "properties": { + "default": { + "type": "boolean" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "map" + } + }, + "required": ["type", "default"] + }, + "then": { + "properties": { + "default": { + "type": "object" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "list" + } + }, + "required": ["type", "default"] + }, + "then": { + "properties": { + "default": { + "type": "array" + } + } + } + } + ] + }, + "payloadDeclaration": { "type": "object", - "additionalProperties": { "$ref": "#/$defs/eventDecl" } + "propertyNames": { + "$ref": "#/$defs/celVisibleIdentifier" + }, + "additionalProperties": { + "$ref": "#/$defs/payloadField" + } }, - "meta": { "type": "object" }, - "migrations": { "type": "array", "items": { "$ref": "#/$defs/migration" } }, - "top": { "$ref": "#/$defs/state", "description": "The outermost state (PSiCC \"top\"); all behavior lives under it." } - }, - "$defs": { - "identifier": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, - "dottedRef": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*$" }, - "cel": { "type": "string", "description": "A CEL expression (string)." }, - "valueType": { "enum": ["string", "int", "float", "bool", "map", "list"] }, - "duration": { "type": "string", "pattern": "^[0-9]+(ms|s|m|h)$" }, - - "esv": { + "publicEventDeclaration": { "type": "object", - "required": ["type"], "additionalProperties": false, - "description": "An extended-state variable, initialized on entry to its declaring state (SPEC §4.4).", "properties": { - "type": { "$ref": "#/$defs/valueType" }, - "init": { "description": "Initial literal value (must match type). Omit for unset/null." }, - "external": { "type": "boolean", "default": false, "description": "Seeded from the host at entry; read-only to assign; retained until refreshed (SPEC §5.4)." } + "direction": { + "enum": ["internal", "input", "output"], + "default": "internal" + }, + "payload": { + "$ref": "#/$defs/payloadDeclaration" + }, + "correlates_to": { + "$ref": "#/$defs/identifier" + } + }, + "allOf": [ + { + "if": { + "required": ["correlates_to"] + }, + "then": { + "required": ["direction"], + "properties": { + "direction": { + "const": "input" + } + } + } + } + ] + }, + "privateEventDeclaration": { + "type": "object", + "additionalProperties": false, + "properties": { + "direction": { + "const": "internal", + "default": "internal" + }, + "payload": { + "$ref": "#/$defs/payloadDeclaration" + } } }, - - "eventDecl": { + "variable": { + "type": "object", + "required": ["type"], + "additionalProperties": false, + "properties": { + "type": { + "enum": ["string", "int", "float", "bool", "map", "list", "instance_reference"] + }, + "init": {}, + "input": { + "type": "boolean", + "default": false + }, + "external": { + "type": "boolean", + "default": false + }, + "nullable": { + "type": "boolean" + }, + "machine_id": { + "$ref": "#/$defs/identifier" + } + }, + "allOf": [ + { + "not": { + "properties": { + "input": { + "const": true + }, + "external": { + "const": true + } + }, + "required": ["input", "external"] + } + }, + { + "if": { + "not": { + "anyOf": [ + { + "properties": { + "input": { + "const": true + } + }, + "required": ["input"] + }, + { + "properties": { + "external": { + "const": true + } + }, + "required": ["external"] + } + ] + } + }, + "then": { + "required": ["init"] + } + }, + { + "if": { + "properties": { + "type": { + "const": "instance_reference" + } + }, + "required": ["type"] + }, + "then": { + "required": ["init", "nullable"], + "properties": { + "init": { + "type": "null" + }, + "input": { + "const": false + }, + "external": { + "const": false + }, + "nullable": { + "const": true + } + } + }, + "else": { + "not": { + "anyOf": [ + { + "required": ["nullable"] + }, + { + "required": ["machine_id"] + } + ] + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "string" + } + }, + "required": ["type", "init"] + }, + "then": { + "properties": { + "init": { + "type": "string" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "int" + } + }, + "required": ["type", "init"] + }, + "then": { + "properties": { + "init": { + "type": "integer", + "minimum": -9223372036854775808, + "maximum": 9223372036854775807 + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "float" + } + }, + "required": ["type", "init"] + }, + "then": { + "properties": { + "init": { + "type": "number", + "minimum": -1.7976931348623157e308, + "maximum": 1.7976931348623157e308 + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "bool" + } + }, + "required": ["type", "init"] + }, + "then": { + "properties": { + "init": { + "type": "boolean" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "map" + } + }, + "required": ["type", "init"] + }, + "then": { + "properties": { + "init": { + "type": "object" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "list" + } + }, + "required": ["type", "init"] + }, + "then": { + "properties": { + "init": { + "type": "array" + } + } + } + } + ] + }, + "target": { + "oneOf": [ + { + "type": "object", + "required": ["self"], + "additionalProperties": false, + "properties": { + "self": { + "const": true + } + } + }, + { + "type": "object", + "required": ["owner"], + "additionalProperties": false, + "properties": { + "owner": { + "const": true + } + } + }, + { + "type": "object", + "required": ["component"], + "additionalProperties": false, + "properties": { + "component": { + "$ref": "#/$defs/identifier" + } + } + }, + { + "type": "object", + "required": ["instance"], + "additionalProperties": false, + "properties": { + "instance": { + "$ref": "#/$defs/expression" + } + } + }, + { + "type": "object", + "required": ["external"], + "additionalProperties": false, + "properties": { + "external": { + "const": true + } + } + } + ] + }, + "send": { "type": "object", + "required": ["event"], "additionalProperties": false, "properties": { - "scope": { "enum": ["internal", "local", "global"], "default": "internal" }, + "event": { + "$ref": "#/$defs/identifier" + }, + "to": { + "$ref": "#/$defs/target" + }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/target" + } + }, "payload": { "type": "object", + "propertyNames": { + "$ref": "#/$defs/celVisibleIdentifier" + }, "additionalProperties": { - "type": "object", - "required": ["type"], - "additionalProperties": false, - "properties": { - "type": { "$ref": "#/$defs/valueType" }, - "required": { "type": "boolean", "default": false }, - "default": {} - } + "$ref": "#/$defs/expression" + } + }, + "correlation_id": { + "$ref": "#/$defs/expression" + } + }, + "not": { + "required": ["to", "targets"] + } + }, + "bindingExpressions": { + "type": "object", + "additionalProperties": false, + "properties": { + "input": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/celVisibleIdentifier" + }, + "additionalProperties": { + "$ref": "#/$defs/expression" } + }, + "external": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/celVisibleIdentifier" + }, + "additionalProperties": { + "$ref": "#/$defs/expression" + } + } + } + }, + "spawn": { + "type": "object", + "required": ["machine_id"], + "additionalProperties": false, + "properties": { + "machine_id": { + "$ref": "#/$defs/identifier" + }, + "bindings": { + "$ref": "#/$defs/bindingExpressions" + }, + "bind_to": { + "$ref": "#/$defs/celVisibleIdentifier" + } + } + }, + "cancel": { + "type": "object", + "required": ["instance"], + "additionalProperties": false, + "properties": { + "instance": { + "$ref": "#/$defs/expression" } } }, - "action": { "type": "object", "minProperties": 1, "maxProperties": 1, "additionalProperties": false, "properties": { - "assign": { "type": "object", "minProperties": 1, "additionalProperties": { "$ref": "#/$defs/cel" } }, - "publish": { + "assign": { "type": "object", - "required": ["event"], - "additionalProperties": false, - "properties": { - "event": { "$ref": "#/$defs/identifier" }, - "to": { "$ref": "#/$defs/cel" }, - "payload": { "type": "object", "additionalProperties": { "$ref": "#/$defs/cel" } } + "minProperties": 1, + "maxProperties": 1, + "propertyNames": { + "$ref": "#/$defs/celVisibleIdentifier" + }, + "additionalProperties": { + "$ref": "#/$defs/expression" } }, + "send": { + "$ref": "#/$defs/send" + }, "refresh": { "type": "object", "additionalProperties": false, "properties": { - "only": { "type": "array", "items": { "$ref": "#/$defs/identifier" } } + "only": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/celVisibleIdentifier" + } + } } }, "spawn": { + "$ref": "#/$defs/spawn" + }, + "cancel": { + "$ref": "#/$defs/cancel" + }, + "stop": { "type": "object", - "required": ["def"], - "additionalProperties": false, - "properties": { - "def": { "$ref": "#/$defs/identifier" }, - "payload": { "type": "object", "additionalProperties": { "$ref": "#/$defs/cel" } }, - "result": { "$ref": "#/$defs/identifier" } - } + "maxProperties": 0 + } + } + }, + "actionList": { + "type": "array", + "items": { + "$ref": "#/$defs/action" + } + }, + "exitAction": { + "allOf": [ + { + "$ref": "#/$defs/action" }, - "stop": { "type": "object", "maxProperties": 0 } + { + "not": { + "anyOf": [ + { + "required": ["spawn"] + }, + { + "required": ["stop"] + } + ] + } + } + ] + }, + "exitActionList": { + "type": "array", + "items": { + "$ref": "#/$defs/exitAction" } }, - "actionList": { "type": "array", "items": { "$ref": "#/$defs/action" } }, - - "transition": { + "historyTarget": { "type": "object", + "required": ["history"], "additionalProperties": false, "properties": { - "transition_to": { "$ref": "#/$defs/dottedRef" }, - "guard": { "$ref": "#/$defs/cel" }, - "lang": { "type": "string" }, - "action": { "$ref": "#/$defs/actionList" }, - "internal": { "type": "boolean" }, - "local": { "type": "boolean" } + "history": { + "$ref": "#/$defs/dottedIdentifier" + } } }, + "transitionTarget": { + "oneOf": [ + { + "$ref": "#/$defs/dottedIdentifier" + }, + { + "$ref": "#/$defs/historyTarget" + } + ] + }, + "transition": { + "type": "object", + "additionalProperties": false, + "properties": { + "transition_to": { + "$ref": "#/$defs/transitionTarget" + }, + "guard": { + "$ref": "#/$defs/expression" + }, + "lang": { + "const": "cel" + }, + "action": { + "$ref": "#/$defs/actionList" + }, + "local": { + "const": true + } + }, + "allOf": [ + { + "if": { + "required": ["local"] + }, + "then": { + "required": ["transition_to"] + } + } + ] + }, "transitionOrList": { "oneOf": [ - { "$ref": "#/$defs/transition" }, - { "type": "array", "items": { "$ref": "#/$defs/transition" }, "minItems": 1 } + { + "$ref": "#/$defs/transition" + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/transition" + } + } ] }, - "initialTransition": { "type": "object", "required": ["transition_to"], "additionalProperties": false, "properties": { - "transition_to": { "$ref": "#/$defs/dottedRef" }, - "guard": { "$ref": "#/$defs/cel" }, - "action": { "$ref": "#/$defs/actionList" } + "transition_to": { + "$ref": "#/$defs/dottedIdentifier" + }, + "action": { + "$ref": "#/$defs/actionList" + } } }, - "choiceBranch": { "type": "object", "required": ["transition_to"], "additionalProperties": false, "properties": { - "transition_to": { "$ref": "#/$defs/dottedRef" }, - "guard": { "$ref": "#/$defs/cel" }, - "action": { "$ref": "#/$defs/actionList" } + "transition_to": { + "$ref": "#/$defs/transitionTarget" + }, + "guard": { + "$ref": "#/$defs/expression" + }, + "action": { + "$ref": "#/$defs/actionList" + } } }, - - "after": { - "type": "object", - "required": ["duration"], - "additionalProperties": false, - "properties": { - "duration": { "$ref": "#/$defs/duration" }, - "transition_to": { "$ref": "#/$defs/dottedRef" }, - "guard": { "$ref": "#/$defs/cel" }, - "action": { "$ref": "#/$defs/actionList" } - } + "activeState": { + "allOf": [ + { + "$ref": "#/$defs/state" + }, + { + "not": { + "required": ["choice"] + } + } + ] }, - - "region": { + "component": { "type": "object", - "required": ["initial", "states"], + "required": ["component_id"], "additionalProperties": false, "properties": { - "initial": { "$ref": "#/$defs/initialTransition" }, - "states": { "type": "object", "minProperties": 1, "additionalProperties": { "$ref": "#/$defs/state" } } - } + "component_id": { + "$ref": "#/$defs/identifier" + }, + "machine_id": { + "$ref": "#/$defs/identifier" + }, + "root": { + "$ref": "#/$defs/activeState" + }, + "with": { + "$ref": "#/$defs/bindingExpressions" + }, + "meta": { + "type": "object" + } + }, + "oneOf": [ + { + "required": ["machine_id"], + "not": { + "required": ["root"] + } + }, + { + "required": ["root"], + "not": { + "required": ["machine_id"] + } + } + ] }, - "state": { "type": "object", "additionalProperties": false, "properties": { - "type": { "enum": ["simple", "composite", "orthogonal", "final"], "default": "simple" }, - "meta": { "type": "object" }, - "esvs": { "type": "object", "additionalProperties": { "$ref": "#/$defs/esv" } }, - "entry": { "$ref": "#/$defs/actionList" }, - "exit": { "$ref": "#/$defs/actionList" }, - "initial": { "$ref": "#/$defs/initialTransition" }, - "states": { "type": "object", "additionalProperties": { "$ref": "#/$defs/state" } }, - "regions": { "type": "array", "minItems": 2, "items": { "$ref": "#/$defs/region" } }, - "on_events": { "type": "object", "additionalProperties": { "$ref": "#/$defs/transitionOrList" } }, - "after": { "type": "array", "items": { "$ref": "#/$defs/after" } }, - "defer": { "type": "array", "items": { "$ref": "#/$defs/identifier" } }, - "history": { "enum": ["none", "shallow", "deep"], "default": "none" }, - "choice": { "type": "array", "minItems": 2, "items": { "$ref": "#/$defs/choiceBranch" } }, - "submachine": { "$ref": "#/$defs/identifier" }, - "with": { "type": "object", "additionalProperties": { "$ref": "#/$defs/cel" } } + "type": { + "enum": ["simple", "composite", "parallel", "final"], + "default": "simple" + }, + "meta": { + "type": "object" + }, + "variables": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/celVisibleIdentifier" + }, + "additionalProperties": { + "$ref": "#/$defs/variable" + } + }, + "entry": { + "$ref": "#/$defs/actionList" + }, + "exit": { + "$ref": "#/$defs/exitActionList" + }, + "initial": { + "$ref": "#/$defs/initialTransition" + }, + "states": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "$ref": "#/$defs/stateIdentifier" + }, + "additionalProperties": { + "$ref": "#/$defs/state" + } + }, + "components": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/$defs/component" + } + }, + "on_events": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/eventHandlerName" + }, + "additionalProperties": { + "$ref": "#/$defs/transitionOrList" + } + }, + "history": { + "enum": ["none", "shallow", "deep"], + "default": "none" + }, + "choice": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/$defs/choiceBranch" + } + } }, "allOf": [ { - "if": { "properties": { "type": { "const": "composite" } }, "required": ["type"] }, - "then": { "required": ["initial", "states"] } + "if": { + "properties": { + "type": { + "const": "simple" + } + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": ["initial"] + }, + { + "required": ["states"] + }, + { + "required": ["components"] + }, + { + "required": ["history"] + } + ] + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "composite" + } + }, + "required": ["type"] + }, + "then": { + "required": ["initial", "states"], + "not": { + "required": ["components"] + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "parallel" + } + }, + "required": ["type"] + }, + "then": { + "required": ["components"], + "not": { + "anyOf": [ + { + "required": ["initial"] + }, + { + "required": ["states"] + }, + { + "required": ["history"] + } + ] + } + } }, { - "if": { "properties": { "type": { "const": "orthogonal" } }, "required": ["type"] }, - "then": { "required": ["regions"] } + "if": { + "properties": { + "type": { + "const": "final" + } + }, + "required": ["type"] + }, + "then": { + "not": { + "anyOf": [ + { + "required": ["exit"] + }, + { + "required": ["initial"] + }, + { + "required": ["states"] + }, + { + "required": ["components"] + }, + { + "required": ["on_events"] + }, + { + "required": ["history"] + }, + { + "required": ["choice"] + } + ] + } + } + }, + { + "if": { + "required": ["history"] + }, + "then": { + "required": ["type"], + "properties": { + "type": { + "const": "composite" + } + } + } + }, + { + "if": { + "required": ["choice"] + }, + "then": { + "not": { + "anyOf": [ + { + "required": ["variables"] + }, + { + "required": ["entry"] + }, + { + "required": ["exit"] + }, + { + "required": ["initial"] + }, + { + "required": ["states"] + }, + { + "required": ["components"] + }, + { + "required": ["on_events"] + }, + { + "required": ["history"] + }, + { + "required": ["type"] + } + ] + } + } } ] }, - - "migration": { + "machine": { + "type": "object", + "required": ["machine_id", "root"], + "additionalProperties": false, + "properties": { + "machine_id": { + "$ref": "#/$defs/identifier" + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807, + "default": 1 + }, + "languages": { + "$ref": "#/$defs/languages" + }, + "events": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/authorEventName" + }, + "additionalProperties": { + "$ref": "#/$defs/privateEventDeclaration" + } + }, + "root": { + "$ref": "#/$defs/activeState" + }, + "meta": { + "type": "object" + } + } + }, + "bundle": { "type": "object", - "required": ["from", "to"], + "required": ["format", "namespace", "machines"], "additionalProperties": false, "properties": { - "from": { "type": "integer", "minimum": 1 }, - "to": { "type": "integer", "minimum": 1 }, - "when": { "$ref": "#/$defs/cel" }, - "state_map": { "type": "object", "additionalProperties": { "$ref": "#/$defs/dottedRef" } }, - "esvs": { "$ref": "#/$defs/actionList" } + "format": { + "const": 1 + }, + "namespace": { + "$ref": "#/$defs/dottedIdentifier" + }, + "events": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/authorEventName" + }, + "additionalProperties": { + "$ref": "#/$defs/publicEventDeclaration" + } + }, + "machines": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/machine" + } + }, + "meta": { + "type": "object" + } } } } diff --git a/src/determa/state/definition.py b/src/determa/state/definition.py index bdf7785..bb342ee 100644 --- a/src/determa/state/definition.py +++ b/src/determa/state/definition.py @@ -1,95 +1,187 @@ -"""Loading machine definitions from YAML text or native mappings (SPEC §2, §4). - -A machine file is one or more ``---``-separated documents; the first is the -root definition (SPEC §9). Each document is validated (structure + reserved -names) before a :class:`Definition` is produced. Later build steps resolve the -raw document into a navigable state model; step 1 keeps the validated raw -mapping as the single source of structure. - -Hosts that build machines in code can pass a native mapping (``dict``) — or a -sequence of them for a multi-document machine — instead of serializing to a -YAML string. The same ``validate()`` path runs either way, so a hand-built -machine is held to the same contract as a file-loaded one. -""" +"""Format-1 bundle loading, normalization, and deterministic fingerprinting.""" from __future__ import annotations -from collections.abc import Mapping, Sequence +import copy +import hashlib +import json +import math +import struct +from collections.abc import Mapping from dataclasses import dataclass -from typing import Any, cast +from typing import Any from . import yaml12 -from .errors import ErrorRecord, ValidationError -from .validator import validate - -#: A machine definition as YAML text, a single mapping, or a sequence of mappings -#: (one per ``---`` document; the first is the root, SPEC §9). -DefinitionSource = str | Mapping[str, Any] | Sequence[Mapping[str, Any]] +from .errors import ValidationError + +BundleSource = str | Mapping[str, Any] + + +def _utf8_key(value: str) -> bytes: + return value.encode("utf-8", errors="strict") + + +def _normalize_typed_literal(declaration: dict[str, Any], member: str) -> None: + if member not in declaration: + return + value = declaration[member] + if ( + declaration.get("type") == "float" + and isinstance(value, int) + and not isinstance(value, bool) + ): + value = float(value) + if isinstance(value, float): + if not math.isfinite(value): + raise ValidationError("numeric_value_out_of_range") + value = 0.0 if value == 0.0 else value + declaration[member] = value + + +def _normalize_event(declaration: dict[str, Any]) -> None: + declaration.setdefault("direction", "internal") + for field in (declaration.get("payload") or {}).values(): + field.setdefault("required", False) + _normalize_typed_literal(field, "default") + + +def _normalize_action(action: dict[str, Any]) -> None: + send = action.get("send") + if isinstance(send, dict) and "to" not in send and "targets" not in send: + send["to"] = {"self": True} + + +def _normalize_transition(transition: dict[str, Any], *, event_transition: bool) -> None: + if event_transition: + transition.setdefault("lang", "cel") + for action in transition.get("action") or []: + _normalize_action(action) + + +def _normalize_state(state: dict[str, Any], *, pointer: str) -> None: + if "choice" in state: + for branch in state["choice"]: + _normalize_transition(branch, event_transition=False) + return + state.setdefault("type", "simple") + if state["type"] == "composite": + state.setdefault("history", "none") + for declaration in (state.get("variables") or {}).values(): + if declaration.get("type") != "instance_reference": + declaration.setdefault("input", False) + declaration.setdefault("external", False) + _normalize_typed_literal(declaration, "init") + for action in state.get("entry") or []: + _normalize_action(action) + for action in state.get("exit") or []: + _normalize_action(action) + initial = state.get("initial") + if isinstance(initial, dict): + _normalize_transition(initial, event_transition=False) + for transition_or_list in (state.get("on_events") or {}).values(): + transitions = ( + transition_or_list if isinstance(transition_or_list, list) else [transition_or_list] + ) + for transition in transitions: + _normalize_transition(transition, event_transition=True) + for name, child in (state.get("states") or {}).items(): + _normalize_state(child, pointer=f"{pointer}/states/{_escape_pointer(name)}") + for index, placement in enumerate(state.get("components") or []): + inline_root = placement.get("root") + if isinstance(inline_root, dict): + _normalize_state(inline_root, pointer=f"{pointer}/components/{index}/root") + + +def _escape_pointer(value: str) -> str: + return value.replace("~", "~0").replace("/", "~1") + + +def normalize_bundle(raw: dict[str, Any]) -> dict[str, Any]: + """Materialize only the normative format-1 defaults.""" + normalized = copy.deepcopy(raw) + for declaration in (normalized.get("events") or {}).values(): + _normalize_event(declaration) + for machine_index, machine in enumerate(normalized.get("machines") or []): + machine.setdefault("version", 1) + languages = machine.setdefault("languages", {}) + languages.setdefault("guard", "cel") + languages.setdefault("action", "determa") + for declaration in (machine.get("events") or {}).values(): + _normalize_event(declaration) + _normalize_state(machine["root"], pointer=f"/machines/{machine_index}/root") + return normalized + + +def _typed_value(value: Any) -> list[Any]: + if value is None: + return ["null"] + if isinstance(value, bool): + return ["boolean", value] + if isinstance(value, str): + return ["string", value] + if isinstance(value, int): + return ["integer", str(value)] + if isinstance(value, float): + bits = struct.pack("!d", 0.0 if value == 0.0 else value).hex() + return ["float", bits] + if isinstance(value, list): + return ["list", [_typed_value(item) for item in value]] + if isinstance(value, dict): + entries = [[key, _typed_value(value[key])] for key in sorted(value, key=_utf8_key)] + return ["map", entries] + raise ValidationError("non_json_value") + + +def canonical_json(value: Any) -> str: + """Canonical JSON for identity tuples, whose members are already normalized.""" + return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + + +def hash_identity(value: Any) -> str: + encoded = canonical_json(value).encode("utf-8", errors="strict") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" + + +def bundle_fingerprint(normalized: dict[str, Any]) -> str: + return hash_identity(["determa-validated-bundle-fingerprint-1", _typed_value(normalized)]) @dataclass(frozen=True) -class Definition: - """A validated machine definition (one YAML document).""" +class Bundle: + """A validated, normalized format-1 bundle.""" - id: str - version: int - format: int raw: dict[str, Any] + fingerprint: str @property - def top(self) -> dict[str, Any]: - """The outermost state node (SPEC §4.5).""" - return cast(dict[str, Any], self.raw["top"]) + def namespace(self) -> str: + return str(self.raw["namespace"]) + @property + def machines(self) -> list[dict[str, Any]]: + return list(self.raw["machines"]) -def _doc_to_definition(doc: Any, index: int) -> Definition: - """Validate one document (mapping) and wrap it as a :class:`Definition`.""" - if not isinstance(doc, Mapping): - raise ValidationError( - [ErrorRecord(path=f"doc[{index}]", message="a machine definition must be a mapping")] - ) - raw = dict(doc) # normalize any Mapping to a plain dict (and defensive copy) - validate(raw) - return Definition( - id=raw["id"], - version=raw.get("version", 1), - format=raw.get("format", 1), - raw=raw, - ) - - -def load_definitions(source: DefinitionSource) -> list[Definition]: - """Parse and validate every document in a machine file or native mapping(s). - - ``source`` is YAML text (``str``), a single native mapping (``dict``), or a - sequence of mappings (multi-document). Each document runs through the same - :func:`validate` path, so building a machine in code is held to the same - contract as loading one from a YAML file. - """ + def machine(self, machine_id: str) -> dict[str, Any] | None: + return next((m for m in self.raw["machines"] if m["machine_id"] == machine_id), None) + + +def load_bundle(source: BundleSource) -> Bundle: + """Parse, structurally validate, semantically validate, and normalize one bundle.""" if isinstance(source, str): - docs: list[Any] = list(yaml12.load_all(source)) + document = yaml12.load(source) elif isinstance(source, Mapping): - docs = [source] + document = copy.deepcopy(dict(source)) + yaml12.validate_portable_values(document) + if not yaml12.validate_unicode(document): + raise ValidationError("invalid_unicode") else: - docs = list(source) - if not docs: - raise ValidationError([ErrorRecord(path="(root)", message="no document")]) - return [_doc_to_definition(doc, i) for i, doc in enumerate(docs)] - - -def load_definition(source: DefinitionSource) -> Definition: - """Load a single-definition machine (from text or a native mapping). - - Errors if the source carries more than one document. - """ - defs = load_definitions(source) - if len(defs) != 1: - raise ValidationError( - [ - ErrorRecord( - path="(root)", - message=f"expected one definition, got {len(defs)}", - ) - ] - ) - return defs[0] + raise ValidationError("non_json_value") + if not isinstance(document, dict): + raise ValidationError("structural_validation") + if document.get("format") != 1 or isinstance(document.get("format"), bool): + raise ValidationError("unsupported_format") + from .validator import validate + + validate(document) + normalized = normalize_bundle(document) + return Bundle(raw=normalized, fingerprint=bundle_fingerprint(normalized)) diff --git a/src/determa/state/engine.py b/src/determa/state/engine.py index d9678ef..60a09b9 100644 --- a/src/determa/state/engine.py +++ b/src/determa/state/engine.py @@ -1,434 +1,2227 @@ -"""Host — owns machine instances and the adapters (SPEC §5.7, §8). - -The host registers definitions, creates the root instance, validates/delivers -events, and runs all instances to quiescence. Bus / queue / clock / store are -adapters with simple in-memory defaults; active-object spawning and the bus are -wired in later build steps. For now the host drives a single instance tree and -records published/spawned events for the conformance harness. -""" +"""Pure foreground format-1 creation and dispatch.""" from __future__ import annotations -import logging -from typing import Any - -from . import cel, values -from .definition import Definition -from .instance import DELIVERABLE_RESERVED_EVENTS, Event, Instance, Status -from .model import Machine, inline_submachines -from .observer import Observer - -log = logging.getLogger(__name__) - - -class Host: - def __init__(self, observer: Observer | None = None) -> None: - self.machines: dict[str, Machine] = {} - self.versions: dict[tuple[str, int], Machine] = {} - self.instances: dict[str, Instance] = {} - self.published: list[str] = [] # event names handed to the bus, in order - self.spawned: list[str] = [] # child defIds, in order - self._spawn_counters: dict[str, int] = {} - # Passive per-step observer (SPEC §8); None = no-op. - self.observer: Observer | None = observer - self.now: int = 0 # virtual clock, in milliseconds (SPEC §5.9) - self.mode: str = "auto" # processing mode, auto|manual (SPEC §14) - self._seq: int = 0 - - # --- registration / creation ------------------------------------------- - def register(self, definition: Definition) -> Machine: - registry = {mid: m.definition.top for mid, m in self.machines.items()} - registry[definition.id] = definition.top - return self._register(definition, registry) - - def register_all(self, definitions: list[Definition]) -> None: - # Two-phase so submachine references can resolve in any order within the batch. - registry = {mid: m.definition.top for mid, m in self.machines.items()} - for d in definitions: - registry[d.id] = d.top - for d in definitions: - self._register(d, registry) - - def _register(self, definition: Definition, registry: dict[str, Any]) -> Machine: - top = inline_submachines(definition.top, registry) - machine = Machine(definition, top_override=top) - self.machines[machine.id] = machine - self.versions[(machine.id, machine.version)] = machine - return machine - - def create_root( +import copy +import math +from dataclasses import dataclass +from typing import Any, Literal, cast + +from . import cel +from .definition import Bundle, BundleSource, _escape_pointer, hash_identity, load_bundle +from .errors import CelError, StepFault, ValidationError +from .model import BundleModel, MachineModel, StateNode +from .yaml12 import validate_portable_values, validate_unicode + +Result = dict[str, Any] +Delivery = dict[str, dict[str, Any]] | None +_INT_MIN = -(2**63) +_INT_MAX = 2**63 - 1 + + +class _StopRuntime(Exception): + pass + + +def _coerce_bundle(bundle: Bundle | BundleSource) -> Bundle: + return bundle if isinstance(bundle, Bundle) else load_bundle(bundle) + + +def _identity(value: list[Any]) -> str: + return hash_identity(value) + + +def _root_runtime_id(bundle: Bundle, machine: dict[str, Any], root_instance_id: str) -> str: + return _identity( + [ + "determa-root-runtime-identity-2", + "1", + bundle.fingerprint, + bundle.namespace, + machine["machine_id"], + str(machine["version"]), + root_instance_id, + ] + ) + + +def _component_runtime_id( + bundle: Bundle, + owner_runtime_id: str, + root_instance_id: str, + pointer: str, + activation_sequence: int, + machine: MachineModel, +) -> str: + namespace, machine_id, version = machine.definition_identity() + return _identity( + [ + "determa-component-runtime-identity-1", + "1", + root_instance_id, + owner_runtime_id, + pointer, + str(activation_sequence), + namespace, + machine_id, + str(version), + ] + ) + + +def _spawned_runtime_id( + bundle: Bundle, + owner_runtime_id: str, + root_instance_id: str, + pointer: str, + spawn_sequence: int, + machine: MachineModel, +) -> str: + namespace, machine_id, version = machine.definition_identity() + return _identity( + [ + "determa-spawned-runtime-identity-1", + "1", + root_instance_id, + owner_runtime_id, + pointer, + str(spawn_sequence), + namespace, + machine_id, + str(version), + ] + ) + + +def _cause_id( + kind: str, + root_instance_id: str, + source_runtime_id: str, + target_runtime_id: str, + parent: str, + step_sequence: int, + locator: str, + ordinal: int, +) -> str: + return _identity( + [ + "determa-cause-identity-1", + "1", + kind, + root_instance_id, + source_runtime_id, + target_runtime_id, + parent, + str(step_sequence), + locator, + str(ordinal), + ] + ) + + +def _event_id( + root_instance_id: str, + source_runtime_id: str, + target_runtime_id: str, + cause_id: str, + step_sequence: int, + locator: str, + ordinal: int, +) -> str: + return _identity( + [ + "determa-event-identity-1", + "1", + root_instance_id, + source_runtime_id, + target_runtime_id, + cause_id, + str(step_sequence), + locator, + str(ordinal), + ] + ) + + +def _effect_id( + machine: MachineModel, + root_instance_id: str, + runtime_id: str, + cause_id: str, + step_sequence: int, + pointer: str, + index: int, +) -> str: + namespace, machine_id, version = machine.definition_identity() + return _identity( + [ + "determa-effect-identity-1", + "1", + [namespace, machine_id, str(version)], + root_instance_id, + runtime_id, + cause_id, + str(step_sequence), + pointer, + str(index), + ] + ) + + +def _value_matches(value: Any, type_name: str) -> bool: + if type_name == "string": + return isinstance(value, str) + if type_name == "bool": + return isinstance(value, bool) + if type_name == "int": + return ( + isinstance(value, int) and not isinstance(value, bool) and _INT_MIN <= value <= _INT_MAX + ) + if type_name == "float": + return ( + isinstance(value, int | float) + and not isinstance(value, bool) + and _INT_MIN <= value <= _INT_MAX + if isinstance(value, int) + else isinstance(value, float) and math.isfinite(value) + ) + if type_name == "list": + return isinstance(value, list) + if type_name == "map": + return isinstance(value, dict) and all(isinstance(key, str) for key in value) + if type_name == "instance_reference": + return value is None or _is_instance_reference(value) + return False + + +def _normalize_value(value: Any, type_name: str) -> Any: + try: + validate_portable_values(value) + except ValidationError as exc: + raise ValueError(type_name) from exc + if not _value_matches(value, type_name): + raise ValueError(type_name) + if type_name == "float": + number = float(value) + return 0.0 if number == 0.0 else number + return copy.deepcopy(value) + + +def _is_instance_reference(value: Any) -> bool: + return ( + isinstance(value, dict) + and set(value) == {"root_instance_id", "instance_id", "machine_id", "machine_version"} + and all( + isinstance(value[key], str) and value[key] + for key in ("root_instance_id", "instance_id", "machine_id") + ) + and isinstance(value["machine_version"], int) + and not isinstance(value["machine_version"], bool) + and 0 < value["machine_version"] <= _INT_MAX + ) + + +def _normalize_payload(declaration: dict[str, Any], payload: Any) -> dict[str, Any] | None: + if payload is None: + payload = {} + if not isinstance(payload, dict): + return None + try: + validate_portable_values(payload) + except ValidationError: + return None + if not validate_unicode(payload): + return None + fields = declaration.get("payload") or {} + if set(payload) - set(fields): + return None + normalized: dict[str, Any] = {} + for name, field in fields.items(): + if name in payload: + try: + normalized[name] = _normalize_value(payload[name], str(field["type"])) + except ValueError: + return None + elif "default" in field: + normalized[name] = copy.deepcopy(field["default"]) + elif field.get("required"): + return None + return normalized + + +def _empty_result(*, status: str, state: dict[str, Any] | None, disposition: str | None) -> Result: + return { + "status": status, + "disposition": disposition, + "state": state, + "emissions": [], + "fault": None, + "rejection": None, + } + + +def create( + bundle: Bundle | BundleSource, + machine_id: str, + root_instance_id: str, + creation_id: str, + bindings: dict[str, dict[str, Any]] | None = None, +) -> Result: + """Create and synchronously initialize one root ownership aggregate.""" + validated = _coerce_bundle(bundle) + if ( + not isinstance(machine_id, str) + or not isinstance(root_instance_id, str) + or not root_instance_id + or not isinstance(creation_id, str) + or not creation_id + or not validate_unicode([machine_id, root_instance_id, creation_id]) + ): + result = _empty_result(status="rejected", state=None, disposition=None) + result["rejection"] = {"code": "invalid_creation_request"} + return result + models = BundleModel(validated) + if machine_id not in models.machines: + result = _empty_result(status="rejected", state=None, disposition=None) + result["rejection"] = {"code": "invalid_machine_target"} + return result + machine = models.machine(machine_id) + try: + root_bindings = _creation_bindings(machine, bindings or {}) + except ValueError: + result = _empty_result(status="rejected", state=None, disposition=None) + result["rejection"] = {"code": "invalid_binding"} + return result + root_id = _root_runtime_id(validated, machine.raw, root_instance_id) + state: dict[str, Any] = { + "validated_bundle_fingerprint": validated.fingerprint, + "namespace": validated.namespace, + "root_instance_id": root_instance_id, + "creation_id": creation_id, + "root_runtime_id": root_id, + "root_machine_id": machine_id, + "status": "running", + "next_logical_step_sequence": 0, + "next_output_sequence": 0, + "runtimes": {}, + "fault": None, + } + execution = _Execution(validated, models, state, step_sequence=0) + runtime = execution.new_runtime( + machine, + root_id, + role="root", + owner_runtime_id=None, + metadata={}, + ) + cause = _cause_id( + "root_initialization", + root_instance_id, + root_id, + root_id, + creation_id, + 0, + machine.root.pointer, + 0, + ) + execution.cause_id = cause + try: + execution.initialize_runtime(runtime, machine, root_bindings) + except StepFault as fault: + state["runtimes"] = {} + diagnostic = execution.new_runtime( + machine, + root_id, + role="root", + owner_runtime_id=None, + metadata={}, + replace=True, + ) + execution.finalize_fault(diagnostic, fault, cause, initialization=True) + state["status"] = "faulted" + state["fault"] = diagnostic["fault"] + state["next_logical_step_sequence"] = 1 + state["next_output_sequence"] = 0 + result = _empty_result(status="faulted", state=state, disposition=None) + result["fault"] = copy.deepcopy(diagnostic["fault"]) + return result + state["next_logical_step_sequence"] = 1 + state["status"] = state["runtimes"][root_id]["status"] + result = _empty_result(status=state["status"], state=state, disposition=None) + result["emissions"] = execution.emissions + return result + + +def dispatch( + bundle: Bundle | BundleSource, + prior_state: dict[str, Any], + delivery: Delivery = None, +) -> Result: + """Validate and process at most one envelope against an aggregate copy.""" + validated = _coerce_bundle(bundle) + if not _valid_prior_state(prior_state, validated): + result = _empty_result( + status=str(prior_state.get("status", "faulted")) + if isinstance(prior_state, dict) + else "faulted", + state=prior_state, + disposition="rejected", + ) + result["rejection"] = {"code": "invalid_prior_state"} + return result + if prior_state["validated_bundle_fingerprint"] != validated.fingerprint: + result = _empty_result( + status=prior_state["status"], state=prior_state, disposition="rejected" + ) + result["rejection"] = {"code": "incompatible_bundle"} + result["fault"] = copy.deepcopy(prior_state.get("fault")) + return result + if delivery is None: + result = _empty_result(status=prior_state["status"], state=prior_state, disposition=None) + result["fault"] = copy.deepcopy(prior_state.get("fault")) + return result + if not isinstance(delivery, dict) or set(delivery) not in ({"input"}, {"internal"}): + return _rejected(prior_state, "invalid_event") + mode = next(iter(delivery)) + envelope = delivery[mode] + models = BundleModel(validated) + delivery_mode: Literal["input", "internal"] = "input" if mode == "input" else "internal" + rejection = _validate_envelope(validated, models, prior_state, envelope, delivery_mode) + if rejection is not None: + return _rejected(prior_state, rejection) + state = copy.deepcopy(prior_state) + step_sequence = int(state["next_logical_step_sequence"]) + execution = _Execution(validated, models, state, step_sequence=step_sequence) + runtime = execution.runtime_for_target(envelope["target"]) + normalized_envelope = copy.deepcopy(envelope) + declaration = execution.event_declaration(runtime, envelope["event"]) + if envelope["event"] == "env" or envelope["event"] in _reserved_events(): + normalized_envelope["payload"] = copy.deepcopy(envelope["payload"]) + else: + assert declaration is not None + normalized_envelope["payload"] = _normalize_payload(declaration, envelope.get("payload")) + execution.event = normalized_envelope + execution.cause_id = str(envelope["event_id"]) + before = copy.deepcopy(state) + try: + handled = execution.process(runtime, normalized_envelope) + except StepFault as fault: + state.clear() + state.update(before) + execution = _Execution(validated, models, state, step_sequence=step_sequence) + runtime = execution.runtime_for_target(envelope["target"]) + execution.finalize_fault(runtime, fault, str(envelope["event_id"])) + state["next_logical_step_sequence"] = step_sequence + 1 + if runtime["role"] == "root": + state["status"] = "faulted" + state["fault"] = copy.deepcopy(runtime["fault"]) + else: + execution.emit_failure(runtime, str(envelope["event_id"])) + result = _empty_result(status=state["status"], state=state, disposition="faulted") + result["fault"] = copy.deepcopy(runtime["fault"]) + result["emissions"] = execution.emissions + return result + if not handled: + result = _empty_result( + status=prior_state["status"], state=prior_state, disposition="unhandled" + ) + result["fault"] = copy.deepcopy(prior_state.get("fault")) + return result + state["next_logical_step_sequence"] = step_sequence + 1 + root = state["runtimes"][state["root_runtime_id"]] + state["status"] = root["status"] + state["fault"] = copy.deepcopy(root.get("fault")) + result = _empty_result(status=state["status"], state=state, disposition="handled") + result["emissions"] = execution.emissions + result["fault"] = copy.deepcopy(root.get("fault")) if state["status"] == "faulted" else None + return result + + +def _rejected(prior_state: dict[str, Any], code: str) -> Result: + result = _empty_result(status=prior_state["status"], state=prior_state, disposition="rejected") + result["fault"] = copy.deepcopy(prior_state.get("fault")) + result["rejection"] = {"code": code} + return result + + +def _valid_prior_state(state: Any, bundle: Bundle) -> bool: + if not isinstance(state, dict): + return False + try: + validate_portable_values(state) + return validate_unicode(state) and _validate_prior_state(state, bundle) + except (IndexError, KeyError, TypeError, ValueError, ValidationError): + return False + + +def _validate_prior_state(state: dict[str, Any], bundle: Bundle) -> bool: + required = { + "validated_bundle_fingerprint", + "namespace", + "root_instance_id", + "creation_id", + "root_runtime_id", + "root_machine_id", + "status", + "next_logical_step_sequence", + "next_output_sequence", + "runtimes", + "fault", + } + if not required <= set(state): + return False + if {"queue", "timers", "dead_letters"} & set(state): + return False + if ( + not isinstance(state["validated_bundle_fingerprint"], str) + or not isinstance(state["namespace"], str) + or not isinstance(state["root_instance_id"], str) + or not state["root_instance_id"] + or not isinstance(state["creation_id"], str) + or not state["creation_id"] + or not isinstance(state["root_runtime_id"], str) + or not isinstance(state["root_machine_id"], str) + or state["status"] not in {"running", "completed", "faulted"} + or not _nonnegative_integer(state["next_logical_step_sequence"]) + or not _nonnegative_integer(state["next_output_sequence"]) + or not isinstance(state["runtimes"], dict) + ): + return False + models = BundleModel(bundle) + if state["root_machine_id"] not in models.machines: + return False + runtimes = state["runtimes"] + root = runtimes.get(state["root_runtime_id"]) + if not isinstance(root, dict) or root.get("role") != "root": + return False + if root.get("owner_runtime_id") is not None or root.get("status") != state["status"]: + return False + expected_root_id = _identity( + [ + "determa-root-runtime-identity-2", + "1", + state["validated_bundle_fingerprint"], + state["namespace"], + root.get("machine_id"), + str(root.get("machine_version")), + state["root_instance_id"], + ] + ) + if root.get("runtime_id") != expected_root_id or root["runtime_id"] != state["root_runtime_id"]: + return False + if root.get("machine_id") != state["root_machine_id"]: + return False + if state["status"] == "faulted": + if state["fault"] != root.get("fault"): + return False + elif state["fault"] is not None: + return False + + for runtime_id, runtime in runtimes.items(): + if not isinstance(runtime_id, str) or not isinstance(runtime, dict): + return False + if runtime.get("runtime_id") != runtime_id: + return False + if not _validate_runtime_state(state, runtime, bundle, models): + return False + + for runtime in runtimes.values(): + owner_id = runtime.get("owner_runtime_id") + if runtime["role"] == "root": + continue + owner = runtimes.get(owner_id) + if not isinstance(owner, dict): + return False + if runtime["role"] == "component": + if owner["components"].get(runtime.get("component_id")) != runtime["runtime_id"]: + return False + elif runtime["role"] != "spawned": + return False + if _ownership_cycle(runtimes, runtime): + return False + return True + + +def _validate_runtime_state( + state: dict[str, Any], + runtime: dict[str, Any], + bundle: Bundle, + models: BundleModel, +) -> bool: + required = { + "runtime_id", + "role", + "owner_runtime_id", + "machine_id", + "machine_version", + "root_pointer", + "status", + "active", + "scopes", + "history", + "fault", + "next_spawn_sequence", + "next_state_activation_sequence", + "state_activation_sequence", + "next_component_activation_sequence", + "components", + } + if not required <= set(runtime): + return False + if {"queue", "timers", "dead_letters"} & set(runtime): + return False + if ( + runtime["role"] not in {"root", "component", "spawned"} + or runtime["status"] not in {"running", "completed", "faulted"} + or not isinstance(runtime["machine_id"], str) + or runtime["machine_id"] not in models.machines + or not _nonnegative_integer(runtime["machine_version"]) + or not isinstance(runtime["root_pointer"], str) + or not isinstance(runtime["active"], list) + or not isinstance(runtime["scopes"], dict) + or not isinstance(runtime["history"], dict) + or not _nonnegative_integer(runtime["next_spawn_sequence"]) + or not _counter_map(runtime["next_state_activation_sequence"]) + or not _counter_map(runtime["state_activation_sequence"]) + or not _counter_map(runtime["next_component_activation_sequence"]) + or not isinstance(runtime["components"], dict) + ): + return False + base = models.machine(runtime["machine_id"]) + if base.version != runtime["machine_version"]: + return False + root = _pointer_get(bundle.raw, runtime["root_pointer"]) + if not isinstance(root, dict): + return False + machine = ( + base + if runtime["root_pointer"] == base.root_pointer + else MachineModel( + bundle, + base.raw, + machine_index=base.machine_index, + root=root, + root_pointer=runtime["root_pointer"], + identity_machine=base.identity_machine, + ) + ) + active = runtime["active"] + if not all(isinstance(path, str) and path in machine.states for path in active): + return False + if active: + if active[0] != "root": + return False + for parent_path, child_path in zip(active, active[1:], strict=False): + if machine.states[child_path].parent is not machine.states[parent_path]: + return False + elif runtime["status"] == "running": + return False + if set(runtime["scopes"]) != set(active): + return False + for path, scope in runtime["scopes"].items(): + declarations = machine.states[path].raw.get("variables") or {} + if not isinstance(scope, dict) or set(scope) != set(declarations): + return False + if any( + not _value_matches(scope[name], str(declaration["type"])) + for name, declaration in declarations.items() + ): + return False + if set(runtime["state_activation_sequence"]) != set(active): + return False + if any(path not in machine.states for path in runtime["next_state_activation_sequence"]): + return False + if any(path not in machine.states for path in runtime["state_activation_sequence"]): + return False + if any( + not isinstance(key, str) or not isinstance(value, (list, type(None))) + for key, value in runtime["history"].items() + ): + return False + if any( + value is not None + and (len(value) != 1 or not isinstance(value[0], str) or value[0] not in machine.states) + for value in runtime["history"].values() + ): + return False + if any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in runtime["components"].items() + ): + return False + if runtime["fault"] is not None and not _valid_fault(runtime["fault"], runtime): + return False + if runtime["status"] == "faulted" and runtime["fault"] is None: + return False + if runtime["status"] != "faulted" and runtime["fault"] is not None: + return False + if runtime["role"] == "component": + if not _valid_component_identity(state, runtime): + return False + elif runtime["role"] == "spawned": + if not _valid_spawned_identity(state, runtime): + return False + return True + + +def _valid_component_identity(state: dict[str, Any], runtime: dict[str, Any]) -> bool: + required = { + "component_id", + "component_runtime_id", + "component_definition_pointer", + "component_declaration_index", + "component_activation_sequence", + "owning_state_path", + "owning_state_activation_sequence", + "target", + } + if not required <= set(runtime): + return False + if ( + not isinstance(runtime["component_id"], str) + or runtime["component_runtime_id"] != runtime["runtime_id"] + or not isinstance(runtime["component_definition_pointer"], str) + or not _nonnegative_integer(runtime["component_declaration_index"]) + or not _nonnegative_integer(runtime["component_activation_sequence"]) + or not isinstance(runtime["owning_state_path"], str) + or not _nonnegative_integer(runtime["owning_state_activation_sequence"]) + ): + return False + expected_id = _identity( + [ + "determa-component-runtime-identity-1", + "1", + state["root_instance_id"], + runtime["owner_runtime_id"], + runtime["component_definition_pointer"], + str(runtime["component_activation_sequence"]), + state["namespace"], + runtime["machine_id"], + str(runtime["machine_version"]), + ] + ) + expected_target = { + "component": { + "root_instance_id": state["root_instance_id"], + "owner_runtime_id": runtime["owner_runtime_id"], + "component_id": runtime["component_id"], + "component_runtime_id": runtime["runtime_id"], + "activation_sequence": runtime["component_activation_sequence"], + } + } + return bool(runtime["runtime_id"] == expected_id and runtime["target"] == expected_target) + + +def _valid_spawned_identity(state: dict[str, Any], runtime: dict[str, Any]) -> bool: + if ( + not _nonnegative_integer(runtime.get("spawn_sequence")) + or not isinstance(runtime.get("spawn_action_pointer"), str) + or not _is_instance_reference(runtime.get("instance_reference")) + or runtime["instance_reference"].get("root_instance_id") != state["root_instance_id"] + or runtime["instance_reference"].get("instance_id") != runtime["runtime_id"] + or runtime["instance_reference"].get("machine_id") != runtime["machine_id"] + or runtime["instance_reference"].get("machine_version") != runtime["machine_version"] + ): + return False + expected_id = _identity( + [ + "determa-spawned-runtime-identity-1", + "1", + state["root_instance_id"], + runtime["owner_runtime_id"], + runtime["spawn_action_pointer"], + str(runtime["spawn_sequence"]), + state["namespace"], + runtime["machine_id"], + str(runtime["machine_version"]), + ] + ) + holder = runtime.get("holder") + if holder is not None and ( + not isinstance(holder, dict) + or set(holder) != {"pointer", "state_path", "state_activation_sequence"} + or not isinstance(holder["pointer"], str) + or not isinstance(holder["state_path"], str) + or not _nonnegative_integer(holder["state_activation_sequence"]) + ): + return False + return bool(runtime["runtime_id"] == expected_id) + + +def _valid_fault(fault: Any, runtime: dict[str, Any]) -> bool: + return ( + isinstance(fault, dict) + and set(fault) == {"runtime_id", "cause_id", "code", "step_sequence", "source_locator"} + and fault["runtime_id"] == runtime["runtime_id"] + and isinstance(fault["cause_id"], str) + and bool(fault["cause_id"]) + and isinstance(fault["code"], str) + and bool(fault["code"]) + and _nonnegative_integer(fault["step_sequence"]) + and isinstance(fault["source_locator"], str) + ) + + +def _nonnegative_integer(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= _INT_MAX + + +def _counter_map(value: Any) -> bool: + return isinstance(value, dict) and all( + isinstance(key, str) and _nonnegative_integer(counter) for key, counter in value.items() + ) + + +def _ownership_cycle(runtimes: dict[str, Any], runtime: dict[str, Any]) -> bool: + seen = {runtime["runtime_id"]} + owner_id = runtime.get("owner_runtime_id") + while owner_id is not None: + if owner_id in seen: + return True + seen.add(owner_id) + owner = runtimes.get(owner_id) + if not isinstance(owner, dict): + return True + owner_id = owner.get("owner_runtime_id") + return False + + +def _creation_bindings( + machine: MachineModel, bindings: dict[str, dict[str, Any]] +) -> dict[str, dict[str, Any]]: + if not isinstance(bindings, dict) or set(bindings) - {"input", "external"}: + raise ValueError + try: + validate_portable_values(bindings) + except ValidationError as exc: + raise ValueError from exc + if not validate_unicode(bindings): + raise ValueError + result: dict[str, dict[str, Any]] = {"input": {}, "external": {}} + declarations = machine.root.raw.get("variables") or {} + for kind in ("input", "external"): + supplied = bindings.get(kind, {}) + if not isinstance(supplied, dict): + raise ValueError + expected = { + name: declaration + for name, declaration in declarations.items() + if declaration.get(kind) is True + } + if set(supplied) - set(expected): + raise ValueError + for name, declaration in expected.items(): + if name in supplied: + result[kind][name] = _normalize_value(supplied[name], str(declaration["type"])) + elif "init" in declaration: + result[kind][name] = copy.deepcopy(declaration["init"]) + else: + raise ValueError + return result + + +def _validate_envelope( + bundle: Bundle, + models: BundleModel, + state: dict[str, Any], + envelope: Any, + mode: Literal["input", "internal"], +) -> str | None: + del models + if state["status"] == "faulted": + return "invalid_instance_target" + if not isinstance(envelope, dict): + return "invalid_event" + allowed_members = {"event", "event_id", "target", "payload", "correlation_id"} + if set(envelope) - allowed_members: + return "invalid_event" + event = envelope.get("event") + event_id = envelope.get("event_id") + if ( + not isinstance(event, str) + or not event + or not isinstance(event_id, str) + or not event_id + or not validate_unicode([event, event_id]) + ): + return "invalid_event" + target = envelope.get("target") + target_code, runtime = _locate_target(state, target) + if target_code is not None: + return target_code + assert runtime is not None + try: + validate_portable_values(target) + except ValidationError: + return "invalid_instance_target" + if not validate_unicode(target): + return "invalid_instance_target" + if runtime["status"] != "running": + return ( + "inactive_component_target" + if runtime["role"] == "component" + else "invalid_instance_target" + ) + if mode == "input" and runtime["role"] == "component": + return "invalid_instance_target" + machine = next( + item for item in bundle.raw["machines"] if item["machine_id"] == runtime["machine_id"] + ) + declarations = dict(bundle.raw.get("events") or {}) + declarations.update(machine.get("events") or {}) + if event == "env": + if not ( + (mode == "input" and runtime["role"] in {"root", "spawned"}) + or (mode == "internal" and runtime["role"] == "component") + ): + return "invalid_event" + if "correlation_id" in envelope: + return "invalid_correlation" + payload = envelope.get("payload") + if not isinstance(payload, dict) or set(payload) != {"changed"}: + return "invalid_payload" + changed = payload["changed"] + if not isinstance(changed, dict) or not changed: + return "invalid_payload" + try: + validate_portable_values(changed) + except ValidationError: + return "invalid_payload" + if not validate_unicode(changed): + return "invalid_payload" + runtime_root = _pointer_get(bundle.raw, runtime["root_pointer"]) + variables = runtime_root.get("variables") or {} + external = { + name: declaration + for name, declaration in variables.items() + if declaration.get("external") is True + } + if set(changed) - set(external): + return "invalid_payload" + try: + for name, value in changed.items(): + _normalize_value(value, str(external[name]["type"])) + except ValueError: + return "invalid_payload" + return None + declaration = declarations.get(event) + if declaration is None: + if mode == "internal" and event in _reserved_events(): + return _validate_reserved_payload(event, envelope) + return "invalid_event" + expected_direction = "input" if mode == "input" else "internal" + if declaration["direction"] != expected_direction: + return "invalid_event" + correlation = envelope.get("correlation_id") + if correlation is not None and ( + not isinstance(correlation, str) or not correlation or not validate_unicode(correlation) + ): + return "invalid_correlation" + if declaration.get("correlates_to") and correlation is None: + return "invalid_correlation" + if "payload" not in envelope: + return "invalid_payload" + if _normalize_payload(declaration, envelope.get("payload")) is None: + return "invalid_payload" + return None + + +def _reserved_events() -> set[str]: + return { + "done", + "determa.component_completed", + "determa.component_failed", + "determa.spawned_instance_failed", + } + + +def _validate_reserved_payload(event: str, envelope: dict[str, Any]) -> str | None: + payload = envelope.get("payload") + if not isinstance(payload, dict): + return "invalid_payload" + try: + validate_portable_values(payload) + except ValidationError: + return "invalid_payload" + if not validate_unicode(payload): + return "invalid_payload" + if event == "determa.component_completed": + valid = set(payload) == {"component_id", "component_runtime_id"} and all( + isinstance(payload[name], str) and bool(payload[name]) + for name in ("component_id", "component_runtime_id") + ) + elif event == "determa.component_failed": + valid = ( + set(payload) == {"component_id", "component_runtime_id", "fault"} + and all( + isinstance(payload[name], str) and bool(payload[name]) + for name in ("component_id", "component_runtime_id") + ) + and _valid_public_fault(payload["fault"]) + ) + elif event == "determa.spawned_instance_failed": + valid = ( + set(payload) == {"instance", "instance_id", "machine_id", "machine_version", "fault"} + and _is_instance_reference(payload["instance"]) + and all( + isinstance(payload[name], str) and bool(payload[name]) + for name in ("instance_id", "machine_id") + ) + and _nonnegative_integer(payload["machine_version"]) + and payload["machine_version"] > 0 + and payload["instance"]["instance_id"] == payload["instance_id"] + and payload["instance"]["machine_id"] == payload["machine_id"] + and payload["instance"]["machine_version"] == payload["machine_version"] + and _valid_public_fault(payload["fault"]) + ) + else: + relationship = payload.get("relationship") + if relationship == "parallel": + valid = set(payload) == {"relationship", "state_path", "owner_runtime_id"} and all( + isinstance(payload[name], str) and bool(payload[name]) + for name in ("state_path", "owner_runtime_id") + ) + elif relationship == "spawned_instance": + valid = ( + set(payload) + == { + "relationship", + "instance", + "instance_id", + "machine_id", + "machine_version", + } + and _is_instance_reference(payload["instance"]) + and all( + isinstance(payload[name], str) and bool(payload[name]) + for name in ("instance_id", "machine_id") + ) + and _nonnegative_integer(payload["machine_version"]) + and payload["machine_version"] > 0 + and payload["instance"]["instance_id"] == payload["instance_id"] + and payload["instance"]["machine_id"] == payload["machine_id"] + and payload["instance"]["machine_version"] == payload["machine_version"] + ) + else: + valid = False + return None if valid else "invalid_payload" + + +def _valid_public_fault(value: Any) -> bool: + return ( + isinstance(value, dict) + and set(value) == {"runtime_id", "cause_id", "code", "step_sequence", "source_locator"} + and all( + isinstance(value[name], str) and bool(value[name]) + for name in ("runtime_id", "cause_id", "code", "source_locator") + ) + and isinstance(value["step_sequence"], str) + and ( + value["step_sequence"] == "0" + or ( + value["step_sequence"].isdigit() + and value["step_sequence"].isascii() + and not value["step_sequence"].startswith("0") + ) + ) + ) + + +def _locate_target(state: dict[str, Any], target: Any) -> tuple[str | None, dict[str, Any] | None]: + if not isinstance(target, dict) or len(target) != 1: + return "invalid_instance_target", None + runtimes = state["runtimes"] + if "root" in target: + value = target["root"] + if ( + not isinstance(value, dict) + or set(value) != {"root_instance_id", "root_runtime_id"} + or value.get("root_instance_id") != state["root_instance_id"] + or value.get("root_runtime_id") != state["root_runtime_id"] + ): + return "invalid_instance_target", None + return None, runtimes[state["root_runtime_id"]] + if "spawned_instance" in target: + reference = target["spawned_instance"] + if not _is_instance_reference(reference): + return "invalid_instance_target", None + runtime = runtimes.get(reference["instance_id"]) + if runtime is None or runtime.get("instance_reference") != reference: + return "invalid_instance_target", None + return None, runtime + if "component" in target: + value = target["component"] + if not isinstance(value, dict): + return "inactive_component_target", None + runtime = runtimes.get(value.get("component_runtime_id")) + if runtime is None or runtime.get("target") != target: + return "inactive_component_target", None + return None, runtime + return "invalid_instance_target", None + + +@dataclass +class _Execution: + bundle: Bundle + models: BundleModel + state: dict[str, Any] + step_sequence: int + emissions: list[dict[str, Any]] + cause_id: str + event: dict[str, Any] | None + + def __init__( + self, + bundle: Bundle, + models: BundleModel, + state: dict[str, Any], + *, + step_sequence: int, + ) -> None: + self.bundle = bundle + self.models = models + self.state = state + self.step_sequence = step_sequence + self.emissions = [] + self.cause_id = "" + self.event = None + + def new_runtime( + self, + machine: MachineModel, + runtime_id: str, + *, + role: str, + owner_runtime_id: str | None, + metadata: dict[str, Any], + replace: bool = False, + ) -> dict[str, Any]: + history = { + state.path if state is not machine.root else "$root": None + for state in machine.states.values() + if state.type == "composite" and state.raw.get("history", "none") != "none" + } + runtime: dict[str, Any] = { + "runtime_id": runtime_id, + "role": role, + "owner_runtime_id": owner_runtime_id, + "machine_id": machine.machine_id, + "machine_version": machine.version, + "root_pointer": machine.root_pointer, + "status": "running", + "active": [], + "scopes": {}, + "history": history, + "fault": None, + "next_spawn_sequence": 0, + "next_state_activation_sequence": {}, + "state_activation_sequence": {}, + "next_component_activation_sequence": {}, + "components": {}, + **copy.deepcopy(metadata), + } + if not replace and runtime_id in self.state["runtimes"]: + raise StepFault("invariant_fault", "system:invariant") + self.state["runtimes"][runtime_id] = runtime + return runtime + + def model_for(self, runtime: dict[str, Any]) -> MachineModel: + base = self.models.machine(runtime["machine_id"]) + if runtime["root_pointer"] == base.root_pointer: + return base + root = _pointer_get(self.bundle.raw, runtime["root_pointer"]) + return MachineModel( + self.bundle, + base.raw, + machine_index=base.machine_index, + root=root, + root_pointer=runtime["root_pointer"], + identity_machine=base.identity_machine, + ) + + def runtime_for_target(self, target: dict[str, Any]) -> dict[str, Any]: + code, runtime = _locate_target(self.state, target) + if code is not None or runtime is None: + raise StepFault(code or "invalid_instance_target", "system:invariant") + return runtime + + def event_declaration(self, runtime: dict[str, Any], event_name: str) -> dict[str, Any] | None: + machine = self.model_for(runtime) + declarations = dict(self.bundle.raw.get("events") or {}) + declarations.update(machine.raw.get("events") or {}) + return declarations.get(event_name) + + def initialize_runtime( self, - machine: Machine, - id: str, - external: dict[str, Any] | None = None, - ) -> Instance: - inst = Instance(machine, id, None, self, external) - self.instances[id] = inst - return inst - - # --- event delivery ----------------------------------------------------- - def validate_event( - self, machine: Machine, event_type: str, payload: dict[str, Any] | None - ) -> tuple[bool, str | None]: - if event_type in DELIVERABLE_RESERVED_EVENTS: - return True, None - events = machine.definition.raw.get("events") or {} - if event_type not in events: - return False, f"undeclared event '{event_type}'" - errs = values.payload_errors(events[event_type], payload) - if errs: - return False, "; ".join(errs) - return True, None - - def deliver( + runtime: dict[str, Any], + machine: MachineModel, + bindings: dict[str, dict[str, Any]], + ) -> None: + try: + self.enter_state(runtime, machine, machine.root, root_bindings=bindings) + except _StopRuntime: + self.complete_runtime(runtime, machine) + + def enter_state( self, - instance_id: str, - event_type: str, - payload: dict[str, Any] | None = None, - ) -> bool: - """Validate and enqueue an event; return False if rejected (§4.3).""" - return self.inject(instance_id, event_type, payload) - - def inject( + runtime: dict[str, Any], + machine: MachineModel, + state: StateNode, + *, + root_bindings: dict[str, dict[str, Any]] | None = None, + descend: bool = True, + ) -> None: + counter = int(runtime["next_state_activation_sequence"].get(state.path, 0)) + runtime["next_state_activation_sequence"][state.path] = counter + 1 + runtime["state_activation_sequence"][state.path] = counter + runtime["active"].append(state.path) + runtime["scopes"][state.path] = self.initialize_variables( + state, root_bindings if state is machine.root else None + ) + pending_components: list[tuple[dict[str, Any], dict[str, Any], MachineModel]] = [] + if state.type == "parallel": + pending_components = self.allocate_components(runtime, machine, state) + self.run_actions( + runtime, + machine, + state, + state.raw.get("entry") or [], + f"{state.pointer}/entry", + event_visible=False, + context="entry", + ) + if state.type == "parallel": + owner_snapshot = self.visible_variables(runtime, machine, state) + for placement, child, child_machine in pending_components: + child = self.state["runtimes"][child["runtime_id"]] + bindings = self.evaluate_author_bindings( + placement.get("with") or {}, + child_machine, + owner_snapshot=owner_snapshot, + runtime=runtime, + machine=machine, + state=state, + pointer=child["component_definition_pointer"], + ) + snapshot = copy.deepcopy(self.state) + emissions_before = len(self.emissions) + child_cause = _cause_id( + "component_initialization", + self.state["root_instance_id"], + runtime["runtime_id"], + child["runtime_id"], + self.cause_id, + self.step_sequence, + child["component_definition_pointer"], + child["component_declaration_index"], + ) + previous_cause = self.cause_id + self.cause_id = child_cause + try: + self.initialize_runtime(child, child_machine, bindings) + except StepFault as fault: + self.restore_contained(snapshot, child["runtime_id"]) + del self.emissions[emissions_before:] + child = self.state["runtimes"][child["runtime_id"]] + self.finalize_fault(child, fault, child_cause, initialization=True) + self.emit_failure(child, child_cause) + finally: + self.cause_id = previous_cause + if state.type == "final": + self.complete_runtime(runtime, machine) + return + if state.type == "composite" and descend: + initial = state.raw["initial"] + target, history = self.resolve_compound_transition( + runtime, + machine, + state, + initial, + f"{state.pointer}/initial", + event_visible=False, + ) + assert target is not None + self.enter_path(runtime, machine, state, target, history=history) + + def initialize_variables( + self, + state: StateNode, + bindings: dict[str, dict[str, Any]] | None, + ) -> dict[str, Any]: + values: dict[str, Any] = {} + for name, declaration in (state.raw.get("variables") or {}).items(): + selected = None + if bindings is not None: + if declaration.get("input") and name in bindings["input"]: + selected = bindings["input"][name] + elif declaration.get("external") and name in bindings["external"]: + selected = bindings["external"][name] + if selected is None and "init" in declaration: + selected = declaration["init"] + if selected is None and declaration["type"] != "instance_reference": + raise StepFault("invariant_fault", "system:invariant") + values[name] = copy.deepcopy(selected) + return values + + def visible_variables( + self, runtime: dict[str, Any], machine: MachineModel, state: StateNode + ) -> dict[str, Any]: + result: dict[str, Any] = {} + for node in reversed(state.ancestors(include_self=True)): + result.update(runtime["scopes"].get(node.path, {})) + return result + + def variable_slot( + self, runtime: dict[str, Any], state: StateNode, name: str + ) -> tuple[str, dict[str, Any]]: + current: StateNode | None = state + while current is not None: + declarations = current.raw.get("variables") or {} + if name in declarations and current.path in runtime["scopes"]: + return current.path, declarations[name] + current = current.parent + raise StepFault("invariant_fault", "system:invariant") + + def activation( + self, + runtime: dict[str, Any], + machine: MachineModel, + state: StateNode, + *, + event_visible: bool, + owner_variables: dict[str, Any] | None = None, + ) -> dict[str, Any]: + activation = self.visible_variables(runtime, machine, state) + if event_visible and self.event is not None: + activation["event"] = {"payload": copy.deepcopy(self.event["payload"])} + if owner_variables is not None: + activation = {"owner": {"variables": copy.deepcopy(owner_variables)}} + return activation + + def evaluate( self, - instance_id: str, - event_type: str, - payload: dict[str, Any] | None = None, - ) -> bool: - """Validate and enqueue without processing, in either mode (SPEC §14).""" - inst = self.instances[instance_id] - ok, _reason = self.validate_event(inst.machine, event_type, payload) - if not ok: + expression: str, + activation: dict[str, Any], + pointer: str, + *, + guard: bool = False, + ) -> Any: + try: + return cel.evaluate(expression, activation) + except CelError as exc: + raise StepFault("guard_fault" if guard else "action_fault", pointer) from exc + + def allocate_components( + self, runtime: dict[str, Any], machine: MachineModel, state: StateNode + ) -> list[tuple[dict[str, Any], dict[str, Any], MachineModel]]: + result: list[tuple[dict[str, Any], dict[str, Any], MachineModel]] = [] + for index, placement in enumerate(state.raw["components"]): + pointer = f"{state.pointer}/components/{index}" + counter = int(runtime["next_component_activation_sequence"].get(pointer, 0)) + runtime["next_component_activation_sequence"][pointer] = counter + 1 + child_machine = ( + self.models.machine(placement["machine_id"]) + if "machine_id" in placement + else self.models.inline_component(machine, placement, pointer) + ) + child_id = _component_runtime_id( + self.bundle, + runtime["runtime_id"], + self.state["root_instance_id"], + pointer, + counter, + child_machine, + ) + target = { + "component": { + "root_instance_id": self.state["root_instance_id"], + "owner_runtime_id": runtime["runtime_id"], + "component_id": placement["component_id"], + "component_runtime_id": child_id, + "activation_sequence": counter, + } + } + child = self.new_runtime( + child_machine, + child_id, + role="component", + owner_runtime_id=runtime["runtime_id"], + metadata={ + "component_id": placement["component_id"], + "component_runtime_id": child_id, + "component_definition_pointer": pointer, + "component_declaration_index": index, + "component_activation_sequence": counter, + "owning_state_path": state.path, + "owning_state_activation_sequence": runtime["state_activation_sequence"][ + state.path + ], + "target": target, + }, + ) + runtime["components"][placement["component_id"]] = child_id + result.append((placement, child, child_machine)) + return result + + def evaluate_author_bindings( + self, + bindings: dict[str, Any], + target: MachineModel, + *, + owner_snapshot: dict[str, Any] | None, + runtime: dict[str, Any], + machine: MachineModel, + state: StateNode, + pointer: str, + ) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {"input": {}, "external": {}} + declarations = target.root.raw.get("variables") or {} + activation = self.activation( + runtime, + machine, + state, + event_visible=self.event is not None, + owner_variables=owner_snapshot, + ) + for kind in ("input", "external"): + supplied = bindings.get(kind) or {} + for name in sorted(supplied, key=lambda item: item.encode("utf-8")): + expression = supplied[name] + value = self.evaluate( + expression, activation, f"{pointer}/with/{kind}/{_escape_pointer(name)}" + ) + declaration = declarations[name] + try: + result[kind][name] = _normalize_value(value, str(declaration["type"])) + except ValueError as exc: + raise StepFault( + "action_fault", f"{pointer}/with/{kind}/{_escape_pointer(name)}" + ) from exc + for name, declaration in declarations.items(): + if declaration.get(kind) and name not in result[kind]: + result[kind][name] = copy.deepcopy(declaration["init"]) + return result + + def process(self, runtime: dict[str, Any], envelope: dict[str, Any]) -> bool: + machine = self.model_for(runtime) + active = machine.states[runtime["active"][-1]] if runtime["active"] else machine.root + selected: tuple[StateNode, dict[str, Any], str] | None = None + current: StateNode | None = active + while current is not None: + transition_or_list = (current.raw.get("on_events") or {}).get(envelope["event"]) + if transition_or_list is not None: + transitions = ( + transition_or_list + if isinstance(transition_or_list, list) + else [transition_or_list] + ) + for index, transition in enumerate(transitions): + pointer = ( + f"{current.pointer}/on_events/{_escape_pointer(envelope['event'])}" + + (f"/{index}" if isinstance(transition_or_list, list) else "") + ) + guard = transition.get("guard") + if guard is None: + selected = (current, transition, pointer) + break + value = self.evaluate( + guard, + self.activation(runtime, machine, current, event_visible=True), + f"{pointer}/guard", + guard=True, + ) + if value is True: + selected = (current, transition, pointer) + break + if selected is not None: + break + current = current.parent + if selected is None: + if envelope["event"] in { + "determa.component_failed", + "determa.spawned_instance_failed", + }: + raise StepFault("contained_runtime_fault", "system:unhandled_contained_failure") return False - inst.queue.append(Event(event_type, payload)) + source, transition, pointer = selected + try: + target, history = self.resolve_compound_transition( + runtime, + machine, + source, + transition, + pointer, + event_visible=True, + ) + if target is None: + return True + self.apply_transition( + runtime, + machine, + source, + target, + local=transition.get("local") is True, + history=history, + ) + except _StopRuntime: + self.complete_runtime(runtime, machine) return True - # --- execution ---------------------------------------------------------- - def maybe_run(self) -> None: - """Run all instances to quiescence in auto mode only (SPEC §14).""" - if self.mode == "auto": - self.run_to_quiescence() - - def step(self, instance: Instance | str, n: int = 1) -> list[dict[str, Any]]: - """Process exactly ``n`` RTC steps of one instance (SPEC §14). - - Returns one per-step record per step taken: - ``{ event, transition, entered, exited, published, spawned, faulted }``. - Stops early if the instance faults or its queue drains. - """ - inst = instance if isinstance(instance, Instance) else self.instances[instance] - records: list[dict[str, Any]] = [] - for _ in range(n): - if inst.status is not Status.ACTIVE or not inst.queue: - break - records.append(self._run_one_step(inst)) - return records - - def _run_one_step(self, inst: Instance) -> dict[str, Any]: - """Dequeue and process one event; build the per-step record and notify the - observer (SPEC §8/§14). The caller guarantees ``inst.queue`` is non-empty.""" - ev = inst.queue.popleft() - before = set(inst.active_leaf_names()) - pub_before = len(self.published) - sp_before = len(self.spawned) - inst._last_target = None # noqa: SLF001 - inst.step(ev) - after = set(inst.active_leaf_names()) - record = { - "event": ev.type, - "transition": inst._last_target, # noqa: SLF001 - "entered": sorted(after - before), - "exited": sorted(before - after), - "published": list(self.published[pub_before:]), - "spawned": list(self.spawned[sp_before:]), - "faulted": inst.status is Status.FAULTED, - } - if self.observer is not None: - self.observer({"instance": inst.id, **record}) - log.debug( - "dispatch instance=%s event=%s transition=%s entered=%s exited=%s", - inst.id, record["event"], record["transition"], record["entered"], record["exited"], + def resolve_compound_transition( + self, + runtime: dict[str, Any], + machine: MachineModel, + source: StateNode, + transition: dict[str, Any], + pointer: str, + *, + event_visible: bool, + ) -> tuple[StateNode | None, bool]: + self.run_actions( + runtime, + machine, + source, + transition.get("action") or [], + f"{pointer}/action", + event_visible=event_visible, + context="transition", ) - return record - - def inspect(self, instance: Instance | str) -> dict[str, Any]: - """Full internal state for debugging, beyond ``state`` (SPEC §14).""" - inst = instance if isinstance(instance, Instance) else self.instances[instance] - out: dict[str, Any] = { - "status": inst.status.value, - "config": inst.active_leaf_names(), - "esvs": inst.resolved_esvs(), - "enabled": inst.enabled_events(), - "queue": [Instance._event_to_snap(e) for e in inst.queue], - "deferred": [Instance._event_to_snap(e) for e in inst.deferred], - "timers": [ - {"fire_at": t["fire_at"], "state_path": t["state_path"], "spec": t["spec"]} - for t in inst.timers - ], - "history": { - p: {"kind": k, "data": d} for p, (k, d) in inst.history.items() - }, - } - if inst.dead_letter: - out["dead_letter"] = list(inst.dead_letter) - return out - - def enabled_events(self, instance: Instance | str) -> list[str]: - """Sorted declared event types the active configuration can handle (§14).""" - inst = instance if isinstance(instance, Instance) else self.instances[instance] - return inst.enabled_events() - - def next_seq(self) -> int: - self._seq += 1 - return self._seq - - def advance(self, duration: str) -> None: - """Advance the virtual clock, enqueueing due `after` timers (§5.9).""" - from .instance import _duration_ms - - self.now += _duration_ms(duration) - due: list[tuple[Instance, dict[str, Any]]] = [] - for inst in self.instances.values(): - if inst.status is not Status.ACTIVE: - continue - for timer in inst.timers: - if timer["fire_at"] <= self.now: - due.append((inst, timer)) - due.sort(key=lambda pair: (pair[1]["fire_at"], pair[1]["seq"])) - for inst, timer in due: - if timer in inst.timers: - inst.timers.remove(timer) - if ( - inst.status is Status.ACTIVE - and timer["state_path"] in inst.config - ): - inst.queue.append( - Event("__time__", after=(timer["state_path"], timer["spec"])) + target_spec = transition.get("transition_to") + if target_spec is None: + return None, False + history = isinstance(target_spec, dict) + target = machine.resolve(target_spec, source) + seen: set[str] = set() + while target.is_choice: + if target.path in seen: + raise StepFault("invariant_fault", "system:invariant") + seen.add(target.path) + branch_selected = None + for index, branch in enumerate(target.raw["choice"]): + guard = branch.get("guard") + branch_pointer = f"{target.pointer}/choice/{index}" + if ( + guard is None + or self.evaluate( + guard, + self.activation(runtime, machine, source, event_visible=False), + f"{branch_pointer}/guard", + guard=True, + ) + is True + ): + branch_selected = (branch, branch_pointer) + break + if branch_selected is None: + raise StepFault("invariant_fault", "system:invariant") + branch, branch_pointer = branch_selected + self.run_actions( + runtime, + machine, + source, + branch.get("action") or [], + f"{branch_pointer}/action", + event_visible=False, + context="choice", + ) + target_spec = branch["transition_to"] + history = isinstance(target_spec, dict) + target = machine.resolve(target_spec, target) + return target, history + + def run_actions( + self, + runtime: dict[str, Any], + machine: MachineModel, + state: StateNode, + actions: list[dict[str, Any]], + pointer: str, + *, + event_visible: bool, + context: str, + ) -> None: + for index, action in enumerate(actions): + action_pointer = f"{pointer}/{index}" + if "assign" in action: + name, expression = next(iter(action["assign"].items())) + value = self.evaluate( + expression, + self.activation(runtime, machine, state, event_visible=event_visible), + f"{action_pointer}/assign/{_escape_pointer(name)}", + ) + scope_path, declaration = self.variable_slot(runtime, state, name) + try: + runtime["scopes"][scope_path][name] = _normalize_value( + value, str(declaration["type"]) + ) + except ValueError as exc: + raise StepFault( + "action_fault", + f"{action_pointer}/assign/{_escape_pointer(name)}", + ) from exc + elif "send" in action: + self.send( + runtime, + machine, + state, + action["send"], + f"{action_pointer}/send", + event_visible=event_visible, + ) + elif "refresh" in action: + self.refresh(runtime, machine, state, action["refresh"], action_pointer) + elif "spawn" in action: + self.spawn( + runtime, + machine, + state, + action["spawn"], + f"{action_pointer}/spawn", ) - log.debug( - "timer fired instance=%s state=%s after=%s", - inst.id, timer["state_path"], timer["spec"], + elif "cancel" in action: + expression = action["cancel"]["instance"] + reference = self.evaluate( + expression, + self.activation(runtime, machine, state, event_visible=event_visible), + f"{action_pointer}/cancel/instance", ) + self.cancel(reference) + elif "stop" in action: + raise _StopRuntime + + def send( + self, + runtime: dict[str, Any], + machine: MachineModel, + state: StateNode, + send: dict[str, Any], + pointer: str, + *, + event_visible: bool, + ) -> None: + activation = self.activation(runtime, machine, state, event_visible=event_visible) + declaration = self.event_declaration(runtime, send["event"]) + payload_values: dict[str, Any] = {} + payload_expressions = send.get("payload") or {} + normalized_payload: dict[str, Any] + if send["event"] == "env": + changed_expression = payload_expressions["changed"] + payload_values["changed"] = self.evaluate( + changed_expression, activation, f"{pointer}/payload/changed" + ) + else: + assert declaration is not None + for name in sorted(payload_expressions, key=lambda item: item.encode("utf-8")): + payload_values[name] = self.evaluate( + payload_expressions[name], + activation, + f"{pointer}/payload/{_escape_pointer(name)}", + ) + correlation = None + if "correlation_id" in send: + correlation = self.evaluate( + send["correlation_id"], activation, f"{pointer}/correlation_id" + ) + target_specs = send.get("targets") or [send.get("to", {"self": True})] + evaluated_targets: list[tuple[dict[str, Any], Any]] = [] + for index, target_spec in enumerate(target_specs): + value = None + if "instance" in target_spec: + suffix = f"/targets/{index}/instance" if "targets" in send else "/to/instance" + value = self.evaluate(target_spec["instance"], activation, f"{pointer}{suffix}") + evaluated_targets.append((target_spec, value)) + if send["event"] == "env": + if not isinstance(payload_values["changed"], dict): + raise StepFault("action_fault", f"{pointer}/payload/changed") + normalized_payload = {"changed": copy.deepcopy(payload_values["changed"])} + else: + assert declaration is not None + payload_result = _normalize_payload(declaration, payload_values) + if payload_result is None: + first = sorted(payload_values, key=lambda item: item.encode("utf-8"))[0] + raise StepFault("action_fault", f"{pointer}/payload/{_escape_pointer(first)}") + normalized_payload = payload_result + resolved = [ + self.resolve_send_target(runtime, target_spec, value, pointer, index, "targets" in send) + for index, (target_spec, value) in enumerate(evaluated_targets) + ] + for index, target in enumerate(resolved): + if target == "external": + sequence = int(self.state["next_output_sequence"]) + self.state["next_output_sequence"] = sequence + 1 + emission = { + "event": send["event"], + "target": "external", + "payload": copy.deepcopy(normalized_payload), + "correlation_id": correlation, + "effect_id": _effect_id( + machine, + self.state["root_instance_id"], + runtime["runtime_id"], + self.cause_id, + self.step_sequence, + pointer, + index, + ), + "sequence": sequence, + } + else: + assert isinstance(target, dict) + target_runtime_id = _target_runtime_id(target) + emission = { + "event": send["event"], + "event_id": _event_id( + self.state["root_instance_id"], + runtime["runtime_id"], + target_runtime_id, + self.cause_id, + self.step_sequence, + pointer, + index, + ), + "target": copy.deepcopy(target), + "payload": copy.deepcopy(normalized_payload), + } + if correlation is not None: + emission["correlation_id"] = correlation + self.emissions.append(emission) - def run_to_quiescence(self) -> None: - progress = True - while progress: - progress = False - for inst in list(self.instances.values()): - if inst.status is not Status.ACTIVE: - continue - while inst.queue: - self._run_one_step(inst) - progress = True - - # --- snapshot round-trip (SPEC §8) ------------------------------------- - def snapshot_all(self) -> list[dict[str, Any]]: - return [inst.to_snapshot() for inst in self.instances.values()] - - def restore_all(self, snapshots: list[dict[str, Any]]) -> None: - new_instances: dict[str, Instance] = {} - for snap in snapshots: - machine = self.versions.get( - (snap["def_id"], snap["def_version"]) - ) or self.machines[snap["def_id"]] - inst = Instance( - machine, snap["id"], snap["parent_id"], self, auto_enter=False + def resolve_send_target( + self, + runtime: dict[str, Any], + target_spec: dict[str, Any], + evaluated: Any, + pointer: str, + index: int, + target_list: bool, + ) -> dict[str, Any] | str: + suffix = f"/targets/{index}" if target_list else "/to" + if target_spec.get("self") is True: + return self.target_for(runtime) + if target_spec.get("owner") is True: + owner_id = runtime.get("owner_runtime_id") + if owner_id is None or owner_id not in self.state["runtimes"]: + raise StepFault("invalid_instance_target", f"{pointer}{suffix}") + return self.target_for(self.state["runtimes"][owner_id]) + if "component" in target_spec: + child_id = runtime["components"].get(target_spec["component"]) + child = self.state["runtimes"].get(child_id) + if child is None or child["status"] != "running": + raise StepFault("inactive_component_target", f"{pointer}{suffix}") + return cast(dict[str, Any], copy.deepcopy(child["target"])) + if "instance" in target_spec: + if not _is_instance_reference(evaluated): + raise StepFault("invalid_instance_target", f"{pointer}{suffix}/instance") + child = self.state["runtimes"].get(evaluated["instance_id"]) + if child is None or child["status"] != "running": + raise StepFault("invalid_instance_target", f"{pointer}{suffix}/instance") + return {"spawned_instance": copy.deepcopy(evaluated)} + if target_spec.get("external") is True: + return "external" + raise StepFault("invalid_instance_target", f"{pointer}{suffix}") + + def target_for(self, runtime: dict[str, Any]) -> dict[str, Any]: + if runtime["role"] == "root": + return { + "root": { + "root_instance_id": self.state["root_instance_id"], + "root_runtime_id": runtime["runtime_id"], + } + } + if runtime["role"] == "component": + return copy.deepcopy(runtime["target"]) + return {"spawned_instance": copy.deepcopy(runtime["instance_reference"])} + + def refresh( + self, + runtime: dict[str, Any], + machine: MachineModel, + state: StateNode, + refresh: dict[str, Any], + pointer: str, + ) -> None: + assert self.event is not None + changed = self.event["payload"]["changed"] + selected = refresh.get("only", list(changed)) + for index, name in enumerate(selected): + if name not in changed: + raise StepFault("action_fault", f"{pointer}/refresh/only/{index}") + for name in selected: + scope_path, declaration = self.variable_slot(runtime, state, name) + runtime["scopes"][scope_path][name] = _normalize_value( + changed[name], str(declaration["type"]) ) - inst.load_snapshot(snap) - new_instances[snap["id"]] = inst - self.instances = new_instances - - # --- versioning / migration (SPEC §10) --------------------------------- - def upgrade(self, target_version: int, root_def_id: str | None = None) -> None: - """Register a newer definition version and migrate eligible instances.""" - if root_def_id is None: - root_def_id = next(iter(self.machines)) if self.machines else None - if root_def_id is None: + + def spawn( + self, + runtime: dict[str, Any], + machine: MachineModel, + state: StateNode, + spawn: dict[str, Any], + pointer: str, + ) -> None: + sequence = int(runtime["next_spawn_sequence"]) + runtime["next_spawn_sequence"] = sequence + 1 + child_machine = self.models.machine(spawn["machine_id"]) + child_id = _spawned_runtime_id( + self.bundle, + runtime["runtime_id"], + self.state["root_instance_id"], + pointer, + sequence, + child_machine, + ) + bindings = self.evaluate_author_bindings( + spawn.get("bindings") or {}, + child_machine, + owner_snapshot=None, + runtime=runtime, + machine=machine, + state=state, + pointer=pointer, + ) + reference = { + "root_instance_id": self.state["root_instance_id"], + "instance_id": child_id, + "machine_id": child_machine.machine_id, + "machine_version": child_machine.version, + } + holder = None + if "bind_to" in spawn: + name = spawn["bind_to"] + scope_path, declaration = self.variable_slot(runtime, state, name) + if runtime["scopes"][scope_path][name] is not None: + raise StepFault("binding_not_empty", f"{pointer}/bind_to") + runtime["scopes"][scope_path][name] = copy.deepcopy(reference) + holder_state = machine.states[scope_path] + holder = { + "pointer": f"{holder_state.pointer}/variables/{_escape_pointer(name)}", + "state_path": scope_path, + "state_activation_sequence": runtime["state_activation_sequence"][scope_path], + } + del declaration + child = self.new_runtime( + child_machine, + child_id, + role="spawned", + owner_runtime_id=runtime["runtime_id"], + metadata={ + "spawn_sequence": sequence, + "spawn_action_pointer": pointer, + "instance_reference": reference, + "holder": holder, + }, + ) + snapshot = copy.deepcopy(self.state) + emissions_before = len(self.emissions) + child_cause = _cause_id( + "spawned_initialization", + self.state["root_instance_id"], + runtime["runtime_id"], + child_id, + self.cause_id, + self.step_sequence, + pointer, + sequence, + ) + previous_cause = self.cause_id + self.cause_id = child_cause + try: + self.initialize_runtime(child, child_machine, bindings) + except StepFault as fault: + self.restore_contained(snapshot, child_id) + del self.emissions[emissions_before:] + child = self.state["runtimes"][child_id] + self.finalize_fault(child, fault, child_cause, initialization=True) + self.emit_failure(child, child_cause) + finally: + self.cause_id = previous_cause + + def restore_contained(self, snapshot: dict[str, Any], child_runtime_id: str) -> None: + """Roll back one contained initialization without replacing its owner object.""" + current_runtimes = self.state["runtimes"] + snapshot_runtimes = snapshot["runtimes"] + for runtime_id in list(current_runtimes): + if runtime_id not in snapshot_runtimes: + current_runtimes.pop(runtime_id) + child = current_runtimes[child_runtime_id] + child.clear() + child.update(copy.deepcopy(snapshot_runtimes[child_runtime_id])) + self.state["next_output_sequence"] = snapshot["next_output_sequence"] + + def cancel(self, reference: Any) -> None: + if not _is_instance_reference(reference): return - new_machine = self.versions.get((root_def_id, target_version)) - if new_machine is None: + child = self.state["runtimes"].get(reference["instance_id"]) + if child is None or child["role"] != "spawned": return - self.machines[root_def_id] = new_machine - for inst in list(self.instances.values()): - if ( - inst.machine.id == root_def_id - and inst.machine.version < target_version - and inst.status is Status.ACTIVE - ): - self._try_migrate(inst, new_machine) - - def _try_migrate(self, inst: Instance, new_machine: Machine) -> None: - migrations = new_machine.definition.raw.get("migrations") or [] - mig = next( - ( - m - for m in migrations - if m.get("from") == inst.machine.version - and m.get("to") == new_machine.version + self.cleanup_runtime(child, dispose=True) + + def apply_transition( + self, + runtime: dict[str, Any], + machine: MachineModel, + source: StateNode, + target: StateNode, + *, + local: bool, + history: bool, + ) -> None: + boundary = _boundary(machine, source, target, local) + exit_nodes = [ + machine.states[path] + for path in reversed(runtime["active"]) + if machine.states[path] is not boundary + and boundary.is_ancestor_of(machine.states[path], strict=True) + ] + leaf = machine.states[runtime["active"][-1]] + for node in exit_nodes: + if node.type == "composite" and node.raw.get("history", "none") != "none": + key = "$root" if node is machine.root else node.path + if node.raw["history"] == "shallow": + direct = leaf + while direct.parent is not node: + assert direct.parent is not None + direct = direct.parent + runtime["history"][key] = [direct.path] + else: + runtime["history"][key] = [leaf.path] + for node in exit_nodes: + self.exit_state(runtime, machine, node) + if target is boundary: + if target.type == "composite": + self.descend_composite(runtime, machine, target, history=history) + return + self.enter_path(runtime, machine, boundary, target, history=history) + + def enter_path( + self, + runtime: dict[str, Any], + machine: MachineModel, + boundary: StateNode, + target: StateNode, + *, + history: bool, + ) -> None: + path: list[StateNode] = [] + current = target + while current is not boundary: + path.append(current) + assert current.parent is not None + current = current.parent + for node in reversed(path): + self.enter_state(runtime, machine, node, descend=False) + if runtime["status"] != "running": + return + if target.type == "composite" and target.path in runtime["active"]: + self.descend_composite(runtime, machine, target, history=history) + + def descend_composite( + self, + runtime: dict[str, Any], + machine: MachineModel, + state: StateNode, + *, + history: bool, + ) -> None: + if history: + key = "$root" if state is machine.root else state.path + record = runtime["history"].get(key) + if record: + destination = machine.states[record[0]] + if state.raw["history"] == "shallow": + self.enter_path(runtime, machine, state, destination, history=False) + else: + self.enter_path(runtime, machine, state, destination, history=False) + return + initial = state.raw["initial"] + target, target_history = self.resolve_compound_transition( + runtime, + machine, + state, + initial, + f"{state.pointer}/initial", + event_visible=False, + ) + assert target is not None + self.enter_path(runtime, machine, state, target, history=target_history) + + def exit_state(self, runtime: dict[str, Any], machine: MachineModel, state: StateNode) -> None: + self.cleanup_state_children(runtime, state) + self.run_actions( + runtime, + machine, + state, + state.raw.get("exit") or [], + f"{state.pointer}/exit", + event_visible=False, + context="exit", + ) + runtime["scopes"].pop(state.path, None) + runtime["state_activation_sequence"].pop(state.path, None) + if state.path in runtime["active"]: + runtime["active"].remove(state.path) + + def cleanup_state_children(self, runtime: dict[str, Any], state: StateNode) -> None: + children = list(self.state["runtimes"].values()) + components = [ + child + for child in children + if child.get("role") == "component" + and child.get("owner_runtime_id") == runtime["runtime_id"] + and child.get("owning_state_path") == state.path + ] + components.sort( + key=lambda child: ( + child["component_definition_pointer"].encode("utf-8"), + child["owning_state_activation_sequence"], + child["component_declaration_index"], + child["component_activation_sequence"], ), - None, + reverse=True, ) - if mig is None: - return - # only at a safe point: quiescent (empty queue + deferred) - if inst.queue or inst.deferred: + for child in components: + self.cleanup_runtime(child, dispose=True) + held = [ + child + for child in list(self.state["runtimes"].values()) + if child.get("role") == "spawned" + and child.get("owner_runtime_id") == runtime["runtime_id"] + and child.get("holder") is not None + and child["holder"]["state_path"] == state.path + and child["holder"]["state_activation_sequence"] + == runtime["state_activation_sequence"].get(state.path) + ] + held.sort(key=_spawn_cleanup_key) + for child in held: + self.cleanup_runtime(child, dispose=True) + + def cleanup_runtime(self, runtime: dict[str, Any], *, dispose: bool) -> None: + machine = self.model_for(runtime) + descendants = [ + child + for child in list(self.state["runtimes"].values()) + if child.get("owner_runtime_id") == runtime["runtime_id"] + ] + components = sorted( + [child for child in descendants if child["role"] == "component"], + key=lambda child: ( + child["component_definition_pointer"].encode("utf-8"), + child["owning_state_activation_sequence"], + child["component_declaration_index"], + child["component_activation_sequence"], + ), + reverse=True, + ) + spawned = sorted( + [child for child in descendants if child["role"] == "spawned"], + key=_spawn_cleanup_key, + ) + for child in [*components, *spawned]: + self.cleanup_runtime(child, dispose=True) + if runtime["status"] == "running": + for path in list(reversed(runtime["active"])): + self.exit_state(runtime, machine, machine.states[path]) + if dispose: + owner_id = runtime.get("owner_runtime_id") + if runtime["role"] == "component" and owner_id in self.state["runtimes"]: + owner = self.state["runtimes"][owner_id] + if owner["components"].get(runtime["component_id"]) == runtime["runtime_id"]: + owner["components"].pop(runtime["component_id"], None) + self.state["runtimes"].pop(runtime["runtime_id"], None) + + def complete_runtime(self, runtime: dict[str, Any], machine: MachineModel) -> None: + if runtime["status"] != "running": return - leaves = inst.active_leaves() - state_binding = leaves[0].name if len(leaves) == 1 else [lf.name for lf in leaves] - when = mig.get("when") - if when is not None and not cel.evaluate(when, {"state": state_binding}): + for child in sorted( + [ + item + for item in list(self.state["runtimes"].values()) + if item.get("owner_runtime_id") == runtime["runtime_id"] + ], + key=lambda item: ( + 0 if item["role"] == "component" else 1, + item.get("component_definition_pointer", "").encode("utf-8"), + item.get("spawn_sequence", 0), + ), + ): + self.cleanup_runtime(child, dispose=True) + for path in list(reversed(runtime["active"])): + self.exit_state(runtime, machine, machine.states[path]) + runtime["status"] = "completed" + if runtime["role"] == "root": + self.state["status"] = "completed" return - state_map = mig.get("state_map") or {} - if any(leaf.name not in state_map for leaf in leaves): + if runtime["role"] == "component": + self.emit_component_completion(runtime) return - # remap the configuration onto the new machine - inst.machine = new_machine - inst.config = self._remap_config(new_machine, leaves, state_map) - # transform esvs (carried over; actions run against the live scope) - esv_actions = mig.get("esvs") or [] - if esv_actions: - inst.run_actions(esv_actions, new_machine.top, None) - - def _remap_config( - self, - new_machine: Machine, - old_leaves: list[Any], - state_map: dict[str, str], - ) -> set[str]: - config: set[str] = set() - config.add(new_machine.top.path) - for leaf in old_leaves: - new_name = state_map[leaf.name] - target = new_machine.find_by_name(new_name) - if target is None: - continue - cur: Any = target - while cur is not None: - config.add(cur.path) - cur = cur.parent - return config - - # --- structured-action hooks (SPEC §6) ---------------------------------- - def spawn_action( + self.emit_spawned_completion(runtime) + self.state["runtimes"].pop(runtime["runtime_id"], None) + + def emit_component_completion(self, runtime: dict[str, Any]) -> None: + owner = self.state["runtimes"][runtime["owner_runtime_id"]] + payload = { + "component_id": runtime["component_id"], + "component_runtime_id": runtime["runtime_id"], + } + self.emit_internal_system( + runtime, + owner, + "determa.component_completed", + payload, + "system:component_completion", + ) + component_ids = owner["components"].values() + if component_ids and all( + self.state["runtimes"][runtime_id]["status"] == "completed" + for runtime_id in component_ids + ): + payload = { + "relationship": "parallel", + "state_path": runtime["owning_state_path"], + "owner_runtime_id": owner["runtime_id"], + } + self.emit_internal_system( + owner, owner, "done", payload, "system:component_completion", ordinal=1 + ) + + def emit_spawned_completion(self, runtime: dict[str, Any]) -> None: + owner = self.state["runtimes"][runtime["owner_runtime_id"]] + payload = { + "relationship": "spawned_instance", + "instance": copy.deepcopy(runtime["instance_reference"]), + "instance_id": runtime["runtime_id"], + "machine_id": runtime["machine_id"], + "machine_version": runtime["machine_version"], + } + self.emit_internal_system(runtime, owner, "done", payload, "system:spawned_completion") + + def emit_internal_system( self, - parent: Instance, - spec: dict[str, Any], - root: Any, - event: Event | None, + source: dict[str, Any], + target_runtime: dict[str, Any], + event: str, + payload: dict[str, Any], + locator: str, + *, + ordinal: int = 0, ) -> None: - def_id = spec["def"] - machine = self.machines[def_id] - n = self._spawn_counters[parent.id] = self._spawn_counters.get(parent.id, 0) + 1 - child_id = f"{parent.id}/{n}" - external: dict[str, Any] | None = None - if "payload" in spec: - scope = parent.scope(root, event) - external = {k: cel.evaluate(v, scope) for k, v in spec["payload"].items()} - child = Instance(machine, child_id, parent.id, self, external=external) - self.instances[child_id] = child - self.spawned.append(def_id) - log.debug("spawn parent=%s child=%s def=%s", parent.id, child_id, def_id) - result = spec.get("result") - if result: - parent.assign_esv(root, result, child_id) - - def publish( + target = self.target_for(target_runtime) + event_id = _event_id( + self.state["root_instance_id"], + source["runtime_id"], + target_runtime["runtime_id"], + self.cause_id, + self.step_sequence, + locator, + ordinal, + ) + self.emissions.append( + { + "event": event, + "event_id": event_id, + "target": target, + "payload": copy.deepcopy(payload), + } + ) + + def finalize_fault( self, - src: Instance, - spec: dict[str, Any], - root: Any, - event: Event | None, + runtime: dict[str, Any], + fault: StepFault, + cause_id: str, + *, + initialization: bool = False, ) -> None: - name = spec["event"] - scope = src.scope(root, event) - payload = { - k: cel.evaluate(v, scope) for k, v in (spec.get("payload") or {}).items() + runtime["status"] = "faulted" + if initialization: + runtime["active"] = [] + runtime["scopes"] = {} + runtime["history"] = {} + runtime["components"] = {} + runtime["next_spawn_sequence"] = 0 + runtime["next_state_activation_sequence"] = {} + runtime["state_activation_sequence"] = {} + runtime["next_component_activation_sequence"] = {} + runtime["fault"] = { + "runtime_id": runtime["runtime_id"], + "cause_id": cause_id, + "code": fault.code, + "step_sequence": self.step_sequence, + "source_locator": fault.source_locator, } - self.published.append(name) - log.debug("publish event=%s from=%s", name, src.id) - if "to" in spec: - target = cel.evaluate(spec["to"], scope) - ids = target if isinstance(target, list) else [target] - for tid in ids: - tid = str(tid) - tgt = self.instances.get(tid) - if tgt is not None and tgt.status is Status.ACTIVE: - tgt.queue.append(Event(name, payload)) + + def emit_failure(self, runtime: dict[str, Any], cause_id: str) -> None: + owner = self.state["runtimes"][runtime["owner_runtime_id"]] + public_fault = copy.deepcopy(runtime["fault"]) + public_fault["step_sequence"] = str(public_fault["step_sequence"]) + if runtime["role"] == "component": + event = "determa.component_failed" + payload = { + "component_id": runtime["component_id"], + "component_runtime_id": runtime["runtime_id"], + "fault": public_fault, + } + locator = "system:component_failure" else: - self._undirected(src, name, payload) + event = "determa.spawned_instance_failed" + payload = { + "instance": copy.deepcopy(runtime["instance_reference"]), + "instance_id": runtime["runtime_id"], + "machine_id": runtime["machine_id"], + "machine_version": runtime["machine_version"], + "fault": public_fault, + } + locator = "system:spawned_failure" + previous = self.cause_id + self.cause_id = cause_id + self.emit_internal_system(runtime, owner, event, payload, locator) + self.cause_id = previous - def _undirected(self, src: Instance, name: str, payload: dict[str, Any]) -> None: - scope_kind = self._event_scope(src.machine, name) - if scope_kind == "internal": - src.queue.append(Event(name, payload)) - return - if scope_kind == "local": - candidate_ids = self._tree_ids(src.id) - else: # global - candidate_ids = list(self.instances.keys()) - for tid in candidate_ids: - t = self.instances.get(tid) - if t is None or t.status is not Status.ACTIVE: - continue - subs = t.machine.definition.raw.get("subscribe") or [] - if name in subs: - t.queue.append(Event(name, payload)) - - def _event_scope(self, machine: Machine, name: str) -> str: - decl = (machine.definition.raw.get("events") or {}).get(name) - if isinstance(decl, dict): - scope = decl.get("scope", "internal") - return scope if isinstance(scope, str) else "internal" - return "internal" - - def _tree_ids(self, root_id: str) -> list[str]: - out = [root_id] - out.extend( - iid - for iid, inst in self.instances.items() - if iid != root_id and self._under_root(iid, root_id) - ) - return out - def _under_root(self, iid: str, root_id: str) -> bool: - inst = self.instances.get(iid) - cur = inst.parent_id if inst else None - while cur is not None: - if cur == root_id: - return True - parent = self.instances.get(cur) if cur else None - cur = parent.parent_id if parent else None - return False +def _boundary( + machine: MachineModel, source: StateNode, target: StateNode, local: bool +) -> StateNode: + if source is target: + assert source.parent is not None + return source.parent + if source.is_ancestor_of(target, strict=True): + if local or source is machine.root: + return source + assert source.parent is not None + return source.parent + if target.is_ancestor_of(source, strict=True): + return target + target_paths = {node.path: node for node in target.ancestors(include_self=True)} + for node in source.ancestors(include_self=True): + if node.path in target_paths: + return node + return machine.root - def refresh( - self, inst: Instance, spec: dict[str, Any], event: Event | None - ) -> None: - if event is None or event.type != "env": - raise ValueError("refresh is only valid while handling an env event") - changed = ((event.payload or {}).get("changed")) or {} - only = spec.get("only") - names = only if only is not None else list(changed.keys()) - for nm in names: - if nm in changed: - inst.refresh_external(nm, changed[nm]) - - def stop(self, inst: Instance) -> None: - inst._pending_terminate = True - - # --- termination (SPEC §5.7) ------------------------------------------- - def terminate(self, inst: Instance) -> None: - if inst.status is Status.TERMINATED: - return - for child in [ - i - for i in self.instances.values() - if i.parent_id == inst.id and i.status is Status.ACTIVE - ]: - self.terminate(child) - inst.terminate_exits() - if inst.parent_id and inst.parent_id in self.instances: - parent = self.instances[inst.parent_id] - if parent.status is Status.ACTIVE: - parent.queue.append(Event("done", {"instance": inst.id})) - inst.status = Status.TERMINATED - inst.queue.clear() + +def _target_runtime_id(target: dict[str, Any]) -> str: + if "root" in target: + return str(target["root"]["root_runtime_id"]) + if "component" in target: + return str(target["component"]["component_runtime_id"]) + return str(target["spawned_instance"]["instance_id"]) + + +def _spawn_cleanup_key(runtime: dict[str, Any]) -> tuple[int, bytes, int, int]: + holder = runtime.get("holder") + if holder is None: + return (1, b"", 0, int(runtime["spawn_sequence"])) + return ( + 0, + holder["pointer"].encode("utf-8"), + int(holder["state_activation_sequence"]), + int(runtime["spawn_sequence"]), + ) + + +def _pointer_get(document: dict[str, Any], pointer: str) -> dict[str, Any]: + current: Any = document + for part in pointer.split("/")[1:]: + key = part.replace("~1", "/").replace("~0", "~") + current = current[int(key)] if isinstance(current, list) else current[key] + return cast(dict[str, Any], current) diff --git a/src/determa/state/errors.py b/src/determa/state/errors.py index 0249025..63b7490 100644 --- a/src/determa/state/errors.py +++ b/src/determa/state/errors.py @@ -1,45 +1,46 @@ -"""Exception types for the Determa State engine (SPEC §2, §13.2/§13.4). - -Validation errors carry a structured list of ``{"path": str, "message": str}`` -records, matching the JSON shape the CLI emits for ``validate`` (SPEC §13.4). -""" +"""Structured errors for format-1 loading and foreground execution.""" from __future__ import annotations -from typing import TypedDict +from dataclasses import dataclass -class ErrorRecord(TypedDict): - """A single validation error, in the §13.4 ``{path, message}`` shape.""" +class DetermaError(Exception): + """Base class for Determa State errors.""" - path: str - message: str +@dataclass(frozen=True) +class ErrorRecord: + """One load-time diagnostic.""" -class DetermaError(Exception): - """Base class for all Determa State errors.""" + code: str + path: str = "" + message: str = "" class ValidationError(DetermaError): - """A machine definition failed schema or semantic validation. - - ``errors`` is the structured list (one record per problem). When raised - without records it still signals failure (e.g. a malformed document). - """ - - def __init__( - self, - errors: list[ErrorRecord] | None = None, - message: str | None = None, - ) -> None: - self.errors: list[ErrorRecord] = list(errors) if errors else [] - if message is None: - message = ( - "; ".join(f"{e['path']}: {e['message']}" for e in self.errors) - or "validation failed" - ) - super().__init__(message) + """A source, schema, or semantic validation failure.""" + + def __init__(self, code: str, path: str = "", message: str = "") -> None: + self.code = code + self.path = path + self.message = message or code + self.errors = [ErrorRecord(code=code, path=path, message=self.message)] + super().__init__(self.message) class SchemaError(DetermaError): - """The bundled JSON Schema itself is unusable (should not happen).""" + """The bundled normative schema is unusable.""" + + +class CelError(DetermaError): + """A portable CEL expression failed to compile or evaluate.""" + + +class StepFault(DetermaError): + """Internal control flow for one atomic RTC fault.""" + + def __init__(self, code: str, source_locator: str) -> None: + self.code = code + self.source_locator = source_locator + super().__init__(f"{code} at {source_locator}") diff --git a/src/determa/state/export.py b/src/determa/state/export.py deleted file mode 100644 index eeb3c31..0000000 --- a/src/determa/state/export.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Machine/instance visualization (SPEC §12, informative). - -Exporters are pluggable by format; ``mermaid`` (``stateDiagram-v2``) is the -built-in default. Without a state config the static structure is rendered; with -one (from a snapshot/observer) the active leaves and their ancestors are -highlighted for current-state visualization. -""" - -from __future__ import annotations - -from .model import Machine, State - - -def export( - machine: Machine, - format: str = "mermaid", - state_config: list[str] | None = None, -) -> str: - """Render ``machine`` (optionally highlighting ``state_config``).""" - if format != "mermaid": - raise ValueError(f"unsupported export format: {format}") - return _to_mermaid(machine, state_config) - - -def _to_mermaid(machine: Machine, state_config: list[str] | None) -> str: - lines: list[str] = ["stateDiagram-v2"] - _emit_state(machine, machine.top, lines, indent=1, in_root=True) - if state_config: - lines.append(" classDef active fill:#9f9,stroke:#3a3") - for name in sorted(_active_names(machine, state_config)): - lines.append(f" class {name} active") - return "\n".join(lines) + "\n" - - -def _emit_state( - machine: Machine, - state: State, - lines: list[str], - indent: int, - in_root: bool, -) -> None: - pad = " " * indent - composite = state.type in ("composite", "orthogonal") - if in_root: - # top is the diagram root; its initial and transitions emit at top level. - _emit_initial(state, lines, indent) - _emit_transitions(state, lines, indent) - for child in state.children.values(): - _emit_state(machine, child, lines, indent, in_root=False) - return - if composite: - lines.append(f"{pad}state {state.name} {{") - _emit_initial(state, lines, indent + 1) - _emit_transitions(state, lines, indent + 1) - for child in state.children.values(): - _emit_state(machine, child, lines, indent + 1, in_root=False) - if state.type == "orthogonal": - regions = state.raw.get("regions") or [] - for _ in range(len(regions) - 1): - lines.append(f"{pad} --") - lines.append(f"{pad}}}") - else: - _emit_transitions(state, lines, indent) - if state.type == "final": - lines.append(f"{pad}{state.name} --> [*]") - - -def _emit_initial(state: State, lines: list[str], indent: int) -> None: - initial = state.raw.get("initial") - if not isinstance(initial, dict): - return - target = _short(initial["transition_to"]) - label = _label(None, initial.get("guard")) - pad = " " * indent - lines.append(f"{pad}[*] --> {target}{label}") - - -def _emit_transitions(state: State, lines: list[str], indent: int) -> None: - pad = " " * indent - for event, spec in (state.raw.get("on_events") or {}).items(): - transitions = spec if isinstance(spec, list) else [spec] - for t in transitions: - target = t.get("transition_to") - if target is None: - continue # internal transition: no edge - lines.append(f"{pad}{state.name} --> {_short(target)}{_label(event, t.get('guard'))}") - for after in state.raw.get("after") or []: - target = after.get("transition_to") - if target is None: - continue - lines.append(f"{pad}{state.name} --> {_short(target)} : after({after['duration']})") - - -def _label(event: str | None, guard: str | None) -> str: - """Edge label `` : event [guard]`` (§12).""" - text = event or "" - if guard: - text = f"{text} [{guard}]" if text else f"[{guard}]" - return f" : {text}" if text else "" - - -def _short(ref: str) -> str: - """A transition_to ref -> the final (leaf-most) component name.""" - return ref.split(".")[-1] - - -def _active_names(machine: Machine, state_config: list[str]) -> set[str]: - """Names of the active leaves and their ancestors (excluding the root `top`).""" - names: set[str] = set() - for path in state_config: - cur = machine.by_path.get(path) - while cur is not None and cur.parent is not None: - names.add(cur.name) - cur = cur.parent - return names diff --git a/src/determa/state/instance.py b/src/determa/state/instance.py deleted file mode 100644 index 5e1ee00..0000000 --- a/src/determa/state/instance.py +++ /dev/null @@ -1,729 +0,0 @@ -"""Machine instance — the active object that runs one statechart (SPEC §3, §5). - -Holds the state configuration, live esv values, FIFO event queue, deferred set, -and timers. ``dispatch`` runs one run-to-completion (RTC) step: find a handler -by searching from the active leaf up, then execute the transition (LCA + -exit/entry ordering per PSiCC, internal/local/external kinds, initial descent). -Extended-state lifetime (init on entry before entry actions, destroy on exit, -re-init on re-entry) and hierarchical scoping (inner shadows outer) live here. -""" - -from __future__ import annotations - -import logging -from collections import deque -from dataclasses import dataclass -from enum import StrEnum -from typing import TYPE_CHECKING, Any - -from . import cel, values -from .cel import CelError -from .errors import DetermaError -from .model import Machine, State - -log = logging.getLogger(__name__) - -if TYPE_CHECKING: - from .engine import Host - -DELIVERABLE_RESERVED_EVENTS = frozenset({"env", "error", "done"}) -RESERVED_LIFECYCLE_EVENTS = frozenset({"entry", "exit", "initial", "done", "error", "env"}) - -_DURATION_UNITS = {"ms": 1, "s": 1000, "m": 60_000, "h": 3_600_000} - - -def _duration_ms(duration: str) -> int: - unit = duration[-2:] if duration.endswith("ms") else duration[-1:] - n = int(duration[: -len(unit)]) - return n * _DURATION_UNITS[unit] - - -class Status(StrEnum): - ACTIVE = "active" - FAULTED = "faulted" - TERMINATED = "terminated" - - -@dataclass -class Event: - type: str - payload: dict[str, Any] | None = None - # For a timer firing: the (state_path, after-spec) to run as a transition. - after: tuple[str, dict[str, Any]] | None = None - - -class Instance: - def __init__( - self, - machine: Machine, - id: str, - parent_id: str | None, - host: Host, - external: dict[str, Any] | None = None, - auto_enter: bool = True, - ) -> None: - self.machine = machine - self.id = id - self.parent_id = parent_id - self.host = host - self.status = Status.ACTIVE - self.config: set[str] = set() # paths of all active states - self.esv_values: dict[str, dict[str, Any]] = {} # state path -> {var: value} - self.queue: deque[Event] = deque() - self.deferred: deque[Event] = deque() - self.timers: list[dict[str, Any]] = [] - self.dead_letter: list[dict[str, Any]] = [] - self.history: dict[str, tuple[str, Any]] = {} - self.external: dict[str, Any] = dict(external or {}) - self.current_event: Event | None = None - self._pending_terminate = False - self._last_target: str | None = None # target of the last transition (§14) - if auto_enter: - self._enter_top() - - # --- creation ----------------------------------------------------------- - def _enter_top(self) -> None: - top = self.machine.top - self._enter_state(top) - self.descend(top) - - # --- esv lifecycle ------------------------------------------------------ - def init_esvs(self, state: State) -> None: - esvs = state.raw.get("esvs") - if not esvs: - return - # A submachine root seeds its `external` esvs from `with:` (CEL over the parent - # scope), not from the root instance's external map (§5.6.1). - seeded: dict[str, Any] = {} - if state.is_sm_boundary and state.sm_with and state.parent is not None: - parent_scope = self.scope(state.parent, self.current_event) - seeded = {k: cel.evaluate(v, parent_scope) for k, v in state.sm_with.items()} - live: dict[str, Any] = {} - for var, decl in esvs.items(): - if decl.get("external"): - if var in seeded: - live[var] = seeded[var] - else: - live[var] = self.external.get(var, decl.get("init")) - elif "init" in decl: - live[var] = decl["init"] - else: - live[var] = None - self.esv_values[state.path] = live - - def destroy_esvs(self, state: State) -> None: - self.esv_values.pop(state.path, None) - - def _scope_chain(self, root: State) -> list[State]: - # A submachine has an isolated esv scope (§5.6.1): the chain stops at the - # submachine root — the parent's esvs are not visible inside it. - chain = [root] - cur = root - while not cur.is_sm_boundary and cur.parent is not None: - cur = cur.parent - chain.append(cur) - if cur.is_sm_boundary: - break - return chain - - def scope(self, root: State, event: Event | None) -> dict[str, Any]: - """Resolved in-scope bindings for a guard/action (inner shadows outer).""" - bindings: dict[str, Any] = {} - for s in reversed(self._scope_chain(root)): # outermost first - live = self.esv_values.get(s.path) - if live: - bindings.update(live) - bindings["id"] = self.id - bindings["parent"] = self.parent_id - ev = {"type": "", "payload": {}} - if event is not None: - ev = {"type": event.type, "payload": event.payload or {}} - bindings["event"] = ev - return bindings - - def assign_esv(self, root: State, name: str, value: Any) -> None: - cur: State | None = root - while cur is not None: - if name in cur.declares_esvs: - live = self.esv_values.get(cur.path) - if live is None: - raise DetermaError(f"esv '{name}' not live") - decl = cur.raw["esvs"][name] - if decl.get("external"): - raise DetermaError(f"external esv '{name}' is read-only") - if not values.matches(value, decl["type"]): - raise DetermaError(f"'{name}' must be {decl['type']}") - live[name] = value - return - if cur.is_sm_boundary: # do not assign across the submachine isolation boundary - break - cur = cur.parent - raise DetermaError(f"no in-scope esv '{name}' to assign") - - # --- actions ------------------------------------------------------------ - def run_actions(self, actions: list[dict[str, Any]], root: State, event: Event | None) -> None: - for action in actions: - self.run_action(action, root, event) - - def run_action(self, action: dict[str, Any], root: State, event: Event | None) -> None: - if "assign" in action: - scope = None - for var, expr in action["assign"].items(): - scope = self.scope(root, event) if scope is None else scope - self.assign_esv(root, var, cel.evaluate(expr, scope)) - return - if "publish" in action: - self.host.publish(self, action["publish"], root, event) - return - if "spawn" in action: - self.host.spawn_action(self, action["spawn"], root, event) - return - if "refresh" in action: - self.host.refresh(self, action["refresh"], event) - return - if "stop" in action: - self.host.stop(self) - return - raise DetermaError(f"unknown action: {action}") - - def run_entry(self, state: State) -> None: - self.run_actions(state.raw.get("entry") or [], state, self.current_event) - - def run_exit(self, state: State) -> None: - self.run_actions(state.raw.get("exit") or [], state, self.current_event) - - # --- configuration queries --------------------------------------------- - def active_leaves(self) -> list[State]: - out: list[State] = [] - for path in self.config: - s = self.machine.by_path[path] - if not any(c.path in self.config for c in s.children.values()): - out.append(s) - return out - - def active_leaf_names(self) -> list[str]: - return sorted(s.name for s in self.active_leaves()) - - def effective_defer_set(self) -> set[str]: - out: set[str] = set() - for path in self.config: - out.update(self.machine.by_path[path].raw.get("defer") or []) - return out - - def enabled_events(self) -> list[str]: - """Declared event types handled by the current active configuration (§14).""" - declared = set(self.machine.definition.raw.get("events") or {}) - enabled: set[str] = set() - for leaf in self.active_leaves(): - cur: State | None = leaf - while cur is not None: - for event_type in cur.raw.get("on_events") or {}: - if ( - event_type in declared - and event_type not in RESERVED_LIFECYCLE_EVENTS - ): - enabled.add(event_type) - cur = cur.parent - return sorted(enabled) - - def resolved_esvs(self) -> dict[str, Any]: - """In-scope esv values resolved from the active leaf (as a guard reads).""" - leaves = self.active_leaves() - root = leaves[0] if leaves else self.machine.top - bindings: dict[str, Any] = {} - for s in reversed(self._scope_chain(root)): - live = self.esv_values.get(s.path) - if live: - bindings.update(live) - return bindings - - # --- dispatch ----------------------------------------------------------- - def _search_up(self, leaf: State, event: Event) -> tuple[State, dict[str, Any]] | None: - """Find the first passing handler from ``leaf`` up the parent chain.""" - cur: State | None = leaf - while cur is not None: - spec = (cur.raw.get("on_events") or {}).get(event.type) - if spec is not None: - chosen = self._select(spec, cur, event) - if chosen is not None: - return cur, chosen - cur = cur.parent - return None - - def _collect_enabled(self, event: Event) -> list[tuple[State, dict[str, Any]]]: - """One enabled transition per active leaf, deduplicated by identity. - - Orthogonal regions are independent: an event is offered to every region - in declared order. A handler reached at a shared ancestor (e.g. the - orthogonal state's own ``done``) is found via multiple leaves but run - once (dedup by transition object identity). - """ - enabled: list[tuple[State, dict[str, Any]]] = [] - seen: set[int] = set() - for leaf in sorted(self.active_leaves(), key=lambda s: s.order): - found = self._search_up(leaf, event) - if found is not None and id(found[1]) not in seen: - seen.add(id(found[1])) - enabled.append(found) - return enabled - - def _select( - self, spec: Any, owner: State, event: Event - ) -> dict[str, Any] | None: - transitions: list[dict[str, Any]] = spec if isinstance(spec, list) else [spec] - for t in transitions: - guard = t.get("guard") - if guard is None: - return t - if cel.evaluate(guard, self.scope(owner, event)): - return t - return None - - def dispatch(self, event: Event) -> bool: - """Run one RTC step. Returns whether the active-leaf config changed.""" - self.current_event = event - if event.after is not None: - return self._dispatch_after(event) - enabled = self._collect_enabled(event) - if not enabled: - if event.type in self.effective_defer_set(): - self.deferred.append(event) - self.current_event = None - return False - before = self.active_leaf_names() - before_complete = self._complete_composites() - for owner, transition in enabled: - self.run_transition(owner, transition, event) - self._completion(before_complete) - self.current_event = None - return before != self.active_leaf_names() - - def _dispatch_after(self, event: Event) -> bool: - """Fire a due `after` timer as a transition owned by its state (§5.9).""" - assert event.after is not None - state_path, spec = event.after - state = self.machine.by_path.get(state_path) - self.current_event = event - if state is None or state_path not in self.config: - self.current_event = None - return False # stale timer (state exited) - guard = spec.get("guard") - if guard is not None and not cel.evaluate(guard, self.scope(state, event)): - self.current_event = None - return False - before = self.active_leaf_names() - before_complete = self._complete_composites() - self.run_transition(state, spec, event) - self._completion(before_complete) - self.current_event = None - return before != self.active_leaf_names() - - def _completion(self, before_complete: set[str]) -> None: - """Enqueue `done` for newly-complete composites / flag termination.""" - for path in self._complete_composites() - before_complete: - if path == "top": - # top reached final: a spawned instance terminates (done -> its - # parent); the root has no parent and rests in the final state. - if self.parent_id is not None: - self._pending_terminate = True - else: - self.queue.append(Event("done", {"state": self._leaf_name(path)})) - - def run_transition( - self, owner: State, transition: dict[str, Any], event: Event - ) -> None: - target_ref = transition.get("transition_to") - actions = transition.get("action") or [] - if target_ref is None: - # internal transition: actions only, no exit/entry (SPEC §5.5) - self._last_target = None - self.run_actions(actions, owner, event) - return - target = self.machine.resolve_target(owner, target_ref) - if target.type == "choice": - # Dynamic branching (§5.5.1): run the triggering action, then resolve the - # choice chain in the SOURCE scope (branch guards see the just-assigned - # esvs), then execute as an external transition to the real target. - self.run_actions(actions, owner, event) - target = self._resolve_choice(target, owner, event) - self._last_target = target.name - lca = self.machine.lca(owner, target) - self.exit_states(owner, lca, False) - self.enter_to(lca, target) - return - self._last_target = target.name - local = bool(transition.get("local")) - if local: - lca = owner # the containing composite is not exited/re-entered - else: - lca = self.machine.lca(owner, target) - self.exit_states(owner, lca, local) - self.run_actions(actions, owner, event) - self.enter_to(lca, target) - - def _resolve_choice(self, node: State, owner: State, event: Event) -> State: - """Resolve a choice pseudostate chain to a real target state (SPEC §5.5.1). - - Branches are tried in order (first passing guard, or the unguarded default); - the chosen branch's action runs in the source scope; chained choices repeat. - """ - seen: set[str] = set() - while node.type == "choice": - if node.path in seen: - raise DetermaError(f"cyclic choice '{node.name}'") - seen.add(node.path) - chosen: dict[str, Any] | None = None - for br in node.raw.get("choice") or []: - guard = br.get("guard") - if guard is None or cel.evaluate(guard, self.scope(owner, event)): - chosen = br - break - if chosen is None: - raise DetermaError(f"choice '{node.name}' has no matching branch") - self.run_actions(chosen.get("action") or [], owner, event) - node = self.machine.resolve_target(node, chosen["transition_to"]) - return node - - # --- completion --------------------------------------------------------- - def _complete_composites(self) -> set[str]: - """Active composite/orthogonal states whose region(s) all reached final.""" - out: set[str] = set() - for path in self.config: - s = self.machine.by_path[path] - if self._state_complete(s): - out.add(path) - return out - - def _state_complete(self, state: State) -> bool: - if state.type == "orthogonal": - regions = state.raw.get("regions") or [] - return bool(regions) and all( - self._region_final(state, i) for i in range(len(regions)) - ) - if state.type == "composite": - leaf = self._composite_leaf(state) - return leaf is not None and leaf.type == "final" - return False - - def _composite_leaf(self, composite: State) -> State | None: - for leaf in self.active_leaves(): - if self._descendant_or_self(leaf, composite): - return leaf - return None - - def _region_final(self, ortho: State, region_idx: int) -> bool: - leaf = self._region_leaf(ortho, region_idx) - return leaf is not None and leaf.type == "final" - - def _region_leaf(self, ortho: State, region_idx: int) -> State | None: - for leaf in self.active_leaves(): - if leaf.region_index == region_idx and self._descendant_or_self(leaf, ortho): - return leaf - return None - - @staticmethod - def _leaf_name(path: str) -> str: - return path.rsplit(".", 1)[-1] - - # --- entry / exit (PSiCC ordering) ------------------------------------- - def exit_states(self, owner: State, lca: State, local: bool) -> None: - """Exit the source-root subtree (confined to the owner's region), innermost - first. For external transitions the exit root is the state just below the - LCA on the owner's path; for local transitions it is the owner's proper - descendants (the owner itself is not re-entered). History of any exited - history-state is recorded first, while the configuration is intact. - """ - to_exit = sorted( - self._exit_set(owner, lca, local), - key=lambda s: (-s.depth, s.order), - ) - for s in to_exit: - self._record_history(s) - exited = {s.path for s in to_exit} - for s in to_exit: # deepest first - self.run_exit(s) - self.destroy_esvs(s) - self.config.discard(s.path) - # exiting a state cancels its outstanding timers (SPEC §5.9) - if exited: - self.timers = [t for t in self.timers if t["state_path"] not in exited] - - def _exit_set(self, owner: State, lca: State, local: bool) -> list[State]: - states = [self.machine.by_path[p] for p in self.config] - # When the transition is local, or its source *is* the LCA (a transition - # owned by the root composite targeting one of its children), only the - # LCA's proper descendants are exited — the LCA itself stays put. - if local or owner is lca: - return [s for s in states if self._strict_descendant(s, lca)] - exit_root = self._source_root(owner, lca) - return [s for s in states if self._descendant_or_self(s, exit_root)] - - @staticmethod - def _source_root(owner: State, lca: State) -> State: - """The state just below ``lca`` on the path to ``owner``.""" - cur: State = owner - while cur.parent is not None and cur.parent is not lca: - cur = cur.parent - return cur - - @staticmethod - def _descendant_or_self(state: State, root: State) -> bool: - cur: State | None = state - while cur is not None: - if cur is root: - return True - cur = cur.parent - return False - - @staticmethod - def _strict_descendant(state: State, root: State) -> bool: - return state is not root and Instance._descendant_or_self(state, root) - - def enter_to(self, lca: State, target: State) -> None: - path: list[State] = [] - cur: State | None = target - while cur is not None and cur is not lca: - path.append(cur) - cur = cur.parent - for s in reversed(path): # outermost first - self._enter_state(s) - self.descend(target) - - def descend(self, state: State) -> None: - if state.type == "composite": - if self._restore_history(state): - return - initial = state.raw["initial"] - self.run_actions(initial.get("action") or [], state, self.current_event) - tgt = self.machine.resolve_target(state, initial["transition_to"]) - self._enter_state(tgt) - self.descend(tgt) - elif state.type == "orthogonal": - if self._restore_history(state): - return - for region in state.raw.get("regions") or []: - initial = region["initial"] - self.run_actions(initial.get("action") or [], state, self.current_event) - tgt = self.machine.resolve_target(state, initial["transition_to"]) - self._enter_state(tgt) - self.descend(tgt) - - def _enter_state(self, state: State) -> None: - """Enter one state: activate, init esvs, run entry, arm `after` timers.""" - self.config.add(state.path) - self.init_esvs(state) - self.run_entry(state) - self._arm_timers(state) - - def _arm_timers(self, state: State) -> None: - for spec in state.raw.get("after") or []: - self.timers.append( - { - "fire_at": self.host.now + _duration_ms(spec["duration"]), - "state_path": state.path, - "spec": spec, - "seq": self.host.next_seq(), - } - ) - - # --- history (SPEC §5.6) ------------------------------------------------ - def _record_history(self, state: State) -> None: - kind = state.raw.get("history", "none") - if kind == "none": - return - if kind == "deep": - sub = [ - self.machine.by_path[p].path - for p in self.config - if self._strict_descendant(self.machine.by_path[p], state) - ] - self.history[state.path] = ("deep", sub) - elif kind == "shallow": - child = next( - ( - p - for p in self.config - if self.machine.by_path[p].parent is state - ), - None, - ) - self.history[state.path] = ("shallow", child) - - def _restore_history(self, state: State) -> bool: - """Re-enter a recorded configuration; return False to take the initial.""" - record = self.history.get(state.path) - if record is None: - return False - kind, data = record - if kind == "deep" and data: - states = sorted( - (self.machine.by_path[p] for p in data), - key=lambda s: (s.depth, s.order), - ) - for s in states: # outermost first - self.config.add(s.path) - self.init_esvs(s) - self.run_entry(s) - return True - if kind == "shallow" and data: - child = self.machine.by_path[data] - self._enter_state(child) - self.descend(child) - return True - return False - - # --- defer + RTC step --------------------------------------------------- - def step(self, event: Event) -> None: - # An RTC step is atomic: if an action faults, abort and roll back (§5.10). - snapshot = self._snapshot() - try: - changed = self.dispatch(event) - except (CelError, DetermaError) as exc: - self._restore(snapshot) - self._handle_fault(event, exc) - return - if changed: - self._undefer() - if self._pending_terminate: - self._pending_terminate = False - self.host.terminate(self) - - # --- faults (SPEC §5.10) ------------------------------------------------ - def _snapshot(self) -> dict[str, Any]: - return { - "config": set(self.config), - "esvs": {p: dict(v) for p, v in self.esv_values.items()}, - "history": dict(self.history), - "deferred": deque(self.deferred), - "timers": list(self.timers), - "pub": len(self.host.published), - "sp": len(self.host.spawned), - "instances": set(self.host.instances.keys()), - } - - def _restore(self, snap: dict[str, Any]) -> None: - self.config = set(snap["config"]) - self.esv_values = {p: dict(v) for p, v in snap["esvs"].items()} - self.history = dict(snap["history"]) - self.deferred = deque(snap["deferred"]) - self.timers = list(snap["timers"]) - del self.host.published[snap["pub"]:] - del self.host.spawned[snap["sp"]:] - for iid in list(self.host.instances): - if iid not in snap["instances"]: - del self.host.instances[iid] - - def _handle_fault(self, event: Event, exc: Exception) -> None: - self.dead_letter.append({"event": event.type, "error": str(exc)}) - log.warning("dead-letter instance=%s event=%s: %s", self.id, event.type, exc) - error_event = Event("error", {"event": event.type, "error": str(exc)}) - # If some active state handles the reserved `error` event, dispatch it - # (the instance recovers); otherwise the instance faults. - self.current_event = error_event - handled = bool(self._collect_enabled(error_event)) - self.current_event = None - if not handled: - log.warning("instance %s faulted: no handler for the error event", self.id) - self.status = Status.FAULTED - return - snapshot = self._snapshot() - try: - changed = self.dispatch(error_event) - except (CelError, DetermaError): - self._restore(snapshot) - log.warning("instance %s faulted: error handler itself faulted", self.id) - self.status = Status.FAULTED - return - if changed: - self._undefer() - if self._pending_terminate: - self._pending_terminate = False - self.host.terminate(self) - - # --- termination (SPEC §5.7) ------------------------------------------- - def to_snapshot(self) -> dict[str, Any]: - """Serialize the instance (SPEC §8); JSON/YAML-representable.""" - return { - "def_id": self.machine.id, - "def_version": self.machine.version, - "id": self.id, - "parent_id": self.parent_id, - "status": self.status.value, - "state_config": sorted(self.config), - "esvs": {p: dict(v) for p, v in self.esv_values.items()}, - "queue": [self._event_to_snap(e) for e in self.queue], - "deferred": [self._event_to_snap(e) for e in self.deferred], - "timers": [ - {"fire_at": t["fire_at"], "state_path": t["state_path"], "spec": t["spec"]} - for t in self.timers - ], - "dead_letter": list(self.dead_letter), - "history": {p: {"kind": k, "data": d} for p, (k, d) in self.history.items()}, - } - - def load_snapshot(self, snap: dict[str, Any]) -> None: - self.status = Status(snap["status"]) - self.config = set(snap["state_config"]) - self.esv_values = {p: dict(v) for p, v in snap["esvs"].items()} - self.queue = deque(self._snap_to_event(e) for e in snap["queue"]) - self.deferred = deque(self._snap_to_event(e) for e in snap["deferred"]) - self.timers = [dict(t) for t in snap["timers"]] - self.dead_letter = list(snap["dead_letter"]) - self.history = { - p: (rec["kind"], rec["data"]) for p, rec in snap["history"].items() - } - - @staticmethod - def _event_to_snap(event: Event) -> dict[str, Any]: - out: dict[str, Any] = {"type": event.type, "payload": event.payload} - if event.after is not None: - state_path, spec = event.after - out["after"] = [state_path, spec] - return out - - @staticmethod - def _snap_to_event(snap: dict[str, Any]) -> Event: - after = None - if snap.get("after") is not None: - after = (snap["after"][0], snap["after"][1]) - return Event(snap["type"], snap.get("payload"), after=after) - - def terminate_exits(self) -> None: - """Run exit actions up the active tree, innermost first.""" - states = sorted( - (self.machine.by_path[p] for p in self.config), - key=lambda s: (-s.depth, s.order), - ) - for s in states: - self.run_exit(s) - self.destroy_esvs(s) - self.config.clear() - - # --- external esvs / refresh (SPEC §5.4) -------------------------------- - def refresh_external(self, name: str, value: Any) -> None: - """Adopt a host change into the in-scope external esv ``name``.""" - for path in self.config: - s = self.machine.by_path[path] - decl = (s.raw.get("esvs") or {}).get(name) - if decl is not None and decl.get("external"): - live = self.esv_values.get(path) - if live is not None: - live[name] = value - return - raise DetermaError(f"no external esv '{name}' to refresh") - - def _undefer(self) -> None: - """Edge-triggered: on a config change, reinsert no-longer-deferred events - at the front of the queue (SPEC §5.8).""" - if not self.deferred: - return - current = self.effective_defer_set() - still: deque[Event] = deque() - moved: deque[Event] = deque() - for ev in self.deferred: - if ev.type in current: - still.append(ev) - else: - moved.append(ev) - self.deferred = still - self.queue.extendleft(reversed(moved)) diff --git a/src/determa/state/model.py b/src/determa/state/model.py index 5a6045e..b9d6b16 100644 --- a/src/determa/state/model.py +++ b/src/determa/state/model.py @@ -1,278 +1,182 @@ -"""Resolved machine model — a navigable state tree built from a Definition. - -The raw validated YAML (``Definition.raw``) is the source of structure; this -module adds parent links, lookup, dotted-target resolution, and reference -validation so the engine can dispatch and transition (SPEC §4.5, §5.5). - -State types are inferred when not stated: ``top`` (and any state with -``states``) is ``composite``; a state with ``regions`` is ``orthogonal``; -otherwise ``simple`` (or ``final`` if declared). -""" +"""Resolved format-1 bundle and state-tree model.""" from __future__ import annotations -import copy from dataclasses import dataclass, field from typing import Any -from .definition import Definition -from .errors import ErrorRecord, ValidationError - -TYPE_ORDER = ("simple", "composite", "orthogonal", "final") - - -def inline_submachines( - node: dict[str, Any], registry: dict[str, dict[str, Any]], stack: frozenset[str] = frozenset() -) -> dict[str, Any]: - """Resolve ``submachine`` references into an inlined state tree (SPEC §5.6.1). - - A state with ``submachine: `` becomes a composite whose single child is the - referenced definition's ``top`` (recursively inlined), marked as an esv-scope - boundary and carrying the ``with:`` seeding. ``registry`` maps definition id -> its - raw ``top``. Raises on an unknown or cyclic reference. - """ - if not isinstance(node, dict): - return node - if "submachine" in node: - sub_id = node["submachine"] - if sub_id not in registry: - raise ValidationError( - [ErrorRecord(path="/submachine", message=f"unknown submachine '{sub_id}'")] - ) - if sub_id in stack: - raise ValidationError( - [ErrorRecord(path="/submachine", message=f"cyclic submachine '{sub_id}'")] - ) - child = copy.deepcopy(inline_submachines(registry[sub_id], registry, stack | {sub_id})) - child["_sm_root"] = True - child["_sm_with"] = dict(node.get("with") or {}) - out = {k: v for k, v in node.items() if k not in ("submachine", "with")} - out["states"] = {sub_id: child} - out["initial"] = {"transition_to": sub_id} - return out - def _inline_states(states: dict[str, Any]) -> dict[str, Any]: - return {k: inline_submachines(v, registry, stack) for k, v in states.items()} - - out = dict(node) - if isinstance(node.get("states"), dict): - out["states"] = _inline_states(node["states"]) - if isinstance(node.get("regions"), list): - out["regions"] = [ - {**r, "states": _inline_states(r.get("states") or {})} if isinstance(r, dict) else r - for r in node["regions"] - ] - return out +from .definition import Bundle, _escape_pointer +from .errors import ValidationError @dataclass -class State: +class StateNode: + """One named active state or choice pseudostate.""" + name: str - path: str # dotted from top, e.g. "top.work.step1"; "top" for the root - parent: State | None - type: str - depth: int - order: int # document/DFS order (stable region + declaration ordering) + path: str + pointer: str raw: dict[str, Any] - meta: dict[str, Any] = field(default_factory=dict) - children: dict[str, State] = field(default_factory=dict) - declares_esvs: set[str] = field(default_factory=set) - region_index: int | None = None # 0-based region for orthogonal substates - is_sm_boundary: bool = False # root of an inlined submachine (esv-scope boundary, §5.6.1) - sm_with: dict[str, Any] = field(default_factory=dict) # `with:` seeding for a submachine root - - -def _infer_type(raw: dict[str, Any]) -> str: - declared = raw.get("type") - if isinstance(declared, str): - return declared - if "choice" in raw: - return "choice" # a transient pseudostate (SPEC §5.5.1) - if "regions" in raw: - return "orthogonal" - if "states" in raw: - return "composite" - return "simple" - - -class Machine: - """A resolved machine definition (navigable state tree + lookups).""" - - def __init__(self, definition: Definition, top_override: dict[str, Any] | None = None) -> None: - self.definition = definition - self.id = definition.id - self.version = definition.version - self.format = definition.format - self.meta = dict(definition.raw.get("meta") or {}) - self._counter = 0 - top_raw = top_override if top_override is not None else definition.top - self.top = self._build("top", "top", None, 0, top_raw, None) - self.by_path: dict[str, State] = {} - self._index(self.top) - self._validate_references() + parent: StateNode | None + order: int + children: dict[str, StateNode] = field(default_factory=dict) + + @property + def type(self) -> str: + if "choice" in self.raw: + return "choice" + return str(self.raw.get("type", "simple")) + + @property + def is_choice(self) -> bool: + return self.type == "choice" + + def ancestors(self, *, include_self: bool = False) -> list[StateNode]: + result: list[StateNode] = [self] if include_self else [] + current = self.parent + while current is not None: + result.append(current) + current = current.parent + return result + + def is_ancestor_of(self, other: StateNode, *, strict: bool = False) -> bool: + if not strict and self is other: + return True + return self in other.ancestors() + + +class MachineModel: + """Resolved state tree for one bundle machine or inline component root.""" + + def __init__( + self, + bundle: Bundle, + raw: dict[str, Any], + *, + machine_index: int, + root: dict[str, Any] | None = None, + root_pointer: str | None = None, + identity_machine: dict[str, Any] | None = None, + ) -> None: + self.bundle = bundle + self.raw = raw + self.machine_index = machine_index + self.machine_id = str(raw["machine_id"]) + self.version = int(raw["version"]) + self.identity_machine = identity_machine or raw + self.root_pointer = root_pointer or f"/machines/{machine_index}/root" + self._order = 0 + self.root = self._build( + "root", + "root", + self.root_pointer, + root if root is not None else raw["root"], + None, + ) + self.states: dict[str, StateNode] = {} + self._index(self.root) - # --- construction ------------------------------------------------------- def _build( self, name: str, path: str, - parent: State | None, - depth: int, + pointer: str, raw: dict[str, Any], - region_index: int | None, - ) -> State: - order = self._counter - self._counter += 1 - state = State( + parent: StateNode | None, + ) -> StateNode: + state = StateNode( name=name, path=path, - parent=parent, - type=_infer_type(raw), - depth=depth, - order=order, + pointer=pointer, raw=raw, - meta=dict(raw.get("meta") or {}), - region_index=region_index, + parent=parent, + order=self._order, ) - esvs = raw.get("esvs") or {} - state.declares_esvs = set(esvs.keys()) - state.is_sm_boundary = bool(raw.get("_sm_root")) - if isinstance(raw.get("_sm_with"), dict): - state.sm_with = raw["_sm_with"] - for cname, cdef in (raw.get("states") or {}).items(): - state.children[cname] = self._build( - cname, f"{path}.{cname}", state, depth + 1, cdef, region_index + self._order += 1 + for child_name, child_raw in (raw.get("states") or {}).items(): + child_path = child_name if path == "root" else f"{path}.{child_name}" + child_pointer = f"{pointer}/states/{_escape_pointer(child_name)}" + state.children[child_name] = self._build( + child_name, child_path, child_pointer, child_raw, state ) - for i, region in enumerate(raw.get("regions") or []): - for cname, cdef in (region.get("states") or {}).items(): - state.children[cname] = self._build( - cname, f"{path}.{cname}", state, depth + 1, cdef, i - ) return state - def _index(self, state: State) -> None: - self.by_path[state.path] = state + def _index(self, state: StateNode) -> None: + if state.path in self.states: + raise ValidationError("semantic_validation", path=state.pointer) + self.states[state.path] = state for child in state.children.values(): self._index(child) - # --- navigation --------------------------------------------------------- - def proper_ancestors(self, state: State) -> list[State]: - """Ancestors excluding ``state`` itself, nearest first, up to ``top``.""" - out: list[State] = [] - cur = state.parent - while cur is not None: - out.append(cur) - cur = cur.parent - return out - - def lca(self, a: State, b: State) -> State: - """Least common *proper* ancestor of ``a`` and ``b`` (never a/b itself).""" - anc_a = {s.path for s in self.proper_ancestors(a)} - for x in self.proper_ancestors(b): # nearest first - if x.path in anc_a: - return x - return self.top - - def resolve_target(self, source: State, ref: str) -> State: - """Resolve a dotted ``transition_to`` reference (SPEC §4.6). - - The first component is found by searching from ``source`` upward (a - state may reference its own children, siblings, or outer states); the - remaining components descend from there. - """ - parts = ref.split(".") - anchor: State | None = None - cur: State | None = source - while cur is not None: - if parts[0] in cur.children: - anchor = cur.children[parts[0]] - break - cur = cur.parent - if anchor is None: - raise KeyError(ref) - node = anchor - for p in parts[1:]: - if p not in node.children: - raise KeyError(ref) - node = node.children[p] - return node + def resolve(self, target: str | dict[str, str], source: StateNode | None = None) -> StateNode: + path = target["history"] if isinstance(target, dict) else target + if path == "root": + return self.root + if path in self.states: + return self.states[path] + parts = path.split(".") + current = source + while current is not None: + if parts[0] in current.children: + resolved = current.children[parts[0]] + for part in parts[1:]: + if part not in resolved.children: + break + resolved = resolved.children[part] + else: + return resolved + current = current.parent + try: + return self.states[path] + except KeyError as exc: + raise ValidationError("semantic_validation", message=f"unknown state {path}") from exc + + def leaves_under(self, state: StateNode) -> list[StateNode]: + return [ + candidate + for candidate in self.states.values() + if not candidate.is_choice + and not candidate.children + and state.is_ancestor_of(candidate) + ] - def find_by_name(self, name: str) -> State | None: - """First state with the given simple name (migration state_map lookup).""" - for state in self.by_path.values(): - if state.name == name: - return state - return None + def definition_identity(self) -> tuple[str, str, int]: + return ( + self.bundle.namespace, + str(self.identity_machine["machine_id"]), + int(self.identity_machine["version"]), + ) - # --- static checks ------------------------------------------------------ - def _validate_references(self) -> None: - errors: list[ErrorRecord] = [] - for state in self.by_path.values(): - refs: list[tuple[str, str]] = [] # (ref, path) - initial = state.raw.get("initial") - if isinstance(initial, dict) and "transition_to" in initial: - refs.append((initial["transition_to"], f"{state.path}/initial")) - for ev, spec in (state.raw.get("on_events") or {}).items(): - for t in _as_transition_list(spec): - if "transition_to" in t: - refs.append((t["transition_to"], f"{state.path}/on_events/{ev}")) - for after in state.raw.get("after") or []: - if "transition_to" in after: - refs.append((after["transition_to"], f"{state.path}/after")) - for i, br in enumerate(state.raw.get("choice") or []): - if "transition_to" in br: - refs.append((br["transition_to"], f"{state.path}/choice/{i}")) - for ref, where in refs: - try: - self.resolve_target(state, ref) - except KeyError: - errors.append( - ErrorRecord( - path=f"/top/{where}/transition_to", - message=f"unresolved target '{ref}' from '{state.name}'", - ) - ) - errors.extend(self._choice_cycle_errors()) - if errors: - raise ValidationError(errors) - def _choice_cycle_errors(self) -> list[ErrorRecord]: - """Choices reachable via `transition_to` MUST be acyclic (§5.5.1).""" - errors: list[ErrorRecord] = [] - for state in self.by_path.values(): - if state.type != "choice": - continue - seen: set[str] = set() - node: State | None = state - while node is not None and node.type == "choice": - if node.path in seen: - errors.append( - ErrorRecord( - path=f"/top/{state.path}/choice", - message=f"cyclic choice reachable from '{state.name}'", - ) - ) - break - seen.add(node.path) - # follow the default (else) branch — a cycle on any branch shows here - # because every branch target is itself checked as a choice root. - branches = node.raw.get("choice") or [] - nxt = None - for br in branches: - if "transition_to" in br: - try: - cand = self.resolve_target(node, br["transition_to"]) - except KeyError: - continue - if cand.type == "choice": - nxt = cand - break - node = nxt - return errors +class BundleModel: + """Resolved models for all same-bundle machine definitions.""" + def __init__(self, bundle: Bundle) -> None: + self.bundle = bundle + self.machines: dict[str, MachineModel] = {} + for index, raw in enumerate(bundle.raw["machines"]): + machine_id = str(raw["machine_id"]) + if machine_id in self.machines: + raise ValidationError("semantic_validation", message="duplicate machine_id") + self.machines[machine_id] = MachineModel(bundle, raw, machine_index=index) -def _as_transition_list(spec: Any) -> list[dict[str, Any]]: - if isinstance(spec, list): - return spec - if isinstance(spec, dict): - return [spec] - return [] + def machine(self, machine_id: str) -> MachineModel: + try: + return self.machines[machine_id] + except KeyError as exc: + raise ValidationError( + "semantic_validation", message=f"unknown machine {machine_id}" + ) from exc + + def inline_component( + self, owner: MachineModel, placement: dict[str, Any], placement_pointer: str + ) -> MachineModel: + root = placement["root"] + return MachineModel( + self.bundle, + owner.raw, + machine_index=owner.machine_index, + root=root, + root_pointer=f"{placement_pointer}/root", + identity_machine=owner.identity_machine, + ) diff --git a/src/determa/state/observer.py b/src/determa/state/observer.py deleted file mode 100644 index 6050ea8..0000000 --- a/src/determa/state/observer.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Observer adapter (SPEC §8): a passive per-step callback. - -When a :class:`~determa.state.engine.Host` is given an observer, it is invoked once per -completed RTC step — for both automatic (run-to-quiescence) and manual (``step``) -processing — with a record:: - - { instance, event, transition, entered, exited, published, spawned, faulted } - -An observer is purely observational: it MUST NOT mutate engine state or influence -dispatch. It is the spec-native mechanism for transition logging and live -visualization, distinct from host-language diagnostic logging (``logging``). -""" - -from __future__ import annotations - -import json -from collections.abc import Callable -from typing import Any, TextIO - -# An observer is any callable taking one per-step record. -Observer = Callable[[dict[str, Any]], None] - - -class JsonlObserver: - """Write one JSON record per line — a drop-in transition log. - - >>> import sys - >>> host = Host(observer=JsonlObserver(sys.stdout)) # doctest: +SKIP - """ - - def __init__(self, stream: TextIO) -> None: - self._stream = stream - - def __call__(self, record: dict[str, Any]) -> None: - self._stream.write(json.dumps(record) + "\n") - self._stream.flush() - - -class CollectingObserver: - """Collect records into ``.records`` — handy for tests and inspection.""" - - def __init__(self) -> None: - self.records: list[dict[str, Any]] = [] - - def __call__(self, record: dict[str, Any]) -> None: - self.records.append(record) diff --git a/src/determa/state/store.py b/src/determa/state/store.py deleted file mode 100644 index 116a86e..0000000 --- a/src/determa/state/store.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Store backends for the CLI (SPEC §8, §13.1). - -A store holds the registered definitions, instance snapshots, the virtual clock, -and the processing mode (§14). It is selected by a ``--store `` scheme: - -- ``file:`` (or a bare ````) — JSON snapshot files under a directory. - **Default** (``./.determa``). -- ``mem:`` — in-memory, ephemeral; only meaningful within a single process - (e.g. one ``run`` batch/streaming session, §13.7, or a test). -- ``sqlite:`` — a single-file SQLite database. - -All backends are behaviorally identical (same CLI results, same snapshot JSON, §8); -the on-disk/in-memory layout is an implementation detail. ``open_store(spec)`` -parses the scheme. -""" - -from __future__ import annotations - -import abc -import copy -import json -import sqlite3 -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -_SCHEMES = {"file", "mem", "sqlite"} - - -@dataclass -class StoreState: - defs: dict[str, str] = field(default_factory=dict) # "id@version" -> yaml text - instances: list[dict[str, Any]] = field(default_factory=list) - now: int = 0 - spawn_counters: dict[str, int] = field(default_factory=dict) - mode: str = "auto" # processing mode, auto|manual (SPEC §14) - - -def _state_from_parts( - defs: dict[str, Any] | None, - instances: list[dict[str, Any]] | None, - meta: dict[str, Any] | None, -) -> StoreState: - meta = meta or {} - return StoreState( - defs=defs or {}, - instances=instances or [], - now=int(meta.get("now", 0)), - spawn_counters=dict(meta.get("spawn_counters") or {}), - mode=str(meta.get("mode", "auto")), - ) - - -def _meta_json(state: StoreState) -> dict[str, Any]: - return {"now": state.now, "spawn_counters": state.spawn_counters, "mode": state.mode} - - -class Store(abc.ABC): - """The store adapter interface (SPEC §8): load/save a ``StoreState``.""" - - @abc.abstractmethod - def load(self) -> StoreState: ... - - @abc.abstractmethod - def save(self, state: StoreState) -> None: ... - - -class FileStore(Store): - """JSON snapshot files under a directory (``file:`` / bare ````).""" - - def __init__(self, path: str | Path) -> None: - self.path = Path(path) - - def _ensure(self) -> None: - self.path.mkdir(parents=True, exist_ok=True) - - def _read_json(self, name: str) -> Any: - p = self.path / name - if not p.exists(): - return None - return json.loads(p.read_text(encoding="utf-8")) - - def load(self) -> StoreState: - return _state_from_parts( - self._read_json("defs.json"), - self._read_json("instances.json"), - self._read_json("meta.json"), - ) - - def save(self, state: StoreState) -> None: - self._ensure() - (self.path / "defs.json").write_text( - json.dumps(state.defs, indent=2), encoding="utf-8" - ) - (self.path / "instances.json").write_text( - json.dumps(state.instances, indent=2), encoding="utf-8" - ) - (self.path / "meta.json").write_text( - json.dumps(_meta_json(state), indent=2), encoding="utf-8" - ) - - -class MemoryStore(Store): - """In-process, ephemeral store (``mem:``); not shared across processes.""" - - def __init__(self) -> None: - self._state: StoreState = StoreState() - - def load(self) -> StoreState: - return copy.deepcopy(self._state) - - def save(self, state: StoreState) -> None: - self._state = copy.deepcopy(state) - - -class SqliteStore(Store): - """A single-file SQLite database (``sqlite:``); ``sqlite3`` is stdlib.""" - - def __init__(self, path: str | Path) -> None: - self.path = Path(path) - if self.path.parent and str(self.path.parent) not in ("", "."): - self.path.parent.mkdir(parents=True, exist_ok=True) - self._conn = sqlite3.connect(str(self.path)) - self._conn.execute( - "CREATE TABLE IF NOT EXISTS determa_state (" - " key TEXT PRIMARY KEY," - " value TEXT NOT NULL" - ")" - ) - self._conn.commit() - - def _get(self, key: str) -> str | None: - row = self._conn.execute( - "SELECT value FROM determa_state WHERE key = ?", (key,) - ).fetchone() - return row[0] if row is not None else None - - def _set(self, key: str, value: str) -> None: - self._conn.execute( - "INSERT INTO determa_state (key, value) VALUES (?, ?) " - "ON CONFLICT(key) DO UPDATE SET value = excluded.value", - (key, value), - ) - - def load(self) -> StoreState: - defs = json.loads(self._get("defs") or "{}") - instances = json.loads(self._get("instances") or "[]") - meta = json.loads(self._get("meta") or "{}") - return _state_from_parts(defs, instances, meta) - - def save(self, state: StoreState) -> None: - self._set("defs", json.dumps(state.defs)) - self._set("instances", json.dumps(state.instances)) - self._set("meta", json.dumps(_meta_json(state))) - self._conn.commit() - - def close(self) -> None: - self._conn.close() - - -def _split_scheme(spec: str) -> tuple[str, str]: - scheme, sep, rest = spec.partition(":") - if sep and scheme in _SCHEMES: - return scheme, rest - return "file", spec - - -def open_store(spec: str) -> Store: - """Select a backend from a ``--store `` scheme (SPEC §13.1).""" - scheme, rest = _split_scheme(spec) - if scheme == "mem": - return MemoryStore() - if scheme == "sqlite": - return SqliteStore(rest) - return FileStore(rest) # "file:" or a bare "" diff --git a/src/determa/state/validator.py b/src/determa/state/validator.py index 08ce92f..2d1c954 100644 --- a/src/determa/state/validator.py +++ b/src/determa/state/validator.py @@ -1,265 +1,798 @@ -"""Machine-definition validation (SPEC §2). - -Two layers: - -1. **Structural** — the document MUST validate against the normative - ``schema/machine.schema.json`` (bundled as package data). -2. **Reserved names** (SPEC §2/§3): - - The structural / CEL-intrinsic names ``top``, ``id``, ``parent``, ``event`` - are forbidden as state and esv identifiers (they collide with the root - state or the guard/action intrinsics). - - The reserved event names ``initial``, ``entry``, ``exit``, ``env``, - ``error``, ``done`` are forbidden only as *declared* event types (they are - implicitly provided by the engine). They MAY be used as ``on_events`` - handlers, and (occupying a different namespace) as state/esv names — e.g. - a state named ``done`` is allowed. - - -Errors are returned as ``{path, message}`` records (the §13.4 ``validate`` -JSON shape). Later build steps add reference-resolution and contract checks. -""" +"""Structural and semantic validation for Determa State format 1.""" from __future__ import annotations import json +import re from functools import lru_cache from pathlib import Path from typing import Any, cast -from .errors import ErrorRecord, ValidationError - -RESERVED_NAMES = frozenset({"top", "id", "parent", "event"}) -RESERVED_EVENTS = frozenset({"initial", "entry", "exit", "env", "error", "done"}) -ALL_RESERVED = RESERVED_NAMES | RESERVED_EVENTS +from . import cel +from .definition import Bundle, _escape_pointer, normalize_bundle +from .errors import CelError, ErrorRecord, ValidationError +from .model import BundleModel, MachineModel, StateNode _SCHEMA_PATH = Path(__file__).parent / "data" / "machine.schema.json" +_RESERVED_EVENTS = frozenset( + { + "env", + "done", + "determa.component_completed", + "determa.component_failed", + "determa.spawned_instance_failed", + } +) @lru_cache(maxsize=1) def schema() -> dict[str, Any]: - """The bundled normative machine JSON Schema (SPEC §4).""" - with _SCHEMA_PATH.open(encoding="utf-8") as fh: - return cast(dict[str, Any], json.load(fh)) - + with _SCHEMA_PATH.open(encoding="utf-8") as source: + return cast(dict[str, Any], json.load(source)) -def _json_path(parts: list[Any]) -> str: - if not parts: - return "(root)" - return "/" + "/".join(str(p) for p in parts) +def validate(document: dict[str, Any]) -> None: + """Validate one parsed bundle or raise the first exact load-layer code.""" + _validate_schema(document) + normalized = normalize_bundle(document) + provisional = Bundle(raw=normalized, fingerprint="") + model = BundleModel(provisional) + _validate_semantics(provisional, model) -def validate(doc: dict[str, Any]) -> None: - """Validate a machine document; raise :class:`ValidationError` on failure.""" - errors = collect_errors(doc) - if errors: - raise ValidationError(errors) +def collect_errors(document: dict[str, Any]) -> list[ErrorRecord]: + try: + validate(document) + except ValidationError as exc: + return exc.errors + return [] -def collect_errors(doc: dict[str, Any]) -> list[ErrorRecord]: - """Return all validation errors (structural + reserved names + static analysis).""" - errors: list[ErrorRecord] = list(_structural_errors(doc)) - errors.extend(_reserved_name_errors(doc)) - if isinstance(doc, dict) and isinstance(doc.get("top"), dict): - errors.extend(_reachability_errors(doc["top"])) - return errors - -def _structural_errors(doc: dict[str, Any]) -> list[ErrorRecord]: - import jsonschema # deferred: ~40ms to import, only needed when validating a machine +def _validate_schema(document: dict[str, Any]) -> None: + import jsonschema validator = jsonschema.Draft202012Validator(schema()) - out: list[ErrorRecord] = [] - for err in sorted(validator.iter_errors(doc), key=lambda e: list(e.absolute_path)): - out.append( - ErrorRecord(path=_json_path(list(err.absolute_path)), message=err.message) + errors = sorted(validator.iter_errors(document), key=lambda error: list(error.absolute_path)) + if errors: + error = errors[0] + path = "/" + "/".join(str(part) for part in error.absolute_path) + raise ValidationError("structural_validation", path=path, message=error.message) + + +def _compatible(actual: str, expected: str) -> bool: + return actual == expected or (expected == "float" and actual == "int") or actual == "unknown" + + +def _literal_matches(value: Any, expected: str) -> bool: + if expected == "string": + return isinstance(value, str) + if expected == "bool": + return isinstance(value, bool) + if expected == "int": + return isinstance(value, int) and not isinstance(value, bool) + if expected == "float": + return isinstance(value, int | float) and not isinstance(value, bool) + if expected == "list": + return isinstance(value, list) + if expected == "map": + return isinstance(value, dict) + if expected == "instance_reference": + return value is None + return False + + +def _event_declarations(bundle: Bundle, machine: MachineModel) -> dict[str, dict[str, Any]]: + declarations = dict(bundle.raw.get("events") or {}) + declarations.update(machine.raw.get("events") or {}) + return declarations + + +def _built_in_event_fields(event_name: str) -> dict[str, str]: + if event_name == "env": + return {"changed": "map"} + if event_name == "determa.component_completed": + return {"component_id": "string", "component_runtime_id": "string"} + if event_name == "determa.component_failed": + return {"component_id": "string", "component_runtime_id": "string", "fault": "map"} + if event_name == "determa.spawned_instance_failed": + return { + "instance": "instance_reference", + "instance_id": "string", + "machine_id": "string", + "machine_version": "int", + "fault": "map", + } + if event_name == "done": + return { + "relationship": "string", + "state_path": "string", + "owner_runtime_id": "string", + "instance": "instance_reference", + "instance_id": "string", + "machine_id": "string", + "machine_version": "int", + } + return {} + + +def _payload_types(declaration: dict[str, Any] | None, event_name: str) -> dict[str, str]: + if declaration is None: + return _built_in_event_fields(event_name) + return {name: str(field["type"]) for name, field in (declaration.get("payload") or {}).items()} + + +def _scope( + machine: MachineModel, state: StateNode +) -> tuple[dict[str, str], dict[str, tuple[StateNode, dict[str, Any], str]]]: + chain = list(reversed(state.ancestors(include_self=True))) + types: dict[str, str] = {} + declarations: dict[str, tuple[StateNode, dict[str, Any], str]] = {} + for node in chain: + for name, declaration in (node.raw.get("variables") or {}).items(): + types[name] = str(declaration["type"]) + declarations[name] = ( + node, + declaration, + f"{node.pointer}/variables/{_escape_pointer(name)}", + ) + return types, declarations + + +def _check_expression( + expression: str, + *, + scope: dict[str, str], + expected: str | None, + event_fields: dict[str, str] | None, + owner_fields: dict[str, str] | None, + allow_event: bool, + allow_owner: bool, +) -> str: + references = cel.referenced_names(expression) + allowed = set(scope) + if allow_event: + allowed.add("event") + if allow_owner: + allowed.add("owner") + instance_names = { + name for name, type_name in scope.items() if type_name == "instance_reference" + } + try: + cel.compile_expression(expression) + except CelError as exc: + raise ValidationError("semantic_validation", message=str(exc)) from exc + if cel.profile_error(expression, instance_names): + raise ValidationError("cel_profile_error") + if references - allowed: + raise ValidationError("semantic_validation", message="unknown CEL activation name") + if not allow_event and re.search(r"\bevent\b", expression): + raise ValidationError("semantic_validation") + if not allow_owner and re.search(r"\bowner\b", expression): + raise ValidationError("semantic_validation") + if re.search(r"\bevent\.(?!payload\b)", expression): + raise ValidationError("semantic_validation") + if re.search(r"\bowner\.(?!variables\b)", expression): + raise ValidationError("semantic_validation") + for field in re.findall(r"\bevent\.payload\.([A-Za-z_][A-Za-z0-9_]*)", expression): + if event_fields is None or field not in event_fields: + raise ValidationError("semantic_validation") + for field in re.findall(r"\bowner\.variables\.([A-Za-z_][A-Za-z0-9_]*)", expression): + if owner_fields is None or field not in owner_fields: + raise ValidationError("semantic_validation") + inferred = cel.infer_type( + expression, scope, event_fields=event_fields, owner_fields=owner_fields + ) + if expected is not None and not _compatible(inferred, expected): + raise ValidationError("semantic_validation") + return inferred + + +def _validate_semantics(bundle: Bundle, model: BundleModel) -> None: + if not isinstance(bundle.raw.get("format"), int) or isinstance(bundle.raw.get("format"), bool): + raise ValidationError("unsupported_format") + events = bundle.raw.get("events") or {} + for _name, declaration in events.items(): + correlation = declaration.get("correlates_to") + if correlation is not None: + target = events.get(correlation) + if declaration["direction"] != "input" or not target or target["direction"] != "output": + raise ValidationError("semantic_validation") + graph: dict[str, set[str]] = {machine_id: set() for machine_id in model.machines} + for machine in model.machines.values(): + if not isinstance(machine.raw["version"], int) or isinstance(machine.raw["version"], bool): + raise ValidationError("semantic_validation") + _validate_machine(bundle, model, machine, graph) + _reject_initialization_cycles(graph) + + +def _validate_machine( + bundle: Bundle, + bundle_model: BundleModel, + machine: MachineModel, + graph: dict[str, set[str]], +) -> None: + declarations = _event_declarations(bundle, machine) + for name, declaration in (machine.raw.get("events") or {}).items(): + if name in _RESERVED_EVENTS or declaration["direction"] != "internal": + raise ValidationError("semantic_validation") + component_ids: set[str] = set() + for state in machine.states.values(): + _validate_variable_literals(state) + _validate_state_structure( + bundle, bundle_model, machine, state, declarations, component_ids, graph + ) + _validate_reachability(machine) + + +def _validate_variable_literals(state: StateNode) -> None: + for declaration in (state.raw.get("variables") or {}).values(): + expected = str(declaration["type"]) + if "init" in declaration and not _literal_matches(declaration["init"], expected): + raise ValidationError("semantic_validation") + if expected == "int" and isinstance(declaration.get("init"), float): + raise ValidationError("semantic_validation") + + +def _validate_payload_literals(declaration: dict[str, Any]) -> None: + for field in (declaration.get("payload") or {}).values(): + if "default" in field and not _literal_matches(field["default"], str(field["type"])): + raise ValidationError("semantic_validation") + if field["type"] == "int" and isinstance(field.get("default"), float): + raise ValidationError("semantic_validation") + + +def _validate_state_structure( + bundle: Bundle, + bundle_model: BundleModel, + machine: MachineModel, + state: StateNode, + events: dict[str, dict[str, Any]], + component_ids: set[str], + graph: dict[str, set[str]], +) -> None: + scope, scope_declarations = _scope(machine, state) + if state is machine.root: + for declaration in (bundle.raw.get("events") or {}).values(): + _validate_payload_literals(declaration) + for declaration in (machine.raw.get("events") or {}).values(): + _validate_payload_literals(declaration) + _validate_actions( + state.raw.get("entry") or [], + bundle=bundle, + bundle_model=bundle_model, + machine=machine, + state=state, + scope=scope, + scope_declarations=scope_declarations, + events=events, + event_name=None, + owner_fields=None, + context="entry", + graph=graph, + ) + _validate_actions( + state.raw.get("exit") or [], + bundle=bundle, + bundle_model=bundle_model, + machine=machine, + state=state, + scope=scope, + scope_declarations=scope_declarations, + events=events, + event_name=None, + owner_fields=None, + context="exit", + graph=graph, + ) + initial = state.raw.get("initial") + if isinstance(initial, dict): + target = machine.resolve(initial["transition_to"], state) + if not state.is_ancestor_of(target, strict=True): + raise ValidationError("semantic_validation") + _validate_transition( + initial, + bundle=bundle, + bundle_model=bundle_model, + machine=machine, + source=state, + scope=scope, + scope_declarations=scope_declarations, + events=events, + event_name=None, + pointer=f"{state.pointer}/initial", + context="initial", + graph=graph, + ) + for event_name, transition_or_list in (state.raw.get("on_events") or {}).items(): + declaration = events.get(event_name) + if declaration is None and event_name not in _RESERVED_EVENTS: + raise ValidationError("semantic_validation") + transitions = ( + transition_or_list if isinstance(transition_or_list, list) else [transition_or_list] + ) + for index, transition in enumerate(transitions): + if index < len(transitions) - 1 and "guard" not in transition: + raise ValidationError("semantic_validation") + suffix = f"/{index}" if isinstance(transition_or_list, list) else "" + _validate_transition( + transition, + bundle=bundle, + bundle_model=bundle_model, + machine=machine, + source=state, + scope=scope, + scope_declarations=scope_declarations, + events=events, + event_name=event_name, + pointer=f"{state.pointer}/on_events/{_escape_pointer(event_name)}{suffix}", + context="event", + graph=graph, + ) + if state.is_choice: + branches = state.raw["choice"] + defaults = [index for index, branch in enumerate(branches) if "guard" not in branch] + if defaults != [len(branches) - 1]: + raise ValidationError("semantic_validation") + for index, branch in enumerate(branches): + _validate_transition( + branch, + bundle=bundle, + bundle_model=bundle_model, + machine=machine, + source=state, + scope=scope, + scope_declarations=scope_declarations, + events=events, + event_name=None, + pointer=f"{state.pointer}/choice/{index}", + context="choice", + graph=graph, + ) + for index, placement in enumerate(state.raw.get("components") or []): + component_id = str(placement["component_id"]) + if component_id in component_ids: + raise ValidationError("semantic_validation") + component_ids.add(component_id) + pointer = f"{state.pointer}/components/{index}" + if "machine_id" in placement: + component_machine = bundle_model.machine(str(placement["machine_id"])) + graph[machine.machine_id].add(component_machine.machine_id) + else: + component_machine = bundle_model.inline_component(machine, placement, pointer) + _validate_inline_machine(bundle, bundle_model, component_machine, graph) + _validate_bindings( + placement.get("with") or {}, + component_machine, + scope={}, + owner_fields=scope, + allow_owner=True, ) - return out - - -def _reserved_name_errors(doc: dict[str, Any]) -> list[ErrorRecord]: - if not isinstance(doc, dict): - return [] - errors: list[ErrorRecord] = [] - top = doc.get("top") - if isinstance(top, dict): - _walk_state("/top", top, errors) - events = doc.get("events") - if isinstance(events, dict): - for name in events: - if name in ALL_RESERVED: - errors.append( - ErrorRecord( - path=f"/events/{name}", - message=f"'{name}' is a reserved event name", - ) - ) - return errors -def _check_choice(path: str, branches: list[Any], errors: list[ErrorRecord]) -> None: - """A choice MUST have exactly one default (unguarded) branch, and it MUST be last - (SPEC §5.5.1).""" - defaults = [i for i, br in enumerate(branches) if isinstance(br, dict) and "guard" not in br] - if not defaults: - errors.append( - ErrorRecord(path=f"{path}/choice", message="choice has no default (else) branch") +def _validate_inline_machine( + bundle: Bundle, + bundle_model: BundleModel, + machine: MachineModel, + graph: dict[str, set[str]], +) -> None: + events = _event_declarations(bundle, machine) + component_ids: set[str] = set() + for state in machine.states.values(): + _validate_variable_literals(state) + _validate_state_structure( + bundle, bundle_model, machine, state, events, component_ids, graph ) - elif len(defaults) > 1: - errors.append( - ErrorRecord(path=f"{path}/choice", message="choice has more than one default branch") + _validate_reachability(machine) + + +def _validate_transition( + transition: dict[str, Any], + *, + bundle: Bundle, + bundle_model: BundleModel, + machine: MachineModel, + source: StateNode, + scope: dict[str, str], + scope_declarations: dict[str, tuple[StateNode, dict[str, Any], str]], + events: dict[str, dict[str, Any]], + event_name: str | None, + pointer: str, + context: str, + graph: dict[str, set[str]], +) -> None: + event_declaration = events.get(event_name) if event_name is not None else None + event_fields = _payload_types(event_declaration, event_name or "") + guard = transition.get("guard") + if guard is not None: + _check_expression( + guard, + scope=scope, + expected="bool", + event_fields=event_fields if context == "event" else None, + owner_fields=None, + allow_event=context == "event", + allow_owner=False, ) - elif defaults[0] != len(branches) - 1: - errors.append( - ErrorRecord(path=f"{path}/choice", message="the default (else) branch must be last") + target = transition.get("transition_to") + target_state = machine.resolve(target, source) if target is not None else None + if target_state is not None: + assert isinstance(target, str | dict) + _validate_target_shape(machine, source, target_state, target, transition) + _validate_actions( + transition.get("action") or [], + bundle=bundle, + bundle_model=bundle_model, + machine=machine, + state=source, + scope=scope, + scope_declarations=scope_declarations, + events=events, + event_name=event_name if context == "event" else None, + owner_fields=None, + context=context, + graph=graph, + ) + if target_state is not None: + _validate_destroyed_destinations( + machine, source, target_state, transition, scope_declarations ) -def _check_transition_list(path: str, branches: list[Any], errors: list[ErrorRecord]) -> None: - """In a guarded transition list, an unguarded branch shadows all later ones, so it - MUST be last — otherwise the later branches are dead (SPEC §2).""" - for br in branches[:-1]: - if isinstance(br, dict) and "guard" not in br: - errors.append( - ErrorRecord( - path=path, - message="an unguarded transition must be last; later branches are dead", - ) +def _validate_target_shape( + machine: MachineModel, + source: StateNode, + target: StateNode, + target_spec: str | dict[str, str], + transition: dict[str, Any], +) -> None: + if target is machine.root: + raise ValidationError("root_reentry") + local = transition.get("local") is True + if local: + if source is machine.root: + raise ValidationError("root_local_transition") + if source.type != "composite" or not source.is_ancestor_of(target, strict=True): + raise ValidationError("semantic_validation") + if isinstance(target_spec, dict): + if target.type != "composite" or target.raw.get("history", "none") == "none": + raise ValidationError("semantic_validation") + if target.is_ancestor_of(source, strict=True): + raise ValidationError("semantic_validation") + + +def _transition_boundary( + machine: MachineModel, source: StateNode, target: StateNode, local: bool +) -> StateNode: + if source is target: + assert source.parent is not None + return source.parent + if source.is_ancestor_of(target, strict=True): + if local or source is machine.root: + return source + assert source.parent is not None + return source.parent + if target.is_ancestor_of(source, strict=True): + return target + target_ancestors = {node.path: node for node in target.ancestors(include_self=True)} + for node in source.ancestors(include_self=True): + if node.path in target_ancestors: + return node + return machine.root + + +def _validate_destroyed_destinations( + machine: MachineModel, + source: StateNode, + target: StateNode, + transition: dict[str, Any], + declarations: dict[str, tuple[StateNode, dict[str, Any], str]], +) -> None: + boundary = _transition_boundary(machine, source, target, transition.get("local") is True) + for action in transition.get("action") or []: + if "assign" in action: + name = next(iter(action["assign"])) + declaration_state = declarations[name][0] + if boundary.is_ancestor_of(declaration_state, strict=True): + raise ValidationError("destroyed_variable_write") + if "refresh" in action and target.type == "final" and target.parent is machine.root: + raise ValidationError("destroyed_variable_write") + if "spawn" in action and "bind_to" in action["spawn"]: + name = action["spawn"]["bind_to"] + declaration_state = declarations[name][0] + if boundary.is_ancestor_of(declaration_state, strict=True): + raise ValidationError("destroyed_reference_binding") + + +def _validate_actions( + actions: list[dict[str, Any]], + *, + bundle: Bundle, + bundle_model: BundleModel, + machine: MachineModel, + state: StateNode, + scope: dict[str, str], + scope_declarations: dict[str, tuple[StateNode, dict[str, Any], str]], + events: dict[str, dict[str, Any]], + event_name: str | None, + owner_fields: dict[str, str] | None, + context: str, + graph: dict[str, set[str]], +) -> None: + event_fields = _payload_types(events.get(event_name), event_name or "") if event_name else None + for action in actions: + if "assign" in action: + name, expression = next(iter(action["assign"].items())) + if name not in scope_declarations or scope_declarations[name][1].get("external"): + raise ValidationError("semantic_validation") + _check_expression( + expression, + scope=scope, + expected=scope[name], + event_fields=event_fields, + owner_fields=owner_fields, + allow_event=event_name is not None, + allow_owner=owner_fields is not None, + ) + elif "send" in action: + _validate_send( + action["send"], + bundle=bundle, + bundle_model=bundle_model, + machine=machine, + state=state, + scope=scope, + events=events, + event_fields=event_fields, + allow_event=event_name is not None, + ) + elif "spawn" in action: + spawn = action["spawn"] + target = bundle_model.machine(str(spawn["machine_id"])) + if context in {"entry", "initial", "choice"}: + graph[machine.machine_id].add(target.machine_id) + _validate_bindings( + spawn.get("bindings") or {}, + target, + scope=scope, + owner_fields=None, + allow_owner=False, + ) + bind_to = spawn.get("bind_to") + if bind_to is not None: + if bind_to not in scope_declarations: + raise ValidationError("semantic_validation") + declaration = scope_declarations[bind_to][1] + if declaration["type"] != "instance_reference": + raise ValidationError("semantic_validation") + constraint = declaration.get("machine_id") + if constraint is not None and constraint != target.machine_id: + raise ValidationError("semantic_validation") + elif "cancel" in action: + _check_expression( + action["cancel"]["instance"], + scope=scope, + expected="instance_reference", + event_fields=event_fields, + owner_fields=None, + allow_event=event_name is not None, + allow_owner=False, + ) + elif "refresh" in action: + if event_name != "env": + raise ValidationError("semantic_validation") + + +def _validate_send( + send: dict[str, Any], + *, + bundle: Bundle, + bundle_model: BundleModel, + machine: MachineModel, + state: StateNode, + scope: dict[str, str], + events: dict[str, dict[str, Any]], + event_fields: dict[str, str] | None, + allow_event: bool, +) -> None: + event_name = str(send["event"]) + declaration = events.get(event_name) + targets = send.get("targets") or [send.get("to", {"self": True})] + external = any(target.get("external") is True for target in targets) + if event_name == "env": + if len(targets) != 1 or "component" not in targets[0]: + raise ValidationError("semantic_validation") + changed = (send.get("payload") or {}).get("changed") + if not isinstance(changed, str) or not changed.strip().startswith("{"): + raise ValidationError("semantic_validation") + if changed.strip() == "{}" or "correlation_id" in send: + raise ValidationError("semantic_validation") + component_id = targets[0]["component"] + placement = next( + ( + item + for item in state.raw.get("components") or [] + if item["component_id"] == component_id + ), + None, + ) + if placement is None: + raise ValidationError("semantic_validation") + pointer = ( + f"{state.pointer}/components/{(state.raw.get('components') or []).index(placement)}" + ) + target_machine = ( + bundle_model.machine(placement["machine_id"]) + if "machine_id" in placement + else bundle_model.inline_component(machine, placement, pointer) + ) + external_variables = { + name: declaration + for name, declaration in (target_machine.root.raw.get("variables") or {}).items() + if declaration.get("external") is True + } + changed_members = _parse_cel_map_literal(changed) + if changed_members is None or set(changed_members) - set(external_variables): + raise ValidationError("semantic_validation") + for name, expression in changed_members.items(): + _check_expression( + expression, + scope=scope, + expected=str(external_variables[name]["type"]), + event_fields=event_fields, + owner_fields=None, + allow_event=allow_event, + allow_owner=False, + ) + return + if declaration is None or event_name in _RESERVED_EVENTS: + raise ValidationError("semantic_validation") + expected_direction = "output" if external else "internal" + if declaration["direction"] != expected_direction: + raise ValidationError("semantic_validation") + if external and "correlation_id" not in send: + raise ValidationError("semantic_validation") + payload_types = _payload_types(declaration, event_name) + supplied = send.get("payload") or {} + if set(supplied) - set(payload_types): + raise ValidationError("semantic_validation") + for name, expression in supplied.items(): + _check_expression( + expression, + scope=scope, + expected=payload_types[name], + event_fields=event_fields, + owner_fields=None, + allow_event=allow_event, + allow_owner=False, + ) + if "correlation_id" in send: + _check_expression( + send["correlation_id"], + scope=scope, + expected="string", + event_fields=event_fields, + owner_fields=None, + allow_event=allow_event, + allow_owner=False, + ) + for target in targets: + if "instance" in target: + _check_expression( + target["instance"], + scope=scope, + expected="instance_reference", + event_fields=event_fields, + owner_fields=None, + allow_event=allow_event, + allow_owner=False, ) - return -def _reachability_errors(top: dict[str, Any]) -> list[ErrorRecord]: - """Flag declared states unreachable from ``top`` (SPEC §2). Conservative and - guard-agnostic: reachability follows every ``initial``/region-initial/``on_events``/ - ``after``/``choice`` target regardless of guards, and entering a state implies its - ancestors (whose own edges are then also followed).""" - raws: dict[str, dict[str, Any]] = {} - parent: dict[str, str | None] = {} - children: dict[str, dict[str, str]] = {} - - def _build(path: str, node: dict[str, Any], par: str | None) -> None: - raws[path] = node - parent[path] = par - children[path] = {} - for cn, cd in (node.get("states") or {}).items(): - if isinstance(cd, dict): - children[path][cn] = f"{path}.{cn}" - _build(f"{path}.{cn}", cd, path) - for region in node.get("regions") or []: - for cn, cd in (region.get("states") or {}).items() if isinstance(region, dict) else []: - if isinstance(cd, dict): - children[path][cn] = f"{path}.{cn}" - _build(f"{path}.{cn}", cd, path) - - _build("top", top, None) - - def _resolve(src: str, ref: object) -> str | None: - if not isinstance(ref, str): +def _parse_cel_map_literal(expression: str) -> dict[str, str] | None: + body = expression.strip() + if not (body.startswith("{") and body.endswith("}")): + return None + body = body[1:-1].strip() + if not body: + return {} + members: dict[str, str] = {} + for item in body.split(","): + match = re.fullmatch(r"\s*(['\"])([A-Za-z_][A-Za-z0-9_]*)\1\s*:\s*(.+?)\s*", item) + if match is None: return None - parts = ref.split(".") - cur: str | None = src - anchor: str | None = None - while cur is not None: - if parts[0] in children.get(cur, {}): - anchor = children[cur][parts[0]] - break - cur = parent.get(cur) - if anchor is None: - return None - node = anchor - for p in parts[1:]: - node = children.get(node, {}).get(p, "") - if not node: - return None - return node - - def _targets(node: dict[str, Any]) -> list[object]: - out: list[object] = [] - initials = [node.get("initial")] - initials += [r.get("initial") for r in node.get("regions") or [] if isinstance(r, dict)] - for t in initials: - if isinstance(t, dict): - out.append(t.get("transition_to")) - for spec in (node.get("on_events") or {}).values(): - for tr in spec if isinstance(spec, list) else [spec]: - if isinstance(tr, dict): - out.append(tr.get("transition_to")) - for group in (node.get("after") or [], node.get("choice") or []): - for tr in group: - if isinstance(tr, dict): - out.append(tr.get("transition_to")) - return out - - reachable: set[str] = set() - stack = ["top"] - while stack: - path = stack.pop() - if path in reachable: - continue - reachable.add(path) - par = parent.get(path) - if par is not None and par not in reachable: - stack.append(par) # entering a state implies its ancestors; follow their edges too - for ref in _targets(raws[path]): - tgt = _resolve(path, ref) - if tgt is not None and tgt not in reachable: - stack.append(tgt) - - errors: list[ErrorRecord] = [] - for path in raws: - if path != "top" and path not in reachable: - errors.append( - ErrorRecord( - path="/" + path.replace(".", "/"), - message=f"unreachable state '{path.rsplit('.', 1)[-1]}'", - ) - ) - return sorted(errors, key=lambda e: e["path"]) + members[match.group(2)] = match.group(3) + return members -def _forbid( - name: object, reserved: frozenset[str], path: str, errors: list[ErrorRecord] +def _validate_bindings( + bindings: dict[str, Any], + target: MachineModel, + *, + scope: dict[str, str], + owner_fields: dict[str, str] | None, + allow_owner: bool, ) -> None: - if name in reserved: - errors.append( - ErrorRecord(path=path, message=f"'{name}' is a reserved name") - ) + root_variables = target.root.raw.get("variables") or {} + for kind in ("input", "external"): + expected = { + name: declaration + for name, declaration in root_variables.items() + if declaration.get(kind) is True + } + supplied = bindings.get(kind) or {} + if set(supplied) - set(expected): + raise ValidationError("invalid_binding") + missing = { + name + for name, declaration in expected.items() + if name not in supplied and "init" not in declaration + } + if missing: + raise ValidationError("invalid_binding") + for name, expression in supplied.items(): + inferred = _check_expression( + expression, + scope=scope, + expected=str(expected[name]["type"]), + event_fields=None, + owner_fields=owner_fields, + allow_event=False, + allow_owner=allow_owner, + ) + if not _compatible(inferred, str(expected[name]["type"])): + raise ValidationError("invalid_binding") -def _walk_state(path: str, state: Any, errors: list[ErrorRecord]) -> None: - """Recurse a StateNode, flagging reserved state/esv names. +def _validate_reachability(machine: MachineModel) -> None: + reachable: set[str] = set() - State and esv names are checked against the structural/intrinsic reserved - set only; reserved event names live in a different namespace and may be - reused (e.g. a state named ``done``). - """ - if not isinstance(state, dict): - return - choice = state.get("choice") - if isinstance(choice, list): - _check_choice(path, choice, errors) - on_events = state.get("on_events") - if isinstance(on_events, dict): - for ev, spec in on_events.items(): - if isinstance(spec, list): - _check_transition_list(f"{path}/on_events/{ev}", spec, errors) - esvs = state.get("esvs") - if isinstance(esvs, dict): - for name in esvs: - _forbid(name, RESERVED_NAMES, f"{path}/esvs/{name}", errors) - states = state.get("states") - if isinstance(states, dict): - for name, child in states.items(): - _forbid(name, RESERVED_NAMES, f"{path}/states/{name}", errors) - _walk_state(f"{path}/states/{name}", child, errors) - regions = state.get("regions") - if isinstance(regions, list): - for i, region in enumerate(regions): - if isinstance(region, dict): - rstates = region.get("states") - if isinstance(rstates, dict): - for name, child in rstates.items(): - _forbid(name, RESERVED_NAMES, f"{path}/regions/{i}/states/{name}", errors) - _walk_state( - f"{path}/regions/{i}/states/{name}", child, errors - ) + def enter(state: StateNode) -> None: + if state.path in reachable: + return + reachable.add(state.path) + for ancestor in state.ancestors(): + reachable.add(ancestor.path) + if state.is_choice: + for branch in state.raw["choice"]: + enter(machine.resolve(branch["transition_to"], state)) + elif state.type == "composite": + enter(machine.resolve(state.raw["initial"]["transition_to"], state)) + + enter(machine.root) + changed = True + while changed: + before = len(reachable) + for path in list(reachable): + state = machine.states[path] + for transition_or_list in (state.raw.get("on_events") or {}).values(): + transitions = ( + transition_or_list + if isinstance(transition_or_list, list) + else [transition_or_list] + ) + for transition in transitions: + if "transition_to" in transition: + enter(machine.resolve(transition["transition_to"], state)) + changed = len(reachable) != before + unreachable = [state for state in machine.states.values() if state.path not in reachable] + if unreachable: + raise ValidationError("semantic_validation", path=unreachable[0].pointer) + + +def _reject_initialization_cycles(graph: dict[str, set[str]]) -> None: + visiting: set[str] = set() + visited: set[str] = set() + + def visit(machine_id: str) -> None: + if machine_id in visiting: + raise ValidationError("semantic_validation") + if machine_id in visited: + return + visiting.add(machine_id) + for target in graph[machine_id]: + visit(target) + visiting.remove(machine_id) + visited.add(machine_id) + + for machine_id in graph: + visit(machine_id) diff --git a/src/determa/state/values.py b/src/determa/state/values.py deleted file mode 100644 index a569828..0000000 --- a/src/determa/state/values.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Runtime type checks: esv value types and event-payload validation (SPEC §4.3). - -``matches`` checks a Python value against a Determa State value type. ``payload_errors`` -validates a delivered payload against an event declaration (required fields -present and typed, no extras) — used at delivery time (§4.3: invalid payloads -are rejected, not enqueued). -""" - -from __future__ import annotations - -from typing import Any - - -def matches(value: Any, type_name: str) -> bool: - if type_name == "int": - return isinstance(value, int) and not isinstance(value, bool) - if type_name == "float": - return isinstance(value, int | float) and not isinstance(value, bool) - if type_name == "bool": - return isinstance(value, bool) - if type_name == "string": - return isinstance(value, str) - if type_name == "list": - return isinstance(value, list) - if type_name == "map": - return isinstance(value, dict) - return False - - -def payload_errors(decl: dict[str, Any], payload: dict[str, Any] | None) -> list[str]: - """Return a list of payload-validation problems (empty == valid).""" - errors: list[str] = [] - fields = decl.get("payload") or {} - payload = payload or {} - for fname, fdef in fields.items(): - if fname in payload: - if not matches(payload[fname], fdef["type"]): - errors.append(f"field '{fname}' must be {fdef['type']}") - elif fdef.get("required"): - errors.append(f"missing required field '{fname}'") - for fname in payload: - if fname not in fields: - errors.append(f"unexpected field '{fname}'") - return errors diff --git a/src/determa/state/yaml12.py b/src/determa/state/yaml12.py index d8cf780..bd93c38 100644 --- a/src/determa/state/yaml12.py +++ b/src/determa/state/yaml12.py @@ -1,97 +1,208 @@ -"""YAML 1.2 core-schema loading (SPEC §2). - -Determa State YAML MUST be parsed under the **YAML 1.2 core schema**, where only -``true``/``false`` (and capitalisations) are booleans. PyYAML defaults to YAML -1.1, in which ``yes``/``no``/``on``/``off``/``y``/``n`` are also booleans and -leading-zero / sexagesimal integers are parsed oddly. This module provides a -PyYAML loader that resolves scalars and constructs values strictly per the -YAML 1.2 core schema (https://yaml.org/spec/1.2.2/#102-core-schema). - -Only the implicit resolvers and the int/float constructors are replaced; the -parser, composer, and (de)serialiser machinery are PyYAML's own. -""" +"""Strict portable YAML/JSON source parsing from specification section 2.""" from __future__ import annotations +import math import re from typing import Any import yaml -# --- YAML 1.2 core-schema scalar patterns ---------------------------------- -# Canonical regexes from the YAML 1.2 core schema resolution table. -_NULL_RE = re.compile(r"^(?:~|null|Null|NULL|)$") -_BOOL_RE = re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$") -_INT_RE = re.compile(r"^(?:[-+]?[0-9]+|[-+]?0o[0-7]+|[-+]?0x[0-9a-fA-F]+)$") -_FLOAT_RE = re.compile( - r"^(?:" - r"[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?" - r"|[-+]?\.(?:inf|Inf|INF)" - r"|\.(?:nan|NaN|NAN)" - r")$" -) - - -class _CoreLoader(yaml.SafeLoader): - """A SafeLoader whose implicit resolvers + int/float ctors are 1.2-core.""" +from .errors import ValidationError +_JSON_NUMBER = re.compile(r"-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\Z") +_INTEGER = re.compile(r"-?(?:0|[1-9][0-9]*)\Z") +_INVALID_BOOLEAN = frozenset({"True", "TRUE", "False", "FALSE"}) +_INVALID_NULL = frozenset({"Null", "NULL", "~", ""}) +_STRING_BOOLEAN_LIKE = frozenset( + value + for word in ("yes", "no", "on", "off", "y", "n") + for value in (word, word.upper(), word.title()) +) +_INT_MIN = -(2**63) +_INT_MAX = 2**63 - 1 + + +def _has_invalid_unicode(value: str) -> bool: + return any(0xD800 <= ord(char) <= 0xDFFF for char in value) + + +def validate_unicode(value: Any) -> bool: + """Return whether every recursively contained string is a Unicode scalar sequence.""" + return _validate_unicode(value, set()) + + +def _validate_unicode(value: Any, ancestors: set[int]) -> bool: + if isinstance(value, str): + return not _has_invalid_unicode(value) + if isinstance(value, list): + identity = id(value) + if identity in ancestors: + return False + ancestors.add(identity) + valid = all(_validate_unicode(item, ancestors) for item in value) + ancestors.remove(identity) + return valid + if isinstance(value, dict): + identity = id(value) + if identity in ancestors: + return False + ancestors.add(identity) + valid = all( + isinstance(key, str) + and _validate_unicode(key, ancestors) + and _validate_unicode(item, ancestors) + for key, item in value.items() + ) + ancestors.remove(identity) + return valid + return True + + +def validate_portable_values(value: Any) -> None: + """Reject host values outside the portable JSON scalar domain.""" + _validate_portable_values(value, set()) + + +def _validate_portable_values(value: Any, ancestors: set[int]) -> None: + if value is None or isinstance(value, (str, bool)): + return + if isinstance(value, int): + if not _INT_MIN <= value <= _INT_MAX: + raise ValidationError("numeric_value_out_of_range") + return + if isinstance(value, float): + if not math.isfinite(value): + raise ValidationError("numeric_value_out_of_range") + return + if isinstance(value, list): + identity = id(value) + if identity in ancestors: + raise ValidationError("non_json_value") + ancestors.add(identity) + for item in value: + _validate_portable_values(item, ancestors) + ancestors.remove(identity) + return + if isinstance(value, dict): + identity = id(value) + if identity in ancestors: + raise ValidationError("non_json_value") + ancestors.add(identity) + for key, item in value.items(): + if not isinstance(key, str): + raise ValidationError("non_string_map_key") + _validate_portable_values(item, ancestors) + ancestors.remove(identity) + return + raise ValidationError("non_json_value") + + +def _numeric_candidate(value: str) -> bool: + lower = value.lower() + return bool( + re.match(r"^[+-]?(?:[0-9]|\.)", value) + or lower.startswith(("0x", "+0x", "-0x", "0o", "+0o", "-0o")) + ) + + +def _resolve_plain(value: str) -> Any: + if value == "true": + return True + if value == "false": + return False + if value in _INVALID_BOOLEAN: + raise ValidationError("invalid_boolean_syntax") + if value == "null": + return None + if value in _INVALID_NULL: + raise ValidationError("invalid_null_syntax") + if value in _STRING_BOOLEAN_LIKE: + return value + if _JSON_NUMBER.fullmatch(value): + if _INTEGER.fullmatch(value): + integer = int(value, 10) + if not _INT_MIN <= integer <= _INT_MAX: + raise ValidationError("numeric_value_out_of_range") + return integer + try: + double = float(value) + except ValueError as exc: + raise ValidationError("invalid_numeric_syntax") from exc + if not math.isfinite(double): + raise ValidationError("numeric_value_out_of_range") + return 0.0 if double == 0.0 else double + if _numeric_candidate(value): + raise ValidationError("invalid_numeric_syntax") + return value + + +class _PortableLoader(yaml.BaseLoader): + """A non-coercing loader with format-1 scalar and mapping construction.""" + + +def _construct_scalar(loader: _PortableLoader, node: yaml.ScalarNode) -> Any: + value = loader.construct_scalar(node) + if _has_invalid_unicode(value): + raise ValidationError("invalid_unicode") + if node.style is None: + return _resolve_plain(value) + return value -# Replace the inherited (YAML 1.1) implicit-resolver table with a fresh one. -_CoreLoader.yaml_implicit_resolvers = {} -# Registration order matters: for a given first character, resolvers are tried -# in registration order and the first match wins. +def _construct_mapping( + loader: _PortableLoader, node: yaml.MappingNode, deep: bool = False +) -> dict[str, Any]: + result: dict[str, Any] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if not isinstance(key, str): + raise ValidationError("non_string_map_key") + if key in result: + raise ValidationError("duplicate_key") + result[key] = loader.construct_object(value_node, deep=deep) + return result -def _add_resolver(tag: str, rx: re.Pattern[str], first: list[str]) -> None: - _CoreLoader.add_implicit_resolver(tag, rx, first) # type: ignore[no-untyped-call] +_PortableLoader.add_constructor("tag:yaml.org,2002:str", _construct_scalar) +_PortableLoader.add_constructor("tag:yaml.org,2002:map", _construct_mapping) -_add_resolver("tag:yaml.org,2002:null", _NULL_RE, list("~nN") + [""]) -_add_resolver("tag:yaml.org,2002:bool", _BOOL_RE, list("tTfF")) -_add_resolver("tag:yaml.org,2002:int", _INT_RE, list("-+0123456789")) -_add_resolver("tag:yaml.org,2002:float", _FLOAT_RE, list("-+0123456789.")) +def _construct_sequence(loader: _PortableLoader, node: yaml.SequenceNode) -> list[Any]: + return list(loader.construct_sequence(node)) -def _construct_int(loader: yaml.Loader, node: yaml.ScalarNode) -> int: - """YAML 1.2 core int: decimal, ``0o`` octal, ``0x`` hex. No leading-zero - octal, no sexagesimals.""" - value = loader.construct_scalar(node) - sign = "" - if value[:1] in "+-": - sign, value = value[0], value[1:] - body = value.lower() - if body[:2] == "0x": - n = int(value, 16) - elif body[:2] == "0o": - n = int(value, 8) - else: - n = int(value, 10) - return -n if sign == "-" else n - - -def _construct_float(loader: yaml.Loader, node: yaml.ScalarNode) -> float: - """YAML 1.2 core float, including ``.inf``/``.nan`` forms.""" - value = loader.construct_scalar(node) - lower = value.lower() - if lower in {".inf", "+.inf"}: - return float("inf") - if lower == "-.inf": - return float("-inf") - if lower == ".nan": - return float("nan") - return float(value) +_PortableLoader.add_constructor("tag:yaml.org,2002:seq", _construct_sequence) -_CoreLoader.add_constructor("tag:yaml.org,2002:int", _construct_int) -_CoreLoader.add_constructor("tag:yaml.org,2002:float", _construct_float) +def _reject_yaml_features(text: str) -> None: + try: + tokens = yaml.scan(text, Loader=_PortableLoader) + for token in tokens: + if isinstance( + token, (yaml.tokens.AliasToken, yaml.tokens.AnchorToken, yaml.tokens.TagToken) + ): + raise ValidationError("unsupported_yaml_feature") + except ValidationError: + raise + except (yaml.YAMLError, UnicodeError) as exc: + raise ValidationError("non_json_value", message=str(exc)) from exc def load(text: str) -> Any: - """Load a single YAML 1.2 document (``None`` if the stream is empty).""" - return yaml.load(text, Loader=_CoreLoader) - - -def load_all(text: str) -> list[Any]: - """Load all ``---``-separated YAML 1.2 documents (empty docs dropped).""" - return [doc for doc in yaml.load_all(text, Loader=_CoreLoader) if doc is not None] + """Parse exactly one portable format-1 source document.""" + if _has_invalid_unicode(text): + raise ValidationError("invalid_unicode") + _reject_yaml_features(text) + try: + documents = list(yaml.load_all(text, Loader=_PortableLoader)) + except ValidationError: + raise + except (yaml.YAMLError, UnicodeError) as exc: + raise ValidationError("non_json_value", message=str(exc)) from exc + if len(documents) != 1: + raise ValidationError("non_json_value", message="source must contain exactly one document") + document = documents[0] + if not validate_unicode(document): + raise ValidationError("invalid_unicode") + return document diff --git a/tests/test_cel.py b/tests/test_cel.py new file mode 100644 index 0000000..64ec52e --- /dev/null +++ b/tests/test_cel.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import pytest + +from determa.state import CelError, cel + + +def test_error_absorbing_boolean_operators_are_commutative() -> None: + assert cel.evaluate("false && (1 / 0 == 0)", {}) is False + assert cel.evaluate("(1 / 0 == 0) && false", {}) is False + assert cel.evaluate("true || (1 / 0 == 0)", {}) is True + assert cel.evaluate("(1 / 0 == 0) || true", {}) is True + + +def test_integer_arithmetic_matches_portable_profile() -> None: + assert cel.evaluate("-7 / 3", {}) == -2 + assert cel.evaluate("-7 % 3", {}) == -1 + with pytest.raises(CelError): + cel.evaluate("9223372036854775807 + 1", {}) + + +def test_unicode_is_not_normalized() -> None: + assert cel.evaluate('size("\\u00e9")', {}) == 1 + assert cel.evaluate('size("e\\u0301")', {}) == 2 + assert cel.evaluate('"\\u00e9" == "e\\u0301"', {}) is False diff --git a/tests/test_choice.py b/tests/test_choice.py deleted file mode 100644 index ae4665c..0000000 --- a/tests/test_choice.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Choice pseudostate — dynamic branching, chaining, and validation (SPEC §5.5.1).""" - -from __future__ import annotations - -import pytest - -from determa.state import Host, collect_errors, load_definitions -from determa.state.errors import ValidationError -from determa.state.model import Machine - -ATM = """\ -id: atm -events: - withdraw: { payload: { amount: { type: int, required: true } } } - reset: {} -top: - esvs: - balance: { type: int, init: 100 } - requested: { type: int, init: 0 } - initial: { transition_to: idle } - states: - idle: - on_events: - withdraw: - action: [ { assign: { requested: "event.payload.amount" } } ] - transition_to: check - check: - choice: - - { guard: "requested <= balance", transition_to: dispensing, - action: [ { assign: { balance: "balance - requested" } } ] } - - { transition_to: insufficient } - dispensing: - on_events: { reset: { transition_to: idle } } - insufficient: - on_events: { reset: { transition_to: idle } } -""" - - -def _atm() -> tuple[Host, object]: - host = Host() - host.register_all(load_definitions(ATM)) - inst = host.create_root(host.machines["atm"], "r") - host.run_to_quiescence() - return host, inst - - -def test_choice_branches_on_freshly_assigned_esv() -> None: - host, inst = _atm() - host.deliver("r", "withdraw", {"amount": 40}) # requested:=40; 40<=100 -> dispensing - host.run_to_quiescence() - assert inst.active_leaf_names() == ["dispensing"] - assert inst.resolved_esvs()["balance"] == 60 - assert inst.resolved_esvs()["requested"] == 40 - - -def test_choice_else_branch() -> None: - host, inst = _atm() - host.deliver("r", "withdraw", {"amount": 500}) # 500<=100 false -> else -> insufficient - host.run_to_quiescence() - assert inst.active_leaf_names() == ["insufficient"] - assert inst.resolved_esvs()["balance"] == 100 # unchanged - - -CHAIN = """\ -id: chain -events: - go: { payload: { n: { type: int, required: true } } } -top: - esvs: { n: { type: int, init: 0 } } - initial: { transition_to: start } - states: - start: - on_events: - go: { action: [ { assign: { n: "event.payload.n" } } ], transition_to: c1 } - c1: - choice: - - { guard: "n < 0", transition_to: negative } - - { transition_to: c2 } - c2: - choice: - - { guard: "n == 0", transition_to: zero } - - { transition_to: positive } - negative: {} - zero: {} - positive: {} -""" - - -@pytest.mark.parametrize("n,expected", [(-3, "negative"), (0, "zero"), (7, "positive")]) -def test_chained_choices(n: int, expected: str) -> None: - host = Host() - host.register_all(load_definitions(CHAIN)) - inst = host.create_root(host.machines["chain"], "r") - host.run_to_quiescence() - host.deliver("r", "go", {"n": n}) - host.run_to_quiescence() - assert inst.active_leaf_names() == [expected] - - -# --- validation ------------------------------------------------------------- -def _machine(pick_branches: str) -> str: - return f"""\ -id: m -events: {{ go: {{}} }} -top: - esvs: {{ x: {{ type: int, init: 1 }} }} - initial: {{ transition_to: a }} - states: - a: {{ on_events: {{ go: {{ transition_to: pick }} }} }} - pick: {{ choice: {pick_branches} }} - b: {{}} - c: {{}} -""" - - -def test_no_else_is_rejected() -> None: - import yaml - src = _machine('[ { guard: "x > 0", transition_to: b }, { guard: "x < 0", transition_to: c } ]') - errs = collect_errors(yaml.safe_load(src)) - assert any("default" in e["message"] for e in errs) - with pytest.raises(ValidationError): - load_definitions(src) - - -def test_else_must_be_last() -> None: - import yaml - src = _machine('[ { transition_to: b }, { guard: "x > 0", transition_to: c } ]') - errs = collect_errors(yaml.safe_load(src)) - assert any("last" in e["message"] for e in errs) - - -def test_cyclic_choice_rejected() -> None: - src = """\ -id: cyc -events: { go: {} } -top: - initial: { transition_to: a } - states: - a: { on_events: { go: { transition_to: c1 } } } - c1: { choice: [ { transition_to: c2 } ] } - c2: { choice: [ { transition_to: c1 } ] } -""" - with pytest.raises(ValidationError): - Machine(load_definitions(src)[0]) - - -def test_unresolved_branch_target_rejected() -> None: - src = _machine('[ { guard: "x > 0", transition_to: nowhere }, { transition_to: b } ]') - with pytest.raises(ValidationError): - Machine(load_definitions(src)[0]) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..78331c8 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import json +import subprocess +import sys + +from determa.state import __version__ +from determa.state.cli import main + + +def test_version_remains_unchanged() -> None: + assert __version__ == "0.0.6" + + +def test_validate_command_reports_fingerprint(tmp_path, capsys) -> None: + machine = tmp_path / "machine.yaml" + machine.write_text( + """ +format: 1 +namespace: example.cli +machines: + - machine_id: cli + root: {} +""", + encoding="utf-8", + ) + + assert main(["validate", str(machine)]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["valid"] is True + assert result["fingerprint"].startswith("sha256:") + + +def test_package_import_keeps_heavy_validators_lazy() -> None: + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; import determa.state; " + "print('celpy' in sys.modules, 'jsonschema' in sys.modules)" + ), + ], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout.strip() == "False False" diff --git a/tests/test_cli_stream.py b/tests/test_cli_stream.py deleted file mode 100644 index 56d166f..0000000 --- a/tests/test_cli_stream.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Unit tests for the batch/streaming CLI mode (SPEC §13.7).""" - -from __future__ import annotations - -import io -import json -from pathlib import Path - -import pytest - -import determa.state.cli as cli - -TURNSTILE = """\ -id: turnstile -events: - coin: { payload: { amount: { type: int, required: true } } } - push: {} -top: - esvs: - fare: { type: int, init: 50 } - initial: { transition_to: locked } - states: - locked: - on_events: - coin: { transition_to: unlocked, guard: "event.payload.amount >= fare" } - unlocked: - on_events: - push: { transition_to: locked } -""" - - -def _run_batch( - tmp_path: Path, - lines: list[list[str]], - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> tuple[int, list[dict]]: - stdin = "".join(json.dumps(line) + "\n" for line in lines) - monkeypatch.setattr("sys.stdin", io.StringIO(stdin)) - rc = cli.main(["--store", str(tmp_path / "store"), "run", "-"]) - out = capsys.readouterr().out - results = [json.loads(x) for x in out.splitlines() if x.strip()] - return rc, results - - -def test_stream_happy_path(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - rc, results = _run_batch( - tmp_path, - [ - ["new", "t1", str(machine)], - ["send", "t1", "coin", "--payload", "amount=100"], - ["state", "t1"], - ], - monkeypatch, - capsys, - ) - assert rc == 0 - assert [r["ok"] for r in results] == [True, True, True] - assert results[0]["result"]["config"] == ["locked"] - assert results[1]["result"]["config"] == ["unlocked"] - assert results[1]["result"]["published"] == [] - assert results[2]["result"]["config"] == ["unlocked"] - - -def test_stream_enabled_command(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - rc, results = _run_batch( - tmp_path, - [ - ["new", "t1", str(machine)], - ["enabled", "t1"], - ["send", "t1", "coin", "--payload", "amount=100"], - ["enabled", "t1"], - ], - monkeypatch, - capsys, - ) - assert rc == 0 - assert results[1]["result"] == {"instance": "t1", "enabled": ["coin"]} - assert results[3]["result"] == {"instance": "t1", "enabled": ["push"]} - - -def test_stream_failure_does_not_abort(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - rc, results = _run_batch( - tmp_path, - [ - ["new", "t1", str(machine)], - ["send", "missing", "push"], # not found -> exit 4 - ["state", "t1"], # still runs - ], - monkeypatch, - capsys, - ) - # process exit is the first non-zero line exit (§13.7). - assert rc == 4 - assert results[1] == { - "ok": False, - "exit": 4, - "result": None, - "error": {"message": results[1]["error"]["message"]}, - } - assert results[1]["error"]["message"] # a diagnostic was captured - assert results[2]["ok"] is True - assert results[2]["result"]["config"] == ["locked"] - - -def test_stream_malformed_line(tmp_path, monkeypatch, capsys): - monkeypatch.setattr("sys.stdin", io.StringIO("not json\n")) - rc = cli.main(["--store", str(tmp_path / "store"), "run", "-"]) - out = capsys.readouterr().out - rec = json.loads(out.strip()) - assert rc == 2 - assert rec["ok"] is False and rec["exit"] == 2 and rec["result"] is None - - -def test_stream_rejects_nested_run(tmp_path, monkeypatch, capsys): - monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(["run", "-"]) + "\n")) - rc = cli.main(["--store", str(tmp_path / "store"), "run", "-"]) - rec = json.loads(capsys.readouterr().out.strip()) - assert rc == 2 - assert rec["ok"] is False and rec["exit"] == 2 diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..3b2309e --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import copy + +import pytest + +from determa.state import create, dispatch, load_bundle +from determa.state.engine import _root_runtime_id + +from .test_loading import FINGERPRINT_BUNDLE + +COUNTER_BUNDLE = """ +format: 1 +namespace: example.counter +events: + increment: + direction: input + explode: + direction: input +machines: + - machine_id: counter + root: + type: composite + variables: + count: { type: int, init: 0 } + initial: { transition_to: running } + states: + running: + on_events: + increment: + action: + - assign: { count: "count + 1" } + explode: + action: + - assign: { count: "count + 1" } + - assign: { count: "count / 0" } +""" + +BINDING_BUNDLE = """ +format: 1 +namespace: example.binding +machines: + - machine_id: binding + root: + variables: + settings: { type: map, input: true } +""" + + +def _root_target(state: dict) -> dict: + return { + "root": { + "root_instance_id": state["root_instance_id"], + "root_runtime_id": state["root_runtime_id"], + } + } + + +def _envelope(state: dict, event: str, event_id: str) -> dict: + return { + "event": event, + "event_id": event_id, + "target": _root_target(state), + "payload": {}, + } + + +def _root_variables(state: dict) -> dict: + root = state["runtimes"][state["root_runtime_id"]] + values: dict = {} + for path in root["active"]: + values.update(root["scopes"][path]) + return values + + +def test_normative_root_runtime_identity_vector() -> None: + bundle = load_bundle(FINGERPRINT_BUNDLE) + machine = bundle.raw["machines"][0] + + assert _root_runtime_id(bundle, machine, "turnstile-42") == ( + "sha256:72dca6d0b2b3690ae28bda2f17a461179b18fbf11daad7a12709d9384a500c64" + ) + + +def test_dispatch_is_pure_and_success_advances_one_logical_step() -> None: + bundle = load_bundle(COUNTER_BUNDLE) + created = create(bundle, "counter", "counter-1", "create-1", {}) + prior = created["state"] + frozen = copy.deepcopy(prior) + + result = dispatch( + bundle, + prior, + {"input": _envelope(prior, "increment", "increment-1")}, + ) + + assert prior == frozen + assert result["state"] is not prior + assert _root_variables(result["state"])["count"] == 1 + assert result["state"]["next_logical_step_sequence"] == 2 + + +def test_fault_rolls_back_author_writes_and_keeps_input_caller_owned() -> None: + bundle = load_bundle(COUNTER_BUNDLE) + prior = create(bundle, "counter", "counter-1", "create-1", {})["state"] + + result = dispatch( + bundle, + prior, + {"input": _envelope(prior, "explode", "explode-1")}, + ) + + assert result["status"] == "faulted" + assert result["disposition"] == "faulted" + assert _root_variables(result["state"])["count"] == 0 + assert result["emissions"] == [] + assert result["fault"]["cause_id"] == "explode-1" + assert result["fault"]["source_locator"].endswith("/on_events/explode/action/1/assign/count") + assert "queue" not in result["state"] + + +def test_rejection_returns_the_exact_prior_state_object() -> None: + bundle = load_bundle(COUNTER_BUNDLE) + prior = create(bundle, "counter", "counter-1", "create-1", {})["state"] + envelope = _envelope(prior, "missing", "missing-1") + + result = dispatch(bundle, prior, {"input": envelope}) + + assert result["disposition"] == "rejected" + assert result["rejection"] == {"code": "invalid_event"} + assert result["state"] is prior + + +def test_changed_bundle_cannot_reinterpret_prior_state() -> None: + bundle = load_bundle(COUNTER_BUNDLE) + prior = create(bundle, "counter", "counter-1", "create-1", {})["state"] + changed = load_bundle( + COUNTER_BUNDLE.replace("events:", "meta: { revision: changed }\nevents:", 1) + ) + + result = dispatch(changed, prior) + + assert result["rejection"] == {"code": "incompatible_bundle"} + assert result["state"] is prior + + +@pytest.mark.parametrize( + "mutate", + [ + lambda state: state["runtimes"][state["root_runtime_id"]]["active"].append("missing"), + lambda state: state["runtimes"][state["root_runtime_id"]]["scopes"].update({"missing": {}}), + lambda state: state.update({"next_logical_step_sequence": True}), + lambda state: state.update({"root_instance_id": "\ud800"}), + ], +) +def test_malformed_prior_state_is_rejected_before_dispatch(mutate) -> None: + bundle = load_bundle(COUNTER_BUNDLE) + prior = create(bundle, "counter", "counter-1", "create-1", {})["state"] + mutate(prior) + + result = dispatch(bundle, prior) + + assert result["disposition"] == "rejected" + assert result["rejection"] == {"code": "invalid_prior_state"} + assert result["state"] is prior + + +def test_nested_nonportable_creation_binding_is_rejected() -> None: + bundle = load_bundle(BINDING_BUNDLE) + + result = create( + bundle, + "binding", + "binding-1", + "create-1", + {"input": {"settings": {"limit": 2**63}}}, + ) + + assert result["status"] == "rejected" + assert result["rejection"] == {"code": "invalid_binding"} + assert result["state"] is None + + +def test_cyclic_payload_is_rejected_without_recursion() -> None: + bundle = load_bundle(COUNTER_BUNDLE) + prior = create(bundle, "counter", "counter-1", "create-1", {})["state"] + payload: dict = {} + payload["amount"] = payload + envelope = _envelope(prior, "increment", "increment-cycle") + envelope["payload"] = payload + + result = dispatch(bundle, prior, {"input": envelope}) + + assert result["rejection"] == {"code": "invalid_payload"} + assert result["state"] is prior + + +def test_root_target_is_a_closed_tagged_union_member() -> None: + bundle = load_bundle(COUNTER_BUNDLE) + prior = create(bundle, "counter", "counter-1", "create-1", {})["state"] + envelope = _envelope(prior, "increment", "increment-extra-target") + envelope["target"]["root"]["extra"] = "not-portable" + + result = dispatch(bundle, prior, {"input": envelope}) + + assert result["rejection"] == {"code": "invalid_instance_target"} + assert result["state"] is prior diff --git a/tests/test_export.py b/tests/test_export.py deleted file mode 100644 index 7d22393..0000000 --- a/tests/test_export.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Mermaid export tests (SPEC §12).""" - -from __future__ import annotations - -from determa.state import load_definition -from determa.state.export import export -from determa.state.model import Machine - -TURNSTILE = """ -id: turnstile -events: - coin: { payload: { amount: { type: int, required: true } } } - push: {} -top: - esvs: - fare: { type: int, init: 50 } - initial: { transition_to: locked } - states: - locked: - on_events: - coin: { transition_to: unlocked, guard: "amount >= fare" } - unlocked: - on_events: - push: { transition_to: locked } -""" - - -def test_static_structure() -> None: - machine = Machine(load_definition(TURNSTILE)) - out = export(machine) - assert out.startswith("stateDiagram-v2") - assert "[*] --> locked" in out - assert "locked --> unlocked : coin [amount >= fare]" in out - assert "unlocked --> locked : push" in out - - -def test_current_state_highlight() -> None: - machine = Machine(load_definition(TURNSTILE)) - # active leaf `unlocked`; its ancestor is `top`. - leaf = machine.by_path["top.unlocked"] - config = [leaf.path] - out = export(machine, state_config=config) - assert "classDef active fill:#9f9,stroke:#3a3" in out - assert "class unlocked active" in out - - -def test_unsupported_format_raises() -> None: - import pytest - - machine = Machine(load_definition(TURNSTILE)) - with pytest.raises(ValueError): - export(machine, format="plantuml") diff --git a/tests/test_library_api.py b/tests/test_library_api.py deleted file mode 100644 index a42aa73..0000000 --- a/tests/test_library_api.py +++ /dev/null @@ -1,212 +0,0 @@ -"""The public library API (SPEC §2 "Library API"). - -Drives a machine end-to-end through the ``determa.state`` public surface **only** — no -``determa.state.cli`` and no file-backed store — exercising every capability the spec -requires an embeddable API to provide. -""" - -from __future__ import annotations - -import pytest - -import determa.state as ds - -GATE = """\ -id: gate -events: - coin: { payload: { amount: { type: int, required: true } } } - push: {} -top: - esvs: - fare: { type: int, external: true } # host-seeded, read-only (SPEC §4.4) - initial: { transition_to: locked } - states: - locked: - on_events: - coin: { transition_to: unlocked, guard: "event.payload.amount >= fare" } - unlocked: - on_events: - push: { transition_to: locked } -""" - -META_GATE = """\ -id: meta_gate -meta: - host: guardrail -events: - go: {} -top: - meta: - owner: root - initial: { transition_to: a } - states: - a: - meta: - tools: [one, two] - on_events: - go: { transition_to: b } - b: {} -""" - -# The GATE machine above, built as a native mapping — no YAML string serialized. -# Hosts can construct machines in code this way (same validate() path). -GATE_DICT = { - "id": "gate", - "events": { - "coin": {"payload": {"amount": {"type": "int", "required": True}}}, - "push": {}, - }, - "top": { - "esvs": {"fare": {"type": "int", "external": True}}, - "initial": {"transition_to": "locked"}, - "states": { - "locked": { - "on_events": { - "coin": { - "transition_to": "unlocked", - "guard": "event.payload.amount >= fare", - } - } - }, - "unlocked": {"on_events": {"push": {"transition_to": "locked"}}}, - }, - }, -} - - -def test_minimum_capability_set_via_public_api() -> None: - # 1. load + validate a definition (raises ValidationError if invalid). - defs = ds.load_definitions(GATE) - for d in defs: - ds.validate(d.raw) - assert ds.collect_errors(defs[0].raw) == [] - - # 2. register definitions + create a root instance with an id and external esvs. - host = ds.Host() - host.register_all(defs) - inst = host.create_root(host.machines["gate"], "g1", external={"fare": 50}) - host.run_to_quiescence() - - # 3. read status, active configuration, and esvs. - assert inst.status is ds.Status.ACTIVE - assert inst.active_leaf_names() == ["locked"] - assert inst.resolved_esvs()["fare"] == 50 - - # 4. deliver a typed event + run to quiescence. - assert host.deliver("g1", "coin", {"amount": 100}) is True - host.run_to_quiescence() - assert inst.active_leaf_names() == ["unlocked"] - - # an invalid payload is rejected, not enqueued (§4.3). - assert host.deliver("g1", "coin", {"amount": "nope"}) is False - - # 5. advance the virtual clock. - host.advance("30s") - assert host.now == 30_000 - - # 6. snapshot + restore an instance (§8) — state survives the round-trip. - snaps = host.snapshot_all() - host.deliver("g1", "push", None) - host.run_to_quiescence() - assert inst.active_leaf_names() == ["locked"] - host.restore_all(snaps) - assert host.instances["g1"].active_leaf_names() == ["unlocked"] - - -def test_public_surface_is_exported() -> None: - """The documented public API stays importable from the top-level package.""" - expected = { - "Definition", - "Host", - "Instance", - "Machine", - "State", - "Status", - "Event", - "DetermaError", - "SchemaError", - "ValidationError", - "ErrorRecord", - "CelError", - "load_definition", - "load_definitions", - "validate", - "collect_errors", - "__version__", - } - assert expected <= set(ds.__all__) - for name in expected: - assert hasattr(ds, name), f"determa.state.{name} not exported" - - -def test_meta_is_validation_only_model_data_not_runtime_state() -> None: - defs = ds.load_definitions(META_GATE) - assert ds.collect_errors(defs[0].raw) == [] - - host = ds.Host() - host.register_all(defs) - machine = host.machines["meta_gate"] - inst = host.create_root(machine, "m1") - host.run_to_quiescence() - - assert machine.meta == {"host": "guardrail"} - assert machine.top.meta == {"owner": "root"} - assert machine.by_path["top.a"].meta == {"tools": ["one", "two"]} - assert inst.active_leaf_names() == ["a"] - - snap = inst.to_snapshot() - assert "meta" not in snap - - assert host.deliver("m1", "go") is True - host.run_to_quiescence() - assert inst.active_leaf_names() == ["b"] - - -def test_build_a_machine_in_code_from_a_mapping() -> None: - # load_definitions accepts a native dict (not just YAML text); the same - # validate() path runs, so the machine is held to the same contract. - defs = ds.load_definitions(GATE_DICT) - assert len(defs) == 1 - assert defs[0].id == "gate" - assert ds.collect_errors(defs[0].raw) == [] - - # registered + driven end-to-end with no YAML string ever involved. - host = ds.Host() - host.register_all(defs) - inst = host.create_root(host.machines["gate"], "g1", external={"fare": 50}) - host.run_to_quiescence() - assert inst.active_leaf_names() == ["locked"] - - assert host.deliver("g1", "coin", {"amount": 100}) is True - host.run_to_quiescence() - assert inst.active_leaf_names() == ["unlocked"] - - -def test_load_definition_singular_accepts_a_mapping() -> None: - single = ds.load_definition(GATE_DICT) - assert single.id == "gate" - - -def test_multi_document_machine_from_a_list_of_mappings() -> None: - child = { - "id": "child", - "top": {"initial": {"transition_to": "on"}, "states": {"on": {}}}, - } - root = { - "id": "root", - "top": {"initial": {"transition_to": "idle"}, "states": {"idle": {}}}, - } - defs = ds.load_definitions([root, child]) - assert [d.id for d in defs] == ["root", "child"] - - -def test_native_mapping_runs_through_the_same_validation() -> None: - # a structurally invalid mapping is rejected just like an invalid YAML file. - bad = {"id": "x"} # missing required "top" - with pytest.raises(ds.ValidationError): - ds.load_definitions(bad) - - # a non-mapping document is rejected. - ok = {"id": "ok", "top": {"initial": {"transition_to": "a"}, "states": {"a": {}}}} - with pytest.raises(ds.ValidationError): - ds.load_definitions([ok, 42]) diff --git a/tests/test_loading.py b/tests/test_loading.py new file mode 100644 index 0000000..f17da30 --- /dev/null +++ b/tests/test_loading.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import math +from pathlib import Path + +import pytest + +from determa.state import ValidationError, load_bundle + +ROOT = Path(__file__).resolve().parent.parent + +FINGERPRINT_BUNDLE = """ +format: 1 +namespace: example.turnstile +events: + tick: + payload: + amount: { type: float, default: 1 } +meta: + large_integer: 9007199254740993 + integer_one: 1 + floating_one: 1.0 +machines: + - machine_id: turnstile + events: + local_notice: + payload: + value: { type: int, required: true } + root: + type: composite + variables: + attempts: { type: int, init: 0 } + initial: { transition_to: locked } + states: + locked: + type: parallel + components: + - component_id: left + root: {} + - component_id: right + root: {} + on_events: + tick: + transition_to: unlocked + action: + - send: + event: local_notice + payload: { value: "1" } + unlocked: {} +""" + + +def test_normative_bundle_fingerprint_vector() -> None: + bundle = load_bundle(FINGERPRINT_BUNDLE) + + assert bundle.fingerprint == ( + "sha256:7e48ad82ea5305c24b7730f4fd24c36ec196a0875c982b85eba5b3a5ddcbb92f" + ) + + +@pytest.mark.parametrize( + ("source_fragment", "code"), + [ + ("meta: { value: 0x10 }", "invalid_numeric_syntax"), + ("meta: { value: +1 }", "invalid_numeric_syntax"), + ("meta: { value: True }", "invalid_boolean_syntax"), + ("meta: { value: NULL }", "invalid_null_syntax"), + ("meta: &anchor { value: 1 }", "unsupported_yaml_feature"), + ], +) +def test_nonportable_source_scalars_fail_before_schema(source_fragment: str, code: str) -> None: + source = f""" +format: 1 +namespace: example.scalar +{source_fragment} +machines: + - machine_id: scalar + root: {{}} +""" + + with pytest.raises(ValidationError, match=code) as caught: + load_bundle(source) + + assert caught.value.code == code + + +def test_duplicate_keys_do_not_use_last_value_wins() -> None: + source = """ +format: 1 +namespace: example.duplicate +namespace: example.overwrite +machines: + - machine_id: duplicate + root: {} +""" + + with pytest.raises(ValidationError) as caught: + load_bundle(source) + + assert caught.value.code == "duplicate_key" + + +def test_yaml_1_1_boolean_like_identifiers_remain_strings() -> None: + bundle = load_bundle( + """ +format: 1 +namespace: example.boolean_like +events: + no: { direction: input } +machines: + - machine_id: boolean_like + root: + type: composite + initial: { transition_to: no } + states: + no: { on_events: { no: { transition_to: off } } } + off: {} +""" + ) + + states = bundle.raw["machines"][0]["root"]["states"] + assert list(states) == ["no", "off"] + assert all(type(name) is str for name in states) + + +@pytest.mark.parametrize("value", [2**63, -(2**63) - 1, math.inf, math.nan, object()]) +def test_native_mappings_use_the_same_portable_scalar_domain(value: object) -> None: + document = { + "format": 1, + "namespace": "example.native", + "meta": {"value": value}, + "machines": [{"machine_id": "native", "root": {}}], + } + + with pytest.raises(ValidationError) as caught: + load_bundle(document) + + assert caught.value.code in {"numeric_value_out_of_range", "non_json_value"} + + +def test_native_mapping_cycles_are_not_json_values() -> None: + cycle: dict[str, object] = {} + cycle["self"] = cycle + document = { + "format": 1, + "namespace": "example.cycle", + "meta": cycle, + "machines": [{"machine_id": "cycle", "root": {}}], + } + + with pytest.raises(ValidationError) as caught: + load_bundle(document) + + assert caught.value.code == "non_json_value" + + +def test_checked_in_format_1_example_is_valid() -> None: + bundle = load_bundle((ROOT / "examples" / "format-1.yaml").read_text(encoding="utf-8")) + + assert bundle.namespace == "example.counter" + assert bundle.machine("counter") is not None diff --git a/tests/test_logging.py b/tests/test_logging.py deleted file mode 100644 index 25d8a3c..0000000 --- a/tests/test_logging.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Standard-library diagnostic logging under the ``determa.state`` logger.""" - -from __future__ import annotations - -import logging - -import determa.state as ds -from determa.state import Host - -TURNSTILE = """\ -id: turnstile -events: - coin: { payload: { amount: { type: int, required: true } } } - push: {} -top: - esvs: - fare: { type: int, init: 50 } - initial: { transition_to: locked } - states: - locked: - on_events: - coin: { transition_to: unlocked, guard: "event.payload.amount >= fare" } - unlocked: - on_events: - push: { transition_to: locked } -""" - -FAULTING = """\ -id: boom -events: - go: {} -top: - esvs: - x: { type: int, init: 1 } - initial: { transition_to: a } - states: - a: - on_events: - go: { transition_to: b, action: [ { assign: { x: "1 / 0" } } ] } - b: {} -""" - - -def _run(machine: str, event: str, payload=None): - host = Host() - host.register_all(ds.load_definitions(machine)) - root = ds.load_definitions(machine)[0].id - host.create_root(host.machines[root], "r") - host.run_to_quiescence() - host.deliver("r", event, payload) - host.run_to_quiescence() - return host - - -def test_silent_by_default(caplog): - """No handler is configured by the app → nothing propagates as output, but the - records still exist at their levels (caplog captures them).""" - # The library attaches only a NullHandler; verify it's present. - log = logging.getLogger("determa.state") - assert any(isinstance(h, logging.NullHandler) for h in log.handlers) - - -def test_dispatch_and_transition_logged_at_debug(caplog): - with caplog.at_level(logging.DEBUG, logger="determa.state"): - _run(TURNSTILE, "coin", {"amount": 100}) - msgs = [r.getMessage() for r in caplog.records] - assert any("dispatch instance=r event=coin" in m for m in msgs) - assert any("transition=unlocked" in m for m in msgs) - - -def test_fault_logged_at_warning(caplog): - with caplog.at_level(logging.DEBUG, logger="determa.state"): - _run(FAULTING, "go") - warnings = [r for r in caplog.records if r.levelno >= logging.WARNING] - assert any("dead-letter" in r.getMessage() for r in warnings) - assert any("faulted" in r.getMessage() for r in warnings) - - -def test_no_debug_records_when_level_is_warning(caplog): - with caplog.at_level(logging.WARNING, logger="determa.state"): - _run(TURNSTILE, "coin", {"amount": 100}) - assert [r for r in caplog.records if r.levelno == logging.DEBUG] == [] diff --git a/tests/test_model.py b/tests/test_model.py deleted file mode 100644 index 8932916..0000000 --- a/tests/test_model.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Unit tests for the resolved model: LCA computation and target resolution.""" - -from __future__ import annotations - -import pytest - -from determa.state import load_definition -from determa.state.model import Machine - -# top -> c (composite, initial a) -> a, b -NESTED = """ -id: m -events: { go: {} } -top: - initial: { transition_to: c } - states: - c: - type: composite - initial: { transition_to: a } - states: - a: { on_events: { go: { transition_to: b } } } - b: { on_events: { go: { transition_to: d } } } - d: {} -""" - - -@pytest.fixture() -def machine() -> Machine: - return Machine(load_definition(NESTED)) - - -def test_lca_siblings(machine: Machine) -> None: - a = machine.by_path["top.c.a"] - b = machine.by_path["top.c.b"] - # external self-ish: LCA(a, b) is their common composite parent c - assert machine.lca(a, b) is machine.by_path["top.c"] - - -def test_lca_target_is_ancestor_self_transition(machine: Machine) -> None: - c = machine.by_path["top.c"] - # a transition owned by c targeting c: LCA(c, c) excludes c -> top - assert machine.lca(c, c) is machine.top - - -def test_lca_never_the_state_itself(machine: Machine) -> None: - a = machine.by_path["top.c.a"] - # a self-transition on leaf a: LCA is its container c (a is exited/re-entered) - assert machine.lca(a, a) is machine.by_path["top.c"] - - -def test_resolve_single_component_finds_child(machine: Machine) -> None: - c = machine.by_path["top.c"] - assert machine.resolve_target(c, "a") is machine.by_path["top.c.a"] - - -def test_resolve_dotted_from_outer(machine: Machine) -> None: - # from a state inside c, a dotted ref `c.a` resolves via top.c - a = machine.by_path["top.c.a"] - assert machine.resolve_target(a, "c.a") is a - - -def test_resolve_searches_upward_to_sibling(machine: Machine) -> None: - # `d` is a sibling of c (child of top): resolvable from inside c - a = machine.by_path["top.c.a"] - assert machine.resolve_target(a, "d") is machine.by_path["top.d"] - - -def test_unresolved_reference_raises_at_build() -> None: - from determa.state import ValidationError - - with pytest.raises(ValidationError): - Machine( - load_definition( - "id: m\ntop:\n initial: { transition_to: s }\n" - " states:\n s: { on_events: { go: { transition_to: nowhere } } }\n" - ) - ) - - -def test_meta_is_exposed_on_machine_and_states() -> None: - machine = Machine( - load_definition( - """\ -id: m -meta: - owner: ui -top: - meta: - role: root - initial: { transition_to: s } - states: - s: - meta: - tools: [search, respond] -""" - ) - ) - - assert machine.meta == {"owner": "ui"} - assert machine.top.meta == {"role": "root"} - assert machine.by_path["top.s"].meta == {"tools": ["search", "respond"]} diff --git a/tests/test_native_values.py b/tests/test_native_values.py deleted file mode 100644 index d52ddd9..0000000 --- a/tests/test_native_values.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Values crossing the boundary are canonical native/JSON types (SPEC §5.1). - -CEL-produced values (assign RHS, published payloads, …) must not surface celpy wrapper -types (``IntType``/``DoubleType``/``BoolType``/``StringType``/``MapType``/``ListType``) -through the public API, snapshots, or `--json`. -""" - -from __future__ import annotations - -import json - -from determa.state import Host, load_definitions - -TYPES = """\ -id: types -events: - go: {} -top: - esvs: - i: { type: int, init: 0 } - f: { type: float, init: 0.0 } - b: { type: bool, init: false } - s: { type: string, init: "" } - m: { type: map, init: {} } - l: { type: list, init: [] } - initial: { transition_to: a } - states: - a: - on_events: - go: - action: - - { assign: { i: "1 + 2" } } - - { assign: { f: "1.5 + 0.5" } } - - { assign: { b: "1 < 2" } } - - { assign: { s: "'a' + 'b'" } } - - { assign: { m: "{'k': 1 + 1}" } } - - { assign: { l: "[1, 2, 3]" } } - transition_to: done_ - done_: {} -""" - - -def _run() -> object: - host = Host() - host.register_all(load_definitions(TYPES)) - inst = host.create_root(host.machines["types"], "r") - host.run_to_quiescence() - host.deliver("r", "go") - host.run_to_quiescence() - return inst - - -def test_cel_assignments_are_native_python() -> None: - esvs = _run().resolved_esvs() - assert type(esvs["i"]) is int - assert type(esvs["f"]) is float - assert type(esvs["b"]) is bool - assert type(esvs["s"]) is str - assert type(esvs["m"]) is dict - assert type(esvs["l"]) is list - # nested values too (no wrappers inside containers) - assert type(esvs["m"]["k"]) is int and esvs["m"]["k"] == 2 - assert [type(x) for x in esvs["l"]] == [int, int, int] - assert esvs["i"] == 3 and esvs["f"] == 2.0 and esvs["b"] is True and esvs["s"] == "ab" - - -def test_snapshot_contains_only_native_json_values() -> None: - snap = _run().to_snapshot() - json.dumps(snap) # must be plain-serializable - - def _walk(v: object) -> None: - # celpy wrappers subclass their natives, so json.dumps alone wouldn't catch them. - assert "celpy" not in type(v).__module__, f"celpy type in snapshot: {type(v)}" - if isinstance(v, dict): - for k, val in v.items(): - _walk(k) - _walk(val) - elif isinstance(v, list): - for x in v: - _walk(x) - - _walk(snap) - - -def test_no_celpy_type_leaks_anywhere_in_esvs() -> None: - esvs = _run().resolved_esvs() - - def _assert_native(v: object) -> None: - assert type(v).__module__ == "builtins", f"non-native value leaked: {type(v)}" - if isinstance(v, dict): - for k, val in v.items(): - _assert_native(k) - _assert_native(val) - elif isinstance(v, list): - for x in v: - _assert_native(x) - - for val in esvs.values(): - _assert_native(val) diff --git a/tests/test_observer.py b/tests/test_observer.py deleted file mode 100644 index 4a49f91..0000000 --- a/tests/test_observer.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Observer adapter (SPEC §8): passive per-step callback.""" - -from __future__ import annotations - -import io -import json - -import determa.state as ds -from determa.state import CollectingObserver, Host, JsonlObserver - -TURNSTILE = """\ -id: turnstile -events: - coin: { payload: { amount: { type: int, required: true } } } - push: {} -top: - esvs: - fare: { type: int, init: 50 } - initial: { transition_to: locked } - states: - locked: - on_events: - coin: { transition_to: unlocked, guard: "event.payload.amount >= fare" } - unlocked: - on_events: - push: { transition_to: locked } -""" - - -def _host(observer=None) -> Host: - host = Host(observer=observer) - host.register_all(ds.load_definitions(TURNSTILE)) - host.create_root(host.machines["turnstile"], "t1") - host.run_to_quiescence() - return host - - -def test_observer_fires_on_auto_processing() -> None: - obs = CollectingObserver() - host = _host(obs) - obs.records.clear() # ignore initial-transition churn; focus on the send - host.deliver("t1", "coin", {"amount": 100}) - host.run_to_quiescence() - assert len(obs.records) == 1 - rec = obs.records[0] - assert rec["instance"] == "t1" - assert rec["event"] == "coin" - assert rec["entered"] == ["unlocked"] - assert rec["exited"] == ["locked"] - assert rec["faulted"] is False - assert set(rec) == { - "instance", "event", "transition", "entered", "exited", - "published", "spawned", "faulted", - } - - -def test_observer_fires_on_manual_step() -> None: - obs = CollectingObserver() - host = _host(obs) - obs.records.clear() - host.inject("t1", "coin", {"amount": 100}) # enqueue, do not process - assert obs.records == [] # nothing fired yet - host.step("t1") # one manual RTC step - assert len(obs.records) == 1 - assert obs.records[0]["entered"] == ["unlocked"] - - -def test_observer_none_is_noop() -> None: - host = _host(None) # must not raise - host.deliver("t1", "coin", {"amount": 100}) - host.run_to_quiescence() - assert host.instances["t1"].active_leaf_names() == ["unlocked"] - - -def test_observer_is_passive() -> None: - """An observer that ignores its record must not change engine behavior.""" - a = _host(CollectingObserver()) - b = _host(None) - a.deliver("t1", "coin", {"amount": 100}) - a.run_to_quiescence() - b.deliver("t1", "coin", {"amount": 100}) - b.run_to_quiescence() - assert a.instances["t1"].active_leaf_names() == b.instances["t1"].active_leaf_names() - - -def test_jsonl_observer_writes_records() -> None: - buf = io.StringIO() - host = _host(JsonlObserver(buf)) - host.deliver("t1", "coin", {"amount": 100}) - host.run_to_quiescence() - lines = [json.loads(x) for x in buf.getvalue().splitlines() if x.strip()] - assert lines[-1]["event"] == "coin" - assert lines[-1]["entered"] == ["unlocked"] diff --git a/tests/test_static_validation.py b/tests/test_static_validation.py deleted file mode 100644 index 1ee276d..0000000 --- a/tests/test_static_validation.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Static validation: unreachable states & dead transition branches (SPEC §2).""" - -from __future__ import annotations - -import pytest -import yaml - -from determa.state import collect_errors, load_definitions -from determa.state.errors import ValidationError - - -def _errors(src: str) -> list[str]: - return [e["message"] for e in collect_errors(yaml.safe_load(src))] - - -# --- unreachable states ----------------------------------------------------- -def test_unreachable_state_flagged() -> None: - src = """\ -id: m -events: { go: {} } -top: - initial: { transition_to: a } - states: - a: { on_events: { go: { transition_to: b } } } - b: {} - orphan: {} -""" - msgs = _errors(src) - assert any("unreachable state 'orphan'" in m for m in msgs) - with pytest.raises(ValidationError): - load_definitions(src) - - -def test_state_reached_only_via_composite_initial_is_ok() -> None: - src = """\ -id: m -events: { go: {} } -top: - initial: { transition_to: outer } - states: - outer: - initial: { transition_to: inner } - states: - inner: { on_events: { go: { transition_to: fin } } } - fin: { type: final } -""" - assert _errors(src) == [] - - -def test_deeply_targeted_state_marks_ancestors_reachable() -> None: - # Transitioning straight to outer.inner must not flag outer as unreachable. - src = """\ -id: m -events: { go: {} } -top: - initial: { transition_to: start } - states: - start: { on_events: { go: { transition_to: outer.inner } } } - outer: - initial: { transition_to: inner } - states: - inner: {} -""" - assert _errors(src) == [] - - -def test_orthogonal_region_reachability() -> None: - src = """\ -id: m -events: { go: {} } -top: - initial: { transition_to: par } - states: - par: - type: orthogonal - regions: - - initial: { transition_to: r1a } - states: - r1a: { on_events: { go: { transition_to: r1b } } } - r1b: {} - - initial: { transition_to: r2a } - states: { r2a: {}, r2orphan: {} } -""" - # r1a/r1b/r2a reachable via region initials + a transition; r2orphan is not. - msgs = _errors(src) - assert any("unreachable state 'r2orphan'" in m for m in msgs) - assert not any("'r1a'" in m or "'r1b'" in m or "'r2a'" in m for m in msgs) - - -# --- dead branches ---------------------------------------------------------- -def test_unguarded_branch_not_last_is_dead() -> None: - src = """\ -id: m -events: { check: { payload: { n: { type: int, required: true } } } } -top: - initial: { transition_to: s } - states: - s: - on_events: - check: - - { transition_to: a } - - { guard: "event.payload.n > 0", transition_to: b } - a: {} - b: {} -""" - assert any("must be last" in m for m in _errors(src)) - with pytest.raises(ValidationError): - load_definitions(src) - - -def test_guarded_list_with_unguarded_last_is_ok() -> None: - src = """\ -id: m -events: { check: { payload: { n: { type: int, required: true } } } } -top: - initial: { transition_to: s } - states: - s: - on_events: - check: - - { guard: "event.payload.n > 0", transition_to: a } - - { transition_to: b } - a: {} - b: {} -""" - assert _errors(src) == [] diff --git a/tests/test_stepping.py b/tests/test_stepping.py deleted file mode 100644 index 94d5658..0000000 --- a/tests/test_stepping.py +++ /dev/null @@ -1,291 +0,0 @@ -"""Introspection + step-by-step execution (SPEC §14). - -Covers the library primitives (``inject`` / ``step`` / ``inspect`` / manual mode) -and the CLI verbs (``mode`` / ``inject`` / ``step`` / ``inspect``), including the -manual-mode toggle where ``send`` enqueues without processing. -""" - -from __future__ import annotations - -import io -import json -from pathlib import Path - -import pytest - -import determa.state as ds -import determa.state.cli as cli - -TURNSTILE = """\ -id: turnstile -events: - coin: { payload: { amount: { type: int, required: true } } } - push: {} - reset: {} -top: - esvs: - fare: { type: int, init: 50 } - on_events: - reset: { transition_to: locked } - initial: { transition_to: locked } - states: - locked: - on_events: - coin: { transition_to: unlocked, guard: "event.payload.amount >= fare" } - env: { transition_to: locked } - unlocked: - on_events: - push: { transition_to: locked } - error: { transition_to: unlocked } -""" - - -def _host() -> ds.Host: - host = ds.Host() - host.register_all(ds.load_definitions(TURNSTILE)) - host.create_root(host.machines["turnstile"], "t1") - host.run_to_quiescence() - return host - - -# --- library: inject / step / inspect --------------------------------------- -def test_inject_enqueues_without_processing() -> None: - host = _host() - inst = host.instances["t1"] - assert inst.active_leaf_names() == ["locked"] - - accepted = host.inject("t1", "coin", {"amount": 100}) - assert accepted is True - # enqueued, but the config is unchanged (nothing processed). - assert inst.active_leaf_names() == ["locked"] - assert [e.type for e in inst.queue] == ["coin"] - - -def test_inject_rejects_invalid_payload() -> None: - host = _host() - assert host.inject("t1", "coin", {"amount": "nope"}) is False - assert len(host.instances["t1"].queue) == 0 # not enqueued - - -def test_step_returns_per_step_record_and_advances() -> None: - host = _host() - host.inject("t1", "coin", {"amount": 100}) - records = host.step("t1", 1) - inst = host.instances["t1"] - - assert len(records) == 1 - rec = records[0] - assert rec["event"] == "coin" - assert rec["transition"] == "unlocked" - assert rec["entered"] == ["unlocked"] - assert rec["exited"] == ["locked"] - assert rec["published"] == [] - assert rec["spawned"] == [] - assert rec["faulted"] is False - assert inst.active_leaf_names() == ["unlocked"] - - -def test_step_drains_only_n_events() -> None: - host = _host() - host.inject("t1", "coin", {"amount": 100}) # -> unlocked - host.inject("t1", "push") # -> locked - records = host.step("t1", 1) # one RTC step only - assert len(records) == 1 - assert host.instances["t1"].active_leaf_names() == ["unlocked"] - # one event still pending. - assert [e.type for e in host.instances["t1"].queue] == ["push"] - - -def test_step_with_empty_queue_returns_no_records() -> None: - host = _host() - assert host.step("t1", 5) == [] - - -def test_inspect_exposes_full_internal_state() -> None: - host = _host() - host.inject("t1", "coin", {"amount": 100}) - info = host.inspect("t1") - assert info["status"] == "active" - assert info["config"] == ["locked"] - assert info["esvs"] == {"fare": 50} - assert info["enabled"] == ["coin", "reset"] - assert [e["type"] for e in info["queue"]] == ["coin"] - assert info["deferred"] == [] - assert info["timers"] == [] - assert info["history"] == {} - - -def test_enabled_events_are_declared_structural_and_lifecycle_filtered() -> None: - host = _host() - inst = host.instances["t1"] - assert inst.enabled_events() == ["coin", "reset"] - assert host.enabled_events("t1") == ["coin", "reset"] - - # Guard failure does not remove the structural handler. - assert host.deliver("t1", "coin", {"amount": 0}) is True - host.run_to_quiescence() - assert host.enabled_events(inst) == ["coin", "reset"] - - assert host.deliver("t1", "coin", {"amount": 100}) is True - host.run_to_quiescence() - assert host.enabled_events("t1") == ["push", "reset"] - - -def test_manual_mode_send_enqueues_via_maybe_run() -> None: - host = _host() - host.mode = "manual" - host.deliver("t1", "coin", {"amount": 100}) - host.maybe_run() # manual -> does NOT run - assert host.instances["t1"].active_leaf_names() == ["locked"] - host.step("t1", 1) # explicit step advances - assert host.instances["t1"].active_leaf_names() == ["unlocked"] - - -# --- CLI verbs --------------------------------------------------------------- -def _run( - tmp_path: Path, - argv: list[str], - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> tuple[int, str]: - rc = cli.main(["--store", str(tmp_path / "store"), *argv]) - out = capsys.readouterr().out - return rc, out - - -def _run_batch( - tmp_path: Path, - lines: list[list[str]], - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> tuple[int, list[dict]]: - stdin = "".join(json.dumps(line) + "\n" for line in lines) - monkeypatch.setattr("sys.stdin", io.StringIO(stdin)) - rc = cli.main(["--store", str(tmp_path / "store"), "run", "-"]) - out = capsys.readouterr().out - return rc, [json.loads(x) for x in out.splitlines() if x.strip()] - - -def test_cli_mode_persists_and_toggles(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - - rc, out = _run(tmp_path, ["mode", "--json"], monkeypatch, capsys) - assert rc == 0 and json.loads(out) == {"mode": "auto"} - - rc, _ = _run(tmp_path, ["mode", "manual"], monkeypatch, capsys) - assert rc == 0 - - rc, out = _run(tmp_path, ["mode", "--json"], monkeypatch, capsys) - assert json.loads(out) == {"mode": "manual"} - - -def test_cli_inject_enqueues_without_processing(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - _run(tmp_path, ["new", "t1", str(machine)], monkeypatch, capsys) - - rc, out = _run( - tmp_path, ["inject", "t1", "coin", "--payload", "amount=100", "--json"], - monkeypatch, capsys, - ) - assert rc == 0 - obj = json.loads(out) - assert obj["config"] == ["locked"] # not processed - - _, out = _run(tmp_path, ["inspect", "t1", "--json"], monkeypatch, capsys) - info = json.loads(out) - assert [e["type"] for e in info["queue"]] == ["coin"] - - -def test_cli_step_advances_and_reports(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - _run(tmp_path, ["new", "t1", str(machine)], monkeypatch, capsys) - _run(tmp_path, ["inject", "t1", "coin", "--payload", "amount=100"], monkeypatch, capsys) - - rc, out = _run(tmp_path, ["step", "t1", "--steps", "1", "--json"], monkeypatch, capsys) - assert rc == 0 - obj = json.loads(out) - assert obj["config"] == ["unlocked"] - assert len(obj["steps"]) == 1 - assert obj["steps"][0]["transition"] == "unlocked" - - -def test_cli_inspect_full_shape(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - _run(tmp_path, ["new", "t1", str(machine)], monkeypatch, capsys) - _run(tmp_path, ["inject", "t1", "coin", "--payload", "amount=100"], monkeypatch, capsys) - - rc, out = _run(tmp_path, ["inspect", "t1", "--json"], monkeypatch, capsys) - assert rc == 0 - info = json.loads(out) - assert info["instance"] == "t1" - assert info["config"] == ["locked"] - assert info["enabled"] == ["coin", "reset"] - assert info["queue"] == [{"type": "coin", "payload": {"amount": 100}}] - assert info["deferred"] == [] - assert info["timers"] == [] - - -def test_cli_enabled_reports_current_events(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - _run(tmp_path, ["new", "t1", str(machine)], monkeypatch, capsys) - - rc, out = _run(tmp_path, ["enabled", "t1", "--json"], monkeypatch, capsys) - assert rc == 0 - assert json.loads(out) == {"instance": "t1", "enabled": ["coin", "reset"]} - - _run(tmp_path, ["send", "t1", "coin", "--payload", "amount=100"], monkeypatch, capsys) - rc, out = _run(tmp_path, ["enabled", "t1"], monkeypatch, capsys) - assert rc == 0 - assert out.splitlines() == ["push", "reset"] - - -def test_cli_send_in_manual_mode_enqueues_only(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - _run(tmp_path, ["new", "t1", str(machine)], monkeypatch, capsys) - _run(tmp_path, ["mode", "manual"], monkeypatch, capsys) - - # send enqueues but does not process -> config stays locked. - rc, out = _run( - tmp_path, ["send", "t1", "coin", "--payload", "amount=100", "--json"], - monkeypatch, capsys, - ) - assert rc == 0 - assert json.loads(out)["config"] == ["locked"] - - _, out = _run(tmp_path, ["inspect", "t1", "--json"], monkeypatch, capsys) - assert [e["type"] for e in json.loads(out)["queue"]] == ["coin"] - - rc, out = _run(tmp_path, ["step", "t1", "--json"], monkeypatch, capsys) - assert rc == 0 and json.loads(out)["config"] == ["unlocked"] - - -def test_cli_batch_stepping_session(tmp_path, monkeypatch, capsys): - """The black-box §13.7 form: one run process, manual mode, step once.""" - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - rc, results = _run_batch( - tmp_path, - [ - ["new", "t1", str(machine)], - ["mode", "manual"], - ["send", "t1", "coin", "--payload", "amount=100"], - ["inspect", "t1"], - ["step", "t1", "--steps", "1"], - ["mode", "auto"], - ], - monkeypatch, - capsys, - ) - assert rc == 0 - assert [r["ok"] for r in results] == [True, True, True, True, True, True] - assert results[1]["result"] == {"mode": "manual"} - assert results[2]["result"]["config"] == ["locked"] - assert [e["type"] for e in results[3]["result"]["queue"]] == ["coin"] - assert results[4]["result"]["config"] == ["unlocked"] - assert results[5]["result"] == {"mode": "auto"} diff --git a/tests/test_stores.py b/tests/test_stores.py deleted file mode 100644 index 81ce830..0000000 --- a/tests/test_stores.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Store backends + the --store scheme (SPEC §8, §13.1). - -All backends (``file``, ``mem``, ``sqlite``) round-trip an instance identically; -``sqlite:`` persists across CLI invocations; ``mem:`` is isolated per process (one -``run`` session). ``open_store`` parses the scheme. -""" - -from __future__ import annotations - -import io -import json - -import pytest - -import determa.state as ds -import determa.state.cli as cli -from determa.state.store import FileStore, MemoryStore, SqliteStore, StoreState, open_store - -TURNSTILE = """\ -id: turnstile -events: - coin: { payload: { amount: { type: int, required: true } } } - push: {} -top: - esvs: - fare: { type: int, init: 50 } - initial: { transition_to: locked } - states: - locked: - on_events: - coin: { transition_to: unlocked, guard: "event.payload.amount >= fare" } - unlocked: - on_events: - push: { transition_to: locked } -""" - - -# --- open_store scheme parsing ---------------------------------------------- -def test_open_store_parses_each_scheme(tmp_path) -> None: - assert isinstance(open_store(str(tmp_path / "f")), FileStore) - assert isinstance(open_store(f"file:{tmp_path / 'f2'}"), FileStore) - assert isinstance(open_store("mem:"), MemoryStore) - assert isinstance(open_store(f"sqlite:{tmp_path / 's.db'}"), SqliteStore) - - -def test_open_store_bare_path_is_file_for_backcompat(tmp_path) -> None: - store = open_store(str(tmp_path / "bare")) - assert isinstance(store, FileStore) - - -# --- round-trip parity across backends -------------------------------------- -def _state() -> StoreState: - host = ds.Host() - host.register_all(ds.load_definitions(TURNSTILE)) - host.create_root(host.machines["turnstile"], "t1") - host.run_to_quiescence() - return StoreState( - defs={"turnstile@1": TURNSTILE}, - instances=host.snapshot_all(), - now=12_000, - spawn_counters={"t1": 3}, - mode="manual", - ) - - -@pytest.mark.parametrize( - "factory", - [ - pytest.param(lambda tmp: FileStore(tmp / "f"), id="file"), - pytest.param(lambda tmp: MemoryStore(), id="mem"), - pytest.param(lambda tmp: SqliteStore(tmp / "s.db"), id="sqlite"), - ], -) -def test_round_trip_state_identical(factory, tmp_path) -> None: - store = factory(tmp_path) - store.save(_state()) - loaded = store.load() - # the snapshot JSON (§8) is identical across backends. - assert loaded.defs == {"turnstile@1": TURNSTILE} - assert loaded.instances == _state().instances - assert loaded.now == 12_000 - assert loaded.spawn_counters == {"t1": 3} - assert loaded.mode == "manual" - - -def test_file_store_writes_snapshot_json_files(tmp_path) -> None: - store = FileStore(tmp_path / "f") - store.save(_state()) - files = sorted(p.name for p in (tmp_path / "f").iterdir()) - assert files == ["defs.json", "instances.json", "meta.json"] - snap = json.loads((tmp_path / "f" / "instances.json").read_text()) - assert snap[0]["def_id"] == "turnstile" - - -def test_sqlite_store_persists_across_handles(tmp_path) -> None: - path = tmp_path / "s.db" - SqliteStore(path).save(_state()) - # a fresh handle on the same file reads it back (a new CLI invocation). - loaded = SqliteStore(path).load() - assert loaded.instances == _state().instances - assert loaded.mode == "manual" - - -def test_memory_store_is_ephemeral_per_instance() -> None: - a = MemoryStore() - a.save(_state()) - b = MemoryStore() # a separate process has no state - assert b.load().instances == [] - - -# --- end-to-end through the CLI --------------------------------------------- -def _run( - store_spec: str, - argv: list[str], - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> tuple[int, str]: - rc = cli.main(["--store", store_spec, *argv]) - return rc, capsys.readouterr().out - - -def _run_batch( - store_spec: str, - lines: list[list[str]], - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> tuple[int, list[dict]]: - stdin = "".join(json.dumps(line) + "\n" for line in lines) - monkeypatch.setattr("sys.stdin", io.StringIO(stdin)) - rc = cli.main(["--store", store_spec, "run", "-"]) - out = capsys.readouterr().out - return rc, [json.loads(x) for x in out.splitlines() if x.strip()] - - -def test_mem_store_holds_state_within_one_run_session(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - # one process: state persists across batch lines via the in-memory store. - rc, results = _run_batch( - "mem:", - [ - ["new", "t1", str(machine)], - ["send", "t1", "coin", "--payload", "amount=100"], - ["state", "t1"], - ], - monkeypatch, - capsys, - ) - assert rc == 0 - assert results[0]["result"]["config"] == ["locked"] - assert results[1]["result"]["config"] == ["unlocked"] - assert results[2]["result"]["config"] == ["unlocked"] - - -def test_mem_store_does_not_persist_across_invocations(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - rc, _ = _run("mem:", ["new", "t1", str(machine)], monkeypatch, capsys) - assert rc == 0 - # a separate process: the mem store is empty, so the instance is gone. - rc, _ = _run("mem:", ["state", "t1"], monkeypatch, capsys) - assert rc == 4 # not found - - -def test_sqlite_store_persists_across_cli_invocations(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - db = f"sqlite:{tmp_path / 's.db'}" - rc, out = _run(db, ["new", "t1", str(machine), "--json"], monkeypatch, capsys) - assert rc == 0 and json.loads(out)["config"] == ["locked"] - rc, out = _run( - db, ["send", "t1", "coin", "--payload", "amount=100", "--json"], monkeypatch, capsys - ) - assert rc == 0 and json.loads(out)["config"] == ["unlocked"] - # a third invocation reads the persisted state. - rc, out = _run(db, ["state", "t1", "--json"], monkeypatch, capsys) - assert rc == 0 and json.loads(out)["config"] == ["unlocked"] - - -def test_backends_produce_identical_cli_results(tmp_path, monkeypatch, capsys): - machine = tmp_path / "m.yaml" - machine.write_text(TURNSTILE) - - def drive(spec: str) -> list[dict]: - _run(spec, ["new", "t1", str(machine)], monkeypatch, capsys) - _, out = _run( - spec, ["send", "t1", "coin", "--payload", "amount=100", "--json"], monkeypatch, capsys - ) - return json.loads(out) - - file_result = drive(str(tmp_path / "file")) - sqlite_result = drive(f"sqlite:{tmp_path / 's.db'}") - assert file_result == sqlite_result diff --git a/tests/test_submachine.py b/tests/test_submachine.py deleted file mode 100644 index 44d83f8..0000000 --- a/tests/test_submachine.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Submachine states — synchronous reuse, seeding, completion, isolation (SPEC §5.6.1).""" - -from __future__ import annotations - -import pytest - -from determa.state import Host, load_definitions -from determa.state.errors import ValidationError - -ORDER = """\ -id: order -events: - pay: { payload: { amount: { type: int, required: true } } } - cancel: {} -top: - esvs: { total: { type: int, init: 100 } } - initial: { transition_to: checkout } - states: - checkout: - submachine: payment - with: { due: "total" } - on_events: - cancel: { transition_to: cancelled } - done: { transition_to: paid } - paid: {} - cancelled: {} ---- -id: payment -events: - pay: { payload: { amount: { type: int, required: true } } } -top: - esvs: - due: { type: int, external: true } - paid: { type: int, init: 0 } - initial: { transition_to: awaiting } - states: - awaiting: - on_events: - pay: - guard: "event.payload.amount >= due" - action: [ { assign: { paid: "event.payload.amount" } } ] - transition_to: settled - settled: { type: final } -""" - - -def _order() -> tuple[Host, object]: - host = Host() - host.register_all(load_definitions(ORDER)) - inst = host.create_root(host.machines["order"], "o") - host.run_to_quiescence() - return host, inst - - -def test_submachine_entered_synchronously() -> None: - _, inst = _order() - assert inst.active_leaf_names() == ["awaiting"] # inlined submachine's initial state - - -def test_submachine_completes_to_parent_via_done() -> None: - host, inst = _order() - host.deliver("o", "pay", {"amount": 100}) # settles -> final -> done -> paid - host.run_to_quiescence() - assert inst.active_leaf_names() == ["paid"] - - -def test_with_seeding_reaches_the_submachine() -> None: - # due is seeded from the parent's total (100). A pay below it fails the guard and - # stays in the submachine — which only holds if `due` was seeded to 100 (not 0/null). - host, inst = _order() - host.deliver("o", "pay", {"amount": 50}) - host.run_to_quiescence() - assert inst.active_leaf_names() == ["awaiting"] - - -def test_parent_interrupts_submachine() -> None: - host, inst = _order() - host.deliver("o", "cancel") # unhandled by the submachine -> bubbles to parent - host.run_to_quiescence() - assert inst.active_leaf_names() == ["cancelled"] - - -def test_esv_isolation_parent_vars_not_visible_inside() -> None: - # Inside the submachine, resolving esvs sees the submachine's own vars, not `total`. - _, inst = _order() - esvs = inst.resolved_esvs() - assert "due" in esvs and "paid" in esvs - assert "total" not in esvs - - -def test_unknown_submachine_rejected() -> None: - src = "id: m\ntop:\n initial: { transition_to: s }\n states:\n s: { submachine: nope }\n" - with pytest.raises(ValidationError): - Host().register_all(load_definitions(src)) - - -def test_cyclic_submachine_rejected() -> None: - src = ( - "id: a\ntop:\n initial: { transition_to: s }\n states:\n s: { submachine: b }\n" - "---\n" - "id: b\ntop:\n initial: { transition_to: s }\n states:\n s: { submachine: a }\n" - ) - with pytest.raises(ValidationError): - Host().register_all(load_definitions(src)) diff --git a/tests/test_validator.py b/tests/test_validator.py deleted file mode 100644 index 2376aaa..0000000 --- a/tests/test_validator.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Validation tests: JSON Schema + reserved-name enforcement (SPEC §2).""" - -from __future__ import annotations - -import pytest - -from determa.state import ValidationError, collect_errors, load_definition, validate, yaml12 -from determa.state.validator import ALL_RESERVED, RESERVED_EVENTS, RESERVED_NAMES - -VALID = """ -id: m -events: - go: {} -top: - esvs: - n: { type: int, init: 0 } - initial: { transition_to: s } - states: - s: - on_events: - go: { transition_to: t } - t: {} -""" - -# A skeleton with placeholders so reserved-name cases can inject a name. -SKELETON_STATES = """ -id: m -top: - initial: {{ transition_to: {name} }} - states: - {name}: {{}} -""" - -SKELETON_ESVS = """ -id: m -top: - esvs: - {name}: {{ type: int }} - initial: {{ transition_to: s }} - states: - s: {{}} -""" - - -def _paths(doc: str) -> set[str]: - return {e["path"] for e in collect_errors(yaml12.load(doc))} - - -def test_valid_machine_has_no_errors() -> None: - validate(yaml12.load(VALID)) - - -def test_missing_required_top_level() -> None: - assert _paths("id: m\n") - - -def test_composite_requires_initial_and_states() -> None: - doc = yaml12.load( - "id: m\ntop:\n initial: { transition_to: s }\n states:\n s: { type: composite }\n" - ) - assert collect_errors(doc) # composite `s` lacks initial+states - - -@pytest.mark.parametrize("reserved", sorted(RESERVED_NAMES)) -def test_reserved_state_name_rejected(reserved: str) -> None: - errs = collect_errors(yaml12.load(SKELETON_STATES.format(name=reserved))) - assert f"/top/states/{reserved}" in {e["path"] for e in errs} - - -@pytest.mark.parametrize("reserved", sorted(RESERVED_EVENTS)) -def test_reserved_event_name_allowed_as_state(reserved: str) -> None: - # Reserved event names occupy a different namespace and may be state names - # (e.g. a state named `done` — conformance cases 16/21 rely on this). - validate(yaml12.load(SKELETON_STATES.format(name=reserved))) - - -@pytest.mark.parametrize("reserved", sorted(RESERVED_NAMES)) -def test_reserved_esv_name_rejected(reserved: str) -> None: - errs = collect_errors(yaml12.load(SKELETON_ESVS.format(name=reserved))) - assert f"/top/esvs/{reserved}" in {e["path"] for e in errs} - - -@pytest.mark.parametrize("reserved", sorted(ALL_RESERVED)) -def test_reserved_declared_event_rejected(reserved: str) -> None: - doc = yaml12.load( - f"id: m\nevents:\n {reserved}: {{}}\ntop:\n" - " initial: { transition_to: s }\n states:\n s: {}\n" - ) - errs = {e["path"] for e in collect_errors(doc)} - assert f"/events/{reserved}" in errs - - -def test_reserved_event_names_allowed_as_handlers() -> None: - # env/error/done may appear as on_events handlers (SPEC §5.4/§5.6/§5.10). - doc = yaml12.load( - "id: m\ntop:\n on_events:\n error: { transition_to: f }\n" - " initial: { transition_to: s }\n states:\n s: {}\n f: {}\n" - ) - validate(doc) - - -def test_load_definition_validates() -> None: - load_definition(VALID) - - -def test_load_definition_rejects_reserved() -> None: - with pytest.raises(ValidationError): - load_definition( - "id: m\ntop:\n esvs:\n id: { type: int }\n" - " initial: { transition_to: s }\n states:\n s: {}\n" - ) - - -def test_error_records_have_path_and_message() -> None: - with pytest.raises(ValidationError) as exc_info: - load_definition("id: m\ntop:\n esvs:\n event: { type: int }\n") - rec = exc_info.value.errors - assert rec - assert all(set(r.keys()) == {"path", "message"} for r in rec) diff --git a/tests/test_yaml12.py b/tests/test_yaml12.py deleted file mode 100644 index aa1ae1f..0000000 --- a/tests/test_yaml12.py +++ /dev/null @@ -1,90 +0,0 @@ -"""YAML 1.2 core-schema resolution tests (SPEC §2). - -PyYAML defaults to YAML 1.1 (yes/no/on/off are booleans, leading-zero octal, -sexagesimals). These pin the 1.2-core behaviour the engine relies on. -""" - -from __future__ import annotations - -import math - -import pytest - -from determa.state import yaml12 - -CASES = { - # booleans — ONLY true/false (and capitalisations) are bool in 1.2 core. - "true": True, - "True": True, - "TRUE": True, - "false": False, - "False": False, - "FALSE": False, - # 1.1 booleans must now be plain strings. - "yes": "yes", - "no": "no", - "on": "on", - "off": "off", - "y": "y", - "n": "n", - "Y": "Y", - "N": "N", - # null forms. - "null": None, - "Null": None, - "NULL": None, - "~": None, - "": None, - # ints — plain decimal, no leading-zero octal, 0o/0x prefixes. - "0": 0, - "42": 42, - "-7": -7, - "+7": 7, - "017": 17, # NOT octal 15 (1.1 behaviour) - "0o17": 15, - "0x1F": 31, - "-0x1F": -31, - # floats. - "3.14": 3.14, - ".5": 0.5, - "1.": 1.0, - "1e3": 1000.0, - "-2.5": -2.5, - "0.0": 0.0, - # plain date/time-like stays a string (no timestamp tag in 1.2 core). - "2024-01-15": "2024-01-15", - # sexagesimal is NOT an int in 1.2 core. - "1:2:3": "1:2:3", -} - - -@pytest.mark.parametrize(("text", "expected"), sorted(CASES.items())) -def test_scalar_resolution(text: str, expected: object) -> None: - assert yaml12.load(text) == expected - - -def test_inf_nan() -> None: - assert math.isinf(yaml12.load(".inf")) - assert yaml12.load(".inf") > 0 - assert yaml12.load("-.inf") < 0 - assert math.isnan(yaml12.load(".nan")) - - -def test_quoted_scalars_are_strings() -> None: - assert yaml12.load("'true'") == "true" - assert yaml12.load('"42"') == "42" - assert yaml12.load("'yes'") == "yes" - - -def test_load_all_multidoc() -> None: - docs = yaml12.load_all("id: a\n---\nid: b\n---\n# empty\n") - assert [d["id"] for d in docs] == ["a", "b"] - - -def test_structures_roundtrip() -> None: - doc = yaml12.load( - "events:\n coin: { payload: { amount: { type: int, required: true } } }\n" - "list: [1, 2, yes]\n" - ) - assert doc["events"]["coin"]["payload"]["amount"]["required"] is True - assert doc["list"] == [1, 2, "yes"] From 8066fbaf2a6e5be65ac154ec6d6ebbeba3b1ba3c Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Tue, 28 Jul 2026 18:46:22 +0900 Subject: [PATCH 2/3] fix: harden format 1 portable semantics --- .github/workflows/test.yml | 4 +- AGENTS.md | 4 +- README.md | 4 +- conformance/harness.py | 51 ++- conformance/pins.py | 4 +- conformance/test_conformance.py | 24 + scripts/sync_schema.py | 2 +- src/determa/state/cel.py | 757 +++++++++++++++++++++++++++----- src/determa/state/engine.py | 171 +++++++- src/determa/state/validator.py | 211 ++++----- src/determa/state/yaml12.py | 22 +- tests/test_cel.py | 172 ++++++++ tests/test_engine.py | 346 ++++++++++++++- tests/test_loading.py | 197 +++++++++ 14 files changed, 1733 insertions(+), 236 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0fb7b8c..3371a04 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,13 +36,13 @@ jobs: uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: repository: fruwehq/determa-state-conformance - ref: 409bbdc6c2d4a4e9d50ddb1d994c5f5cd7d97762 + ref: fc4842010ab8d83bf4c5c6280a5627ca86829f7f path: .pinned/determa-state-conformance - name: Check out pinned specification uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: repository: fruwehq/determa-state-spec - ref: 03771fac569a47b82f27891cd3700d4d1d876f8b + ref: 4bd4d9588d11b75d376380b6120676a056a4bc45 path: .pinned/determa-state-spec - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: diff --git a/AGENTS.md b/AGENTS.md index ff5a6ed..51df62d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,8 @@ package so it can coexist with the umbrella `determa` launcher. The implementation is conformant only when it passes the language-neutral suite. Format-1 work currently uses these immutable pre-release inputs: -- specification: `03771fac569a47b82f27891cd3700d4d1d876f8b`; -- conformance: `409bbdc6c2d4a4e9d50ddb1d994c5f5cd7d97762` (75 core cases). +- specification: `4bd4d9588d11b75d376380b6120676a056a4bc45`; +- conformance: `fc4842010ab8d83bf4c5c6280a5627ca86829f7f` (75 core cases). The package version is still `0.0.6`; the specification, conformance suite, Python engine, and Rust engine version together. diff --git a/README.md b/README.md index a9835a3..d6bbb78 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Python implementation of [Determa State](https://github.com/fruwehq/determa-stat a language-agnostic statechart engine with a shared normative conformance suite. This pre-release implements Determa State `format: 1` at the approved specification -commit `03771fac569a47b82f27891cd3700d4d1d876f8b`. Correctness is determined by the +commit `4bd4d9588d11b75d376380b6120676a056a4bc45`. Correctness is determined by the 75-case core suite at conformance commit -`409bbdc6c2d4a4e9d50ddb1d994c5f5cd7d97762`. +`fc4842010ab8d83bf4c5c6280a5627ca86829f7f`. The package version remains `0.0.6` until the specification, conformance suite, Python engine, and Rust engine are released together. diff --git a/conformance/harness.py b/conformance/harness.py index d6c0694..6914c7f 100644 --- a/conformance/harness.py +++ b/conformance/harness.py @@ -112,6 +112,7 @@ def run_case(case: CoreCase) -> None: captures: dict[str, list[dict[str, Any]]] = {} for index, step in enumerate(test.get("steps") or []): prior_state = state + prior_state_snapshot = copy.deepcopy(state) target_runtime_id = state["root_runtime_id"] dispatch_bundle = bundle if "send" in step: @@ -135,11 +136,13 @@ def run_case(case: CoreCase) -> None: } if "correlation_id" in send: envelope["correlation_id"] = send["correlation_id"] + envelope_snapshot = copy.deepcopy(envelope) result = dispatch(dispatch_bundle, state, {"input": envelope}) elif "deliver" in step: delivery = step["deliver"] envelope = copy.deepcopy(captures[delivery["captured"]][delivery["index"]]) target_runtime_id = _target_runtime_id(envelope["target"]) + envelope_snapshot = copy.deepcopy(envelope) result = dispatch(dispatch_bundle, state, {"internal": envelope}) else: raise AssertionError(f"{case.name} step {index}: unsupported driver step") @@ -149,6 +152,9 @@ def run_case(case: CoreCase) -> None: target_runtime_id, captures, prior_state=prior_state, + prior_state_snapshot=prior_state_snapshot, + input_envelope=envelope, + input_envelope_snapshot=envelope_snapshot, ) state = result["state"] if "capture_emissions_as" in step: @@ -182,16 +188,42 @@ def _assert_result( captures: dict[str, list[dict[str, Any]]], *, prior_state: dict[str, Any] | None = None, + prior_state_snapshot: dict[str, Any] | None = None, + input_envelope: dict[str, Any] | None = None, + input_envelope_snapshot: dict[str, Any] | None = None, ) -> None: del captures + supported = { + "status", + "disposition", + "rejection", + "fault", + "caller_still_owns_input", + "state", + "config", + "variables", + "history", + "components", + "owned_instances", + "emissions", + } + assert set(expected) <= supported, set(expected) - supported for name in ("status", "disposition"): if name in expected: assert result[name] == expected[name], (name, result[name], expected[name]) + if "state" in expected: + assert result["state"] == expected["state"] if "rejection" in expected: _assert_partial(result["rejection"], expected["rejection"], state=result["state"]) if "fault" in expected: _assert_partial(result["fault"], expected["fault"], state=result["state"]) if expected.get("caller_still_owns_input"): + assert input_envelope is not None + assert input_envelope_snapshot is not None + assert input_envelope == input_envelope_snapshot + assert prior_state is not None + assert prior_state_snapshot is not None + assert prior_state == prior_state_snapshot assert result["state"] is not None assert not {"queue", "timers", "dead_letters"} & set(result["state"]) assert all( @@ -254,7 +286,8 @@ def _assert_runtime( assert len(children) == len(expected["owned_instances"]) for child, child_expected in zip(children, expected["owned_instances"], strict=True): key = child_expected["key"] - if key.get("owner") == "root": + if "owner" in key: + assert key["owner"] == "root" assert child["owner_runtime_id"] == state["root_runtime_id"] if "spawn_sequence" in key: assert child["spawn_sequence"] == key["spawn_sequence"] @@ -308,6 +341,8 @@ def _assert_emission( elif isinstance(value, dict) and "bound_instance" in value: reference = _visible_variables(state, runtime)[value["bound_instance"]] assert actual["target"] == {"spawned_instance": reference} + else: + raise AssertionError(f"unsupported target assertion: {value!r}") elif key == "payload": _assert_partial(actual["payload"], value, state=state) else: @@ -319,6 +354,7 @@ def _assert_partial(actual: Any, expected: Any, *, state: dict[str, Any] | None assert isinstance(actual, dict), (actual, expected) if set(expected) == {"instance_reference"}: assertion = expected["instance_reference"] + assert set(assertion) <= {"machine_id", "targetable"} assert _is_reference(actual) if "machine_id" in assertion: assert actual["machine_id"] == assertion["machine_id"] @@ -331,6 +367,7 @@ def _assert_partial(actual: Any, expected: Any, *, state: dict[str, Any] | None and target.get("role") == "spawned" and target.get("status") == "running" and target.get("instance_reference") == actual + and not _has_faulted_ancestor(state, target) ) assert targetable is assertion["targetable"] return @@ -437,3 +474,15 @@ def _is_descendant_runtime( parent = state["runtimes"].get(owner_id) owner_id = parent.get("owner_runtime_id") if parent is not None else None return False + + +def _has_faulted_ancestor(state: dict[str, Any], runtime: dict[str, Any]) -> bool: + owner_id = runtime.get("owner_runtime_id") + while owner_id is not None: + owner = state["runtimes"].get(owner_id) + if owner is None: + return True + if owner.get("status") == "faulted": + return True + owner_id = owner.get("owner_runtime_id") + return False diff --git a/conformance/pins.py b/conformance/pins.py index 89fec02..bc404af 100644 --- a/conformance/pins.py +++ b/conformance/pins.py @@ -4,8 +4,8 @@ from pathlib import Path -CONFORMANCE_COMMIT = "409bbdc6c2d4a4e9d50ddb1d994c5f5cd7d97762" -SPEC_COMMIT = "03771fac569a47b82f27891cd3700d4d1d876f8b" +CONFORMANCE_COMMIT = "fc4842010ab8d83bf4c5c6280a5627ca86829f7f" +SPEC_COMMIT = "4bd4d9588d11b75d376380b6120676a056a4bc45" ROOT = Path(__file__).resolve().parent.parent CONFORMANCE_CACHE = ROOT / ".cache" / f"determa-state-conformance-{CONFORMANCE_COMMIT[:12]}" diff --git a/conformance/test_conformance.py b/conformance/test_conformance.py index 9b05c99..c2d1ce8 100644 --- a/conformance/test_conformance.py +++ b/conformance/test_conformance.py @@ -7,7 +7,9 @@ from pathlib import Path import pytest +from jsonschema import Draft202012Validator +from determa.state import load_bundle from determa.state.validator import schema as bundled_schema from .harness import CORE_DIR, CoreCase, core_cases, run_case @@ -21,6 +23,14 @@ def _spec_schema() -> dict | None: return json.loads(path.read_text(encoding="utf-8")) if path.exists() else None +def _spec_root() -> Path | None: + override = os.environ.get("DETERMA_SPEC_DIR") + if not override: + return None + root = Path(override) + return root if root.exists() else None + + def test_suite_present() -> None: assert CORE_DIR.exists(), "pinned conformance suite is unavailable" assert len(core_cases()) == 75 @@ -32,6 +42,20 @@ def test_bundled_schema_matches_pinned_spec() -> None: assert bundled_schema() == upstream +def test_bundled_schema_is_valid_draft_2020_12() -> None: + Draft202012Validator.check_schema(bundled_schema()) + + +@pytest.mark.parametrize("name", ["minimal.yaml", "full.yaml"]) +def test_authoritative_spec_examples_load_semantically(name: str) -> None: + root = _spec_root() + assert root is not None, "pinned specification is unavailable" + + bundle = load_bundle((root / "examples" / name).read_text(encoding="utf-8")) + + assert bundle.raw["format"] == 1 + + @pytest.mark.parametrize("case", core_cases(), ids=lambda case: case.name) def test_core_case(case: CoreCase) -> None: run_case(case) diff --git a/scripts/sync_schema.py b/scripts/sync_schema.py index 85ae99f..a0f93d3 100644 --- a/scripts/sync_schema.py +++ b/scripts/sync_schema.py @@ -19,7 +19,7 @@ ROOT = Path(__file__).resolve().parent.parent DEST = ROOT / "src" / "determa" / "state" / "data" / "machine.schema.json" -SPEC_COMMIT = "03771fac569a47b82f27891cd3700d4d1d876f8b" +SPEC_COMMIT = "4bd4d9588d11b75d376380b6120676a056a4bc45" def _fetch() -> str: diff --git a/src/determa/state/cel.py b/src/determa/state/cel.py index ecbd3bd..9076b6a 100644 --- a/src/determa/state/cel.py +++ b/src/determa/state/cel.py @@ -2,8 +2,11 @@ from __future__ import annotations +import ast import math -import re +from collections.abc import Mapping +from dataclasses import dataclass +from decimal import Decimal from functools import lru_cache from typing import TYPE_CHECKING, Any, cast @@ -17,8 +20,107 @@ _environment: Any = None _INT_MIN = -(2**63) _INT_MAX = 2**63 - 1 -_ALLOWED_FUNCTIONS = frozenset({"size", "has", "double", "int", "string"}) -_CEL_WORDS = frozenset({"true", "false", "null", "in"}) + + +class CelProfileError(CelError): + """An expression uses a symbol or overload outside the portable profile.""" + + +class CelTypeError(CelError): + """An expression has an invalid activation, field, or destination type.""" + + +@dataclass(frozen=True) +class StaticType: + """One load-time type in the closed portable CEL profile.""" + + kind: str + element: StaticType | None = None + fields: tuple[tuple[str, StaticType], ...] = () + record_name: str | None = None + machine_id: str | None = None + nullable: bool = False + + def field(self, name: str) -> StaticType | None: + return dict(self.fields).get(name) + + +NULL = StaticType("null") +BOOL = StaticType("bool") +INT = StaticType("int") +FLOAT = StaticType("float") +STRING = StaticType("string") +DYNAMIC = StaticType("dynamic") +LIST = StaticType("list", element=DYNAMIC) +MAP = StaticType("map", element=DYNAMIC) + + +def type_from_name(name: str) -> StaticType: + """Return the portable static type for one schema type name.""" + return { + "bool": BOOL, + "int": INT, + "float": FLOAT, + "string": STRING, + "list": LIST, + "map": MAP, + "instance_reference": StaticType("instance_reference"), + }[name] + + +def _merge_elements(types: list[StaticType]) -> StaticType: + if not types: + return DYNAMIC + first = types[0] + return first if all(item == first for item in types[1:]) else DYNAMIC + + +def _literal_type(value: Any) -> StaticType: + if value is None: + return NULL + if isinstance(value, bool): + return BOOL + if isinstance(value, int): + return INT + if isinstance(value, float): + return FLOAT + if isinstance(value, str): + return STRING + if isinstance(value, list): + return StaticType("list", element=_merge_elements([_literal_type(item) for item in value])) + if isinstance(value, dict): + fields = tuple((str(name), _literal_type(item)) for name, item in value.items()) + return StaticType( + "map", + element=_merge_elements([item_type for _, item_type in fields]), + fields=fields, + ) + return DYNAMIC + + +def type_from_declaration( + declaration: Mapping[str, Any], *, refine_container: bool = False +) -> StaticType: + """Build a static type, retaining safe literal container refinements.""" + kind = str(declaration["type"]) + if kind == "instance_reference": + return StaticType( + kind, + machine_id=( + str(declaration["machine_id"]) if declaration.get("machine_id") else None + ), + nullable=bool(declaration.get("nullable")), + ) + declared = type_from_name(kind) + if refine_container and kind in {"list", "map"} and "init" in declaration: + literal = _literal_type(declaration["init"]) + if literal.kind == kind: + return literal + if refine_container and kind in {"list", "map"} and "default" in declaration: + literal = _literal_type(declaration["default"]) + if literal.kind == kind: + return literal + return declared def _load() -> tuple[Any, Any, Any]: @@ -33,130 +135,567 @@ def _load() -> tuple[Any, Any, Any]: return _celpy, _celtypes, _environment +@lru_cache(maxsize=4096) +def _tree(expression: str) -> Any: + _, _, environment = _load() + try: + return environment.compile(expression) + except Exception as exc: + raise CelError(f"invalid CEL expression: {expression}") from exc + + @lru_cache(maxsize=4096) def _program(expression: str) -> celpy.Runner: - celpy_module, _, environment = _load() + _, _, environment = _load() try: - return cast("celpy.Runner", environment.program(environment.compile(expression))) + return cast( + "celpy.Runner", + environment.program( + _tree(expression), + functions={ + "double": _portable_double, + "int": _portable_int, + "string": _portable_string, + }, + ), + ) except Exception as exc: raise CelError(f"invalid CEL expression: {expression}") from exc def compile_expression(expression: str) -> None: """Parse an expression without evaluating it.""" - _program(expression) + _tree(expression) -def _without_strings(expression: str) -> str: - pattern = r"""(?s)'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*\"""" - return re.sub(pattern, " ", expression) +def _rule(node: Any) -> str: + return str(node.data) -def profile_error(expression: str, instance_reference_names: set[str] | None = None) -> bool: - """Return whether an expression uses a construct outside the closed profile.""" - stripped = _without_strings(expression) - if re.search(r"\.\s*[A-Za-z_][A-Za-z0-9_]*\s*\(", stripped): - return True - functions = set(re.findall(r"(? None: + if not condition: + raise CelTypeError(message) + + +def _profile(condition: bool, message: str = "CEL overload is outside the profile") -> None: + if not condition: + raise CelProfileError(message) + + +def _assignable(actual: StaticType, expected: StaticType) -> bool: + if actual.kind == "dynamic": + return False + if actual.kind == expected.kind: + if actual.kind != "instance_reference": + return True + return expected.machine_id is None or actual.machine_id == expected.machine_id + if expected.kind == "float" and actual.kind == "int": return True - if re.search(r"\bowner\.(?!variables\b)", stripped): + if ( + expected.kind == "instance_reference" + and expected.nullable + and actual.kind == "null" + ): return True - if re.search(r"\b[0-9]+\s*(?:==|!=|<=|>=|<|>|\+|-|\*|/|%)\s*[0-9]+\.[0-9]", stripped): + return False + + +def _references_compatible(left: StaticType, right: StaticType) -> bool: + if left.kind == "null" and right.kind == "null": return True - if re.search(r"\b[0-9]+\.[0-9]\s*(?:==|!=|<=|>=|<|>|\+|-|\*|/|%)\s*[0-9]+\b", stripped): + if left.kind == "null": + return right.kind == "instance_reference" and right.nullable + if right.kind == "null": + return left.kind == "instance_reference" and left.nullable + if left.kind != "instance_reference" or right.kind != "instance_reference": + return False + return ( + left.machine_id is None + or right.machine_id is None + or left.machine_id == right.machine_id + ) + + +def _common_type(left: StaticType, right: StaticType) -> StaticType | None: + if left == right: + return left + if _references_compatible(left, right): + reference = right if left.kind == "null" else left + machine_id = reference.machine_id + if left.kind == right.kind == "instance_reference": + machine_id = left.machine_id if left.machine_id == right.machine_id else None + return StaticType( + "instance_reference", + machine_id=machine_id, + nullable=reference.nullable or left.kind == "null" or right.kind == "null", + ) + return None + + +def _unwrap(node: Any) -> Any: + wrappers = { + "expr", + "conditionalor", + "conditionaland", + "relation", + "addition", + "multiplication", + "unary", + "member", + "primary", + "paren_expr", + } + while ( + hasattr(node, "children") + and _rule(node) in wrappers + and len(node.children) == 1 + ): + child = node.children[0] + if not hasattr(child, "data"): + break + node = child + return node + + +def _arguments(node: Any) -> list[Any]: + if len(node.children) == 1: + return [] + expression_list = node.children[1] + return list(expression_list.children) + + +def _literal(node: Any) -> StaticType: + token = node.children[0] + token_type = str(token.type) + text = str(token) + if token_type == "NULL_LIT": + return NULL + if token_type == "BOOL_LIT": + return BOOL + if token_type == "INT_LIT": + try: + value = int(text, 10) + except ValueError as exc: + raise CelTypeError("invalid int literal") from exc + _expect(_INT_MIN <= value <= _INT_MAX, "int literal is outside signed 64-bit range") + return INT + if token_type == "FLOAT_LIT": + try: + double_value = float(text) + except ValueError as exc: + raise CelTypeError("invalid double literal") from exc + _expect(math.isfinite(double_value), "double literal is not finite") + return FLOAT + if token_type == "STRING_LIT": + return STRING + raise CelProfileError(f"{token_type} literal is outside the portable profile") + + +class _Checker: + def __init__( + self, + scope: Mapping[str, StaticType], + event_fields: Mapping[str, StaticType] | None, + owner_fields: Mapping[str, StaticType] | None, + ) -> None: + self.scope = scope + self.event_fields = event_fields + self.owner_fields = owner_fields + + def check(self, node: Any) -> StaticType: + rule = _rule(node) + method = getattr(self, f"_check_{rule}", None) + if method is None: + raise CelProfileError(f"CEL syntax {rule} is outside the portable profile") + return cast(StaticType, method(node)) + + def _single(self, node: Any) -> StaticType: + _profile(len(node.children) == 1) + return self.check(node.children[0]) + + _check_member = _single + _check_primary = _single + + def _check_expr(self, node: Any) -> StaticType: + if len(node.children) == 1: + return self.check(node.children[0]) + _profile(len(node.children) == 3) + condition = self.check(node.children[0]) + _profile(condition.kind == "bool") + selected = self.check(node.children[1]) + unselected = self.check(node.children[2]) + result = _common_type(selected, unselected) + _profile(result is not None) + assert result is not None + return result + + def _check_boolean(self, node: Any) -> StaticType: + _profile(len(node.children) == 2) + _profile(self.check(node.children[0]).kind == "bool") + _profile(self.check(node.children[1]).kind == "bool") + return BOOL + + def _check_conditionalor(self, node: Any) -> StaticType: + return self._check_boolean(node) if len(node.children) == 2 else self._single(node) + + def _check_conditionaland(self, node: Any) -> StaticType: + return self._check_boolean(node) if len(node.children) == 2 else self._single(node) + + def _binary_operator(self, node: Any) -> tuple[str, StaticType, StaticType]: + _profile(len(node.children) == 2) + operator = node.children[0] + _profile(len(operator.children) == 1) + return ( + _rule(operator), + self.check(operator.children[0]), + self.check(node.children[1]), + ) + + def _equality(self, left: StaticType, right: StaticType) -> StaticType: + if left.kind == "instance_reference" or right.kind == "instance_reference": + _profile(_references_compatible(left, right)) + else: + _profile(left.kind == right.kind) + return BOOL + + def _ordered_relation(self, left: StaticType, right: StaticType) -> StaticType: + _profile(left.kind == right.kind and left.kind in {"int", "float", "string"}) + return BOOL + + def _membership(self, left: StaticType, right: StaticType) -> StaticType: + if right.kind == "list": + if right.element is not None and right.element.kind != "dynamic": + _profile(_assignable(left, right.element)) + return BOOL + if right.kind == "map": + _profile(left.kind == "string") + return BOOL + raise CelProfileError("in requires a list or string-keyed map") + + def _check_relation(self, node: Any) -> StaticType: + if len(node.children) == 1: + return self._single(node) + operator, left, right = self._binary_operator(node) + if operator in {"relation_eq", "relation_ne"}: + return self._equality(left, right) + if operator in { + "relation_lt", + "relation_le", + "relation_gt", + "relation_ge", + }: + return self._ordered_relation(left, right) + if operator == "relation_in": + return self._membership(left, right) + raise CelProfileError(f"operator {operator} is outside the portable profile") + + def _addition(self, left: StaticType, right: StaticType) -> StaticType: + _profile(left.kind == right.kind) + _profile(left.kind in {"int", "float", "string", "list"}) + if left.kind == "list": + element = _common_type(left.element or DYNAMIC, right.element or DYNAMIC) + if element is None: + element = DYNAMIC + return StaticType("list", element=element) + return left + + def _numeric( + self, left: StaticType, right: StaticType, *, modulo: bool = False + ) -> StaticType: + permitted = {"int"} if modulo else {"int", "float"} + _profile(left.kind == right.kind and left.kind in permitted) + return left + + def _check_addition(self, node: Any) -> StaticType: + if len(node.children) == 1: + return self._single(node) + operator, left, right = self._binary_operator(node) + if operator == "addition_add": + return self._addition(left, right) + if operator == "addition_sub": + return self._numeric(left, right) + raise CelProfileError(f"operator {operator} is outside the portable profile") + + def _check_multiplication(self, node: Any) -> StaticType: + if len(node.children) == 1: + return self._single(node) + operator, left, right = self._binary_operator(node) + if operator in {"multiplication_mul", "multiplication_div"}: + return self._numeric(left, right) + if operator == "multiplication_mod": + return self._numeric(left, right, modulo=True) + raise CelProfileError(f"operator {operator} is outside the portable profile") + + def _check_unary_not(self, node: Any) -> StaticType: + _profile(len(node.children) == 0) + return BOOL + + def _check_unary_neg(self, node: Any) -> StaticType: + _profile(len(node.children) == 0) + return StaticType("unary_negation_marker") + + def _check_unary(self, node: Any) -> StaticType: + if len(node.children) == 1: + return self.check(node.children[0]) + _profile(len(node.children) == 2) + operator = self.check(node.children[0]) + operand = self.check(node.children[1]) + if operator.kind == "bool": + _profile(operand.kind == "bool") + return BOOL + _profile(operator.kind == "unary_negation_marker") + _profile(operand.kind in {"int", "float"}) + return operand + + def _check_ident(self, node: Any) -> StaticType: + name = str(node.children[0]) + if name == "event": + _expect(self.event_fields is not None, "event is unavailable in this context") + payload = StaticType( + "record", + fields=tuple(cast(Mapping[str, StaticType], self.event_fields).items()), + record_name="event_payload", + ) + return StaticType( + "record", fields=(("payload", payload),), record_name="event" + ) + if name == "owner": + _expect(self.owner_fields is not None, "owner is unavailable in this context") + variables = StaticType( + "record", + fields=tuple(cast(Mapping[str, StaticType], self.owner_fields).items()), + record_name="owner_variables", + ) + return StaticType( + "record", fields=(("variables", variables),), record_name="owner" + ) + result = self.scope.get(name) + _expect(result is not None, f"unknown CEL activation name: {name}") + return cast(StaticType, result) + + def _check_literal(self, node: Any) -> StaticType: + return _literal(node) + + def _check_paren_expr(self, node: Any) -> StaticType: + return self._single(node) + + def _check_list_lit(self, node: Any) -> StaticType: + if not node.children: + return LIST + values = [self.check(item) for item in node.children[0].children] + return StaticType("list", element=_merge_elements(values)) + + def _check_map_lit(self, node: Any) -> StaticType: + if not node.children: + return MAP + members = node.children[0].children + fields: list[tuple[str, StaticType]] = [] + values: list[StaticType] = [] + for index in range(0, len(members), 2): + key_node = members[index] + key_type = self.check(key_node) + _profile(key_type.kind == "string") + value_type = self.check(members[index + 1]) + values.append(value_type) + key = _string_literal_value(key_node) + if key is not None: + fields.append((key, value_type)) + return StaticType( + "map", element=_merge_elements(values), fields=tuple(fields) + ) + + def _check_member_dot(self, node: Any) -> StaticType: + base = self.check(node.children[0]) + name = str(node.children[1]) + if base.kind == "record": + result = base.field(name) + if result is None: + raise CelProfileError(f"record field {name} is outside the portable profile") + return result + if base.kind == "map": + return base.field(name) or base.element or DYNAMIC + if base.kind == "instance_reference": + raise CelProfileError("instance_reference is opaque") + raise CelProfileError("field selection requires a record or map") + + def _check_member_index(self, node: Any) -> StaticType: + _profile(len(node.children) == 2) + base = self.check(node.children[0]) + index = self.check(node.children[1]) + if base.kind == "list": + _profile(index.kind == "int") + return base.element or DYNAMIC + if base.kind == "map": + _profile(index.kind == "string") + return base.element or DYNAMIC + raise CelProfileError("indexing requires a list or string-keyed map") + + def _check_member_dot_arg(self, node: Any) -> StaticType: + raise CelProfileError("receiver methods are outside the portable profile") + + def _check_member_object(self, node: Any) -> StaticType: + raise CelProfileError("object construction is outside the portable profile") + + def _check_ident_arg(self, node: Any) -> StaticType: + name = str(node.children[0]) + arguments = _arguments(node) + if name == "has": + _profile(len(arguments) == 1) + _profile(_is_permitted_has(arguments[0], self)) + return BOOL + argument_types = [self.check(item) for item in arguments] + _profile(len(argument_types) == 1) + argument = argument_types[0] + if name == "size": + _profile(argument.kind in {"string", "list", "map"}) + return INT + if name == "double": + _profile(argument.kind == "int") + return FLOAT + if name == "int": + _profile(argument.kind == "float") + return INT + if name == "string": + _profile(argument.kind in {"bool", "int", "float", "string"}) + return STRING + raise CelProfileError(f"function {name} is outside the portable profile") + + +def _string_literal_value(node: Any) -> str | None: + unwrapped = _unwrap(node) + if _rule(unwrapped) != "literal": + return None + token = unwrapped.children[0] + if str(token.type) != "STRING_LIT": + return None + try: + value = ast.literal_eval(str(token)) + except (SyntaxError, ValueError): + return None + return value if isinstance(value, str) else None + + +def _is_permitted_has(node: Any, checker: _Checker) -> bool: + unwrapped = _unwrap(node) + if _rule(unwrapped) != "member_dot": + return False + base_node = unwrapped.children[0] + base = checker.check(base_node) + field_name = str(unwrapped.children[1]) + if base.kind == "map": return True - for name in instance_reference_names or set(): - if re.search(rf"\b{re.escape(name)}\s*\.", stripped): - return True - if re.search(rf"\bstring\s*\(\s*{re.escape(name)}\s*\)", stripped): - return True - return False + return base.record_name == "event_payload" and base.field(field_name) is not None -def referenced_names(expression: str) -> set[str]: - """Conservatively collect bare activation identifiers.""" - stripped = _without_strings(expression) - names = set(re.findall(r"(? StaticType: + """Parse and completely type-check one expression against the closed profile.""" + checker = _Checker(scope, event_fields, owner_fields) + actual = checker.check(_tree(expression)) + if expected is not None: + destination = type_from_name(expected) if isinstance(expected, str) else expected + _expect(_assignable(actual, destination)) + return actual -def infer_type( +def check_map_literal( expression: str, - scope: dict[str, str], + expected_fields: Mapping[str, StaticType], *, - event_fields: dict[str, str] | None = None, - owner_fields: dict[str, str] | None = None, -) -> str: - """Infer the portable type for the expression shapes used by format 1.""" - expr = expression.strip() - if expr in scope: - return scope[expr] - event_match = re.fullmatch(r"event\.payload\.([A-Za-z_][A-Za-z0-9_]*)", expr) - if event_match and event_fields is not None: - return event_fields.get(event_match.group(1), "unknown") - owner_match = re.fullmatch(r"owner\.variables\.([A-Za-z_][A-Za-z0-9_]*)", expr) - if owner_match and owner_fields is not None: - return owner_fields.get(owner_match.group(1), "unknown") - if expr in {"true", "false"}: - return "bool" - if expr == "null": - return "null" - if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", expr): - return "int" - if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", expr): - return "float" - if re.fullmatch(r"""'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*\"""", expr): - return "string" - if expr.startswith("[") and expr.endswith("]"): - return "list" - if expr.startswith("{") and expr.endswith("}"): - return "map" - if re.match(r"^(?:size|int)\s*\(", expr): - return "int" - if re.match(r"^double\s*\(", expr): - return "float" - if re.match(r"^string\s*\(", expr): - return "string" - ternary = re.match(r"^.+\?(.+):(.+)$", expr) - if ternary: - left = infer_type( - ternary.group(1).strip(), - scope, - event_fields=event_fields, - owner_fields=owner_fields, - ) - right = infer_type( - ternary.group(2).strip(), - scope, - event_fields=event_fields, - owner_fields=owner_fields, - ) - return left if left == right else "unknown" - if re.search(r"\[[^\]]+\]\s*$", expr): - return "unknown" - if ( - "==" in expr - or "!=" in expr - or re.search(r"(?:<=|>=|<|>)", expr) - or "&&" in expr - or "||" in expr - or expr.startswith("!") - or expr.startswith("has(") - or re.search(r"\bin\b", expr) - ): - return "bool" - for name, type_name in scope.items(): - if re.search(rf"\b{re.escape(name)}\b", expr): - return type_name - return "unknown" + scope: Mapping[str, StaticType], + event_fields: Mapping[str, StaticType] | None, +) -> None: + """Validate an exact string-keyed map literal and each destination value.""" + tree = _tree(expression) + map_node = _unwrap(tree) + if _rule(map_node) != "map_lit": + raise CelTypeError("expected a CEL map literal") + if not map_node.children: + supplied: dict[str, Any] = {} + else: + members = map_node.children[0].children + supplied = {} + for index in range(0, len(members), 2): + key = _string_literal_value(members[index]) + if key is None or key in supplied: + raise CelTypeError("map literal keys must be unique string literals") + supplied[key] = members[index + 1] + if not supplied or set(supplied) - set(expected_fields): + raise CelTypeError("map literal has invalid fields") + checker = _Checker(scope, event_fields, None) + for name, value_node in supplied.items(): + actual = checker.check(value_node) + _expect(_assignable(actual, expected_fields[name])) + + +def _portable_double(value: Any) -> Any: + _, celtypes, _ = _load() + if not isinstance(value, celtypes.IntType): + raise TypeError("double requires int") + integer = int(value) + if not _INT_MIN <= integer <= _INT_MAX: + raise ValueError("integer is outside signed 64-bit range") + result = float(integer) + if not math.isfinite(result): + raise ValueError("double conversion is not finite") + return celtypes.DoubleType(0.0 if result == 0.0 else result) + + +def _portable_int(value: Any) -> Any: + _, celtypes, _ = _load() + if not isinstance(value, celtypes.DoubleType): + raise TypeError("int requires double") + double = float(value) + if not math.isfinite(double): + raise ValueError("double conversion is not finite") + integer = math.trunc(double) + if not _INT_MIN <= integer <= _INT_MAX: + raise ValueError("integer conversion is outside signed 64-bit range") + return celtypes.IntType(integer) + + +def _jcs_number(value: float) -> str: + if not math.isfinite(value): + raise ValueError("non-finite double") + if value == 0.0: + return "0" + raw = repr(value).lower() + magnitude = abs(value) + if 1e-6 <= magnitude < 1e21: + fixed = format(Decimal(raw), "f") + if "." in fixed: + fixed = fixed.rstrip("0").rstrip(".") + return fixed + if "e" not in raw: + raw = format(Decimal(raw).normalize(), "e") + mantissa, exponent_text = raw.split("e", 1) + if "." in mantissa: + mantissa = mantissa.rstrip("0").rstrip(".") + exponent = int(exponent_text) + sign = "+" if exponent >= 0 else "" + return f"{mantissa}e{sign}{exponent}" + + +def _portable_string(value: Any) -> Any: + _, celtypes, _ = _load() + if isinstance(value, celtypes.BoolType): + return celtypes.StringType("true" if bool(value) else "false") + if isinstance(value, celtypes.IntType): + integer = int(value) + if not _INT_MIN <= integer <= _INT_MAX: + raise ValueError("integer is outside signed 64-bit range") + return celtypes.StringType(str(integer)) + if isinstance(value, celtypes.DoubleType): + return celtypes.StringType(_jcs_number(float(value))) + if isinstance(value, celtypes.StringType): + return value + raise TypeError("string requires bool, int, double, or string") def _to_cel(value: Any) -> Any: @@ -206,7 +745,15 @@ def _from_cel(value: Any) -> Any: return result if isinstance(value, list): return [_from_cel(item) for item in value] - if value is None or isinstance(value, bool | int | float | str): + if isinstance(value, int) and not isinstance(value, bool): + if not _INT_MIN <= value <= _INT_MAX: + raise CelError("integer overflow") + return value + if isinstance(value, float): + if not math.isfinite(value): + raise CelError("non-finite double") + return 0.0 if value == 0.0 else value + if value is None or isinstance(value, bool | str): return value raise CelError(f"unsupported CEL result: {type(value).__name__}") diff --git a/src/determa/state/engine.py b/src/determa/state/engine.py index 60a09b9..a900cda 100644 --- a/src/determa/state/engine.py +++ b/src/determa/state/engine.py @@ -535,10 +535,23 @@ def _validate_prior_state(state: dict[str, Any], bundle: Bundle) -> bool: if runtime["role"] == "component": if owner["components"].get(runtime.get("component_id")) != runtime["runtime_id"]: return False + if not _valid_component_relation(bundle, models, owner, runtime): + return False elif runtime["role"] != "spawned": return False + elif not _valid_spawned_relation(bundle, models, owner, runtime): + return False if _ownership_cycle(runtimes, runtime): return False + for owner in runtimes.values(): + expected_components = { + child["component_id"]: child["runtime_id"] + for child in runtimes.values() + if child.get("role") == "component" + and child.get("owner_runtime_id") == owner["runtime_id"] + } + if owner["components"] != expected_components: + return False return True @@ -633,6 +646,18 @@ def _validate_runtime_state( return False if any(path not in machine.states for path in runtime["state_activation_sequence"]): return False + if any( + runtime["next_state_activation_sequence"].get(path, 0) <= sequence + for path, sequence in runtime["state_activation_sequence"].items() + ): + return False + history_states = { + "$root" if node is machine.root else node.path: node + for node in machine.states.values() + if node.type == "composite" and node.raw.get("history", "none") != "none" + } + if set(runtime["history"]) != set(history_states): + return False if any( not isinstance(key, str) or not isinstance(value, (list, type(None))) for key, value in runtime["history"].items() @@ -644,6 +669,22 @@ def _validate_runtime_state( for value in runtime["history"].values() ): return False + for key, value in runtime["history"].items(): + if value is None: + continue + history_state = history_states[key] + destination = machine.states[value[0]] + if not history_state.is_ancestor_of(destination, strict=True): + return False + if history_state.raw["history"] == "shallow" and destination.parent is not history_state: + return False + component_pointers = { + f"{node.pointer}/components/{index}" + for node in machine.states.values() + for index, _placement in enumerate(node.raw.get("components") or []) + } + if set(runtime["next_component_activation_sequence"]) - component_pointers: + return False if any( not isinstance(key, str) or not isinstance(value, str) for key, value in runtime["components"].items() @@ -748,6 +789,106 @@ def _valid_spawned_identity(state: dict[str, Any], runtime: dict[str, Any]) -> b return bool(runtime["runtime_id"] == expected_id) +def _runtime_model( + bundle: Bundle, + models: BundleModel, + runtime: dict[str, Any], +) -> MachineModel: + base = models.machine(runtime["machine_id"]) + if runtime["root_pointer"] == base.root_pointer: + return base + root = _pointer_get(bundle.raw, runtime["root_pointer"]) + return MachineModel( + bundle, + base.raw, + machine_index=base.machine_index, + root=root, + root_pointer=runtime["root_pointer"], + identity_machine=base.identity_machine, + ) + + +def _valid_component_relation( + bundle: Bundle, + models: BundleModel, + owner: dict[str, Any], + runtime: dict[str, Any], +) -> bool: + owner_machine = _runtime_model(bundle, models, owner) + owning_path = runtime["owning_state_path"] + if owning_path not in owner["active"] or owning_path not in owner_machine.states: + return False + owning_state = owner_machine.states[owning_path] + if ( + owning_state.type != "parallel" + or owner["state_activation_sequence"].get(owning_path) + != runtime["owning_state_activation_sequence"] + ): + return False + index = runtime["component_declaration_index"] + placements = owning_state.raw.get("components") or [] + if index >= len(placements): + return False + placement = placements[index] + pointer = f"{owning_state.pointer}/components/{index}" + if ( + runtime["component_definition_pointer"] != pointer + or placement["component_id"] != runtime["component_id"] + or owner["next_component_activation_sequence"].get(pointer, 0) + <= runtime["component_activation_sequence"] + ): + return False + if "machine_id" in placement: + target = models.machine(placement["machine_id"]) + return bool( + runtime["machine_id"] == target.machine_id + and runtime["machine_version"] == target.version + and runtime["root_pointer"] == target.root_pointer + ) + return bool( + runtime["machine_id"] == owner["machine_id"] + and runtime["machine_version"] == owner["machine_version"] + and runtime["root_pointer"] == f"{pointer}/root" + ) + + +def _valid_spawned_relation( + bundle: Bundle, + models: BundleModel, + owner: dict[str, Any], + runtime: dict[str, Any], +) -> bool: + if owner["next_spawn_sequence"] <= runtime["spawn_sequence"]: + return False + spawn = _pointer_get(bundle.raw, runtime["spawn_action_pointer"]) + if not isinstance(spawn, dict) or spawn.get("machine_id") != runtime["machine_id"]: + return False + holder = runtime.get("holder") + if holder is None: + return True + owner_machine = _runtime_model(bundle, models, owner) + state_path = holder["state_path"] + if ( + state_path not in owner["active"] + or state_path not in owner_machine.states + or owner["state_activation_sequence"].get(state_path) + != holder["state_activation_sequence"] + ): + return False + state = owner_machine.states[state_path] + prefix = f"{state.pointer}/variables/" + if not holder["pointer"].startswith(prefix): + return False + encoded_name = holder["pointer"][len(prefix) :] + name = encoded_name.replace("~1", "/").replace("~0", "~") + declarations = state.raw.get("variables") or {} + return bool( + name in declarations + and declarations[name].get("type") == "instance_reference" + and owner["scopes"][state_path].get(name) == runtime["instance_reference"] + ) + + def _valid_fault(fault: Any, runtime: dict[str, Any]) -> bool: return ( isinstance(fault, dict) @@ -1040,7 +1181,8 @@ def _locate_target(state: dict[str, Any], target: Any) -> tuple[str | None, dict or value.get("root_runtime_id") != state["root_runtime_id"] ): return "invalid_instance_target", None - return None, runtimes[state["root_runtime_id"]] + runtime = runtimes[state["root_runtime_id"]] + return _target_eligibility(state, runtime), runtime if "spawned_instance" in target: reference = target["spawned_instance"] if not _is_instance_reference(reference): @@ -1048,7 +1190,7 @@ def _locate_target(state: dict[str, Any], target: Any) -> tuple[str | None, dict runtime = runtimes.get(reference["instance_id"]) if runtime is None or runtime.get("instance_reference") != reference: return "invalid_instance_target", None - return None, runtime + return _target_eligibility(state, runtime), runtime if "component" in target: value = target["component"] if not isinstance(value, dict): @@ -1056,10 +1198,29 @@ def _locate_target(state: dict[str, Any], target: Any) -> tuple[str | None, dict runtime = runtimes.get(value.get("component_runtime_id")) if runtime is None or runtime.get("target") != target: return "inactive_component_target", None - return None, runtime + return _target_eligibility(state, runtime), runtime return "invalid_instance_target", None +def _target_eligibility(state: dict[str, Any], runtime: dict[str, Any]) -> str | None: + code = ( + "inactive_component_target" + if runtime["role"] == "component" + else "invalid_instance_target" + ) + if runtime["status"] != "running": + return code + owner_id = runtime.get("owner_runtime_id") + while owner_id is not None: + owner = state["runtimes"].get(owner_id) + if not isinstance(owner, dict): + return code + if owner["status"] == "faulted": + return code + owner_id = owner.get("owner_runtime_id") + return None + + @dataclass class _Execution: bundle: Bundle @@ -1706,14 +1867,14 @@ def resolve_send_target( if "component" in target_spec: child_id = runtime["components"].get(target_spec["component"]) child = self.state["runtimes"].get(child_id) - if child is None or child["status"] != "running": + if child is None or _target_eligibility(self.state, child) is not None: raise StepFault("inactive_component_target", f"{pointer}{suffix}") return cast(dict[str, Any], copy.deepcopy(child["target"])) if "instance" in target_spec: if not _is_instance_reference(evaluated): raise StepFault("invalid_instance_target", f"{pointer}{suffix}/instance") child = self.state["runtimes"].get(evaluated["instance_id"]) - if child is None or child["status"] != "running": + if child is None or _target_eligibility(self.state, child) is not None: raise StepFault("invalid_instance_target", f"{pointer}{suffix}/instance") return {"spawned_instance": copy.deepcopy(evaluated)} if target_spec.get("external") is True: diff --git a/src/determa/state/validator.py b/src/determa/state/validator.py index 2d1c954..7fe5a27 100644 --- a/src/determa/state/validator.py +++ b/src/determa/state/validator.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import re from functools import lru_cache from pathlib import Path from typing import Any, cast @@ -59,10 +58,6 @@ def _validate_schema(document: dict[str, Any]) -> None: raise ValidationError("structural_validation", path=path, message=error.message) -def _compatible(actual: str, expected: str) -> bool: - return actual == expected or (expected == "float" and actual == "int") or actual == "unknown" - - def _literal_matches(value: Any, expected: str) -> bool: if expected == "string": return isinstance(value, str) @@ -87,49 +82,69 @@ def _event_declarations(bundle: Bundle, machine: MachineModel) -> dict[str, dict return declarations -def _built_in_event_fields(event_name: str) -> dict[str, str]: +def _built_in_event_fields(event_name: str) -> dict[str, cel.StaticType]: if event_name == "env": - return {"changed": "map"} + return {"changed": cel.MAP} if event_name == "determa.component_completed": - return {"component_id": "string", "component_runtime_id": "string"} + return {"component_id": cel.STRING, "component_runtime_id": cel.STRING} if event_name == "determa.component_failed": - return {"component_id": "string", "component_runtime_id": "string", "fault": "map"} + return { + "component_id": cel.STRING, + "component_runtime_id": cel.STRING, + "fault": cel.MAP, + } if event_name == "determa.spawned_instance_failed": return { - "instance": "instance_reference", - "instance_id": "string", - "machine_id": "string", - "machine_version": "int", - "fault": "map", + "instance": cel.StaticType("instance_reference", nullable=False), + "instance_id": cel.STRING, + "machine_id": cel.STRING, + "machine_version": cel.INT, + "fault": cel.MAP, } if event_name == "done": return { - "relationship": "string", - "state_path": "string", - "owner_runtime_id": "string", - "instance": "instance_reference", - "instance_id": "string", - "machine_id": "string", - "machine_version": "int", + "relationship": cel.STRING, + "state_path": cel.STRING, + "owner_runtime_id": cel.STRING, + "instance": cel.StaticType("instance_reference", nullable=True), + "instance_id": cel.STRING, + "machine_id": cel.STRING, + "machine_version": cel.INT, } return {} -def _payload_types(declaration: dict[str, Any] | None, event_name: str) -> dict[str, str]: +def _payload_types( + declaration: dict[str, Any] | None, event_name: str +) -> dict[str, cel.StaticType]: if declaration is None: return _built_in_event_fields(event_name) - return {name: str(field["type"]) for name, field in (declaration.get("payload") or {}).items()} + return { + name: cel.type_from_declaration(field) + for name, field in (declaration.get("payload") or {}).items() + } def _scope( machine: MachineModel, state: StateNode -) -> tuple[dict[str, str], dict[str, tuple[StateNode, dict[str, Any], str]]]: +) -> tuple[ + dict[str, cel.StaticType], + dict[str, tuple[StateNode, dict[str, Any], str]], +]: chain = list(reversed(state.ancestors(include_self=True))) - types: dict[str, str] = {} + assigned_names = _assigned_variable_names(machine) + types: dict[str, cel.StaticType] = {} declarations: dict[str, tuple[StateNode, dict[str, Any], str]] = {} for node in chain: for name, declaration in (node.raw.get("variables") or {}).items(): - types[name] = str(declaration["type"]) + types[name] = cel.type_from_declaration( + declaration, + refine_container=( + name not in assigned_names + and not declaration.get("input") + and not declaration.get("external") + ), + ) declarations[name] = ( node, declaration, @@ -138,53 +153,51 @@ def _scope( return types, declarations +def _assigned_variable_names(machine: MachineModel) -> set[str]: + names: set[str] = set() + for state in machine.states.values(): + action_lists = [state.raw.get("entry") or [], state.raw.get("exit") or []] + if state.is_choice: + action_lists.extend(branch.get("action") or [] for branch in state.raw["choice"]) + initial = state.raw.get("initial") + if isinstance(initial, dict): + action_lists.append(initial.get("action") or []) + for transition_or_list in (state.raw.get("on_events") or {}).values(): + transitions = ( + transition_or_list + if isinstance(transition_or_list, list) + else [transition_or_list] + ) + action_lists.extend(transition.get("action") or [] for transition in transitions) + for actions in action_lists: + for action in actions: + if "assign" in action: + names.update(action["assign"]) + return names + + def _check_expression( expression: str, *, - scope: dict[str, str], - expected: str | None, - event_fields: dict[str, str] | None, - owner_fields: dict[str, str] | None, + scope: dict[str, cel.StaticType], + expected: cel.StaticType | str | None, + event_fields: dict[str, cel.StaticType] | None, + owner_fields: dict[str, cel.StaticType] | None, allow_event: bool, allow_owner: bool, -) -> str: - references = cel.referenced_names(expression) - allowed = set(scope) - if allow_event: - allowed.add("event") - if allow_owner: - allowed.add("owner") - instance_names = { - name for name, type_name in scope.items() if type_name == "instance_reference" - } +) -> cel.StaticType: try: - cel.compile_expression(expression) + return cel.check_expression( + expression, + scope, + expected=expected, + event_fields=event_fields if allow_event else None, + owner_fields=owner_fields if allow_owner else None, + ) + except cel.CelProfileError as exc: + raise ValidationError("cel_profile_error", message=str(exc)) from exc except CelError as exc: raise ValidationError("semantic_validation", message=str(exc)) from exc - if cel.profile_error(expression, instance_names): - raise ValidationError("cel_profile_error") - if references - allowed: - raise ValidationError("semantic_validation", message="unknown CEL activation name") - if not allow_event and re.search(r"\bevent\b", expression): - raise ValidationError("semantic_validation") - if not allow_owner and re.search(r"\bowner\b", expression): - raise ValidationError("semantic_validation") - if re.search(r"\bevent\.(?!payload\b)", expression): - raise ValidationError("semantic_validation") - if re.search(r"\bowner\.(?!variables\b)", expression): - raise ValidationError("semantic_validation") - for field in re.findall(r"\bevent\.payload\.([A-Za-z_][A-Za-z0-9_]*)", expression): - if event_fields is None or field not in event_fields: - raise ValidationError("semantic_validation") - for field in re.findall(r"\bowner\.variables\.([A-Za-z_][A-Za-z0-9_]*)", expression): - if owner_fields is None or field not in owner_fields: - raise ValidationError("semantic_validation") - inferred = cel.infer_type( - expression, scope, event_fields=event_fields, owner_fields=owner_fields - ) - if expected is not None and not _compatible(inferred, expected): - raise ValidationError("semantic_validation") - return inferred def _validate_semantics(bundle: Bundle, model: BundleModel) -> None: @@ -392,7 +405,7 @@ def _validate_transition( bundle_model: BundleModel, machine: MachineModel, source: StateNode, - scope: dict[str, str], + scope: dict[str, cel.StaticType], scope_declarations: dict[str, tuple[StateNode, dict[str, Any], str]], events: dict[str, dict[str, Any]], event_name: str | None, @@ -402,6 +415,17 @@ def _validate_transition( ) -> None: event_declaration = events.get(event_name) if event_name is not None else None event_fields = _payload_types(event_declaration, event_name or "") + if event_name == "env": + external_fields = tuple( + (name, cel.type_from_declaration(declaration)) + for name, declaration in (machine.root.raw.get("variables") or {}).items() + if declaration.get("external") is True + ) + event_fields = { + "changed": cel.StaticType( + "map", element=cel.DYNAMIC, fields=external_fields + ) + } guard = transition.get("guard") if guard is not None: _check_expression( @@ -510,11 +534,11 @@ def _validate_actions( bundle_model: BundleModel, machine: MachineModel, state: StateNode, - scope: dict[str, str], + scope: dict[str, cel.StaticType], scope_declarations: dict[str, tuple[StateNode, dict[str, Any], str]], events: dict[str, dict[str, Any]], event_name: str | None, - owner_fields: dict[str, str] | None, + owner_fields: dict[str, cel.StaticType] | None, context: str, graph: dict[str, set[str]], ) -> None: @@ -589,9 +613,9 @@ def _validate_send( bundle_model: BundleModel, machine: MachineModel, state: StateNode, - scope: dict[str, str], + scope: dict[str, cel.StaticType], events: dict[str, dict[str, Any]], - event_fields: dict[str, str] | None, + event_fields: dict[str, cel.StaticType] | None, allow_event: bool, ) -> None: event_name = str(send["event"]) @@ -626,23 +650,21 @@ def _validate_send( else bundle_model.inline_component(machine, placement, pointer) ) external_variables = { - name: declaration + name: cel.type_from_declaration(declaration) for name, declaration in (target_machine.root.raw.get("variables") or {}).items() if declaration.get("external") is True } - changed_members = _parse_cel_map_literal(changed) - if changed_members is None or set(changed_members) - set(external_variables): - raise ValidationError("semantic_validation") - for name, expression in changed_members.items(): - _check_expression( - expression, + try: + cel.check_map_literal( + changed, + external_variables, scope=scope, - expected=str(external_variables[name]["type"]), event_fields=event_fields, - owner_fields=None, - allow_event=allow_event, - allow_owner=False, ) + except cel.CelProfileError as exc: + raise ValidationError("cel_profile_error", message=str(exc)) from exc + except CelError as exc: + raise ValidationError("semantic_validation", message=str(exc)) from exc return if declaration is None or event_name in _RESERVED_EVENTS: raise ValidationError("semantic_validation") @@ -687,29 +709,12 @@ def _validate_send( allow_owner=False, ) - -def _parse_cel_map_literal(expression: str) -> dict[str, str] | None: - body = expression.strip() - if not (body.startswith("{") and body.endswith("}")): - return None - body = body[1:-1].strip() - if not body: - return {} - members: dict[str, str] = {} - for item in body.split(","): - match = re.fullmatch(r"\s*(['\"])([A-Za-z_][A-Za-z0-9_]*)\1\s*:\s*(.+?)\s*", item) - if match is None: - return None - members[match.group(2)] = match.group(3) - return members - - def _validate_bindings( bindings: dict[str, Any], target: MachineModel, *, - scope: dict[str, str], - owner_fields: dict[str, str] | None, + scope: dict[str, cel.StaticType], + owner_fields: dict[str, cel.StaticType] | None, allow_owner: bool, ) -> None: root_variables = target.root.raw.get("variables") or {} @@ -730,17 +735,15 @@ def _validate_bindings( if missing: raise ValidationError("invalid_binding") for name, expression in supplied.items(): - inferred = _check_expression( + _check_expression( expression, scope=scope, - expected=str(expected[name]["type"]), + expected=cel.type_from_declaration(expected[name]), event_fields=None, owner_fields=owner_fields, allow_event=False, allow_owner=allow_owner, ) - if not _compatible(inferred, str(expected[name]["type"])): - raise ValidationError("invalid_binding") def _validate_reachability(machine: MachineModel) -> None: diff --git a/src/determa/state/yaml12.py b/src/determa/state/yaml12.py index bd93c38..87121b4 100644 --- a/src/determa/state/yaml12.py +++ b/src/determa/state/yaml12.py @@ -12,6 +12,18 @@ _JSON_NUMBER = re.compile(r"-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\Z") _INTEGER = re.compile(r"-?(?:0|[1-9][0-9]*)\Z") +_NONPORTABLE_YAML_NUMBER = re.compile( + r""" + [+-]?(?: + 0[xX][0-9a-fA-F_]+ + |0[oO][0-7_]+ + |(?:[0-9][0-9_]*)(?:\.[0-9_]*)?(?:[eE][+-]?[0-9_]+)? + |\.[0-9][0-9_]*(?:[eE][+-]?[0-9_]+)? + |\.(?:inf|nan) + )\Z + """, + re.IGNORECASE | re.VERBOSE, +) _INVALID_BOOLEAN = frozenset({"True", "TRUE", "False", "FALSE"}) _INVALID_NULL = frozenset({"Null", "NULL", "~", ""}) _STRING_BOOLEAN_LIKE = frozenset( @@ -98,14 +110,6 @@ def _validate_portable_values(value: Any, ancestors: set[int]) -> None: raise ValidationError("non_json_value") -def _numeric_candidate(value: str) -> bool: - lower = value.lower() - return bool( - re.match(r"^[+-]?(?:[0-9]|\.)", value) - or lower.startswith(("0x", "+0x", "-0x", "0o", "+0o", "-0o")) - ) - - def _resolve_plain(value: str) -> Any: if value == "true": return True @@ -132,7 +136,7 @@ def _resolve_plain(value: str) -> Any: if not math.isfinite(double): raise ValidationError("numeric_value_out_of_range") return 0.0 if double == 0.0 else double - if _numeric_candidate(value): + if _NONPORTABLE_YAML_NUMBER.fullmatch(value): raise ValidationError("invalid_numeric_syntax") return value diff --git a/tests/test_cel.py b/tests/test_cel.py index 64ec52e..82e8135 100644 --- a/tests/test_cel.py +++ b/tests/test_cel.py @@ -23,3 +23,175 @@ def test_unicode_is_not_normalized() -> None: assert cel.evaluate('size("\\u00e9")', {}) == 1 assert cel.evaluate('size("e\\u0301")', {}) == 2 assert cel.evaluate('"\\u00e9" == "e\\u0301"', {}) is False + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ("integer_value + 1", cel.INT), + ("double(integer_value)", cel.FLOAT), + ("int(floating_value)", cel.INT), + ("string(true)", cel.STRING), + ("string(integer_value)", cel.STRING), + ("string(floating_value)", cel.STRING), + ("string(text)", cel.STRING), + ("size(text)", cel.INT), + ("size(numbers)", cel.INT), + ("integer_value in numbers", cel.BOOL), + ("has(attributes.present)", cel.BOOL), + ("has(event.payload.optional)", cel.BOOL), + ("event.payload.required == text", cel.BOOL), + ("reference == null", cel.BOOL), + ("reference != other_reference", cel.BOOL), + ( + "flag ? reference : null", + cel.StaticType("instance_reference", machine_id="worker", nullable=True), + ), + ], +) +def test_static_checker_accepts_only_declared_profile_overloads( + expression: str, expected: cel.StaticType +) -> None: + scope = { + "integer_value": cel.INT, + "floating_value": cel.FLOAT, + "text": cel.STRING, + "flag": cel.BOOL, + "numbers": cel.StaticType("list", element=cel.INT), + "attributes": cel.MAP, + "reference": cel.StaticType( + "instance_reference", machine_id="worker", nullable=True + ), + "other_reference": cel.StaticType( + "instance_reference", machine_id="worker", nullable=True + ), + } + event_fields = {"required": cel.STRING, "optional": cel.STRING} + + assert cel.check_expression( + expression, + scope, + expected=expected, + event_fields=event_fields, + ) == expected + + +@pytest.mark.parametrize( + "expression", + [ + "integer_value != floating_value", + "integer_value + floating_value", + "integer_value < floating_value", + "string(null)", + "int(integer_value)", + "double(floating_value)", + "size(integer_value)", + "matches(text, 'x')", + "text.startsWith('x')", + "numbers.map(value, value)", + "uint(1)", + "b'bytes'", + "reference.instance_id", + "string(reference)", + "reference < other_reference", + "has(text)", + "has(owner.variables.text)", + "event.event_id", + "event.payload.missing", + "owner.missing", + ], +) +def test_static_checker_rejects_unavailable_symbols_and_overloads(expression: str) -> None: + scope = { + "integer_value": cel.INT, + "floating_value": cel.FLOAT, + "text": cel.STRING, + "numbers": cel.StaticType("list", element=cel.INT), + "reference": cel.StaticType( + "instance_reference", machine_id="worker", nullable=True + ), + "other_reference": cel.StaticType( + "instance_reference", machine_id="worker", nullable=True + ), + } + + with pytest.raises(cel.CelProfileError): + cel.check_expression( + expression, + scope, + expected=None, + event_fields={"known": cel.STRING}, + owner_fields={"text": cel.STRING}, + ) + + +@pytest.mark.parametrize( + "expression", + ["missing_name"], +) +def test_static_checker_rejects_unknown_activation_names_and_fields(expression: str) -> None: + with pytest.raises(cel.CelTypeError): + cel.check_expression( + expression, + {}, + expected=None, + event_fields={"known": cel.STRING}, + owner_fields={"known": cel.STRING}, + ) + + +def test_static_checker_does_not_flow_dynamic_values_to_concrete_destinations() -> None: + with pytest.raises(cel.CelTypeError): + cel.check_expression( + "attributes['value']", + {"attributes": cel.MAP}, + expected=cel.STRING, + ) + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ("string(true)", "true"), + ("string(false)", "false"), + ("string(1.0)", "1"), + ("string(-0.0)", "0"), + ("string(1e-7)", "1e-7"), + ("string(1e-6)", "0.000001"), + ("string(1e20)", "100000000000000000000"), + ("string(1e21)", "1e+21"), + ("string(333333333.33333329)", "333333333.3333333"), + ("string(4.50)", "4.5"), + ("string(2e-3)", "0.002"), + ("string(1e-27)", "1e-27"), + ('string("\\u00e9")', "\u00e9"), + ("string(9223372036854775807)", "9223372036854775807"), + ("int(1.9)", 1), + ("int(-1.9)", -1), + ("int(-9223372036854775808.0)", -(2**63)), + ("double(9007199254740993)", 9007199254740992.0), + ], +) +def test_portable_conversion_vectors(expression: str, expected: object) -> None: + assert cel.evaluate(expression, {}) == expected + + +@pytest.mark.parametrize( + "expression", + [ + "int(9.223372036854776e18)", + "int(1e1000)", + "1e308 * 1e308", + ], +) +def test_portable_conversion_and_double_results_are_checked(expression: str) -> None: + with pytest.raises(CelError): + cel.evaluate(expression, {}) + + +def test_static_destination_checking_has_only_the_documented_numeric_widening() -> None: + assert cel.check_expression("integer_value", {"integer_value": cel.INT}, expected=cel.FLOAT) + with pytest.raises(cel.CelTypeError): + cel.check_expression("floating_value", {"floating_value": cel.FLOAT}, expected=cel.INT) + with pytest.raises(cel.CelTypeError): + cel.check_expression("integer_value", {"integer_value": cel.INT}, expected=cel.STRING) diff --git a/tests/test_engine.py b/tests/test_engine.py index 3b2309e..789ee9f 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -4,8 +4,10 @@ import pytest -from determa.state import create, dispatch, load_bundle -from determa.state.engine import _root_runtime_id +from determa.state import Bundle, create, dispatch, load_bundle +from determa.state.engine import _cause_id, _Execution, _root_runtime_id +from determa.state.errors import StepFault +from determa.state.model import BundleModel from .test_loading import FINGERPRINT_BUNDLE @@ -46,6 +48,92 @@ settings: { type: map, input: true } """ +FROZEN_SUBTREE_BUNDLE = """ +format: 1 +namespace: example.frozen_subtree +events: + start: { direction: input } + boom: { direction: input } + ping: { direction: input } + cancel_parent: { direction: input } +machines: + - machine_id: owner + root: + variables: + parent_reference: + type: instance_reference + nullable: true + init: null + machine_id: parent + on_events: + start: + action: + - spawn: { machine_id: parent, bind_to: parent_reference } + cancel_parent: + action: + - cancel: { instance: parent_reference } + - machine_id: parent + root: + type: parallel + variables: + child_reference: + type: instance_reference + nullable: true + init: null + machine_id: grandchild + value: { type: int, init: 0 } + entry: + - spawn: { machine_id: grandchild, bind_to: child_reference } + components: + - component_id: retained_component + machine_id: component_worker + - component_id: retained_component_two + machine_id: component_worker + on_events: + boom: + action: + - assign: { value: "1 / 0" } + - machine_id: grandchild + root: + on_events: + ping: { action: [] } + - machine_id: component_worker + events: + internal_ping: { direction: internal } + root: + on_events: + internal_ping: { action: [] } +""" + +IDENTITY_EMISSION_BUNDLE = """ +format: 1 +namespace: example.identity_emission +events: + emit: { direction: input } + internal_notice: + direction: internal + payload: + value: { type: int, required: true } + external_notice: + direction: output + payload: + value: { type: int, required: true } +machines: + - machine_id: identity_emission + root: + on_events: + emit: + action: + - send: + event: internal_notice + payload: { value: "1" } + - send: + event: external_notice + to: { external: true } + payload: { value: "2" } + correlation_id: "'correlation-1'" +""" + def _root_target(state: dict) -> dict: return { @@ -73,6 +161,17 @@ def _root_variables(state: dict) -> dict: return values +def _nested_runtime_state() -> tuple[Bundle, dict]: + bundle = load_bundle(FROZEN_SUBTREE_BUNDLE) + state = create(bundle, "owner", "owner-prior", "create-prior", {})["state"] + state = dispatch( + bundle, + state, + {"input": _envelope(state, "start", "start-prior")}, + )["state"] + return bundle, state + + def test_normative_root_runtime_identity_vector() -> None: bundle = load_bundle(FINGERPRINT_BUNDLE) machine = bundle.raw["machines"][0] @@ -82,6 +181,87 @@ def test_normative_root_runtime_identity_vector() -> None: ) +def test_normative_first_component_and_root_initialization_cause_vectors() -> None: + bundle = load_bundle(FINGERPRINT_BUNDLE) + result = create(bundle, "turnstile", "turnstile-42", "create-7", {}) + state = result["state"] + left = next( + runtime + for runtime in state["runtimes"].values() + if runtime.get("component_id") == "left" + ) + + assert left["runtime_id"] == ( + "sha256:43db74b6a8d6f31543f7d142fb5e25a49e33eb3bf548e7bfd20d59513778cbc3" + ) + assert _cause_id( + "root_initialization", + "turnstile-42", + state["root_runtime_id"], + state["root_runtime_id"], + "create-7", + 0, + "/machines/0/root", + 0, + ) == "sha256:c9e8e89a01362f40e9a74c01392d09abe2323f31c8f14f22e05bfcaf6dfac0ab" + + +def test_identity_counter_operands_use_canonical_decimal_above_javascript_range() -> None: + bundle = load_bundle(FINGERPRINT_BUNDLE) + state = create(bundle, "turnstile", "turnstile-42", "create-7", {})["state"] + + assert _cause_id( + "root_initialization", + "turnstile-42", + state["root_runtime_id"], + state["root_runtime_id"], + "create-7", + 9007199254740993, + "/machines/0/root", + 9007199254740995, + ) == "sha256:2df23aef5335fe81713038d71e9d1d3f5c91512d995b2175070b8ab77e20da2b" + + +def test_exact_internal_event_and_external_effect_identity_vectors() -> None: + bundle = load_bundle(IDENTITY_EMISSION_BUNDLE) + state = create( + bundle, + "identity_emission", + "identity-root-1", + "identity-create-1", + {}, + )["state"] + result = dispatch( + bundle, + state, + {"input": _envelope(state, "emit", "identity-input-1")}, + ) + + assert state["root_runtime_id"] == ( + "sha256:cdfc68fdcbeef09460a7d51758a1d60fa673351d2196357c5c34bd64511ffac2" + ) + assert result["emissions"] == [ + { + "event": "internal_notice", + "event_id": ( + "sha256:521c9fa9e3f1d6ba7187b89f97d9a26bd118213e1ebec9a662bcaf27f96f0e9c" + ), + "target": _root_target(state), + "payload": {"value": 1}, + }, + { + "event": "external_notice", + "target": "external", + "payload": {"value": 2}, + "correlation_id": "correlation-1", + "effect_id": ( + "sha256:d01f6d7dbf678ed598a7a37fea7a025f3818e8ff777d0a9363b92f578fcee5d7" + ), + "sequence": 0, + }, + ] + + def test_dispatch_is_pure_and_success_advances_one_logical_step() -> None: bundle = load_bundle(COUNTER_BUNDLE) created = create(bundle, "counter", "counter-1", "create-1", {}) @@ -103,13 +283,18 @@ def test_dispatch_is_pure_and_success_advances_one_logical_step() -> None: def test_fault_rolls_back_author_writes_and_keeps_input_caller_owned() -> None: bundle = load_bundle(COUNTER_BUNDLE) prior = create(bundle, "counter", "counter-1", "create-1", {})["state"] + envelope = _envelope(prior, "explode", "explode-1") + prior_snapshot = copy.deepcopy(prior) + envelope_snapshot = copy.deepcopy(envelope) result = dispatch( bundle, prior, - {"input": _envelope(prior, "explode", "explode-1")}, + {"input": envelope}, ) + assert prior == prior_snapshot + assert envelope == envelope_snapshot assert result["status"] == "faulted" assert result["disposition"] == "faulted" assert _root_variables(result["state"])["count"] == 0 @@ -165,6 +350,63 @@ def test_malformed_prior_state_is_rejected_before_dispatch(mutate) -> None: assert result["state"] is prior +@pytest.mark.parametrize( + "corruption", + [ + "contained_runtime_identity", + "owner_relation", + "component_definition_path", + "component_owning_state_path", + "history_key", + "component_activation_counter", + "spawn_counter", + "holder_pointer", + ], +) +def test_recursive_malformed_prior_state_is_rejected_atomically(corruption: str) -> None: + bundle, prior = _nested_runtime_state() + parent = next( + runtime + for runtime in prior["runtimes"].values() + if runtime["role"] == "spawned" and runtime["machine_id"] == "parent" + ) + grandchild = next( + runtime + for runtime in prior["runtimes"].values() + if runtime["role"] == "spawned" and runtime["machine_id"] == "grandchild" + ) + component = next( + runtime + for runtime in prior["runtimes"].values() + if runtime["role"] == "component" + ) + if corruption == "contained_runtime_identity": + grandchild["runtime_id"] = "corrupt-runtime-id" + elif corruption == "owner_relation": + grandchild["owner_runtime_id"] = prior["root_runtime_id"] + elif corruption == "component_definition_path": + component["root_pointer"] = "/machines/999/root" + elif corruption == "component_owning_state_path": + component["owning_state_path"] = "root.missing" + elif corruption == "history_key": + parent["history"]["root.missing"] = None + elif corruption == "component_activation_counter": + pointer = component["component_definition_pointer"] + parent["next_component_activation_sequence"][pointer] = 0 + elif corruption == "spawn_counter": + parent["next_spawn_sequence"] = grandchild["spawn_sequence"] + elif corruption == "holder_pointer": + grandchild["holder"]["pointer"] = "/machines/1/root/variables/missing" + snapshot = copy.deepcopy(prior) + + result = dispatch(bundle, prior) + + assert prior == snapshot + assert result["disposition"] == "rejected" + assert result["rejection"] == {"code": "invalid_prior_state"} + assert result["state"] is prior + + def test_nested_nonportable_creation_binding_is_rejected() -> None: bundle = load_bundle(BINDING_BUNDLE) @@ -205,3 +447,101 @@ def test_root_target_is_a_closed_tagged_union_member() -> None: assert result["rejection"] == {"code": "invalid_instance_target"} assert result["state"] is prior + + +def test_running_descendants_of_faulted_runtime_are_frozen_and_owner_can_cancel() -> None: + bundle = load_bundle(FROZEN_SUBTREE_BUNDLE) + state = create(bundle, "owner", "owner-1", "create-1", {})["state"] + state = dispatch( + bundle, + state, + {"input": _envelope(state, "start", "start-1")}, + )["state"] + parent = next( + runtime + for runtime in state["runtimes"].values() + if runtime["role"] == "spawned" and runtime["machine_id"] == "parent" + ) + grandchild = next( + runtime + for runtime in state["runtimes"].values() + if runtime["role"] == "spawned" and runtime["machine_id"] == "grandchild" + ) + components = [ + runtime + for runtime in state["runtimes"].values() + if runtime["role"] == "component" + ] + component = components[0] + state = dispatch( + bundle, + state, + { + "input": { + "event": "boom", + "event_id": "boom-1", + "target": {"spawned_instance": parent["instance_reference"]}, + "payload": {}, + } + }, + )["state"] + frozen = copy.deepcopy(state) + + spawned_result = dispatch( + bundle, + state, + { + "input": { + "event": "ping", + "event_id": "ping-1", + "target": {"spawned_instance": grandchild["instance_reference"]}, + "payload": {}, + } + }, + ) + component_result = dispatch( + bundle, + state, + { + "internal": { + "event": "internal_ping", + "event_id": "internal-ping-1", + "target": component["target"], + "payload": {}, + } + }, + ) + + assert state == frozen + assert spawned_result["rejection"] == {"code": "invalid_instance_target"} + assert spawned_result["state"] is state + assert component_result["rejection"] == {"code": "inactive_component_target"} + assert component_result["state"] is state + + execution = _Execution( + bundle, + BundleModel(bundle), + copy.deepcopy(state), + step_sequence=int(state["next_logical_step_sequence"]), + ) + with pytest.raises(StepFault, match="invalid_instance_target"): + execution.resolve_send_target( + execution.state["runtimes"][execution.state["root_runtime_id"]], + {"instance": "retained_child_reference"}, + grandchild["instance_reference"], + "/test/send", + 0, + False, + ) + + cancelled = dispatch( + bundle, + state, + {"input": _envelope(state, "cancel_parent", "cancel-parent-1")}, + ) + retained_ids = { + parent["runtime_id"], + grandchild["runtime_id"], + *(runtime["runtime_id"] for runtime in components), + } + assert retained_ids.isdisjoint(cancelled["state"]["runtimes"]) diff --git a/tests/test_loading.py b/tests/test_loading.py index f17da30..e7997c5 100644 --- a/tests/test_loading.py +++ b/tests/test_loading.py @@ -62,10 +62,19 @@ def test_normative_bundle_fingerprint_vector() -> None: ("source_fragment", "code"), [ ("meta: { value: 0x10 }", "invalid_numeric_syntax"), + ("meta: { value: 0o10 }", "invalid_numeric_syntax"), + ("meta: { value: 1_000 }", "invalid_numeric_syntax"), ("meta: { value: +1 }", "invalid_numeric_syntax"), + ("meta: { value: 01 }", "invalid_numeric_syntax"), + ("meta: { value: .5 }", "invalid_numeric_syntax"), + ("meta: { value: 1. }", "invalid_numeric_syntax"), + ("meta: { value: .inf }", "invalid_numeric_syntax"), + ("meta: { value: .NaN }", "invalid_numeric_syntax"), ("meta: { value: True }", "invalid_boolean_syntax"), ("meta: { value: NULL }", "invalid_null_syntax"), + ("meta: { value: }", "invalid_null_syntax"), ("meta: &anchor { value: 1 }", "unsupported_yaml_feature"), + ("meta: { value: !!str tagged }", "unsupported_yaml_feature"), ], ) def test_nonportable_source_scalars_fail_before_schema(source_fragment: str, code: str) -> None: @@ -84,6 +93,111 @@ def test_nonportable_source_scalars_fail_before_schema(source_fragment: str, cod assert caught.value.code == code +@pytest.mark.parametrize( + "value", + [ + "1alpha", + "1.2.3", + "1e", + ".release", + "+release", + "0xrelease", + ], +) +def test_numeric_looking_plain_strings_remain_strings(value: str) -> None: + bundle = load_bundle( + f""" +format: 1 +namespace: example.numeric_string +meta: {{ release: {value} }} +machines: + - machine_id: numeric_string + root: {{}} +""" + ) + + assert bundle.raw["meta"]["release"] == value + assert type(bundle.raw["meta"]["release"]) is str + + +@pytest.mark.parametrize( + "value", + [ + "0x10", + "0o10", + "1_000", + "+1", + "01", + ".5", + "1.", + ".inf", + ".nan", + "true", + "null", + ], +) +def test_quoted_scalar_forms_remain_strings(value: str) -> None: + bundle = load_bundle( + f""" +format: 1 +namespace: example.quoted_scalar +meta: {{ value: "{value}" }} +machines: + - machine_id: quoted_scalar + root: {{}} +""" + ) + + assert bundle.raw["meta"]["value"] == value + assert type(bundle.raw["meta"]["value"]) is str + + +def test_empty_mapping_and_sequence_are_values_but_empty_scalar_is_not() -> None: + bundle = load_bundle( + """ +format: 1 +namespace: example.empty_containers +meta: + mapping: {} + sequence: [] +machines: + - machine_id: empty_containers + root: {} +""" + ) + + assert bundle.raw["meta"] == {"mapping": {}, "sequence": []} + + +def test_alias_tag_non_string_key_and_surrogate_source_forms_are_rejected() -> None: + fragments = [ + "meta: { first: &value one, second: *value }", + "meta: { value: !custom one }", + "meta: { 1: one }", + 'meta: { value: "\\uD800" }', + ] + expected = [ + "unsupported_yaml_feature", + "unsupported_yaml_feature", + "non_string_map_key", + "invalid_unicode", + ] + + for fragment, code in zip(fragments, expected, strict=True): + with pytest.raises(ValidationError) as caught: + load_bundle( + f""" +format: 1 +namespace: example.invalid_source +{fragment} +machines: + - machine_id: invalid_source + root: {{}} +""" + ) + assert caught.value.code == code + + def test_duplicate_keys_do_not_use_last_value_wins() -> None: source = """ format: 1 @@ -159,3 +273,86 @@ def test_checked_in_format_1_example_is_valid() -> None: assert bundle.namespace == "example.counter" assert bundle.machine("counter") is not None + + +@pytest.mark.parametrize( + ("guard", "assignment"), + [ + ("integer_value != floating_value", "text"), + ("true", "string(null)"), + ], +) +def test_bundle_loading_rejects_invalid_cel_overload_types( + guard: str, assignment: str +) -> None: + source = f""" +format: 1 +namespace: example.invalid_cel_types +events: + go: {{ direction: input }} +machines: + - machine_id: invalid_cel_types + root: + variables: + integer_value: {{ type: int, init: 1 }} + floating_value: {{ type: float, init: 1.0 }} + text: {{ type: string, init: "" }} + on_events: + go: + guard: "{guard}" + action: + - assign: {{ text: "{assignment}" }} +""" + + with pytest.raises(ValidationError) as caught: + load_bundle(source) + + assert caught.value.code == "cel_profile_error" + + +def test_bundle_loading_accepts_checked_int_from_double_conversion() -> None: + bundle = load_bundle( + """ +format: 1 +namespace: example.valid_cel_conversion +events: + go: { direction: input } +machines: + - machine_id: valid_cel_conversion + root: + variables: + integer_value: { type: int, init: 0 } + floating_value: { type: float, init: 1.5 } + on_events: + go: + action: + - assign: { integer_value: "int(floating_value)" } +""" + ) + + assert bundle.machine("valid_cel_conversion") is not None + + +def test_mutable_container_literal_does_not_create_an_unsound_element_type() -> None: + source = """ +format: 1 +namespace: example.mutable_container_type +events: + replace: { direction: input } +machines: + - machine_id: mutable_container_type + root: + variables: + numbers: { type: list, init: [1] } + selected: { type: int, init: 0 } + on_events: + replace: + action: + - assign: { numbers: "['not-an-integer']" } + - assign: { selected: "numbers[0]" } +""" + + with pytest.raises(ValidationError) as caught: + load_bundle(source) + + assert caught.value.code == "semantic_validation" From b51b8ded7131101ea4700fa869b30fb8725f0e5f Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Tue, 28 Jul 2026 19:18:34 +0900 Subject: [PATCH 3/3] fix: harden portable runtime semantics --- src/determa/state/cel.py | 96 ++++++ src/determa/state/engine.py | 297 ++++++++++++----- src/determa/state/validator.py | 7 + tests/test_cel.py | 28 ++ tests/test_engine.py | 570 ++++++++++++++++++++++++++++++++- tests/test_loading.py | 68 ++++ 6 files changed, 982 insertions(+), 84 deletions(-) diff --git a/src/determa/state/cel.py b/src/determa/state/cel.py index 9076b6a..654b043 100644 --- a/src/determa/state/cel.py +++ b/src/determa/state/cel.py @@ -153,6 +153,9 @@ def _program(expression: str) -> celpy.Runner: environment.program( _tree(expression), functions={ + "_==_": _portable_equal, + "_!=_": _portable_not_equal, + "_in_": _portable_membership, "double": _portable_double, "int": _portable_int, "string": _portable_string, @@ -634,6 +637,99 @@ def check_map_literal( _expect(_assignable(actual, expected_fields[name])) +def _portable_kind(value: Any) -> str: + celpy_module, celtypes, _ = _load() + if isinstance(value, celpy_module.CELEvalError): + return "error" + if value is None: + return "null" + if isinstance(value, (celtypes.BoolType, bool)): + return "bool" + if isinstance(value, (celtypes.IntType, int)): + integer = int(value) + if not _INT_MIN <= integer <= _INT_MAX: + raise ValueError("integer is outside signed 64-bit range") + return "int" + if isinstance(value, (celtypes.DoubleType, float)): + if not math.isfinite(float(value)): + raise ValueError("double is not finite") + return "float" + if isinstance(value, (celtypes.StringType, str)): + return "string" + if isinstance(value, (celtypes.ListType, list)): + return "list" + if isinstance(value, (celtypes.MapType, dict)): + return "map" + raise TypeError(f"unsupported portable equality value: {type(value).__name__}") + + +def _portable_map(value: Any) -> dict[str, Any]: + _, celtypes, _ = _load() + result: dict[str, Any] = {} + for key, item in value.items(): + if not isinstance(key, (celtypes.StringType, str)): + raise TypeError("portable maps require string keys") + result[str(key)] = item + return result + + +def _portable_equal_value(left: Any, right: Any) -> bool: + left_kind = _portable_kind(left) + right_kind = _portable_kind(right) + if left_kind == "error" or right_kind == "error": + raise TypeError("cannot compare an evaluation error") + if left_kind != right_kind: + return False + if left_kind == "list": + return len(left) == len(right) and all( + _portable_equal_value(left_item, right_item) + for left_item, right_item in zip(left, right, strict=True) + ) + if left_kind == "map": + left_map = _portable_map(left) + right_map = _portable_map(right) + return set(left_map) == set(right_map) and all( + _portable_equal_value(left_map[key], right_map[key]) for key in left_map + ) + return bool(left == right) + + +def _portable_equal(left: Any, right: Any) -> Any: + celpy_module, celtypes, _ = _load() + if isinstance(left, celpy_module.CELEvalError): + return left + if isinstance(right, celpy_module.CELEvalError): + return right + return celtypes.BoolType(_portable_equal_value(left, right)) + + +def _portable_not_equal(left: Any, right: Any) -> Any: + celpy_module, celtypes, _ = _load() + if isinstance(left, celpy_module.CELEvalError): + return left + if isinstance(right, celpy_module.CELEvalError): + return right + return celtypes.BoolType(not _portable_equal_value(left, right)) + + +def _portable_membership(item: Any, container: Any) -> Any: + celpy_module, celtypes, _ = _load() + if isinstance(item, celpy_module.CELEvalError): + return item + if isinstance(container, celpy_module.CELEvalError): + return container + kind = _portable_kind(container) + if kind == "list": + return celtypes.BoolType( + any(_portable_equal_value(item, candidate) for candidate in container) + ) + if kind == "map": + return celtypes.BoolType( + any(_portable_equal_value(item, key) for key in container) + ) + raise TypeError("in requires a list or string-keyed map") + + def _portable_double(value: Any) -> Any: _, celtypes, _ = _load() if not isinstance(value, celtypes.IntType): diff --git a/src/determa/state/engine.py b/src/determa/state/engine.py index a900cda..9f54da8 100644 --- a/src/determa/state/engine.py +++ b/src/determa/state/engine.py @@ -4,6 +4,7 @@ import copy import math +from collections.abc import Callable from dataclasses import dataclass from typing import Any, Literal, cast @@ -448,12 +449,70 @@ def _valid_prior_state(state: Any, bundle: Bundle) -> bool: if not isinstance(state, dict): return False try: - validate_portable_values(state) + _validate_prior_state_values(state) return validate_unicode(state) and _validate_prior_state(state, bundle) except (IndexError, KeyError, TypeError, ValueError, ValidationError): return False +def _is_prior_counter_path(path: tuple[str | int, ...]) -> bool: + if path in { + ("next_logical_step_sequence",), + ("next_output_sequence",), + ("fault", "step_sequence"), + }: + return True + if len(path) < 3 or path[0] != "runtimes" or not isinstance(path[1], str): + return False + suffix = path[2:] + if suffix in { + ("next_spawn_sequence",), + ("component_activation_sequence",), + ("owning_state_activation_sequence",), + ("spawn_sequence",), + ("fault", "step_sequence"), + ("holder", "state_activation_sequence"), + ("target", "component", "activation_sequence"), + }: + return True + return len(suffix) == 2 and suffix[0] in { + "next_state_activation_sequence", + "state_activation_sequence", + "next_component_activation_sequence", + } + + +def _validate_prior_state_values(state: dict[str, Any]) -> None: + def visit(value: Any, path: tuple[str | int, ...], ancestors: set[int]) -> None: + if _is_prior_counter_path(path): + if not _logical_counter(value): + raise ValidationError("numeric_value_out_of_range") + return + if isinstance(value, list): + identity = id(value) + if identity in ancestors: + raise ValidationError("non_json_value") + ancestors.add(identity) + for index, item in enumerate(value): + visit(item, (*path, index), ancestors) + ancestors.remove(identity) + return + if isinstance(value, dict): + identity = id(value) + if identity in ancestors: + raise ValidationError("non_json_value") + ancestors.add(identity) + for key, item in value.items(): + if not isinstance(key, str): + raise ValidationError("non_string_map_key") + visit(item, (*path, key), ancestors) + ancestors.remove(identity) + return + validate_portable_values(value) + + visit(state, (), set()) + + def _validate_prior_state(state: dict[str, Any], bundle: Bundle) -> bool: required = { "validated_bundle_fingerprint", @@ -482,8 +541,8 @@ def _validate_prior_state(state: dict[str, Any], bundle: Bundle) -> bool: or not isinstance(state["root_runtime_id"], str) or not isinstance(state["root_machine_id"], str) or state["status"] not in {"running", "completed", "faulted"} - or not _nonnegative_integer(state["next_logical_step_sequence"]) - or not _nonnegative_integer(state["next_output_sequence"]) + or not _logical_counter(state["next_logical_step_sequence"]) + or not _logical_counter(state["next_output_sequence"]) or not isinstance(state["runtimes"], dict) ): return False @@ -522,9 +581,14 @@ def _validate_prior_state(state: dict[str, Any], bundle: Bundle) -> bool: return False if runtime.get("runtime_id") != runtime_id: return False + if runtime_id != state["root_runtime_id"] and runtime.get("role") == "root": + return False if not _validate_runtime_state(state, runtime, bundle, models): return False + if state["status"] == "completed" and len(runtimes) != 1: + return False + for runtime in runtimes.values(): owner_id = runtime.get("owner_runtime_id") if runtime["role"] == "root": @@ -586,14 +650,15 @@ def _validate_runtime_state( if ( runtime["role"] not in {"root", "component", "spawned"} or runtime["status"] not in {"running", "completed", "faulted"} + or (runtime["role"] == "spawned" and runtime["status"] == "completed") or not isinstance(runtime["machine_id"], str) or runtime["machine_id"] not in models.machines - or not _nonnegative_integer(runtime["machine_version"]) + or not _bounded_nonnegative_integer(runtime["machine_version"]) or not isinstance(runtime["root_pointer"], str) or not isinstance(runtime["active"], list) or not isinstance(runtime["scopes"], dict) or not isinstance(runtime["history"], dict) - or not _nonnegative_integer(runtime["next_spawn_sequence"]) + or not _logical_counter(runtime["next_spawn_sequence"]) or not _counter_map(runtime["next_state_activation_sequence"]) or not _counter_map(runtime["state_activation_sequence"]) or not _counter_map(runtime["next_component_activation_sequence"]) @@ -690,12 +755,21 @@ def _validate_runtime_state( for key, value in runtime["components"].items() ): return False - if runtime["fault"] is not None and not _valid_fault(runtime["fault"], runtime): + if runtime["fault"] is not None and not _valid_fault( + runtime["fault"], runtime, state["next_logical_step_sequence"] + ): return False if runtime["status"] == "faulted" and runtime["fault"] is None: return False if runtime["status"] != "faulted" and runtime["fault"] is not None: return False + if runtime["status"] == "completed" and ( + runtime["active"] + or runtime["scopes"] + or runtime["state_activation_sequence"] + or runtime["components"] + ): + return False if runtime["role"] == "component": if not _valid_component_identity(state, runtime): return False @@ -722,10 +796,10 @@ def _valid_component_identity(state: dict[str, Any], runtime: dict[str, Any]) -> not isinstance(runtime["component_id"], str) or runtime["component_runtime_id"] != runtime["runtime_id"] or not isinstance(runtime["component_definition_pointer"], str) - or not _nonnegative_integer(runtime["component_declaration_index"]) - or not _nonnegative_integer(runtime["component_activation_sequence"]) + or not _bounded_nonnegative_integer(runtime["component_declaration_index"]) + or not _logical_counter(runtime["component_activation_sequence"]) or not isinstance(runtime["owning_state_path"], str) - or not _nonnegative_integer(runtime["owning_state_activation_sequence"]) + or not _logical_counter(runtime["owning_state_activation_sequence"]) ): return False expected_id = _identity( @@ -755,7 +829,7 @@ def _valid_component_identity(state: dict[str, Any], runtime: dict[str, Any]) -> def _valid_spawned_identity(state: dict[str, Any], runtime: dict[str, Any]) -> bool: if ( - not _nonnegative_integer(runtime.get("spawn_sequence")) + not _logical_counter(runtime.get("spawn_sequence")) or not isinstance(runtime.get("spawn_action_pointer"), str) or not _is_instance_reference(runtime.get("instance_reference")) or runtime["instance_reference"].get("root_instance_id") != state["root_instance_id"] @@ -783,7 +857,7 @@ def _valid_spawned_identity(state: dict[str, Any], runtime: dict[str, Any]) -> b or set(holder) != {"pointer", "state_path", "state_activation_sequence"} or not isinstance(holder["pointer"], str) or not isinstance(holder["state_path"], str) - or not _nonnegative_integer(holder["state_activation_sequence"]) + or not _logical_counter(holder["state_activation_sequence"]) ): return False return bool(runtime["runtime_id"] == expected_id) @@ -889,7 +963,33 @@ def _valid_spawned_relation( ) -def _valid_fault(fault: Any, runtime: dict[str, Any]) -> bool: +def _valid_fault( + fault: Any, + runtime: dict[str, Any], + next_logical_step_sequence: int, +) -> bool: + pointer_codes = { + "guard_fault", + "action_fault", + "invalid_instance_target", + "inactive_component_target", + "binding_not_empty", + } + system_locators = { + "contained_runtime_fault": "system:unhandled_contained_failure", + "cascade_fault": "system:cascade_cleanup", + "invariant_fault": "system:invariant", + } + code = fault.get("code") if isinstance(fault, dict) else None + locator = fault.get("source_locator") if isinstance(fault, dict) else None + valid_locator = ( + isinstance(code, str) + and isinstance(locator, str) + and ( + (code in pointer_codes and locator.startswith("/")) + or system_locators.get(code) == locator + ) + ) return ( isinstance(fault, dict) and set(fault) == {"runtime_id", "cause_id", "code", "step_sequence", "source_locator"} @@ -898,18 +998,23 @@ def _valid_fault(fault: Any, runtime: dict[str, Any]) -> bool: and bool(fault["cause_id"]) and isinstance(fault["code"], str) and bool(fault["code"]) - and _nonnegative_integer(fault["step_sequence"]) - and isinstance(fault["source_locator"], str) + and _logical_counter(fault["step_sequence"]) + and fault["step_sequence"] < next_logical_step_sequence + and valid_locator ) -def _nonnegative_integer(value: Any) -> bool: +def _bounded_nonnegative_integer(value: Any) -> bool: return isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= _INT_MAX +def _logical_counter(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + def _counter_map(value: Any) -> bool: return isinstance(value, dict) and all( - isinstance(key, str) and _nonnegative_integer(counter) for key, counter in value.items() + isinstance(key, str) and _logical_counter(counter) for key, counter in value.items() ) @@ -1108,7 +1213,7 @@ def _validate_reserved_payload(event: str, envelope: dict[str, Any]) -> str | No isinstance(payload[name], str) and bool(payload[name]) for name in ("instance_id", "machine_id") ) - and _nonnegative_integer(payload["machine_version"]) + and _bounded_nonnegative_integer(payload["machine_version"]) and payload["machine_version"] > 0 and payload["instance"]["instance_id"] == payload["instance_id"] and payload["instance"]["machine_id"] == payload["machine_id"] @@ -1137,7 +1242,7 @@ def _validate_reserved_payload(event: str, envelope: dict[str, Any]) -> str | No isinstance(payload[name], str) and bool(payload[name]) for name in ("instance_id", "machine_id") ) - and _nonnegative_integer(payload["machine_version"]) + and _bounded_nonnegative_integer(payload["machine_version"]) and payload["machine_version"] > 0 and payload["instance"]["instance_id"] == payload["instance_id"] and payload["instance"]["machine_id"] == payload["machine_id"] @@ -1746,7 +1851,7 @@ def run_actions( self.activation(runtime, machine, state, event_visible=event_visible), f"{action_pointer}/cancel/instance", ) - self.cancel(reference) + self.cancel(runtime, reference) elif "stop" in action: raise _StopRuntime @@ -1799,8 +1904,13 @@ def send( assert declaration is not None payload_result = _normalize_payload(declaration, payload_values) if payload_result is None: - first = sorted(payload_values, key=lambda item: item.encode("utf-8"))[0] - raise StepFault("action_fault", f"{pointer}/payload/{_escape_pointer(first)}") + supplied = sorted(payload_values, key=lambda item: item.encode("utf-8")) + locator = ( + f"{pointer}/payload/{_escape_pointer(supplied[0])}" + if supplied + else f"{pointer}/payload" + ) + raise StepFault("action_fault", locator) normalized_payload = payload_result resolved = [ self.resolve_send_target(runtime, target_spec, value, pointer, index, "targets" in send) @@ -2010,13 +2120,32 @@ def restore_contained(self, snapshot: dict[str, Any], child_runtime_id: str) -> child.update(copy.deepcopy(snapshot_runtimes[child_runtime_id])) self.state["next_output_sequence"] = snapshot["next_output_sequence"] - def cancel(self, reference: Any) -> None: + def cancel(self, runtime: dict[str, Any], reference: Any) -> None: if not _is_instance_reference(reference): return child = self.state["runtimes"].get(reference["instance_id"]) - if child is None or child["role"] != "spawned": + if ( + child is None + or child["role"] != "spawned" + or not self.owns_descendant(runtime, child) + ): return - self.cleanup_runtime(child, dispose=True) + self.cleanup_descendant(child) + + def owns_descendant( + self, + runtime: dict[str, Any], + descendant: dict[str, Any], + ) -> bool: + owner_id = descendant.get("owner_runtime_id") + while owner_id is not None: + if owner_id == runtime["runtime_id"]: + return True + owner = self.state["runtimes"].get(owner_id) + if not isinstance(owner, dict): + return False + owner_id = owner.get("owner_runtime_id") + return False def apply_transition( self, @@ -2124,63 +2253,66 @@ def exit_state(self, runtime: dict[str, Any], machine: MachineModel, state: Stat runtime["active"].remove(state.path) def cleanup_state_children(self, runtime: dict[str, Any], state: StateNode) -> None: - children = list(self.state["runtimes"].values()) - components = [ - child - for child in children - if child.get("role") == "component" - and child.get("owner_runtime_id") == runtime["runtime_id"] - and child.get("owning_state_path") == state.path - ] - components.sort( - key=lambda child: ( - child["component_definition_pointer"].encode("utf-8"), - child["owning_state_activation_sequence"], - child["component_declaration_index"], - child["component_activation_sequence"], - ), - reverse=True, - ) - for child in components: - self.cleanup_runtime(child, dispose=True) - held = [ - child - for child in list(self.state["runtimes"].values()) - if child.get("role") == "spawned" - and child.get("owner_runtime_id") == runtime["runtime_id"] - and child.get("holder") is not None - and child["holder"]["state_path"] == state.path - and child["holder"]["state_activation_sequence"] - == runtime["state_activation_sequence"].get(state.path) - ] - held.sort(key=_spawn_cleanup_key) - for child in held: - self.cleanup_runtime(child, dispose=True) + activation_sequence = runtime["state_activation_sequence"].get(state.path) + + def selected(child: dict[str, Any]) -> bool: + if child["role"] == "component": + return child.get("owning_state_path") == state.path + holder = child.get("holder") + return bool( + holder is not None + and holder["state_path"] == state.path + and holder["state_activation_sequence"] == activation_sequence + ) - def cleanup_runtime(self, runtime: dict[str, Any], *, dispose: bool) -> None: - machine = self.model_for(runtime) - descendants = [ + for child in self.ordered_children(runtime, selected): + self.cleanup_descendant(child) + + def ordered_children( + self, + runtime: dict[str, Any], + selected: Callable[[dict[str, Any]], bool] | None = None, + ) -> list[dict[str, Any]]: + children = [ child for child in list(self.state["runtimes"].values()) if child.get("owner_runtime_id") == runtime["runtime_id"] + and (selected is None or selected(child)) ] components = sorted( - [child for child in descendants if child["role"] == "component"], - key=lambda child: ( - child["component_definition_pointer"].encode("utf-8"), - child["owning_state_activation_sequence"], - child["component_declaration_index"], - child["component_activation_sequence"], - ), + (child for child in children if child["role"] == "component"), + key=_component_cleanup_key, reverse=True, ) spawned = sorted( - [child for child in descendants if child["role"] == "spawned"], + (child for child in children if child["role"] == "spawned"), key=_spawn_cleanup_key, ) - for child in [*components, *spawned]: - self.cleanup_runtime(child, dispose=True) - if runtime["status"] == "running": + return [*components, *spawned] + + def cleanup_descendant( + self, + runtime: dict[str, Any], + *, + frozen: bool = False, + ) -> None: + try: + self.cleanup_runtime(runtime, dispose=True, frozen=frozen) + except StepFault as exc: + raise StepFault("cascade_fault", "system:cascade_cleanup") from exc + + def cleanup_runtime( + self, + runtime: dict[str, Any], + *, + dispose: bool, + frozen: bool = False, + ) -> None: + machine = self.model_for(runtime) + frozen = frozen or runtime["status"] == "faulted" + for child in self.ordered_children(runtime): + self.cleanup_descendant(child, frozen=frozen) + if runtime["status"] == "running" and not frozen: for path in list(reversed(runtime["active"])): self.exit_state(runtime, machine, machine.states[path]) if dispose: @@ -2194,19 +2326,8 @@ def cleanup_runtime(self, runtime: dict[str, Any], *, dispose: bool) -> None: def complete_runtime(self, runtime: dict[str, Any], machine: MachineModel) -> None: if runtime["status"] != "running": return - for child in sorted( - [ - item - for item in list(self.state["runtimes"].values()) - if item.get("owner_runtime_id") == runtime["runtime_id"] - ], - key=lambda item: ( - 0 if item["role"] == "component" else 1, - item.get("component_definition_pointer", "").encode("utf-8"), - item.get("spawn_sequence", 0), - ), - ): - self.cleanup_runtime(child, dispose=True) + for child in self.ordered_children(runtime): + self.cleanup_descendant(child) for path in list(reversed(runtime["active"])): self.exit_state(runtime, machine, machine.states[path]) runtime["status"] = "completed" @@ -2380,6 +2501,16 @@ def _spawn_cleanup_key(runtime: dict[str, Any]) -> tuple[int, bytes, int, int]: ) +def _component_cleanup_key(runtime: dict[str, Any]) -> tuple[bytes, int, int, int]: + owning_state_pointer = runtime["component_definition_pointer"].rsplit("/components/", 1)[0] + return ( + owning_state_pointer.encode("utf-8"), + int(runtime["owning_state_activation_sequence"]), + int(runtime["component_declaration_index"]), + int(runtime["component_activation_sequence"]), + ) + + def _pointer_get(document: dict[str, Any], pointer: str) -> dict[str, Any]: current: Any = document for part in pointer.split("/")[1:]: diff --git a/src/determa/state/validator.py b/src/determa/state/validator.py index 7fe5a27..822c9a7 100644 --- a/src/determa/state/validator.py +++ b/src/determa/state/validator.py @@ -677,6 +677,13 @@ def _validate_send( supplied = send.get("payload") or {} if set(supplied) - set(payload_types): raise ValidationError("semantic_validation") + required = { + name + for name, field in (declaration.get("payload") or {}).items() + if field.get("required") is True and "default" not in field + } + if required - set(supplied): + raise ValidationError("semantic_validation") for name, expression in supplied.items(): _check_expression( expression, diff --git a/tests/test_cel.py b/tests/test_cel.py index 82e8135..38540e5 100644 --- a/tests/test_cel.py +++ b/tests/test_cel.py @@ -25,6 +25,34 @@ def test_unicode_is_not_normalized() -> None: assert cel.evaluate('"\\u00e9" == "e\\u0301"', {}) is False +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ("[1] == [1.0]", False), + ("[1] != [1.0]", True), + ("{'x': 1} == {'x': 1.0}", False), + ("{'x': 1} != {'x': 1.0}", True), + ("[{'x': [1]}] == [{'x': [1.0]}]", False), + ("[{'x': [1]}] != [{'x': [1.0]}]", True), + ("{'a': 1, 'b': [2]} == {'b': [2], 'a': 1}", True), + ("[true] == [1]", False), + ("['1'] == [1]", False), + ("1 in [1.0]", False), + ("1 in [1]", True), + ("{'x': [1]} in [{'x': [1.0]}]", False), + ("{'x': [1]} in [{'x': [1]}]", True), + ("'x' in {'x': 1}", True), + ("'missing' in {'x': 1}", False), + ], +) +def test_profile_owns_recursive_collection_equality_and_membership( + expression: str, + expected: bool, +) -> None: + assert cel.check_expression(expression, {}, expected=cel.BOOL) == cel.BOOL + assert cel.evaluate(expression, {}) is expected + + @pytest.mark.parametrize( ("expression", "expected"), [ diff --git a/tests/test_engine.py b/tests/test_engine.py index 789ee9f..3c068c5 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -5,7 +5,13 @@ import pytest from determa.state import Bundle, create, dispatch, load_bundle -from determa.state.engine import _cause_id, _Execution, _root_runtime_id +from determa.state.engine import ( + _cause_id, + _component_runtime_id, + _Execution, + _root_runtime_id, + _spawned_runtime_id, +) from determa.state.errors import StepFault from determa.state.model import BundleModel @@ -56,6 +62,7 @@ boom: { direction: input } ping: { direction: input } cancel_parent: { direction: input } + cleanup: { direction: output } machines: - machine_id: owner root: @@ -97,6 +104,11 @@ root: on_events: ping: { action: [] } + exit: + - send: + event: cleanup + to: { external: true } + correlation_id: "'frozen-cleanup'" - machine_id: component_worker events: internal_ping: { direction: internal } @@ -134,6 +146,194 @@ correlation_id: "'correlation-1'" """ +HIGH_COUNTER_BUNDLE = """ +format: 1 +namespace: example.high_counter +events: + activate: { direction: input } + activated: { direction: output } +machines: + - machine_id: high_counter + root: + type: composite + variables: + worker_reference: + type: instance_reference + nullable: true + init: null + machine_id: worker + initial: { transition_to: idle } + states: + idle: + on_events: + activate: + action: + - spawn: { machine_id: worker, bind_to: worker_reference } + - send: + event: activated + to: { external: true } + correlation_id: "'high-correlation'" + transition_to: group + group: + type: parallel + components: + - component_id: first + machine_id: worker + - component_id: second + machine_id: worker + - machine_id: worker + root: {} +""" + +CANCEL_OWNERSHIP_BUNDLE = """ +format: 1 +namespace: example.cancel_ownership +events: + setup: { direction: input } + spawn_descendant: { direction: input } +machines: + - machine_id: owner + root: + variables: + first: + type: instance_reference + nullable: true + init: null + machine_id: participant + second: + type: instance_reference + nullable: true + init: null + machine_id: participant + on_events: + setup: + action: + - spawn: { machine_id: participant, bind_to: first } + - spawn: { machine_id: participant, bind_to: second } + - machine_id: participant + root: + variables: + middle: + type: instance_reference + nullable: true + init: null + machine_id: middle + on_events: + spawn_descendant: + action: + - spawn: { machine_id: middle, bind_to: middle } + - machine_id: middle + root: + variables: + leaf: + type: instance_reference + nullable: true + init: null + machine_id: leaf + entry: + - spawn: { machine_id: leaf, bind_to: leaf } + - machine_id: leaf + root: {} +""" + +CASCADE_FAULT_BUNDLE = """ +format: 1 +namespace: example.cascade_fault +events: + start: { direction: input } + cancel_child: { direction: input } + cleanup: + direction: output +machines: + - machine_id: owner + root: + variables: + child: + type: instance_reference + nullable: true + init: null + machine_id: child + on_events: + start: + action: + - spawn: { machine_id: child, bind_to: child } + cancel_child: + action: + - cancel: { instance: child } + - machine_id: child + root: + variables: + value: { type: int, init: 0 } + exit: + - send: + event: cleanup + to: { external: true } + correlation_id: "'cleanup'" + - assign: { value: "1 / 0" } +""" + +_CASCADE_COMPONENTS = "\n".join( + f""" + - component_id: component_{index} + machine_id: cleanup_worker + with: + input: + marker: "'component-{index}'" +""" + for index in range(11) +) + +CASCADE_ORDER_BUNDLE = f""" +format: 1 +namespace: example.cascade_order +events: + finish: {{ direction: input }} + cleanup: + direction: output + payload: + marker: {{ type: string, required: true }} +machines: + - machine_id: owner + root: + type: parallel + variables: + z_reference: + type: instance_reference + nullable: true + init: null + machine_id: cleanup_worker + a_reference: + type: instance_reference + nullable: true + init: null + machine_id: cleanup_worker + entry: + - spawn: + machine_id: cleanup_worker + bindings: {{ input: {{ marker: "'z-spawn'" }} }} + bind_to: z_reference + - spawn: + machine_id: cleanup_worker + bindings: {{ input: {{ marker: "'a-spawn'" }} }} + bind_to: a_reference + components: +{_CASCADE_COMPONENTS} + on_events: + finish: + action: + - stop: {{}} + - machine_id: cleanup_worker + root: + variables: + marker: {{ type: string, input: true }} + exit: + - send: + event: cleanup + to: {{ external: true }} + payload: {{ marker: marker }} + correlation_id: "'cleanup'" +""" + def _root_target(state: dict) -> dict: return { @@ -153,6 +353,20 @@ def _envelope(state: dict, event: str, event_id: str) -> dict: } +def _runtime_envelope( + runtime: dict, + event: str, + event_id: str, + payload: dict | None = None, +) -> dict: + return { + "event": event, + "event_id": event_id, + "target": {"spawned_instance": copy.deepcopy(runtime["instance_reference"])}, + "payload": copy.deepcopy(payload or {}), + } + + def _root_variables(state: dict) -> dict: root = state["runtimes"][state["root_runtime_id"]] values: dict = {} @@ -262,6 +476,139 @@ def test_exact_internal_event_and_external_effect_identity_vectors() -> None: ] +def test_dispatch_allocates_unbounded_logical_counters_with_canonical_id_operands() -> None: + bundle = load_bundle(HIGH_COUNTER_BUNDLE) + state = create(bundle, "high_counter", "high-root", "high-create", {})["state"] + root = state["runtimes"][state["root_runtime_id"]] + step_sequence = 2**63 + 9 + output_sequence = 2**63 + 11 + spawn_sequence = 2**63 + 13 + root_activation = 2**63 + 17 + idle_activation = 2**63 + 19 + group_activation = 2**63 + 23 + component_activation = 2**63 + 29 + state["next_logical_step_sequence"] = step_sequence + state["next_output_sequence"] = output_sequence + root["next_spawn_sequence"] = spawn_sequence + root["state_activation_sequence"]["root"] = root_activation + root["next_state_activation_sequence"]["root"] = root_activation + 1 + root["state_activation_sequence"]["idle"] = idle_activation + root["next_state_activation_sequence"]["idle"] = idle_activation + 1 + root["next_state_activation_sequence"]["group"] = group_activation + first_pointer = "/machines/0/root/states/group/components/0" + second_pointer = "/machines/0/root/states/group/components/1" + root["next_component_activation_sequence"][first_pointer] = component_activation + root["next_component_activation_sequence"][second_pointer] = component_activation + 1 + + result = dispatch( + bundle, + state, + {"input": _envelope(state, "activate", "high-activate")}, + ) + + assert result["disposition"] == "handled" + assert result["state"]["next_logical_step_sequence"] == step_sequence + 1 + assert result["state"]["next_output_sequence"] == output_sequence + 1 + result_root = result["state"]["runtimes"][state["root_runtime_id"]] + assert result_root["state_activation_sequence"]["group"] == group_activation + spawned = next( + runtime + for runtime in result["state"]["runtimes"].values() + if runtime["role"] == "spawned" + ) + first = next( + runtime + for runtime in result["state"]["runtimes"].values() + if runtime.get("component_id") == "first" + ) + worker = BundleModel(bundle).machine("worker") + assert spawned["spawn_sequence"] == spawn_sequence + assert spawned["runtime_id"] == _spawned_runtime_id( + bundle, + state["root_runtime_id"], + state["root_instance_id"], + "/machines/0/root/states/idle/on_events/activate/action/0/spawn", + spawn_sequence, + worker, + ) + assert first["component_activation_sequence"] == component_activation + assert first["runtime_id"] == _component_runtime_id( + bundle, + state["root_runtime_id"], + state["root_instance_id"], + first_pointer, + component_activation, + worker, + ) + assert spawned["runtime_id"] == ( + "sha256:92bc50f1329857e0a74ec7ec1309f8378b8af1fd67ba83cad343984ac30cb97c" + ) + assert first["runtime_id"] == ( + "sha256:afe3002d5165a5a22ea198ff60853f7bdbbd8ff7b8fb9c32916c9215968bad5e" + ) + assert result["emissions"] == [ + { + "event": "activated", + "target": "external", + "payload": {}, + "correlation_id": "high-correlation", + "effect_id": ( + "sha256:f5e454de1e9c3f70801148fc9719491cc1b4dc460a8129a342cbee0648f7659c" + ), + "sequence": output_sequence, + } + ] + + +@pytest.mark.parametrize("nested", [False, True]) +def test_oversized_ordinary_prior_values_remain_invalid(nested: bool) -> None: + if nested: + bundle = load_bundle(BINDING_BUNDLE) + state = create( + bundle, + "binding", + "binding-high", + "binding-create", + {"input": {"settings": {"nested": [1]}}}, + )["state"] + root = state["runtimes"][state["root_runtime_id"]] + root["scopes"]["root"]["settings"]["nested"][0] = 2**63 + else: + bundle = load_bundle(COUNTER_BUNDLE) + state = create(bundle, "counter", "counter-high", "counter-create", {})["state"] + root = state["runtimes"][state["root_runtime_id"]] + root["scopes"]["root"]["count"] = 2**63 + + result = dispatch(bundle, state) + + assert result["disposition"] == "rejected" + assert result["rejection"] == {"code": "invalid_prior_state"} + assert result["state"] is state + + +def test_retained_fault_step_sequence_is_an_unbounded_logical_counter() -> None: + bundle = load_bundle(COUNTER_BUNDLE) + state = create(bundle, "counter", "counter-fault-high", "fault-create", {})["state"] + state = dispatch( + bundle, + state, + {"input": _envelope(state, "explode", "fault-high")}, + )["state"] + step_sequence = 2**63 + 101 + state["next_logical_step_sequence"] = step_sequence + 1 + state["fault"]["step_sequence"] = step_sequence + root = state["runtimes"][state["root_runtime_id"]] + root["fault"]["step_sequence"] = step_sequence + snapshot = copy.deepcopy(state) + + result = dispatch(bundle, state) + + assert result["status"] == "faulted" + assert result["disposition"] is None + assert result["state"] is state + assert result["state"] == snapshot + + def test_dispatch_is_pure_and_success_advances_one_logical_step() -> None: bundle = load_bundle(COUNTER_BUNDLE) created = create(bundle, "counter", "counter-1", "create-1", {}) @@ -335,6 +682,7 @@ def test_changed_bundle_cannot_reinterpret_prior_state() -> None: lambda state: state["runtimes"][state["root_runtime_id"]]["active"].append("missing"), lambda state: state["runtimes"][state["root_runtime_id"]]["scopes"].update({"missing": {}}), lambda state: state.update({"next_logical_step_sequence": True}), + lambda state: state.update({"next_output_sequence": -1}), lambda state: state.update({"root_instance_id": "\ud800"}), ], ) @@ -407,6 +755,108 @@ def test_recursive_malformed_prior_state_is_rejected_atomically(corruption: str) assert result["state"] is prior +def test_completed_root_cannot_retain_owned_descendants() -> None: + bundle, prior = _nested_runtime_state() + root = prior["runtimes"][prior["root_runtime_id"]] + prior["status"] = "completed" + root["status"] = "completed" + + result = dispatch(bundle, prior) + + assert result["disposition"] == "rejected" + assert result["rejection"] == {"code": "invalid_prior_state"} + assert result["state"] is prior + + +def test_completed_spawned_runtime_cannot_be_retained() -> None: + bundle, prior = _nested_runtime_state() + grandchild = next( + runtime + for runtime in prior["runtimes"].values() + if runtime["role"] == "spawned" and runtime["machine_id"] == "grandchild" + ) + grandchild["status"] = "completed" + grandchild["active"] = [] + grandchild["scopes"] = {} + grandchild["state_activation_sequence"] = {} + + result = dispatch(bundle, prior) + + assert result["disposition"] == "rejected" + assert result["rejection"] == {"code": "invalid_prior_state"} + assert result["state"] is prior + + +def test_completed_component_remains_a_valid_retained_runtime() -> None: + bundle, prior = _nested_runtime_state() + component = next( + runtime for runtime in prior["runtimes"].values() if runtime["role"] == "component" + ) + component["status"] = "completed" + component["active"] = [] + component["scopes"] = {} + component["state_activation_sequence"] = {} + snapshot = copy.deepcopy(prior) + + result = dispatch(bundle, prior) + + assert result["disposition"] is None + assert result["state"] == snapshot + assert result["state"] is prior + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("code", "arbitrary_fault"), + ("source_locator", "not-a-pointer"), + ("source_locator", "system:not_reserved"), + ("step_sequence", "next"), + ], +) +def test_root_fault_record_uses_closed_committed_domain(field: str, value: object) -> None: + bundle = load_bundle(COUNTER_BUNDLE) + state = create(bundle, "counter", "fault-domain", "fault-create", {})["state"] + state = dispatch( + bundle, + state, + {"input": _envelope(state, "explode", "fault-input")}, + )["state"] + root = state["runtimes"][state["root_runtime_id"]] + replacement = state["next_logical_step_sequence"] if value == "next" else value + root["fault"][field] = replacement + state["fault"][field] = replacement + + result = dispatch(bundle, state) + + assert result["disposition"] == "rejected" + assert result["rejection"] == {"code": "invalid_prior_state"} + assert result["state"] is state + + +def test_nested_fault_record_uses_closed_committed_domain() -> None: + bundle, state = _nested_runtime_state() + parent = next( + runtime + for runtime in state["runtimes"].values() + if runtime["role"] == "spawned" and runtime["machine_id"] == "parent" + ) + result = dispatch( + bundle, + state, + {"input": _runtime_envelope(parent, "boom", "nested-fault-input")}, + ) + state = result["state"] + parent = state["runtimes"][parent["runtime_id"]] + parent["fault"]["code"] = "arbitrary_fault" + + rejected = dispatch(bundle, state) + + assert rejected["disposition"] == "rejected" + assert rejected["rejection"] == {"code": "invalid_prior_state"} + assert rejected["state"] is state + + def test_nested_nonportable_creation_binding_is_rejected() -> None: bundle = load_bundle(BINDING_BUNDLE) @@ -545,3 +995,121 @@ def test_running_descendants_of_faulted_runtime_are_frozen_and_owner_can_cancel( *(runtime["runtime_id"] for runtime in components), } assert retained_ids.isdisjoint(cancelled["state"]["runtimes"]) + assert cancelled["emissions"] == [] + + +def test_cancel_only_disposes_runtime_owned_descendants() -> None: + bundle = load_bundle(CANCEL_OWNERSHIP_BUNDLE) + state = create(bundle, "owner", "cancel-owner", "cancel-create", {})["state"] + state = dispatch( + bundle, + state, + {"input": _envelope(state, "setup", "cancel-setup")}, + )["state"] + participants = sorted( + ( + runtime + for runtime in state["runtimes"].values() + if runtime.get("machine_id") == "participant" + ), + key=lambda runtime: runtime["spawn_sequence"], + ) + first, second = participants + state = dispatch( + bundle, + state, + { + "input": _runtime_envelope( + first, + "spawn_descendant", + "spawn-descendant", + ) + }, + )["state"] + first = state["runtimes"][first["runtime_id"]] + second = state["runtimes"][second["runtime_id"]] + middle = next( + runtime + for runtime in state["runtimes"].values() + if runtime.get("owner_runtime_id") == first["runtime_id"] + ) + leaf = next( + runtime + for runtime in state["runtimes"].values() + if runtime.get("owner_runtime_id") == middle["runtime_id"] + ) + execution = _Execution( + bundle, + BundleModel(bundle), + copy.deepcopy(state), + step_sequence=int(state["next_logical_step_sequence"]), + ) + first = execution.state["runtimes"][first["runtime_id"]] + second = execution.state["runtimes"][second["runtime_id"]] + middle = execution.state["runtimes"][middle["runtime_id"]] + leaf = execution.state["runtimes"][leaf["runtime_id"]] + + execution.cancel(second, leaf["instance_reference"]) + assert leaf["runtime_id"] in execution.state["runtimes"] + + execution.cancel(leaf, first["instance_reference"]) + assert first["runtime_id"] in execution.state["runtimes"] + + execution.cancel(first, leaf["instance_reference"]) + assert leaf["runtime_id"] not in execution.state["runtimes"] + assert middle["runtime_id"] in execution.state["runtimes"] + + +def test_runtime_completion_uses_canonical_component_and_holder_order() -> None: + bundle = load_bundle(CASCADE_ORDER_BUNDLE) + state = create(bundle, "owner", "cascade-order", "cascade-create", {})["state"] + + result = dispatch( + bundle, + state, + {"input": _envelope(state, "finish", "cascade-finish")}, + ) + + assert result["status"] == "completed" + assert [ + emission["payload"]["marker"] for emission in result["emissions"] + ] == [ + *(f"component-{index}" for index in range(10, -1, -1)), + "a-spawn", + "z-spawn", + ] + assert [emission["sequence"] for emission in result["emissions"]] == list(range(13)) + + +def test_descendant_cleanup_failure_rolls_back_and_faults_owner_as_cascade() -> None: + bundle = load_bundle(CASCADE_FAULT_BUNDLE) + state = create(bundle, "owner", "cascade-fault", "cascade-create", {})["state"] + state = dispatch( + bundle, + state, + {"input": _envelope(state, "start", "cascade-start")}, + )["state"] + prior_snapshot = copy.deepcopy(state) + envelope = _envelope(state, "cancel_child", "cascade-cancel") + envelope_snapshot = copy.deepcopy(envelope) + + result = dispatch(bundle, state, {"input": envelope}) + + assert state == prior_snapshot + assert envelope == envelope_snapshot + assert result["status"] == "faulted" + assert result["disposition"] == "faulted" + assert result["emissions"] == [] + assert result["fault"] == { + "runtime_id": state["root_runtime_id"], + "cause_id": "cascade-cancel", + "code": "cascade_fault", + "step_sequence": state["next_logical_step_sequence"], + "source_locator": "system:cascade_cleanup", + } + child_ids = { + runtime["runtime_id"] + for runtime in state["runtimes"].values() + if runtime["role"] == "spawned" + } + assert child_ids <= set(result["state"]["runtimes"]) diff --git a/tests/test_loading.py b/tests/test_loading.py index e7997c5..d8760eb 100644 --- a/tests/test_loading.py +++ b/tests/test_loading.py @@ -356,3 +356,71 @@ def test_mutable_container_literal_does_not_create_an_unsound_element_type() -> load_bundle(source) assert caught.value.code == "semantic_validation" + + +@pytest.mark.parametrize( + "payload", + [ + "", + "payload: { optional_value: \"'provided'\" }", + ], +) +def test_send_requires_every_required_payload_expression(payload: str) -> None: + source = f""" +format: 1 +namespace: example.required_send_payload +events: + go: {{ direction: input }} + notice: + direction: internal + payload: + required_value: {{ type: string, required: true }} + optional_value: {{ type: string }} +machines: + - machine_id: required_send_payload + root: + on_events: + go: + action: + - send: + event: notice + {payload} +""" + + with pytest.raises(ValidationError) as caught: + load_bundle(source) + + assert caught.value.code == "semantic_validation" + + +@pytest.mark.parametrize( + "payload_declaration", + [ + "optional_value: { type: string }", + "defaulted_value: { type: string, default: fallback }", + ], +) +def test_send_allows_absent_optional_or_defaulted_payload_fields( + payload_declaration: str, +) -> None: + bundle = load_bundle( + f""" +format: 1 +namespace: example.optional_send_payload +events: + go: {{ direction: input }} + notice: + direction: internal + payload: + {payload_declaration} +machines: + - machine_id: optional_send_payload + root: + on_events: + go: + action: + - send: {{ event: notice }} +""" + ) + + assert bundle.machine("optional_send_payload") is not None