signalsafe-tree-spec 0.1.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josh Martin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: signalsafe-tree-spec
3
+ Version: 0.1.1
4
+ Summary: SignalSafe TreeSpec wire contract for Python: models, parsing, lint, and patch helpers.
5
+ Author: Josh Martin
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/SignalSafeSoftware/tree-spec-python
8
+ Project-URL: Repository, https://github.com/SignalSafeSoftware/tree-spec-python
9
+ Project-URL: Documentation, https://github.com/SignalSafeSoftware/tree-spec-python#readme
10
+ Project-URL: Issues, https://github.com/SignalSafeSoftware/tree-spec-python/issues
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: <4.0,>=3.12
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: pydantic<3,>=2.11.3
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8; extra == "dev"
23
+ Requires-Dist: pytest-cov>=6; extra == "dev"
24
+ Requires-Dist: build>=1.2; extra == "dev"
25
+ Requires-Dist: twine>=6.1; extra == "dev"
26
+ Requires-Dist: flake8>=7; extra == "dev"
27
+ Requires-Dist: bandit>=1.7; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # signalsafe-tree-spec (Python)
31
+
32
+ Python implementation of the **TreeSpec wire contract**: Pydantic models, parsing, lint, patch helpers, and constants. Backend counterpart to [`@signalsafe/tree-spec`](https://github.com/SignalSafeSoftware/tree-spec) on npm.
33
+
34
+ | | |
35
+ |---|---|
36
+ | **PyPI distribution name** | `signalsafe-tree-spec` |
37
+ | **Import path** | `deliveryplus_tree_spec` (stable; historical name) |
38
+ | **GitHub** | [SignalSafeSoftware/tree-spec-python](https://github.com/SignalSafeSoftware/tree-spec-python) |
39
+ | **Python** | 3.12+ (`requires-python >=3.12,<4.0`) |
40
+ | **Runtime dependency** | Pydantic v2 (`pydantic>=2.11.3,<3`) |
41
+
42
+ > **License:** MIT — see [LICENSE](./LICENSE). See [SECURITY.md](./SECURITY.md) for vulnerability reporting.
43
+
44
+ ## What this package does
45
+
46
+ - Validate and parse **TreeSpec wire JSON** into typed Pydantic models (`TreeSpec`, `Node`, `Transition`, …).
47
+ - **Lint** wire payloads (`lint_tree_spec`) with structured issues.
48
+ - **Build** wire documents programmatically (`TreeSpecBuilder`).
49
+ - **Apply JSON patches** to wire dicts (`apply_patch_to_spec_dict`).
50
+ - Expose wire constants (`END_NODE_ID`, `TREESPEC_WIRE_VERSION`).
51
+
52
+ ## What this package does not do
53
+
54
+ - HTTP APIs, authentication, persistence, or training UI.
55
+ - Scenario simulation (use `@signalsafe/simulator-core` / `simulator-react` in TypeScript stacks).
56
+ - Graph editor UI (use `@signalsafe/tree-spec-editor-*` packages).
57
+ - General sandboxing or authorization — contract validation only; host apps decide trust and access control.
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ pip install signalsafe-tree-spec
63
+ ```
64
+
65
+ Development (this repo uses [uv](https://docs.astral.sh/uv/)):
66
+
67
+ ```bash
68
+ uv sync --extra dev
69
+ uv run pytest
70
+ ```
71
+
72
+ ## Distribution name vs import path
73
+
74
+ ```bash
75
+ pip install signalsafe-tree-spec
76
+ ```
77
+
78
+ ```python
79
+ import deliveryplus_tree_spec
80
+ from deliveryplus_tree_spec import TreeSpec, lint_tree_spec, TreeSpecBuilder
81
+ ```
82
+
83
+ The PyPI name (`signalsafe-tree-spec`) differs from the import path (`deliveryplus_tree_spec`) for backward compatibility with existing backends.
84
+
85
+ ## Quick start
86
+
87
+ ```python
88
+ from deliveryplus_tree_spec import (
89
+ END_NODE_ID,
90
+ TREESPEC_WIRE_VERSION,
91
+ TreeSpec,
92
+ TreeSpecBuilder,
93
+ lint_tree_spec,
94
+ )
95
+
96
+ wire = {
97
+ "wire_version": TREESPEC_WIRE_VERSION,
98
+ "start_node": "start",
99
+ "nodes": {
100
+ "start": {
101
+ "type": "prompt",
102
+ "prompt": "Verify the sender before clicking?",
103
+ "choices": [{"id": "inspect", "label": "Inspect sender"}],
104
+ }
105
+ },
106
+ "transitions": [
107
+ {"from": ["start", "inspect"], "to": END_NODE_ID, "outcome": "safe"},
108
+ ],
109
+ }
110
+
111
+ issues = lint_tree_spec(wire)
112
+ assert not issues
113
+
114
+ spec = TreeSpec.model_validate(wire)
115
+ builder = TreeSpecBuilder()
116
+ # builder.add_node(...) — see tests/ for full builder flows
117
+ ```
118
+
119
+ ## Public API
120
+
121
+ All symbols below are exported from the top-level `deliveryplus_tree_spec` package:
122
+
123
+ | Category | Symbols |
124
+ |---|---|
125
+ | Constants | `END_NODE_ID`, `TREESPEC_WIRE_VERSION` |
126
+ | Models | `TreeSpec`, `Node`, `Transition`, `Choice`, `Delta`, `MicroFeedback`, `ABMeta`, `ABVariant`, … |
127
+ | Lint | `lint_tree_spec`, `TreeSpecIssue` |
128
+ | Builder | `TreeSpecBuilder`, `TreeSpecError` |
129
+ | Patch | `apply_patch_to_spec_dict`, `PatchApplyError`, `PatchDict`, `ReplacePatch`, … |
130
+
131
+ There are no subpath exports — import from `deliveryplus_tree_spec` only.
132
+
133
+ ## Wire contract (high level)
134
+
135
+ - **`wire_version`:** optional; omit for implicit v1. Constant `TREESPEC_WIRE_VERSION` documents the current version string.
136
+ - **`start_node`:** entry node id.
137
+ - **`nodes`:** map of node id → node object (`type`, `prompt`, `choices` or legacy `options`, optional `render_hints`).
138
+ - **`transitions`:** list of `{ from: [nodeId, choiceId], to, outcome? }`. Terminal transitions target `END_NODE_ID` and require `outcome`.
139
+ - **`_meta`:** optional tree-level metadata (for example graph-editor viewport persisted by editor packages).
140
+
141
+ Legacy payloads may use `options` instead of `choices` and legacy terminal ids; the TypeScript package normalizes these on compile/decompile. Python lint validates the wire shape your backend accepts.
142
+
143
+ ## Python / TypeScript parity
144
+
145
+ | Concern | Python (`tree-spec-python`) | TypeScript (`@signalsafe/tree-spec`) |
146
+ |---|---|---|
147
+ | Wire models & lint | Pydantic models, `lint_tree_spec` | `TreeSpecWire`, `lintTreeSpecWire` |
148
+ | Authoring graph compile | — | `compileTreeSpec` / `decompileTreeSpec` |
149
+ | Cross-language fixtures | Share JSON fixtures in your product repo or CI | Same |
150
+
151
+ Keep fixture JSON in sync when changing wire rules in either language. Full compile/decompile parity lives in TypeScript today; Python focuses on validation, lint, builder, and patch flows used by backends.
152
+
153
+ ## Development
154
+
155
+ ```bash
156
+ uv sync --extra dev
157
+ uv run pytest
158
+ uv run flake8 .
159
+ uv run python -m build # wheel/sdist smoke — full artifact tests in Batch 6
160
+ ```
161
+
162
+ ## Security
163
+
164
+ See [SECURITY.md](./SECURITY.md). TreeSpec parsing is **contract validation**, not a sandbox. Validate and authorize TreeSpec JSON in your application layer before treating content as trusted.
165
+
166
+ ## Changelog and releases
167
+
168
+ - [CHANGELOG.md](./CHANGELOG.md)
169
+ - [RELEASING.md](./RELEASING.md)
170
+
171
+ ## Related packages
172
+
173
+ - [`@signalsafe/tree-spec`](https://github.com/SignalSafeSoftware/tree-spec) — TypeScript wire contract and compile/lint.
174
+ - [`tree-spec-editor-*`](https://github.com/SignalSafeSoftware/tree-spec-editor) — authoring UI (TypeScript/React).
@@ -0,0 +1,145 @@
1
+ # signalsafe-tree-spec (Python)
2
+
3
+ Python implementation of the **TreeSpec wire contract**: Pydantic models, parsing, lint, patch helpers, and constants. Backend counterpart to [`@signalsafe/tree-spec`](https://github.com/SignalSafeSoftware/tree-spec) on npm.
4
+
5
+ | | |
6
+ |---|---|
7
+ | **PyPI distribution name** | `signalsafe-tree-spec` |
8
+ | **Import path** | `deliveryplus_tree_spec` (stable; historical name) |
9
+ | **GitHub** | [SignalSafeSoftware/tree-spec-python](https://github.com/SignalSafeSoftware/tree-spec-python) |
10
+ | **Python** | 3.12+ (`requires-python >=3.12,<4.0`) |
11
+ | **Runtime dependency** | Pydantic v2 (`pydantic>=2.11.3,<3`) |
12
+
13
+ > **License:** MIT — see [LICENSE](./LICENSE). See [SECURITY.md](./SECURITY.md) for vulnerability reporting.
14
+
15
+ ## What this package does
16
+
17
+ - Validate and parse **TreeSpec wire JSON** into typed Pydantic models (`TreeSpec`, `Node`, `Transition`, …).
18
+ - **Lint** wire payloads (`lint_tree_spec`) with structured issues.
19
+ - **Build** wire documents programmatically (`TreeSpecBuilder`).
20
+ - **Apply JSON patches** to wire dicts (`apply_patch_to_spec_dict`).
21
+ - Expose wire constants (`END_NODE_ID`, `TREESPEC_WIRE_VERSION`).
22
+
23
+ ## What this package does not do
24
+
25
+ - HTTP APIs, authentication, persistence, or training UI.
26
+ - Scenario simulation (use `@signalsafe/simulator-core` / `simulator-react` in TypeScript stacks).
27
+ - Graph editor UI (use `@signalsafe/tree-spec-editor-*` packages).
28
+ - General sandboxing or authorization — contract validation only; host apps decide trust and access control.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install signalsafe-tree-spec
34
+ ```
35
+
36
+ Development (this repo uses [uv](https://docs.astral.sh/uv/)):
37
+
38
+ ```bash
39
+ uv sync --extra dev
40
+ uv run pytest
41
+ ```
42
+
43
+ ## Distribution name vs import path
44
+
45
+ ```bash
46
+ pip install signalsafe-tree-spec
47
+ ```
48
+
49
+ ```python
50
+ import deliveryplus_tree_spec
51
+ from deliveryplus_tree_spec import TreeSpec, lint_tree_spec, TreeSpecBuilder
52
+ ```
53
+
54
+ The PyPI name (`signalsafe-tree-spec`) differs from the import path (`deliveryplus_tree_spec`) for backward compatibility with existing backends.
55
+
56
+ ## Quick start
57
+
58
+ ```python
59
+ from deliveryplus_tree_spec import (
60
+ END_NODE_ID,
61
+ TREESPEC_WIRE_VERSION,
62
+ TreeSpec,
63
+ TreeSpecBuilder,
64
+ lint_tree_spec,
65
+ )
66
+
67
+ wire = {
68
+ "wire_version": TREESPEC_WIRE_VERSION,
69
+ "start_node": "start",
70
+ "nodes": {
71
+ "start": {
72
+ "type": "prompt",
73
+ "prompt": "Verify the sender before clicking?",
74
+ "choices": [{"id": "inspect", "label": "Inspect sender"}],
75
+ }
76
+ },
77
+ "transitions": [
78
+ {"from": ["start", "inspect"], "to": END_NODE_ID, "outcome": "safe"},
79
+ ],
80
+ }
81
+
82
+ issues = lint_tree_spec(wire)
83
+ assert not issues
84
+
85
+ spec = TreeSpec.model_validate(wire)
86
+ builder = TreeSpecBuilder()
87
+ # builder.add_node(...) — see tests/ for full builder flows
88
+ ```
89
+
90
+ ## Public API
91
+
92
+ All symbols below are exported from the top-level `deliveryplus_tree_spec` package:
93
+
94
+ | Category | Symbols |
95
+ |---|---|
96
+ | Constants | `END_NODE_ID`, `TREESPEC_WIRE_VERSION` |
97
+ | Models | `TreeSpec`, `Node`, `Transition`, `Choice`, `Delta`, `MicroFeedback`, `ABMeta`, `ABVariant`, … |
98
+ | Lint | `lint_tree_spec`, `TreeSpecIssue` |
99
+ | Builder | `TreeSpecBuilder`, `TreeSpecError` |
100
+ | Patch | `apply_patch_to_spec_dict`, `PatchApplyError`, `PatchDict`, `ReplacePatch`, … |
101
+
102
+ There are no subpath exports — import from `deliveryplus_tree_spec` only.
103
+
104
+ ## Wire contract (high level)
105
+
106
+ - **`wire_version`:** optional; omit for implicit v1. Constant `TREESPEC_WIRE_VERSION` documents the current version string.
107
+ - **`start_node`:** entry node id.
108
+ - **`nodes`:** map of node id → node object (`type`, `prompt`, `choices` or legacy `options`, optional `render_hints`).
109
+ - **`transitions`:** list of `{ from: [nodeId, choiceId], to, outcome? }`. Terminal transitions target `END_NODE_ID` and require `outcome`.
110
+ - **`_meta`:** optional tree-level metadata (for example graph-editor viewport persisted by editor packages).
111
+
112
+ Legacy payloads may use `options` instead of `choices` and legacy terminal ids; the TypeScript package normalizes these on compile/decompile. Python lint validates the wire shape your backend accepts.
113
+
114
+ ## Python / TypeScript parity
115
+
116
+ | Concern | Python (`tree-spec-python`) | TypeScript (`@signalsafe/tree-spec`) |
117
+ |---|---|---|
118
+ | Wire models & lint | Pydantic models, `lint_tree_spec` | `TreeSpecWire`, `lintTreeSpecWire` |
119
+ | Authoring graph compile | — | `compileTreeSpec` / `decompileTreeSpec` |
120
+ | Cross-language fixtures | Share JSON fixtures in your product repo or CI | Same |
121
+
122
+ Keep fixture JSON in sync when changing wire rules in either language. Full compile/decompile parity lives in TypeScript today; Python focuses on validation, lint, builder, and patch flows used by backends.
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ uv sync --extra dev
128
+ uv run pytest
129
+ uv run flake8 .
130
+ uv run python -m build # wheel/sdist smoke — full artifact tests in Batch 6
131
+ ```
132
+
133
+ ## Security
134
+
135
+ See [SECURITY.md](./SECURITY.md). TreeSpec parsing is **contract validation**, not a sandbox. Validate and authorize TreeSpec JSON in your application layer before treating content as trusted.
136
+
137
+ ## Changelog and releases
138
+
139
+ - [CHANGELOG.md](./CHANGELOG.md)
140
+ - [RELEASING.md](./RELEASING.md)
141
+
142
+ ## Related packages
143
+
144
+ - [`@signalsafe/tree-spec`](https://github.com/SignalSafeSoftware/tree-spec) — TypeScript wire contract and compile/lint.
145
+ - [`tree-spec-editor-*`](https://github.com/SignalSafeSoftware/tree-spec-editor) — authoring UI (TypeScript/React).
@@ -0,0 +1 @@
1
+ 0.1.1
@@ -0,0 +1,54 @@
1
+ """
2
+ Reusable TreeSpec contract and validation (backend counterpart to ``packages/tree-spec``).
3
+
4
+ No , HTTP, or simulator UI — wire models, parsing, lint, and patch helpers only.
5
+
6
+ See ``README.md`` in this package for scope, dependency rules, and cross-language fixtures
7
+ (``contracts/tree-spec/``).
8
+ """
9
+
10
+ from deliveryplus_tree_spec.builder import TreeSpecBuilder
11
+ from deliveryplus_tree_spec.builder import TreeSpecError
12
+ from deliveryplus_tree_spec.constants import END_NODE_ID
13
+ from deliveryplus_tree_spec.constants import TREESPEC_WIRE_VERSION
14
+ from deliveryplus_tree_spec.lint import lint_tree_spec
15
+ from deliveryplus_tree_spec.lint import TreeSpecIssue
16
+ from deliveryplus_tree_spec.models import ABMeta
17
+ from deliveryplus_tree_spec.models import ABVariant
18
+ from deliveryplus_tree_spec.models import Choice
19
+ from deliveryplus_tree_spec.models import Delta
20
+ from deliveryplus_tree_spec.models import FeedbackDict
21
+ from deliveryplus_tree_spec.models import MicroFeedback
22
+ from deliveryplus_tree_spec.models import Node
23
+ from deliveryplus_tree_spec.models import PatchDict
24
+ from deliveryplus_tree_spec.models import ReplaceMatch
25
+ from deliveryplus_tree_spec.models import ReplacePatch
26
+ from deliveryplus_tree_spec.models import ReplaceSet
27
+ from deliveryplus_tree_spec.models import Transition
28
+ from deliveryplus_tree_spec.models import TreeSpec
29
+ from deliveryplus_tree_spec.patch import apply_patch_to_spec_dict
30
+ from deliveryplus_tree_spec.patch import PatchApplyError
31
+
32
+ __all__ = [
33
+ "ABMeta",
34
+ "ABVariant",
35
+ "Choice",
36
+ "Delta",
37
+ "END_NODE_ID",
38
+ "FeedbackDict",
39
+ "MicroFeedback",
40
+ "Node",
41
+ "PatchApplyError",
42
+ "PatchDict",
43
+ "ReplaceMatch",
44
+ "ReplacePatch",
45
+ "ReplaceSet",
46
+ "TREESPEC_WIRE_VERSION",
47
+ "Transition",
48
+ "TreeSpec",
49
+ "TreeSpecBuilder",
50
+ "TreeSpecError",
51
+ "TreeSpecIssue",
52
+ "apply_patch_to_spec_dict",
53
+ "lint_tree_spec",
54
+ ]
@@ -0,0 +1,98 @@
1
+ """Parse and navigate TreeSpec; no DB, API, or training heuristics."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Any
5
+ from typing import Dict
6
+ from typing import Optional
7
+ from pydantic import ValidationError as PydanticValidationError
8
+ from deliveryplus_tree_spec.models import ABMeta
9
+ from deliveryplus_tree_spec.models import MicroFeedback
10
+ from deliveryplus_tree_spec.models import Node
11
+ from deliveryplus_tree_spec.models import Transition
12
+ from deliveryplus_tree_spec.models import TreeSpec
13
+
14
+ _TREE_SPEC_PARSE_ERRORS = (ValueError, TypeError, KeyError, PydanticValidationError)
15
+
16
+
17
+ class TreeSpecError(Exception):
18
+ """TreeSpec is malformed or internally inconsistent."""
19
+
20
+
21
+ class TreeSpecBuilder:
22
+ """
23
+ Pure TreeSpec domain logic.
24
+
25
+ - Parses and validates TreeSpec
26
+ - Resolves nodes, choices, transitions
27
+ - Extracts micro-feedback
28
+
29
+ Training/simulator heuristics (e.g. inferring flags from choice id) live in
30
+ `vega.common.tree_spec.builder`, which extends this class.
31
+ """
32
+
33
+ def __init__(self, spec: TreeSpec):
34
+ self.spec = spec
35
+
36
+ @staticmethod
37
+ def _parse_tree_spec_or_none(raw: Dict[str, Any]) -> Optional[TreeSpec]:
38
+ try:
39
+ return TreeSpec.parse_tree_spec(raw)
40
+ except _TREE_SPEC_PARSE_ERRORS:
41
+ return None
42
+
43
+ @classmethod
44
+ def from_raw(cls, raw: Dict[str, Any]) -> "TreeSpecBuilder":
45
+ try:
46
+ spec = TreeSpec.parse_tree_spec(raw)
47
+ except _TREE_SPEC_PARSE_ERRORS as e:
48
+ raise TreeSpecError(f"Invalid tree_spec: {e}") from e
49
+ return cls(spec)
50
+
51
+ def get_start_node_id(self) -> str:
52
+ return str(self.spec.start_node)
53
+
54
+ def get_node(self, node_id: str) -> Node:
55
+ try:
56
+ return self.spec.get_node(node_id)
57
+ except KeyError as e:
58
+ raise TreeSpecError(str(e)) from e
59
+
60
+ def choice_exists(self, node: Node, choice_id: str) -> bool:
61
+ return any(c.id == choice_id for c in node.choices)
62
+
63
+ def find_transition(self, node_id: str, choice_id: str) -> Transition:
64
+ for t in self.spec.transitions:
65
+ if t.from_[0] == node_id and t.from_[1] == choice_id:
66
+ return t
67
+ raise TreeSpecError(f"Missing transition for ({node_id}, {choice_id})")
68
+
69
+ def extract_micro_feedback(
70
+ self, *, node_id: str, choice_id: str, transition: Transition
71
+ ) -> Optional[MicroFeedback]:
72
+ if transition.feedback:
73
+ return transition.feedback
74
+ node = self.get_node(node_id)
75
+ for c in node.choices:
76
+ if c.id == choice_id:
77
+ return c.feedback
78
+ return None
79
+
80
+ @staticmethod
81
+ def get_ab_meta_from_spec(spec_dict: Dict[str, Any]) -> ABMeta:
82
+ """Get A/B metadata from tree_spec dict as ABMeta model."""
83
+ tree_spec = TreeSpecBuilder._parse_tree_spec_or_none(spec_dict)
84
+ if tree_spec is None:
85
+ return ABMeta()
86
+ return tree_spec.ab or ABMeta()
87
+
88
+ @staticmethod
89
+ def update_spec_with_ab_meta(spec_dict: Dict[str, Any], ab_meta: ABMeta) -> None:
90
+ """Update tree_spec dict with ABMeta. Writes canonical key '_ab' only."""
91
+ tree_spec = TreeSpecBuilder._parse_tree_spec_or_none(spec_dict)
92
+ if tree_spec is not None:
93
+ tree_spec.ab = ab_meta
94
+ serialized = tree_spec.model_dump(by_alias=True, exclude_none=False).get("_ab")
95
+ spec_dict["_ab"] = serialized
96
+ return
97
+ serialized = ab_meta.model_dump(exclude_none=True)
98
+ spec_dict["_ab"] = serialized
@@ -0,0 +1,12 @@
1
+ """TreeSpec wire-format constants (aligned with TypeScript `packages/tree-spec`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Canonical id for the terminal node in the wire format.
6
+ END_NODE_ID = "END"
7
+
8
+ # Legacy alias seen in older payloads; may be normalized on read in consumers.
9
+ LEGACY_END_NODE_ID = "__END__"
10
+
11
+ # Bump when the JSON shape changes (interoperability with TS `TREESPEC_WIRE_VERSION`).
12
+ TREESPEC_WIRE_VERSION = 1
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import List
4
+ from typing import Optional
5
+ from deliveryplus_tree_spec.builder import TreeSpecBuilder
6
+ from deliveryplus_tree_spec.constants import END_NODE_ID
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class TreeSpecIssue:
11
+ level: str # "error" | "warning"
12
+ code: str
13
+ message: str
14
+ node_id: Optional[str] = None
15
+ choice_id: Optional[str] = None
16
+
17
+
18
+ def _find_duplicate_transition_keys(builder: TreeSpecBuilder) -> list[tuple[str, str]]:
19
+ seen: set[tuple[str, str]] = set()
20
+ dupes: set[tuple[str, str]] = set()
21
+ for transition in builder.spec.transitions:
22
+ key = (transition.from_[0], transition.from_[1])
23
+ if key in seen:
24
+ dupes.add(key)
25
+ else:
26
+ seen.add(key)
27
+ return sorted(dupes)
28
+
29
+
30
+ def _build_duplicate_transition_issues(
31
+ duplicate_keys: list[tuple[str, str]],
32
+ ) -> list[TreeSpecIssue]:
33
+ return [
34
+ TreeSpecIssue(
35
+ level="error",
36
+ code="duplicate_transition",
37
+ message=(
38
+ f"Duplicate transition for ({from_node}, {choice_id}). Each choice must have exactly one transition."
39
+ ),
40
+ node_id=from_node,
41
+ choice_id=choice_id,
42
+ )
43
+ for from_node, choice_id in duplicate_keys
44
+ ]
45
+
46
+
47
+ def _build_transition_map(builder: TreeSpecBuilder) -> dict[tuple[str, str], str]:
48
+ return {
49
+ (transition.from_[0], transition.from_[1]): transition.to
50
+ for transition in builder.spec.transitions
51
+ }
52
+
53
+
54
+ def _find_missing_transition_issues(
55
+ builder: TreeSpecBuilder,
56
+ trans_map: dict[tuple[str, str], str],
57
+ ) -> list[TreeSpecIssue]:
58
+ issues: list[TreeSpecIssue] = []
59
+ for node_id, node in builder.spec.nodes.items():
60
+ for choice in node.choices:
61
+ if (node_id, choice.id) in trans_map:
62
+ continue
63
+ issues.append(
64
+ TreeSpecIssue(
65
+ level="error",
66
+ code="missing_transition",
67
+ message=f"Missing transition for choice '{choice.id}' on node '{node_id}'.",
68
+ node_id=node_id,
69
+ choice_id=choice.id,
70
+ )
71
+ )
72
+ return issues
73
+
74
+
75
+ def _find_missing_target_node_issues(
76
+ builder: TreeSpecBuilder,
77
+ trans_map: dict[tuple[str, str], str],
78
+ ) -> list[TreeSpecIssue]:
79
+ issues: list[TreeSpecIssue] = []
80
+ for (from_node, choice_id), to_node in trans_map.items():
81
+ if to_node == END_NODE_ID or to_node in builder.spec.nodes:
82
+ continue
83
+ issues.append(
84
+ TreeSpecIssue(
85
+ level="error",
86
+ code="missing_target_node",
87
+ message=f"Transition ({from_node}, {choice_id}) points to missing node '{to_node}'.",
88
+ node_id=from_node,
89
+ choice_id=choice_id,
90
+ )
91
+ )
92
+ return issues
93
+
94
+
95
+ def _collect_reachable_nodes(
96
+ builder: TreeSpecBuilder,
97
+ trans_map: dict[tuple[str, str], str],
98
+ ) -> set[str]:
99
+ reachable: set[str] = set()
100
+ stack: list[str] = [builder.get_start_node_id()]
101
+ while stack:
102
+ node_id = stack.pop()
103
+ if node_id in reachable:
104
+ continue
105
+ reachable.add(node_id)
106
+ current = builder.spec.nodes.get(node_id)
107
+ if current is None:
108
+ continue
109
+ for choice in current.choices:
110
+ next_node = trans_map.get((node_id, choice.id))
111
+ if not next_node or next_node == END_NODE_ID or next_node in reachable:
112
+ continue
113
+ stack.append(next_node)
114
+ return reachable
115
+
116
+
117
+ def _find_unreachable_node_issues(
118
+ builder: TreeSpecBuilder,
119
+ reachable: set[str],
120
+ ) -> list[TreeSpecIssue]:
121
+ start_node_id = builder.get_start_node_id()
122
+ return [
123
+ TreeSpecIssue(
124
+ level="warning",
125
+ code="unreachable_node",
126
+ message=f"Node '{node_id}' is unreachable from start node '{start_node_id}'.",
127
+ node_id=node_id,
128
+ )
129
+ for node_id in builder.spec.nodes.keys()
130
+ if node_id not in reachable
131
+ ]
132
+
133
+
134
+ def lint_tree_spec(builder: TreeSpecBuilder) -> List[TreeSpecIssue]:
135
+ """Domain linting beyond Pydantic shape validation.
136
+
137
+ The TreeSpec models enforce structural invariants, but lints catch authoring mistakes:
138
+ - missing transitions for choices
139
+ - transitions that point to missing nodes
140
+ - unreachable nodes
141
+
142
+ Keep these rules stable and re-use them across:
143
+ - seed_demo (--training-content-only)
144
+ - admin validate endpoint
145
+ - publish gating
146
+ """
147
+
148
+ duplicate_keys = _find_duplicate_transition_keys(builder)
149
+ trans_map = _build_transition_map(builder)
150
+ reachable = _collect_reachable_nodes(builder, trans_map)
151
+
152
+ return [
153
+ *_build_duplicate_transition_issues(duplicate_keys),
154
+ *_find_missing_transition_issues(builder, trans_map),
155
+ *_find_missing_target_node_issues(builder, trans_map),
156
+ *_find_unreachable_node_issues(builder, reachable),
157
+ ]