metaspn-gates 0.1.0__py3-none-any.whl

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.
@@ -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,10 @@
1
+ metaspn_gates/__init__.py,sha256=59UWXxsEhyjlGhh1B_2ueS3g0d_D51Q-5Pm1dJ9xHLc,867
2
+ metaspn_gates/applier.py,sha256=OWxSpllAnTRZ4S7Bf8927qg54vbQfaLcmz_A2F1mNzI,4086
3
+ metaspn_gates/config.py,sha256=0Ll5JyhLS_WxHuQ_L3NEuKeTk5cpezkljR5__ntIxJ0,8752
4
+ metaspn_gates/evaluator.py,sha256=yuQ8Mag3hYpS1Pi2lC1WfmOiz1xqu53pbqzn8cU_Yt4,6232
5
+ metaspn_gates/models.py,sha256=oDcaTy8nFp5qpr139apDdsWdTjyNtChaHaCkkDNrGaw,1888
6
+ metaspn_gates/schemas.py,sha256=L2gVQbPYiOLqnke_8biSx0fNx1v_ILf_fqfdhCFA3Q8,1571
7
+ metaspn_gates-0.1.0.dist-info/METADATA,sha256=WS06Z69BM7ZOrbKFy7OR-cALnO46CKaYrZM-3SFzTV4,2641
8
+ metaspn_gates-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
+ metaspn_gates-0.1.0.dist-info/top_level.txt,sha256=9JbnEotLrfsh1YhXWzpyyfU9IJP0Ftqf7Y2Hk7t2miQ,14
10
+ metaspn_gates-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ metaspn_gates