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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ Implements the **harel spec v0.0.1** (early alpha; all fruwehq harel repos share
[synchronized version](https://github.com/fruwehq/harel)).

Status: **passing the full conformance suite** — all 22 engine cases
(`conformance/01`–`22`) plus `conformance/cli/01`–`02`. Implements YAML 1.2 loading
(`conformance/01`–`25`) plus `conformance/cli/01`–`03`. Implements YAML 1.2 loading
+ validation, the full statechart semantics (RTC dispatch, hierarchy, orthogonal
regions + `done`, shallow/deep history, esvs, CEL guards, structured actions,
regions + `done`, shallow/deep history, choice pseudostates, 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].
Expand Down
14 changes: 12 additions & 2 deletions conformance/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ def conformance_root() -> Path:
"20-contract-fail",
"21-snapshot-roundtrip",
"22-migration",
"23-choice",
"24-choice-chain",
"25-choice-invalid",
}
)

Expand Down Expand Up @@ -156,7 +159,15 @@ def run_engine_case(case: EngineCase) -> None:
assert case.machine_files, f"{case.name}: no machine files"

if "static" in test:
root_raw = load_definitions(case.machine_files[0].read_text(encoding="utf-8"))[0].raw
from harel 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"
Expand All @@ -166,7 +177,6 @@ def run_engine_case(case: EngineCase) -> None:
contracts[c["id"]] = c
errors.extend(validate_contracts(root_raw, contracts))
valid = not errors
expected = bool(test["static"]["valid"])
assert valid is expected, (
f"{case.name}: static valid={valid} != {expected} ({errors})"
)
Expand Down
9 changes: 8 additions & 1 deletion conformance/test_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,15 @@ def _spec_schema() -> dict | 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():
Expand Down Expand Up @@ -85,7 +92,7 @@ def test_bundled_schema_matches_spec() -> None:
def test_suite_present() -> None:
if not CONFORMANCE_DIR.exists():
pytest.skip("conformance suite not fetched (offline; set HAREL_CONFORMANCE_DIR)")
assert len(engine_cases()) == 22, "expected 22 engine cases"
assert len(engine_cases()) == 25, "expected 25 engine cases"
assert len(cli_cases()) == 3, "expected 3 CLI cases"


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "harel-python"
version = "0.0.1"
version = "0.0.2"
description = "Python reference implementation of the harel statechart engine"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/harel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"__version__",
]

__version__ = "0.0.1"
__version__ = "0.0.2"

# Diagnostic logging under the ``harel`` logger; silent unless the host app
# configures logging (e.g. ``logging.basicConfig(level=logging.DEBUG)``).
Expand Down
14 changes: 13 additions & 1 deletion src/harel/data/machine.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,17 @@
}
},

"choiceBranch": {
"type": "object",
"required": ["transition_to"],
"additionalProperties": false,
"properties": {
"transition_to": { "$ref": "#/$defs/dottedRef" },
"guard": { "$ref": "#/$defs/cel" },
"action": { "$ref": "#/$defs/actionList" }
}
},

"after": {
"type": "object",
"required": ["duration"],
Expand Down Expand Up @@ -172,7 +183,8 @@
"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" }
"history": { "enum": ["none", "shallow", "deep"], "default": "none" },
"choice": { "type": "array", "minItems": 2, "items": { "$ref": "#/$defs/choiceBranch" } }
},
"allOf": [
{
Expand Down
34 changes: 34 additions & 0 deletions src/harel/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,17 @@ def run_transition(
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:
Expand All @@ -313,6 +324,29 @@ def run_transition(
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 HarelError(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 HarelError(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."""
Expand Down
40 changes: 40 additions & 0 deletions src/harel/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ 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:
Expand Down Expand Up @@ -164,6 +166,9 @@ def _validate_references(self) -> None:
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)
Expand All @@ -174,9 +179,44 @@ def _validate_references(self) -> None:
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


def _as_transition_list(spec: Any) -> list[dict[str, Any]]:
if isinstance(spec, list):
Expand Down
21 changes: 21 additions & 0 deletions src/harel/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,24 @@ def _reserved_name_errors(doc: dict[str, Any]) -> list[ErrorRecord]:
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")
)
elif len(defaults) > 1:
errors.append(
ErrorRecord(path=f"{path}/choice", message="choice has more than one default branch")
)
elif defaults[0] != len(branches) - 1:
errors.append(
ErrorRecord(path=f"{path}/choice", message="the default (else) branch must be last")
)


def _forbid(
name: object, reserved: frozenset[str], path: str, errors: list[ErrorRecord]
) -> None:
Expand All @@ -112,6 +130,9 @@ def _walk_state(path: str, state: Any, errors: list[ErrorRecord]) -> None:
"""
if not isinstance(state, dict):
return
choice = state.get("choice")
if isinstance(choice, list):
_check_choice(path, choice, errors)
esvs = state.get("esvs")
if isinstance(esvs, dict):
for name in esvs:
Expand Down
Loading
Loading