metaspn-gates 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- metaspn_gates-0.1.0/PKG-INFO +64 -0
- metaspn_gates-0.1.0/README.md +46 -0
- metaspn_gates-0.1.0/metaspn_gates/__init__.py +33 -0
- metaspn_gates-0.1.0/metaspn_gates/applier.py +97 -0
- metaspn_gates-0.1.0/metaspn_gates/config.py +248 -0
- metaspn_gates-0.1.0/metaspn_gates/evaluator.py +187 -0
- metaspn_gates-0.1.0/metaspn_gates/models.py +83 -0
- metaspn_gates-0.1.0/metaspn_gates/schemas.py +64 -0
- metaspn_gates-0.1.0/metaspn_gates.egg-info/PKG-INFO +64 -0
- metaspn_gates-0.1.0/metaspn_gates.egg-info/SOURCES.txt +14 -0
- metaspn_gates-0.1.0/metaspn_gates.egg-info/dependency_links.txt +1 -0
- metaspn_gates-0.1.0/metaspn_gates.egg-info/requires.txt +1 -0
- metaspn_gates-0.1.0/metaspn_gates.egg-info/top_level.txt +1 -0
- metaspn_gates-0.1.0/pyproject.toml +30 -0
- metaspn_gates-0.1.0/setup.cfg +4 -0
- metaspn_gates-0.1.0/tests/test_gates.py +246 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metaspn-gates
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Config-driven gate and state machine evaluator for MetaSPN entity pipelines
|
|
5
|
+
License-Expression: LicenseRef-Proprietary
|
|
6
|
+
Project-URL: Homepage, https://github.com/leoguinan/metaspn-gates
|
|
7
|
+
Project-URL: Repository, https://github.com/leoguinan/metaspn-gates
|
|
8
|
+
Project-URL: Issues, https://github.com/leoguinan/metaspn-gates/issues
|
|
9
|
+
Keywords: metaspn,state-machine,gates,workflow
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: metaspn-schemas==0.1.0
|
|
18
|
+
|
|
19
|
+
# metaspn-gates
|
|
20
|
+
|
|
21
|
+
`metaspn-gates` is a config-driven gate + state-machine evaluator for MetaSPN entity pipelines.
|
|
22
|
+
|
|
23
|
+
## Current implementation (v0.1 seed)
|
|
24
|
+
|
|
25
|
+
- Deterministic gate evaluation from config
|
|
26
|
+
- Hard requirements and soft thresholds
|
|
27
|
+
- Per-gate per-entity cooldown checks
|
|
28
|
+
- Transition attempt snapshots
|
|
29
|
+
- Transition application + task emissions
|
|
30
|
+
- Config parsing and validation from mapping
|
|
31
|
+
- `metaspn-schemas` integration hooks (currently used for typed emission shaping)
|
|
32
|
+
- Optional `metaspn-schemas` emission shaping (`Task` + `EmissionEnvelope`)
|
|
33
|
+
|
|
34
|
+
## Public API
|
|
35
|
+
|
|
36
|
+
- `evaluate_gates(config, entity_state, features, now)`
|
|
37
|
+
- `apply_decisions(entity_state, decisions, caused_by=None)`
|
|
38
|
+
- `parse_state_machine_config(payload)`
|
|
39
|
+
- `load_state_machine_config(path)`
|
|
40
|
+
|
|
41
|
+
## Notes
|
|
42
|
+
|
|
43
|
+
- `load_state_machine_config` can use `metaspn_schemas` parsing/validation hooks when exposed by that package version.
|
|
44
|
+
- If `metaspn_schemas` is unavailable, it falls back to JSON parsing only.
|
|
45
|
+
- Current dependency target: `metaspn-schemas==0.1.0`.
|
|
46
|
+
- `apply_decisions(..., use_schema_envelopes=True)` attaches schema-shaped payloads when `entity_state.entity_id` is present.
|
|
47
|
+
|
|
48
|
+
## Release
|
|
49
|
+
|
|
50
|
+
- GitHub Actions workflow: `/Users/leoguinan/MetaSPN/metaspn-gates/.github/workflows/publish.yml`
|
|
51
|
+
- Publish trigger: GitHub Release published (or manual `workflow_dispatch`)
|
|
52
|
+
- Publishing method: PyPI Trusted Publishing via `pypa/gh-action-pypi-publish`
|
|
53
|
+
|
|
54
|
+
### One-time setup
|
|
55
|
+
|
|
56
|
+
1. In PyPI, create project `metaspn-gates` and configure Trusted Publisher for this GitHub repo/workflow.
|
|
57
|
+
2. In GitHub, create environment `pypi` (optional protection rules supported).
|
|
58
|
+
|
|
59
|
+
### Release flow
|
|
60
|
+
|
|
61
|
+
1. Bump `/Users/leoguinan/MetaSPN/metaspn-gates/pyproject.toml` version.
|
|
62
|
+
2. Tag and push a release commit.
|
|
63
|
+
3. Publish a GitHub Release for that tag.
|
|
64
|
+
4. `publish.yml` builds and uploads to PyPI.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# metaspn-gates
|
|
2
|
+
|
|
3
|
+
`metaspn-gates` is a config-driven gate + state-machine evaluator for MetaSPN entity pipelines.
|
|
4
|
+
|
|
5
|
+
## Current implementation (v0.1 seed)
|
|
6
|
+
|
|
7
|
+
- Deterministic gate evaluation from config
|
|
8
|
+
- Hard requirements and soft thresholds
|
|
9
|
+
- Per-gate per-entity cooldown checks
|
|
10
|
+
- Transition attempt snapshots
|
|
11
|
+
- Transition application + task emissions
|
|
12
|
+
- Config parsing and validation from mapping
|
|
13
|
+
- `metaspn-schemas` integration hooks (currently used for typed emission shaping)
|
|
14
|
+
- Optional `metaspn-schemas` emission shaping (`Task` + `EmissionEnvelope`)
|
|
15
|
+
|
|
16
|
+
## Public API
|
|
17
|
+
|
|
18
|
+
- `evaluate_gates(config, entity_state, features, now)`
|
|
19
|
+
- `apply_decisions(entity_state, decisions, caused_by=None)`
|
|
20
|
+
- `parse_state_machine_config(payload)`
|
|
21
|
+
- `load_state_machine_config(path)`
|
|
22
|
+
|
|
23
|
+
## Notes
|
|
24
|
+
|
|
25
|
+
- `load_state_machine_config` can use `metaspn_schemas` parsing/validation hooks when exposed by that package version.
|
|
26
|
+
- If `metaspn_schemas` is unavailable, it falls back to JSON parsing only.
|
|
27
|
+
- Current dependency target: `metaspn-schemas==0.1.0`.
|
|
28
|
+
- `apply_decisions(..., use_schema_envelopes=True)` attaches schema-shaped payloads when `entity_state.entity_id` is present.
|
|
29
|
+
|
|
30
|
+
## Release
|
|
31
|
+
|
|
32
|
+
- GitHub Actions workflow: `/Users/leoguinan/MetaSPN/metaspn-gates/.github/workflows/publish.yml`
|
|
33
|
+
- Publish trigger: GitHub Release published (or manual `workflow_dispatch`)
|
|
34
|
+
- Publishing method: PyPI Trusted Publishing via `pypa/gh-action-pypi-publish`
|
|
35
|
+
|
|
36
|
+
### One-time setup
|
|
37
|
+
|
|
38
|
+
1. In PyPI, create project `metaspn-gates` and configure Trusted Publisher for this GitHub repo/workflow.
|
|
39
|
+
2. In GitHub, create environment `pypi` (optional protection rules supported).
|
|
40
|
+
|
|
41
|
+
### Release flow
|
|
42
|
+
|
|
43
|
+
1. Bump `/Users/leoguinan/MetaSPN/metaspn-gates/pyproject.toml` version.
|
|
44
|
+
2. Tag and push a release commit.
|
|
45
|
+
3. Publish a GitHub Release for that tag.
|
|
46
|
+
4. `publish.yml` builds and uploads to PyPI.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Config-driven gate and state-machine evaluator for MetaSPN pipelines."""
|
|
2
|
+
|
|
3
|
+
from .models import (
|
|
4
|
+
GateConfig,
|
|
5
|
+
StateMachineConfig,
|
|
6
|
+
GateDecision,
|
|
7
|
+
TransitionApplied,
|
|
8
|
+
TransitionAttempted,
|
|
9
|
+
HardRequirement,
|
|
10
|
+
SoftThreshold,
|
|
11
|
+
)
|
|
12
|
+
from .config import ConfigError, load_state_machine_config, parse_state_machine_config, schemas_backend_available
|
|
13
|
+
from .evaluator import Evaluator, evaluate_gates
|
|
14
|
+
from .schemas import schemas_available
|
|
15
|
+
from .applier import apply_decisions
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"GateConfig",
|
|
19
|
+
"StateMachineConfig",
|
|
20
|
+
"GateDecision",
|
|
21
|
+
"TransitionApplied",
|
|
22
|
+
"TransitionAttempted",
|
|
23
|
+
"HardRequirement",
|
|
24
|
+
"SoftThreshold",
|
|
25
|
+
"Evaluator",
|
|
26
|
+
"evaluate_gates",
|
|
27
|
+
"apply_decisions",
|
|
28
|
+
"parse_state_machine_config",
|
|
29
|
+
"load_state_machine_config",
|
|
30
|
+
"schemas_backend_available",
|
|
31
|
+
"schemas_available",
|
|
32
|
+
"ConfigError",
|
|
33
|
+
]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Iterable, Mapping
|
|
6
|
+
|
|
7
|
+
from .models import GateDecision, TransitionApplied
|
|
8
|
+
from .schemas import build_task_and_emission, schemas_available
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def apply_decisions(
|
|
12
|
+
entity_state: Mapping[str, Any],
|
|
13
|
+
decisions: Iterable[GateDecision],
|
|
14
|
+
caused_by: str | None = None,
|
|
15
|
+
use_schema_envelopes: bool = False,
|
|
16
|
+
default_task_priority: int = 50,
|
|
17
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
18
|
+
new_state: dict[str, Any] = deepcopy(dict(entity_state))
|
|
19
|
+
emissions: list[dict[str, Any]] = []
|
|
20
|
+
|
|
21
|
+
attempts = new_state.setdefault("gate_attempts", [])
|
|
22
|
+
applied = new_state.setdefault("transitions_applied", [])
|
|
23
|
+
cooldowns = new_state.setdefault("gate_cooldowns", {})
|
|
24
|
+
|
|
25
|
+
if not isinstance(attempts, list):
|
|
26
|
+
raise ValueError("entity_state.gate_attempts must be a list when present")
|
|
27
|
+
if not isinstance(applied, list):
|
|
28
|
+
raise ValueError("entity_state.transitions_applied must be a list when present")
|
|
29
|
+
if not isinstance(cooldowns, dict):
|
|
30
|
+
raise ValueError("entity_state.gate_cooldowns must be an object when present")
|
|
31
|
+
|
|
32
|
+
for decision in decisions:
|
|
33
|
+
attempted = decision.transition_attempted
|
|
34
|
+
attempts.append(
|
|
35
|
+
{
|
|
36
|
+
"gate_id": attempted.gate_id,
|
|
37
|
+
"from": attempted.from_state,
|
|
38
|
+
"to": attempted.to_state,
|
|
39
|
+
"passed": attempted.passed,
|
|
40
|
+
"reason": attempted.reason,
|
|
41
|
+
"failed_requirement_id": attempted.failed_requirement_id,
|
|
42
|
+
"timestamp": attempted.timestamp.isoformat(),
|
|
43
|
+
"snapshot": deepcopy(dict(attempted.snapshot)),
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if decision.passed:
|
|
48
|
+
new_state["state"] = decision.to_state
|
|
49
|
+
|
|
50
|
+
record = TransitionApplied(
|
|
51
|
+
gate_id=decision.gate_id,
|
|
52
|
+
from_state=decision.from_state,
|
|
53
|
+
to_state=decision.to_state,
|
|
54
|
+
caused_by=caused_by,
|
|
55
|
+
timestamp=attempted.timestamp,
|
|
56
|
+
snapshot=deepcopy(dict(attempted.snapshot)),
|
|
57
|
+
)
|
|
58
|
+
applied.append(
|
|
59
|
+
{
|
|
60
|
+
"gate_id": record.gate_id,
|
|
61
|
+
"from": record.from_state,
|
|
62
|
+
"to": record.to_state,
|
|
63
|
+
"caused_by": record.caused_by,
|
|
64
|
+
"timestamp": record.timestamp.isoformat(),
|
|
65
|
+
"snapshot": record.snapshot,
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
for task in decision.enqueue_tasks_on_pass:
|
|
70
|
+
base_emission: dict[str, Any] = {
|
|
71
|
+
"kind": "task_enqueued",
|
|
72
|
+
"task_id": task,
|
|
73
|
+
"gate_id": decision.gate_id,
|
|
74
|
+
"caused_by": caused_by,
|
|
75
|
+
"timestamp": attempted.timestamp.isoformat(),
|
|
76
|
+
}
|
|
77
|
+
if use_schema_envelopes and schemas_available():
|
|
78
|
+
entity_id = new_state.get("entity_id")
|
|
79
|
+
if not isinstance(entity_id, str) or not entity_id:
|
|
80
|
+
raise ValueError("entity_state.entity_id is required when use_schema_envelopes=True")
|
|
81
|
+
schema_payload = build_task_and_emission(
|
|
82
|
+
task_id=task,
|
|
83
|
+
created_at=attempted.timestamp,
|
|
84
|
+
caused_by=caused_by or "unknown",
|
|
85
|
+
entity_id=entity_id,
|
|
86
|
+
gate_id=decision.gate_id,
|
|
87
|
+
emission_id=f"{decision.gate_id}:{task}:{int(attempted.timestamp.timestamp())}",
|
|
88
|
+
priority=default_task_priority,
|
|
89
|
+
)
|
|
90
|
+
if schema_payload is not None:
|
|
91
|
+
base_emission["schema"] = schema_payload
|
|
92
|
+
emissions.append(base_emission)
|
|
93
|
+
|
|
94
|
+
if decision.cooldown_on == "attempt" or (decision.cooldown_on == "pass" and decision.passed):
|
|
95
|
+
cooldowns[decision.gate_id] = decision.transition_attempted.timestamp.isoformat()
|
|
96
|
+
|
|
97
|
+
return new_state, emissions
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, Mapping
|
|
7
|
+
|
|
8
|
+
from .models import GateConfig, HardRequirement, SoftThreshold, StateMachineConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigError(ValueError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _mapping_from_object(value: Any) -> Mapping[str, Any] | None:
|
|
16
|
+
if isinstance(value, Mapping):
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
for attr in ("model_dump", "dict", "to_dict"):
|
|
20
|
+
fn = getattr(value, attr, None)
|
|
21
|
+
if callable(fn):
|
|
22
|
+
candidate = fn()
|
|
23
|
+
if isinstance(candidate, Mapping):
|
|
24
|
+
return candidate
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_schemas_backend() -> Any | None:
|
|
29
|
+
try:
|
|
30
|
+
return importlib.import_module("metaspn_schemas")
|
|
31
|
+
except ImportError:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def schemas_backend_available() -> bool:
|
|
36
|
+
return _load_schemas_backend() is not None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _call_parser(fn: Callable[..., Any], raw: str, path: Path) -> Any:
|
|
40
|
+
last_exc: Exception | None = None
|
|
41
|
+
for args in ((raw, str(path)), (raw,), (str(path),), ()):
|
|
42
|
+
try:
|
|
43
|
+
return fn(*args)
|
|
44
|
+
except TypeError as exc:
|
|
45
|
+
last_exc = exc
|
|
46
|
+
if last_exc is not None:
|
|
47
|
+
raise last_exc
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_with_schemas_backend(raw: str, path: Path, backend: Any) -> Mapping[str, Any] | None:
|
|
52
|
+
parser_names = (
|
|
53
|
+
"parse_state_machine_config_yaml",
|
|
54
|
+
"parse_state_machine_yaml",
|
|
55
|
+
"load_state_machine_config",
|
|
56
|
+
"load_yaml",
|
|
57
|
+
"parse_yaml",
|
|
58
|
+
)
|
|
59
|
+
for name in parser_names:
|
|
60
|
+
fn = getattr(backend, name, None)
|
|
61
|
+
if callable(fn):
|
|
62
|
+
parsed = _call_parser(fn, raw, path)
|
|
63
|
+
mapping = _mapping_from_object(parsed)
|
|
64
|
+
if mapping is not None:
|
|
65
|
+
return mapping
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _validate_with_schemas_backend(payload: Mapping[str, Any], backend: Any) -> Mapping[str, Any]:
|
|
70
|
+
validate_fn = getattr(backend, "validate_state_machine_config", None)
|
|
71
|
+
if callable(validate_fn):
|
|
72
|
+
try:
|
|
73
|
+
validated = validate_fn(payload)
|
|
74
|
+
except Exception as exc: # pragma: no cover - backend-defined exception types
|
|
75
|
+
raise ConfigError(f"metaspn_schemas validation failed: {exc}") from exc
|
|
76
|
+
mapping = _mapping_from_object(validated)
|
|
77
|
+
if mapping is not None:
|
|
78
|
+
return mapping
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
schema_cls = getattr(backend, "StateMachineConfig", None)
|
|
82
|
+
if schema_cls is None:
|
|
83
|
+
return payload
|
|
84
|
+
|
|
85
|
+
model_validate = getattr(schema_cls, "model_validate", None)
|
|
86
|
+
parse_obj = getattr(schema_cls, "parse_obj", None)
|
|
87
|
+
try:
|
|
88
|
+
if callable(model_validate):
|
|
89
|
+
validated = model_validate(payload)
|
|
90
|
+
elif callable(parse_obj):
|
|
91
|
+
validated = parse_obj(payload)
|
|
92
|
+
else:
|
|
93
|
+
return payload
|
|
94
|
+
except Exception as exc: # pragma: no cover - backend-defined exception types
|
|
95
|
+
raise ConfigError(f"metaspn_schemas validation failed: {exc}") from exc
|
|
96
|
+
|
|
97
|
+
mapping = _mapping_from_object(validated)
|
|
98
|
+
if mapping is not None:
|
|
99
|
+
return mapping
|
|
100
|
+
return payload
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _require_str(mapping: Mapping[str, Any], key: str) -> str:
|
|
104
|
+
value = mapping.get(key)
|
|
105
|
+
if not isinstance(value, str) or not value:
|
|
106
|
+
raise ConfigError(f"{key} must be a non-empty string")
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_hard_requirements(raw: Any) -> tuple[HardRequirement, ...]:
|
|
111
|
+
if raw is None:
|
|
112
|
+
return ()
|
|
113
|
+
if not isinstance(raw, list):
|
|
114
|
+
raise ConfigError("hard_requirements must be a list")
|
|
115
|
+
|
|
116
|
+
parsed: list[HardRequirement] = []
|
|
117
|
+
for item in raw:
|
|
118
|
+
if not isinstance(item, Mapping):
|
|
119
|
+
raise ConfigError("hard requirement entries must be objects")
|
|
120
|
+
parsed.append(
|
|
121
|
+
HardRequirement(
|
|
122
|
+
requirement_id=_require_str(item, "requirement_id"),
|
|
123
|
+
field=_require_str(item, "field"),
|
|
124
|
+
op=_require_str(item, "op"),
|
|
125
|
+
value=item.get("value"),
|
|
126
|
+
source=item.get("source", "features"),
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
return tuple(parsed)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_soft_thresholds(raw: Any) -> tuple[SoftThreshold, ...]:
|
|
133
|
+
if raw is None:
|
|
134
|
+
return ()
|
|
135
|
+
if not isinstance(raw, list):
|
|
136
|
+
raise ConfigError("soft_thresholds must be a list")
|
|
137
|
+
|
|
138
|
+
parsed: list[SoftThreshold] = []
|
|
139
|
+
for item in raw:
|
|
140
|
+
if not isinstance(item, Mapping):
|
|
141
|
+
raise ConfigError("soft threshold entries must be objects")
|
|
142
|
+
if "value" not in item:
|
|
143
|
+
raise ConfigError("soft threshold value is required")
|
|
144
|
+
parsed.append(
|
|
145
|
+
SoftThreshold(
|
|
146
|
+
threshold_id=_require_str(item, "threshold_id"),
|
|
147
|
+
field=_require_str(item, "field"),
|
|
148
|
+
op=_require_str(item, "op"),
|
|
149
|
+
value=item["value"],
|
|
150
|
+
source=item.get("source", "features"),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return tuple(parsed)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_state_machine_config(payload: Mapping[str, Any]) -> StateMachineConfig:
|
|
157
|
+
backend = _load_schemas_backend()
|
|
158
|
+
if backend is not None:
|
|
159
|
+
payload = _validate_with_schemas_backend(payload, backend)
|
|
160
|
+
|
|
161
|
+
config_version = _require_str(payload, "config_version")
|
|
162
|
+
|
|
163
|
+
raw_gates = payload.get("gates")
|
|
164
|
+
if not isinstance(raw_gates, list) or not raw_gates:
|
|
165
|
+
raise ConfigError("gates must be a non-empty list")
|
|
166
|
+
|
|
167
|
+
gates: list[GateConfig] = []
|
|
168
|
+
gate_ids: set[str] = set()
|
|
169
|
+
|
|
170
|
+
for gate in raw_gates:
|
|
171
|
+
if not isinstance(gate, Mapping):
|
|
172
|
+
raise ConfigError("gate entries must be objects")
|
|
173
|
+
|
|
174
|
+
gate_id = _require_str(gate, "gate_id")
|
|
175
|
+
if gate_id in gate_ids:
|
|
176
|
+
raise ConfigError(f"duplicate gate_id: {gate_id}")
|
|
177
|
+
gate_ids.add(gate_id)
|
|
178
|
+
|
|
179
|
+
raw_tasks = gate.get("enqueue_tasks_on_pass") or []
|
|
180
|
+
if not isinstance(raw_tasks, list) or not all(isinstance(t, str) and t for t in raw_tasks):
|
|
181
|
+
raise ConfigError("enqueue_tasks_on_pass must be a list of non-empty strings")
|
|
182
|
+
|
|
183
|
+
raw_taxonomy = gate.get("failure_taxonomy") or {}
|
|
184
|
+
if not isinstance(raw_taxonomy, Mapping):
|
|
185
|
+
raise ConfigError("failure_taxonomy must be an object")
|
|
186
|
+
|
|
187
|
+
cooldown_seconds = gate.get("cooldown_seconds", 0)
|
|
188
|
+
if not isinstance(cooldown_seconds, int) or cooldown_seconds < 0:
|
|
189
|
+
raise ConfigError("cooldown_seconds must be a non-negative integer")
|
|
190
|
+
|
|
191
|
+
cooldown_on = gate.get("cooldown_on", "pass")
|
|
192
|
+
if cooldown_on not in {"pass", "attempt"}:
|
|
193
|
+
raise ConfigError("cooldown_on must be either 'pass' or 'attempt'")
|
|
194
|
+
|
|
195
|
+
min_soft_passed = gate.get("min_soft_passed")
|
|
196
|
+
if min_soft_passed is not None and (not isinstance(min_soft_passed, int) or min_soft_passed < 0):
|
|
197
|
+
raise ConfigError("min_soft_passed must be a non-negative integer when provided")
|
|
198
|
+
|
|
199
|
+
parsed = GateConfig(
|
|
200
|
+
gate_id=gate_id,
|
|
201
|
+
version=_require_str(gate, "version"),
|
|
202
|
+
track=gate.get("track"),
|
|
203
|
+
from_state=_require_str(gate, "from"),
|
|
204
|
+
to_state=_require_str(gate, "to"),
|
|
205
|
+
hard_requirements=_parse_hard_requirements(gate.get("hard_requirements")),
|
|
206
|
+
soft_thresholds=_parse_soft_thresholds(gate.get("soft_thresholds")),
|
|
207
|
+
min_soft_passed=min_soft_passed,
|
|
208
|
+
cooldown_seconds=cooldown_seconds,
|
|
209
|
+
cooldown_on=cooldown_on,
|
|
210
|
+
enqueue_tasks_on_pass=tuple(raw_tasks),
|
|
211
|
+
failure_taxonomy={str(k): str(v) for k, v in raw_taxonomy.items()},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if parsed.min_soft_passed is not None and parsed.min_soft_passed > len(parsed.soft_thresholds):
|
|
215
|
+
raise ConfigError("min_soft_passed cannot exceed number of soft_thresholds")
|
|
216
|
+
|
|
217
|
+
gates.append(parsed)
|
|
218
|
+
|
|
219
|
+
# Stable deterministic order.
|
|
220
|
+
gates.sort(key=lambda g: (g.track or "", g.from_state, g.gate_id))
|
|
221
|
+
|
|
222
|
+
return StateMachineConfig(config_version=config_version, gates=tuple(gates))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load_state_machine_config(path: str | Path) -> StateMachineConfig:
|
|
226
|
+
"""Loads config using metaspn_schemas when available, otherwise JSON fallback."""
|
|
227
|
+
|
|
228
|
+
path = Path(path)
|
|
229
|
+
raw = path.read_text(encoding="utf-8")
|
|
230
|
+
|
|
231
|
+
backend = _load_schemas_backend()
|
|
232
|
+
payload: Mapping[str, Any] | None = None
|
|
233
|
+
if backend is not None:
|
|
234
|
+
payload = _parse_with_schemas_backend(raw, path, backend)
|
|
235
|
+
|
|
236
|
+
if payload is None:
|
|
237
|
+
try:
|
|
238
|
+
parsed = json.loads(raw)
|
|
239
|
+
except json.JSONDecodeError as exc:
|
|
240
|
+
raise ConfigError(
|
|
241
|
+
"Config parsing failed. Install metaspn-schemas for YAML parsing, or provide JSON content."
|
|
242
|
+
) from exc
|
|
243
|
+
payload = _mapping_from_object(parsed)
|
|
244
|
+
|
|
245
|
+
if payload is None or not isinstance(payload, Mapping):
|
|
246
|
+
raise ConfigError("top-level config must be an object")
|
|
247
|
+
|
|
248
|
+
return parse_state_machine_config(payload)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from .models import GateDecision, GateConfig, StateMachineConfig, TransitionAttempted
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_path(mapping: Mapping[str, Any], path: str) -> tuple[bool, Any]:
|
|
11
|
+
current: Any = mapping
|
|
12
|
+
for key in path.split("."):
|
|
13
|
+
if not isinstance(current, Mapping) or key not in current:
|
|
14
|
+
return False, None
|
|
15
|
+
current = current[key]
|
|
16
|
+
return True, current
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _compare(op: str, actual: Any, expected: Any) -> bool:
|
|
20
|
+
if op == "eq":
|
|
21
|
+
return actual == expected
|
|
22
|
+
if op == "ne":
|
|
23
|
+
return actual != expected
|
|
24
|
+
if op == "gt":
|
|
25
|
+
return actual > expected
|
|
26
|
+
if op == "gte":
|
|
27
|
+
return actual >= expected
|
|
28
|
+
if op == "lt":
|
|
29
|
+
return actual < expected
|
|
30
|
+
if op == "lte":
|
|
31
|
+
return actual <= expected
|
|
32
|
+
if op == "in":
|
|
33
|
+
return actual in expected
|
|
34
|
+
if op == "not_in":
|
|
35
|
+
return actual not in expected
|
|
36
|
+
raise ValueError(f"unsupported operator: {op}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _requirement_passed(source: Mapping[str, Any], field: str, op: str, value: Any) -> bool:
|
|
40
|
+
if op == "exists":
|
|
41
|
+
exists, _ = _get_path(source, field)
|
|
42
|
+
return exists
|
|
43
|
+
if op == "not_exists":
|
|
44
|
+
exists, _ = _get_path(source, field)
|
|
45
|
+
return not exists
|
|
46
|
+
|
|
47
|
+
exists, actual = _get_path(source, field)
|
|
48
|
+
if not exists:
|
|
49
|
+
return False
|
|
50
|
+
return _compare(op, actual, value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_cooldown(gate: GateConfig, entity_state: Mapping[str, Any], now: datetime) -> bool:
|
|
54
|
+
if gate.cooldown_seconds <= 0:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
cooldowns = entity_state.get("gate_cooldowns")
|
|
58
|
+
if not isinstance(cooldowns, Mapping):
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
raw_last = cooldowns.get(gate.gate_id)
|
|
62
|
+
if raw_last is None:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
if isinstance(raw_last, str):
|
|
66
|
+
last_attempt = datetime.fromisoformat(raw_last)
|
|
67
|
+
elif isinstance(raw_last, datetime):
|
|
68
|
+
last_attempt = raw_last
|
|
69
|
+
else:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
if last_attempt.tzinfo is None:
|
|
73
|
+
last_attempt = last_attempt.replace(tzinfo=timezone.utc)
|
|
74
|
+
if now.tzinfo is None:
|
|
75
|
+
now = now.replace(tzinfo=timezone.utc)
|
|
76
|
+
|
|
77
|
+
return now < (last_attempt + timedelta(seconds=gate.cooldown_seconds))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _matches_state(gate: GateConfig, entity_state: Mapping[str, Any]) -> bool:
|
|
81
|
+
if entity_state.get("state") != gate.from_state:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
if gate.track is None:
|
|
85
|
+
return True
|
|
86
|
+
return entity_state.get("track") == gate.track
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Evaluator:
|
|
90
|
+
def evaluate_gates(
|
|
91
|
+
self,
|
|
92
|
+
config: StateMachineConfig,
|
|
93
|
+
entity_state: Mapping[str, Any],
|
|
94
|
+
features: Mapping[str, Any],
|
|
95
|
+
now: datetime,
|
|
96
|
+
) -> list[GateDecision]:
|
|
97
|
+
return evaluate_gates(config, entity_state, features, now)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def evaluate_gates(
|
|
101
|
+
config: StateMachineConfig,
|
|
102
|
+
entity_state: Mapping[str, Any],
|
|
103
|
+
features: Mapping[str, Any],
|
|
104
|
+
now: datetime,
|
|
105
|
+
) -> list[GateDecision]:
|
|
106
|
+
decisions: list[GateDecision] = []
|
|
107
|
+
|
|
108
|
+
failure_overrides = entity_state.get("failure_overrides")
|
|
109
|
+
if not isinstance(failure_overrides, Mapping):
|
|
110
|
+
failure_overrides = {}
|
|
111
|
+
|
|
112
|
+
for gate in config.gates:
|
|
113
|
+
if not _matches_state(gate, entity_state):
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
cooldown_active = _check_cooldown(gate, entity_state, now)
|
|
117
|
+
passed = not cooldown_active
|
|
118
|
+
reason: str | None = "cooldown_active" if cooldown_active else None
|
|
119
|
+
failed_requirement_id: str | None = None
|
|
120
|
+
|
|
121
|
+
feature_source = features
|
|
122
|
+
entity_source = entity_state
|
|
123
|
+
|
|
124
|
+
# Hard requirements short-circuit on first failure.
|
|
125
|
+
if passed:
|
|
126
|
+
for requirement in gate.hard_requirements:
|
|
127
|
+
source = entity_source if requirement.source == "entity" else feature_source
|
|
128
|
+
if not _requirement_passed(source, requirement.field, requirement.op, requirement.value):
|
|
129
|
+
passed = False
|
|
130
|
+
failed_requirement_id = requirement.requirement_id
|
|
131
|
+
reason = gate.failure_taxonomy.get(requirement.requirement_id, "hard_requirement_failed")
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
if passed and gate.soft_thresholds:
|
|
135
|
+
soft_passes = 0
|
|
136
|
+
for threshold in gate.soft_thresholds:
|
|
137
|
+
source = entity_source if threshold.source == "entity" else feature_source
|
|
138
|
+
if _requirement_passed(source, threshold.field, threshold.op, threshold.value):
|
|
139
|
+
soft_passes += 1
|
|
140
|
+
|
|
141
|
+
needed = gate.min_soft_passed if gate.min_soft_passed is not None else len(gate.soft_thresholds)
|
|
142
|
+
if soft_passes < needed:
|
|
143
|
+
passed = False
|
|
144
|
+
reason = "soft_threshold_failed"
|
|
145
|
+
|
|
146
|
+
override_reason = failure_overrides.get(gate.gate_id)
|
|
147
|
+
if not passed and isinstance(override_reason, str) and override_reason:
|
|
148
|
+
reason = override_reason
|
|
149
|
+
|
|
150
|
+
snapshot = {
|
|
151
|
+
"feature_snapshot": deepcopy(dict(features)),
|
|
152
|
+
"entity_snapshot": deepcopy(dict(entity_state)),
|
|
153
|
+
"config_version": config.config_version,
|
|
154
|
+
"gate_version": gate.version,
|
|
155
|
+
"timestamp": now.isoformat(),
|
|
156
|
+
"cooldown_active": cooldown_active,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
transition_attempted = TransitionAttempted(
|
|
160
|
+
gate_id=gate.gate_id,
|
|
161
|
+
from_state=gate.from_state,
|
|
162
|
+
to_state=gate.to_state,
|
|
163
|
+
passed=passed,
|
|
164
|
+
timestamp=now,
|
|
165
|
+
reason=reason,
|
|
166
|
+
failed_requirement_id=failed_requirement_id,
|
|
167
|
+
snapshot=snapshot,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
decisions.append(
|
|
171
|
+
GateDecision(
|
|
172
|
+
gate_id=gate.gate_id,
|
|
173
|
+
gate_version=gate.version,
|
|
174
|
+
track=gate.track,
|
|
175
|
+
from_state=gate.from_state,
|
|
176
|
+
to_state=gate.to_state,
|
|
177
|
+
passed=passed,
|
|
178
|
+
reason=reason,
|
|
179
|
+
failed_requirement_id=failed_requirement_id,
|
|
180
|
+
cooldown_active=cooldown_active,
|
|
181
|
+
cooldown_on=gate.cooldown_on,
|
|
182
|
+
enqueue_tasks_on_pass=gate.enqueue_tasks_on_pass,
|
|
183
|
+
transition_attempted=transition_attempted,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return decisions
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class HardRequirement:
|
|
10
|
+
requirement_id: str
|
|
11
|
+
field: str
|
|
12
|
+
op: str
|
|
13
|
+
value: Any = None
|
|
14
|
+
source: str = "features"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SoftThreshold:
|
|
19
|
+
threshold_id: str
|
|
20
|
+
field: str
|
|
21
|
+
op: str
|
|
22
|
+
value: Any
|
|
23
|
+
source: str = "features"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class GateConfig:
|
|
28
|
+
gate_id: str
|
|
29
|
+
version: str
|
|
30
|
+
track: str | None
|
|
31
|
+
from_state: str
|
|
32
|
+
to_state: str
|
|
33
|
+
hard_requirements: tuple[HardRequirement, ...] = field(default_factory=tuple)
|
|
34
|
+
soft_thresholds: tuple[SoftThreshold, ...] = field(default_factory=tuple)
|
|
35
|
+
min_soft_passed: int | None = None
|
|
36
|
+
cooldown_seconds: int = 0
|
|
37
|
+
cooldown_on: str = "pass" # pass | attempt
|
|
38
|
+
enqueue_tasks_on_pass: tuple[str, ...] = field(default_factory=tuple)
|
|
39
|
+
failure_taxonomy: Mapping[str, str] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class StateMachineConfig:
|
|
44
|
+
config_version: str
|
|
45
|
+
gates: tuple[GateConfig, ...]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class TransitionAttempted:
|
|
50
|
+
gate_id: str
|
|
51
|
+
from_state: str
|
|
52
|
+
to_state: str
|
|
53
|
+
passed: bool
|
|
54
|
+
timestamp: datetime
|
|
55
|
+
reason: str | None
|
|
56
|
+
failed_requirement_id: str | None
|
|
57
|
+
snapshot: Mapping[str, Any]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class GateDecision:
|
|
62
|
+
gate_id: str
|
|
63
|
+
gate_version: str
|
|
64
|
+
track: str | None
|
|
65
|
+
from_state: str
|
|
66
|
+
to_state: str
|
|
67
|
+
passed: bool
|
|
68
|
+
reason: str | None
|
|
69
|
+
failed_requirement_id: str | None
|
|
70
|
+
cooldown_active: bool
|
|
71
|
+
cooldown_on: str
|
|
72
|
+
enqueue_tasks_on_pass: tuple[str, ...]
|
|
73
|
+
transition_attempted: TransitionAttempted
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class TransitionApplied:
|
|
78
|
+
gate_id: str
|
|
79
|
+
from_state: str
|
|
80
|
+
to_state: str
|
|
81
|
+
caused_by: str | None
|
|
82
|
+
timestamp: datetime
|
|
83
|
+
snapshot: Mapping[str, Any]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _load_backend() -> Any | None:
|
|
8
|
+
try:
|
|
9
|
+
return importlib.import_module("metaspn_schemas")
|
|
10
|
+
except ImportError:
|
|
11
|
+
return None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def schemas_available() -> bool:
|
|
15
|
+
return _load_backend() is not None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _to_dict(value: Any) -> Mapping[str, Any]:
|
|
19
|
+
if isinstance(value, Mapping):
|
|
20
|
+
return value
|
|
21
|
+
to_dict = getattr(value, "to_dict", None)
|
|
22
|
+
if callable(to_dict):
|
|
23
|
+
out = to_dict()
|
|
24
|
+
if isinstance(out, Mapping):
|
|
25
|
+
return out
|
|
26
|
+
raise TypeError("schema object is not serializable to mapping")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_task_and_emission(
|
|
30
|
+
*,
|
|
31
|
+
task_id: str,
|
|
32
|
+
created_at,
|
|
33
|
+
caused_by: str,
|
|
34
|
+
entity_id: str,
|
|
35
|
+
gate_id: str,
|
|
36
|
+
emission_id: str,
|
|
37
|
+
priority: int = 50,
|
|
38
|
+
) -> dict[str, Any] | None:
|
|
39
|
+
backend = _load_backend()
|
|
40
|
+
if backend is None:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
entity_ref = backend.EntityRef(ref_type="entity_id", value=entity_id)
|
|
44
|
+
task = backend.Task(
|
|
45
|
+
task_id=task_id,
|
|
46
|
+
task_type=task_id,
|
|
47
|
+
created_at=created_at,
|
|
48
|
+
priority=priority,
|
|
49
|
+
entity_ref=entity_ref,
|
|
50
|
+
context={"gate_id": gate_id, "caused_by": caused_by},
|
|
51
|
+
)
|
|
52
|
+
envelope = backend.EmissionEnvelope(
|
|
53
|
+
emission_id=emission_id,
|
|
54
|
+
timestamp=created_at,
|
|
55
|
+
emission_type="task_enqueued",
|
|
56
|
+
payload=task,
|
|
57
|
+
caused_by=caused_by,
|
|
58
|
+
entity_refs=(entity_ref,),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
"task": dict(_to_dict(task)),
|
|
63
|
+
"emission_envelope": dict(_to_dict(envelope)),
|
|
64
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metaspn-gates
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Config-driven gate and state machine evaluator for MetaSPN entity pipelines
|
|
5
|
+
License-Expression: LicenseRef-Proprietary
|
|
6
|
+
Project-URL: Homepage, https://github.com/leoguinan/metaspn-gates
|
|
7
|
+
Project-URL: Repository, https://github.com/leoguinan/metaspn-gates
|
|
8
|
+
Project-URL: Issues, https://github.com/leoguinan/metaspn-gates/issues
|
|
9
|
+
Keywords: metaspn,state-machine,gates,workflow
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: metaspn-schemas==0.1.0
|
|
18
|
+
|
|
19
|
+
# metaspn-gates
|
|
20
|
+
|
|
21
|
+
`metaspn-gates` is a config-driven gate + state-machine evaluator for MetaSPN entity pipelines.
|
|
22
|
+
|
|
23
|
+
## Current implementation (v0.1 seed)
|
|
24
|
+
|
|
25
|
+
- Deterministic gate evaluation from config
|
|
26
|
+
- Hard requirements and soft thresholds
|
|
27
|
+
- Per-gate per-entity cooldown checks
|
|
28
|
+
- Transition attempt snapshots
|
|
29
|
+
- Transition application + task emissions
|
|
30
|
+
- Config parsing and validation from mapping
|
|
31
|
+
- `metaspn-schemas` integration hooks (currently used for typed emission shaping)
|
|
32
|
+
- Optional `metaspn-schemas` emission shaping (`Task` + `EmissionEnvelope`)
|
|
33
|
+
|
|
34
|
+
## Public API
|
|
35
|
+
|
|
36
|
+
- `evaluate_gates(config, entity_state, features, now)`
|
|
37
|
+
- `apply_decisions(entity_state, decisions, caused_by=None)`
|
|
38
|
+
- `parse_state_machine_config(payload)`
|
|
39
|
+
- `load_state_machine_config(path)`
|
|
40
|
+
|
|
41
|
+
## Notes
|
|
42
|
+
|
|
43
|
+
- `load_state_machine_config` can use `metaspn_schemas` parsing/validation hooks when exposed by that package version.
|
|
44
|
+
- If `metaspn_schemas` is unavailable, it falls back to JSON parsing only.
|
|
45
|
+
- Current dependency target: `metaspn-schemas==0.1.0`.
|
|
46
|
+
- `apply_decisions(..., use_schema_envelopes=True)` attaches schema-shaped payloads when `entity_state.entity_id` is present.
|
|
47
|
+
|
|
48
|
+
## Release
|
|
49
|
+
|
|
50
|
+
- GitHub Actions workflow: `/Users/leoguinan/MetaSPN/metaspn-gates/.github/workflows/publish.yml`
|
|
51
|
+
- Publish trigger: GitHub Release published (or manual `workflow_dispatch`)
|
|
52
|
+
- Publishing method: PyPI Trusted Publishing via `pypa/gh-action-pypi-publish`
|
|
53
|
+
|
|
54
|
+
### One-time setup
|
|
55
|
+
|
|
56
|
+
1. In PyPI, create project `metaspn-gates` and configure Trusted Publisher for this GitHub repo/workflow.
|
|
57
|
+
2. In GitHub, create environment `pypi` (optional protection rules supported).
|
|
58
|
+
|
|
59
|
+
### Release flow
|
|
60
|
+
|
|
61
|
+
1. Bump `/Users/leoguinan/MetaSPN/metaspn-gates/pyproject.toml` version.
|
|
62
|
+
2. Tag and push a release commit.
|
|
63
|
+
3. Publish a GitHub Release for that tag.
|
|
64
|
+
4. `publish.yml` builds and uploads to PyPI.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
metaspn_gates/__init__.py
|
|
4
|
+
metaspn_gates/applier.py
|
|
5
|
+
metaspn_gates/config.py
|
|
6
|
+
metaspn_gates/evaluator.py
|
|
7
|
+
metaspn_gates/models.py
|
|
8
|
+
metaspn_gates/schemas.py
|
|
9
|
+
metaspn_gates.egg-info/PKG-INFO
|
|
10
|
+
metaspn_gates.egg-info/SOURCES.txt
|
|
11
|
+
metaspn_gates.egg-info/dependency_links.txt
|
|
12
|
+
metaspn_gates.egg-info/requires.txt
|
|
13
|
+
metaspn_gates.egg-info/top_level.txt
|
|
14
|
+
tests/test_gates.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
metaspn-schemas==0.1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
metaspn_gates
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "metaspn-gates"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Config-driven gate and state machine evaluator for MetaSPN entity pipelines"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "LicenseRef-Proprietary"
|
|
12
|
+
keywords = ["metaspn", "state-machine", "gates", "workflow"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"metaspn-schemas==0.1.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/leoguinan/metaspn-gates"
|
|
26
|
+
Repository = "https://github.com/leoguinan/metaspn-gates"
|
|
27
|
+
Issues = "https://github.com/leoguinan/metaspn-gates/issues"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
packages = ["metaspn_gates"]
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tempfile import TemporaryDirectory
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
from metaspn_gates import apply_decisions, evaluate_gates, parse_state_machine_config
|
|
10
|
+
from metaspn_gates.config import ConfigError, load_state_machine_config, schemas_backend_available
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
BASE_CONFIG = {
|
|
14
|
+
"config_version": "sm.v1",
|
|
15
|
+
"gates": [
|
|
16
|
+
{
|
|
17
|
+
"gate_id": "g.qualify.a",
|
|
18
|
+
"version": "1",
|
|
19
|
+
"track": "A",
|
|
20
|
+
"from": "candidate",
|
|
21
|
+
"to": "qualified",
|
|
22
|
+
"hard_requirements": [
|
|
23
|
+
{
|
|
24
|
+
"requirement_id": "hr.followers",
|
|
25
|
+
"field": "social.followers",
|
|
26
|
+
"op": "gte",
|
|
27
|
+
"value": 1000,
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"soft_thresholds": [
|
|
31
|
+
{
|
|
32
|
+
"threshold_id": "st.quality",
|
|
33
|
+
"field": "quality.score",
|
|
34
|
+
"op": "gte",
|
|
35
|
+
"value": 0.7,
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"min_soft_passed": 1,
|
|
39
|
+
"cooldown_seconds": 3600,
|
|
40
|
+
"cooldown_on": "attempt",
|
|
41
|
+
"enqueue_tasks_on_pass": ["task.review"],
|
|
42
|
+
"failure_taxonomy": {"hr.followers": "insufficient_reach"},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"gate_id": "g.qualify.b",
|
|
46
|
+
"version": "1",
|
|
47
|
+
"track": "B",
|
|
48
|
+
"from": "candidate",
|
|
49
|
+
"to": "qualified",
|
|
50
|
+
"hard_requirements": [],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class GateTests(unittest.TestCase):
|
|
57
|
+
def test_deterministic_gate_evaluation(self) -> None:
|
|
58
|
+
config = parse_state_machine_config(BASE_CONFIG)
|
|
59
|
+
entity_state = {"state": "candidate", "track": "A"}
|
|
60
|
+
features = {"social": {"followers": 1500}, "quality": {"score": 0.8}}
|
|
61
|
+
|
|
62
|
+
now = datetime(2026, 2, 5, 0, 0, tzinfo=timezone.utc)
|
|
63
|
+
d1 = evaluate_gates(config, entity_state, features, now)
|
|
64
|
+
d2 = evaluate_gates(config, entity_state, features, now)
|
|
65
|
+
|
|
66
|
+
self.assertEqual([d.gate_id for d in d1], ["g.qualify.a"])
|
|
67
|
+
self.assertEqual([d.passed for d in d1], [True])
|
|
68
|
+
self.assertEqual([(d.gate_id, d.passed, d.reason) for d in d1], [(d.gate_id, d.passed, d.reason) for d in d2])
|
|
69
|
+
|
|
70
|
+
def test_cooldown_correctness(self) -> None:
|
|
71
|
+
config = parse_state_machine_config(BASE_CONFIG)
|
|
72
|
+
now = datetime(2026, 2, 5, 0, 0, tzinfo=timezone.utc)
|
|
73
|
+
recent = now - timedelta(minutes=5)
|
|
74
|
+
|
|
75
|
+
entity_state = {
|
|
76
|
+
"state": "candidate",
|
|
77
|
+
"track": "A",
|
|
78
|
+
"gate_cooldowns": {"g.qualify.a": recent.isoformat()},
|
|
79
|
+
}
|
|
80
|
+
features = {"social": {"followers": 1500}, "quality": {"score": 0.9}}
|
|
81
|
+
|
|
82
|
+
decisions = evaluate_gates(config, entity_state, features, now)
|
|
83
|
+
self.assertEqual(len(decisions), 1)
|
|
84
|
+
self.assertFalse(decisions[0].passed)
|
|
85
|
+
self.assertTrue(decisions[0].cooldown_active)
|
|
86
|
+
self.assertEqual(decisions[0].reason, "cooldown_active")
|
|
87
|
+
|
|
88
|
+
def test_config_parsing_validation(self) -> None:
|
|
89
|
+
bad = {
|
|
90
|
+
"config_version": "x",
|
|
91
|
+
"gates": [
|
|
92
|
+
{
|
|
93
|
+
"gate_id": "a",
|
|
94
|
+
"version": "1",
|
|
95
|
+
"from": "s1",
|
|
96
|
+
"to": "s2",
|
|
97
|
+
"cooldown_seconds": -1,
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
}
|
|
101
|
+
with self.assertRaises(ConfigError):
|
|
102
|
+
parse_state_machine_config(bad)
|
|
103
|
+
|
|
104
|
+
def test_snapshot_completeness(self) -> None:
|
|
105
|
+
config = parse_state_machine_config(BASE_CONFIG)
|
|
106
|
+
now = datetime(2026, 2, 5, 0, 0, tzinfo=timezone.utc)
|
|
107
|
+
entity_state = {"state": "candidate", "track": "A"}
|
|
108
|
+
features = {"social": {"followers": 1500}, "quality": {"score": 0.9}}
|
|
109
|
+
|
|
110
|
+
decisions = evaluate_gates(config, entity_state, features, now)
|
|
111
|
+
self.assertEqual(len(decisions), 1)
|
|
112
|
+
snapshot = decisions[0].transition_attempted.snapshot
|
|
113
|
+
self.assertIn("feature_snapshot", snapshot)
|
|
114
|
+
self.assertIn("entity_snapshot", snapshot)
|
|
115
|
+
self.assertIn("config_version", snapshot)
|
|
116
|
+
self.assertIn("gate_version", snapshot)
|
|
117
|
+
self.assertIn("timestamp", snapshot)
|
|
118
|
+
|
|
119
|
+
def test_apply_decisions_emits_and_transitions(self) -> None:
|
|
120
|
+
config = parse_state_machine_config(BASE_CONFIG)
|
|
121
|
+
now = datetime(2026, 2, 5, 0, 0, tzinfo=timezone.utc)
|
|
122
|
+
entity_state = {"state": "candidate", "track": "A"}
|
|
123
|
+
features = {"social": {"followers": 1500}, "quality": {"score": 0.9}}
|
|
124
|
+
|
|
125
|
+
decisions = evaluate_gates(config, entity_state, features, now)
|
|
126
|
+
new_state, emissions = apply_decisions(entity_state, decisions, caused_by="sig-123")
|
|
127
|
+
|
|
128
|
+
self.assertEqual(new_state["state"], "qualified")
|
|
129
|
+
self.assertEqual(len(new_state["gate_attempts"]), 1)
|
|
130
|
+
self.assertEqual(len(new_state["transitions_applied"]), 1)
|
|
131
|
+
self.assertEqual(len(emissions), 1)
|
|
132
|
+
self.assertEqual(emissions[0]["task_id"], "task.review")
|
|
133
|
+
|
|
134
|
+
def test_apply_decisions_schema_emissions(self) -> None:
|
|
135
|
+
config = parse_state_machine_config(BASE_CONFIG)
|
|
136
|
+
now = datetime(2026, 2, 5, 0, 0, tzinfo=timezone.utc)
|
|
137
|
+
entity_state = {"entity_id": "ent-1", "state": "candidate", "track": "A"}
|
|
138
|
+
features = {"social": {"followers": 1500}, "quality": {"score": 0.9}}
|
|
139
|
+
|
|
140
|
+
decisions = evaluate_gates(config, entity_state, features, now)
|
|
141
|
+
with mock.patch("metaspn_gates.applier.schemas_available", return_value=True):
|
|
142
|
+
with mock.patch(
|
|
143
|
+
"metaspn_gates.applier.build_task_and_emission",
|
|
144
|
+
return_value={"task": {"task_id": "task.review"}, "emission_envelope": {"emission_type": "task_enqueued"}},
|
|
145
|
+
):
|
|
146
|
+
_, emissions = apply_decisions(entity_state, decisions, caused_by="sig-123", use_schema_envelopes=True)
|
|
147
|
+
self.assertIn("schema", emissions[0])
|
|
148
|
+
self.assertEqual(emissions[0]["schema"]["task"]["task_id"], "task.review")
|
|
149
|
+
|
|
150
|
+
def test_apply_decisions_schema_emissions_require_entity_id(self) -> None:
|
|
151
|
+
config = parse_state_machine_config(BASE_CONFIG)
|
|
152
|
+
now = datetime(2026, 2, 5, 0, 0, tzinfo=timezone.utc)
|
|
153
|
+
entity_state = {"state": "candidate", "track": "A"}
|
|
154
|
+
features = {"social": {"followers": 1500}, "quality": {"score": 0.9}}
|
|
155
|
+
|
|
156
|
+
decisions = evaluate_gates(config, entity_state, features, now)
|
|
157
|
+
with mock.patch("metaspn_gates.applier.schemas_available", return_value=True):
|
|
158
|
+
with self.assertRaises(ValueError):
|
|
159
|
+
apply_decisions(entity_state, decisions, caused_by="sig-123", use_schema_envelopes=True)
|
|
160
|
+
|
|
161
|
+
def test_failure_override_hook(self) -> None:
|
|
162
|
+
config = parse_state_machine_config(BASE_CONFIG)
|
|
163
|
+
now = datetime(2026, 2, 5, 0, 0, tzinfo=timezone.utc)
|
|
164
|
+
entity_state = {
|
|
165
|
+
"state": "candidate",
|
|
166
|
+
"track": "A",
|
|
167
|
+
"failure_overrides": {"g.qualify.a": "manual_override_reason"},
|
|
168
|
+
}
|
|
169
|
+
features = {"social": {"followers": 10}, "quality": {"score": 0.1}}
|
|
170
|
+
|
|
171
|
+
decisions = evaluate_gates(config, entity_state, features, now)
|
|
172
|
+
self.assertEqual(decisions[0].reason, "manual_override_reason")
|
|
173
|
+
|
|
174
|
+
def test_load_state_machine_config_json_fallback(self) -> None:
|
|
175
|
+
with mock.patch("metaspn_gates.config.importlib.import_module", side_effect=ImportError):
|
|
176
|
+
with TemporaryDirectory() as tmp:
|
|
177
|
+
path = Path(tmp) / "config.yaml"
|
|
178
|
+
path.write_text(
|
|
179
|
+
'{"config_version":"sm.v1","gates":[{"gate_id":"g1","version":"1","from":"a","to":"b"}]}',
|
|
180
|
+
encoding="utf-8",
|
|
181
|
+
)
|
|
182
|
+
config = load_state_machine_config(path)
|
|
183
|
+
self.assertEqual(config.config_version, "sm.v1")
|
|
184
|
+
self.assertEqual(config.gates[0].gate_id, "g1")
|
|
185
|
+
|
|
186
|
+
def test_load_state_machine_config_uses_schemas_backend(self) -> None:
|
|
187
|
+
class FakeSchemas:
|
|
188
|
+
@staticmethod
|
|
189
|
+
def parse_yaml(raw: str) -> dict:
|
|
190
|
+
if "config_version: sm.v2" not in raw:
|
|
191
|
+
raise ValueError("unexpected input")
|
|
192
|
+
return {
|
|
193
|
+
"config_version": "sm.v2",
|
|
194
|
+
"gates": [
|
|
195
|
+
{
|
|
196
|
+
"gate_id": "g1",
|
|
197
|
+
"version": "2",
|
|
198
|
+
"from": "start",
|
|
199
|
+
"to": "next",
|
|
200
|
+
}
|
|
201
|
+
],
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
with mock.patch("metaspn_gates.config.importlib.import_module", return_value=FakeSchemas):
|
|
205
|
+
with TemporaryDirectory() as tmp:
|
|
206
|
+
path = Path(tmp) / "config.yaml"
|
|
207
|
+
path.write_text(
|
|
208
|
+
"config_version: sm.v2\n"
|
|
209
|
+
"gates:\n"
|
|
210
|
+
" - gate_id: g1\n"
|
|
211
|
+
" version: '2'\n"
|
|
212
|
+
" from: start\n"
|
|
213
|
+
" to: next\n",
|
|
214
|
+
encoding="utf-8",
|
|
215
|
+
)
|
|
216
|
+
config = load_state_machine_config(path)
|
|
217
|
+
self.assertEqual(config.config_version, "sm.v2")
|
|
218
|
+
self.assertEqual(config.gates[0].version, "2")
|
|
219
|
+
|
|
220
|
+
def test_schemas_backend_available_helper(self) -> None:
|
|
221
|
+
with mock.patch("metaspn_gates.config.importlib.import_module", return_value=object()):
|
|
222
|
+
self.assertTrue(schemas_backend_available())
|
|
223
|
+
with mock.patch("metaspn_gates.config.importlib.import_module", side_effect=ImportError):
|
|
224
|
+
self.assertFalse(schemas_backend_available())
|
|
225
|
+
|
|
226
|
+
def test_parse_state_machine_config_runs_backend_validation(self) -> None:
|
|
227
|
+
class FakeSchemas:
|
|
228
|
+
called = False
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def validate_state_machine_config(payload: dict) -> dict:
|
|
232
|
+
FakeSchemas.called = True
|
|
233
|
+
out = dict(payload)
|
|
234
|
+
out["config_version"] = "validated"
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
with mock.patch("metaspn_gates.config.importlib.import_module", return_value=FakeSchemas):
|
|
238
|
+
config = parse_state_machine_config(
|
|
239
|
+
{"config_version": "raw", "gates": [{"gate_id": "g1", "version": "1", "from": "a", "to": "b"}]}
|
|
240
|
+
)
|
|
241
|
+
self.assertTrue(FakeSchemas.called)
|
|
242
|
+
self.assertEqual(config.config_version, "validated")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
unittest.main()
|