behavior-contracts 0.1.3__tar.gz → 0.2.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.
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/PKG-INFO +3 -3
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/README.md +2 -2
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/pyproject.toml +1 -1
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/__init__.py +25 -0
- behavior_contracts-0.2.0/src/behavior_contracts/behavior.py +260 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/conformance.py +183 -1
- behavior_contracts-0.2.0/src/behavior_contracts/fingerprint.py +89 -0
- behavior_contracts-0.2.0/src/behavior_contracts/guard.py +189 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/plan.py +102 -21
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/PKG-INFO +3 -3
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/SOURCES.txt +4 -0
- behavior_contracts-0.2.0/tests/test_codegen_generated.py +126 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/tests/test_conformance.py +1 -1
- behavior_contracts-0.2.0/tests/test_plan_parallel.py +215 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/tests/test_public_api.py +145 -1
- behavior_contracts-0.1.3/src/behavior_contracts/guard.py +0 -54
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/setup.cfg +0 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/canonical.py +0 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/codec.py +0 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/envelope.py +0 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/expr_eval.py +0 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/template.py +0 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/dependency_links.txt +0 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/entry_points.txt +0 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/requires.txt +0 -0
- {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: behavior-contracts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Multi-language IR-Runtime common core (Python port). The COMMON 'thin core' primitives from dsl-contracts runtime-boundary.md — validate_envelope / evaluate_expression / render_template / run_plan / canonical_value / canonical_json / py_float_repr / assert_portable — with zero backend (DynamoDB/graphddb) dependencies.
|
|
5
5
|
Author: foo-log
|
|
6
6
|
License: MIT
|
|
@@ -60,12 +60,12 @@ string matching the conformance protocol's failure-code set.
|
|
|
60
60
|
|
|
61
61
|
## Conformance
|
|
62
62
|
|
|
63
|
-
Runs the
|
|
63
|
+
Runs the 7 shared suites (147 vectors) from `../conformance/vectors/*.json`
|
|
64
64
|
through this package per [`../conformance/PROTOCOL.md`](../conformance/PROTOCOL.md),
|
|
65
65
|
including the pre-flight version fail-closed sweep.
|
|
66
66
|
|
|
67
67
|
```bash
|
|
68
|
-
python -m behavior_contracts.conformance # → "
|
|
68
|
+
python -m behavior_contracts.conformance # → "147 passed, 0 failed / 147 vectors across 7 suites"
|
|
69
69
|
# or the console script:
|
|
70
70
|
behavior-contracts-conformance
|
|
71
71
|
```
|
|
@@ -48,12 +48,12 @@ string matching the conformance protocol's failure-code set.
|
|
|
48
48
|
|
|
49
49
|
## Conformance
|
|
50
50
|
|
|
51
|
-
Runs the
|
|
51
|
+
Runs the 7 shared suites (147 vectors) from `../conformance/vectors/*.json`
|
|
52
52
|
through this package per [`../conformance/PROTOCOL.md`](../conformance/PROTOCOL.md),
|
|
53
53
|
including the pre-flight version fail-closed sweep.
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
|
-
python -m behavior_contracts.conformance # → "
|
|
56
|
+
python -m behavior_contracts.conformance # → "147 passed, 0 failed / 147 vectors across 7 suites"
|
|
57
57
|
# or the console script:
|
|
58
58
|
behavior-contracts-conformance
|
|
59
59
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "behavior-contracts"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Multi-language IR-Runtime common core (Python port). The COMMON 'thin core' primitives from dsl-contracts runtime-boundary.md — validate_envelope / evaluate_expression / render_template / run_plan / canonical_value / canonical_json / py_float_repr / assert_portable — with zero backend (DynamoDB/graphddb) dependencies."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -22,6 +22,9 @@ SPEC_VERSIONS = {
|
|
|
22
22
|
"template": 1,
|
|
23
23
|
"plan": 1,
|
|
24
24
|
"canonical": 2, # v2: obj 値の __proto__ own key を FORBIDDEN_KEY に(fail-closed)
|
|
25
|
+
"behavior": 2, # v2: map.when / map.into / map.batched / handler ctx(bc#22。v1 IR の意味論は不変)
|
|
26
|
+
"guard": 1, # 初版(bc#25: assert_portable_component_graph の accept/reject を vector-pin)
|
|
27
|
+
"c2": 1, # 初版(bc#28: c2-catalog-swap — catalog-swap 実行 + IR 構造一致の 5 言語 pin)
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
# validate_envelope に渡す graphddb 形式の既定 supported 版(``"<major>.<minor>"``)。
|
|
@@ -66,8 +69,16 @@ from .envelope import ( # noqa: E402
|
|
|
66
69
|
|
|
67
70
|
# ── guard(Portability Guard) ────────────────────────────────────────────────
|
|
68
71
|
from .guard import ( # noqa: E402
|
|
72
|
+
PORTABLE_EXPR_OPERATORS,
|
|
69
73
|
PortabilityError,
|
|
70
74
|
assert_portable,
|
|
75
|
+
assert_portable_component_graph,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# ── behavior(component-graph IR + run_behavior) ──────────────────────────────
|
|
79
|
+
from .behavior import ( # noqa: E402
|
|
80
|
+
BehaviorFailure,
|
|
81
|
+
run_behavior,
|
|
71
82
|
)
|
|
72
83
|
|
|
73
84
|
# ── conformance runner adapter 共通部 ─────────────────────────────────────────
|
|
@@ -76,6 +87,12 @@ from .codec import ( # noqa: E402
|
|
|
76
87
|
deep_equals,
|
|
77
88
|
)
|
|
78
89
|
|
|
90
|
+
# ── generator 支援(共通 Generator の生成モジュールが使う fingerprint, bc#13)──
|
|
91
|
+
from .fingerprint import ( # noqa: E402
|
|
92
|
+
FingerprintFailure,
|
|
93
|
+
fingerprint_component_graph,
|
|
94
|
+
)
|
|
95
|
+
|
|
79
96
|
__all__ = [
|
|
80
97
|
"SPEC_VERSIONS",
|
|
81
98
|
"ENVELOPE_SPEC_VERSION",
|
|
@@ -103,8 +120,16 @@ __all__ = [
|
|
|
103
120
|
"EnvelopeFailure",
|
|
104
121
|
# guard
|
|
105
122
|
"assert_portable",
|
|
123
|
+
"assert_portable_component_graph",
|
|
124
|
+
"PORTABLE_EXPR_OPERATORS",
|
|
106
125
|
"PortabilityError",
|
|
126
|
+
# behavior
|
|
127
|
+
"run_behavior",
|
|
128
|
+
"BehaviorFailure",
|
|
107
129
|
# codec
|
|
108
130
|
"decode_value",
|
|
109
131
|
"deep_equals",
|
|
132
|
+
# generator 支援(fingerprint)
|
|
133
|
+
"fingerprint_component_graph",
|
|
134
|
+
"FingerprintFailure",
|
|
110
135
|
]
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""behavior.py — component-graph IR + run_behavior 統合実行 IF(Python port)。
|
|
2
|
+
|
|
3
|
+
TS 参照実装 ``ts/src/behavior.ts`` の意味論を完全一致で移植(scp-ir-architecture.md §5–§7)。
|
|
4
|
+
|
|
5
|
+
component-graph IR(``components[]{name, inputPorts, body[], output, plan}``)を、既存 COMMON の
|
|
6
|
+
``run_plan``(stage 実行・Skip 伝播・Policy Kind)+ ``evaluate``(Expression IR)の上に実行する。
|
|
7
|
+
専用コンポーネントの実装は handler registry(catalog名 → 実装。境界注入)で名前解決して委譲する。
|
|
8
|
+
|
|
9
|
+
body ノード種:
|
|
10
|
+
- componentRef: ``{id, component, ports, parent?, bindField?, relationKind?, policy?}``
|
|
11
|
+
- map: ``{id, map:{over, as, component, ports, when?, into?, batched?, parent?,
|
|
12
|
+
relationKind?, policy?}}``(when/into/batched は behaviorVersion 2)
|
|
13
|
+
- cond: ``{id, cond:{if, then, else, parent?}}``(純 Expression、handler を呼ばない)
|
|
14
|
+
|
|
15
|
+
behaviorVersion 2(bc#22):
|
|
16
|
+
- ``map.when`` — per-element guard。``{cond:[when,true,false]}`` へ lower して評価
|
|
17
|
+
(strict-bool・非 bool は TYPE_MISMATCH で fail-closed)。false の要素は skip。
|
|
18
|
+
- ``map.into`` — zip-attach。結果は「over の各 guard 通過要素へ into キーで handler 結果を
|
|
19
|
+
書き戻した augment 済みリスト」(over と同じ長さ・順序。skip 要素は無変更で pass through)。
|
|
20
|
+
- ``map.batched``— guard 通過全要素の ports を先に評価し handler を 1 回だけ
|
|
21
|
+
``handler({"items": [...]}, ctx)`` で呼ぶ。結果は items と同じ長さ・順序のリスト契約
|
|
22
|
+
(違反は MAP_BATCH_RESULT_MISMATCH)。通過 0 件なら handler は呼ばれず空リスト。
|
|
23
|
+
- handler ctx — 全 handler 呼び出しに ``{"nodeId", "component"}``(map 非 batched は
|
|
24
|
+
さらに ``"bound"``)を渡す。追加のみ・既存 handler 互換。
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import threading
|
|
30
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional
|
|
31
|
+
|
|
32
|
+
from .expr_eval import evaluate as evaluate_expression
|
|
33
|
+
from .plan import run_plan
|
|
34
|
+
|
|
35
|
+
Value = Any
|
|
36
|
+
|
|
37
|
+
# handler(ports: dict, ctx: dict) -> ExecOutcome({"ok": Value} | {"error": str})
|
|
38
|
+
Handler = Callable[[Dict[str, Value], Dict[str, Any]], Mapping[str, Any]]
|
|
39
|
+
Handlers = Mapping[str, Handler]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BehaviorFailure(Exception):
|
|
43
|
+
"""run_behavior の Failure(UNKNOWN_COMPONENT / MAP_OVER_NOT_ARRAY / ...)。"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, code: str, message: str) -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.code = code
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _bfail(code: str, message: str) -> "None":
|
|
51
|
+
raise BehaviorFailure(code, message)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _node_kind(n: Mapping[str, Any]) -> str:
|
|
55
|
+
if "map" in n:
|
|
56
|
+
return "map"
|
|
57
|
+
if "cond" in n:
|
|
58
|
+
return "cond"
|
|
59
|
+
if "component" in n:
|
|
60
|
+
return "componentRef"
|
|
61
|
+
_bfail("UNKNOWN_NODE_KIND", f"body node '{n.get('id', '?')}' is not componentRef/map/cond")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _node_parent(n: Mapping[str, Any]) -> Optional[str]:
|
|
65
|
+
if "map" in n:
|
|
66
|
+
return n["map"].get("parent")
|
|
67
|
+
if "cond" in n:
|
|
68
|
+
return n["cond"].get("parent")
|
|
69
|
+
return n.get("parent")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _node_bind_field(n: Mapping[str, Any]) -> Optional[str]:
|
|
73
|
+
if "map" in n or "cond" in n:
|
|
74
|
+
return None
|
|
75
|
+
return n.get("bindField")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _node_policy(n: Mapping[str, Any]) -> Optional[str]:
|
|
79
|
+
if "map" in n:
|
|
80
|
+
return n["map"].get("policy")
|
|
81
|
+
if "cond" in n:
|
|
82
|
+
return None
|
|
83
|
+
return n.get("policy")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _node_relation_kind(n: Mapping[str, Any]) -> Optional[str]:
|
|
87
|
+
if "map" in n:
|
|
88
|
+
return n["map"].get("relationKind")
|
|
89
|
+
if "cond" in n:
|
|
90
|
+
return None
|
|
91
|
+
return n.get("relationKind")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _eval_ports(ports: Mapping[str, Any], scope: Dict[str, Value]) -> Dict[str, Value]:
|
|
95
|
+
# key はソートせず宣言順のまま。FORBIDDEN_KEY 等は evaluate が投げる(fail-closed)。
|
|
96
|
+
return {k: evaluate_expression(v, scope) for k, v in ports.items()}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_behavior(
|
|
100
|
+
ir: Mapping[str, Any],
|
|
101
|
+
handlers: Handlers,
|
|
102
|
+
input: Optional[Mapping[str, Value]] = None,
|
|
103
|
+
entry: Optional[str] = None,
|
|
104
|
+
) -> Value:
|
|
105
|
+
"""component-graph IR の統合実行 IF(scp-ir-architecture.md §7)。
|
|
106
|
+
|
|
107
|
+
:param ir: component-graph 可搬 IR(``{components:[...]}``)。
|
|
108
|
+
:param handlers: 専用コンポーネント handler registry(catalog名 → 実装。境界注入)。
|
|
109
|
+
:param input: エントリ component の inputPorts 束縛(param 値)。
|
|
110
|
+
:param entry: 実行する component 名(省略時は先頭 component)。
|
|
111
|
+
:returns: ``output`` を評価した最終 Value(Φ 合流)。
|
|
112
|
+
"""
|
|
113
|
+
input = dict(input or {})
|
|
114
|
+
components = list(ir.get("components", []))
|
|
115
|
+
comp = None
|
|
116
|
+
if entry is not None:
|
|
117
|
+
comp = next((c for c in components if c.get("name") == entry), None)
|
|
118
|
+
elif components:
|
|
119
|
+
comp = components[0]
|
|
120
|
+
if comp is None:
|
|
121
|
+
_bfail("UNKNOWN_ENTRY", f"component '{entry if entry is not None else '<first>'}' not found in IR")
|
|
122
|
+
|
|
123
|
+
body: List[Mapping[str, Any]] = list(comp["body"])
|
|
124
|
+
id_to_index = {n["id"]: i for i, n in enumerate(body)}
|
|
125
|
+
|
|
126
|
+
ops: List[Dict[str, Any]] = []
|
|
127
|
+
for n in body:
|
|
128
|
+
pid = _node_parent(n)
|
|
129
|
+
parent = id_to_index.get(pid) if pid is not None else None
|
|
130
|
+
op: Dict[str, Any] = {"id": n["id"], "parent": parent}
|
|
131
|
+
bf = _node_bind_field(n)
|
|
132
|
+
if bf is not None:
|
|
133
|
+
op["bindField"] = bf
|
|
134
|
+
rk = _node_relation_kind(n)
|
|
135
|
+
if rk is not None:
|
|
136
|
+
op["relationKind"] = rk
|
|
137
|
+
pol = _node_policy(n)
|
|
138
|
+
if pol is not None:
|
|
139
|
+
op["policy"] = pol
|
|
140
|
+
ops.append(op)
|
|
141
|
+
|
|
142
|
+
# run_plan は concurrency > 1 の stage で exec を worker thread から並行に呼ぶ(bc#23)。
|
|
143
|
+
# results への書き込みは disjoint スロット(自ノード id)だが、base_scope の dict 展開
|
|
144
|
+
# (iteration)と挿入が並行すると RuntimeError になり得るため lock で直列化する。
|
|
145
|
+
# 兄弟は互いのスロットを読まない(§1: stage 内は相互独立)ので結果は決定的。
|
|
146
|
+
results: Dict[str, Value] = {}
|
|
147
|
+
results_lock = threading.Lock()
|
|
148
|
+
|
|
149
|
+
def base_scope() -> Dict[str, Value]:
|
|
150
|
+
with results_lock:
|
|
151
|
+
return {**input, **results}
|
|
152
|
+
|
|
153
|
+
def exec_node(op: Mapping[str, Any], _bound: Optional[Value]) -> Mapping[str, Any]:
|
|
154
|
+
idx = id_to_index[op["id"]]
|
|
155
|
+
node = body[idx]
|
|
156
|
+
kind = _node_kind(node)
|
|
157
|
+
|
|
158
|
+
if kind == "cond":
|
|
159
|
+
c = node["cond"]
|
|
160
|
+
value = evaluate_expression({"cond": [c["if"], c["then"], c["else"]]}, base_scope())
|
|
161
|
+
return {"ok": value}
|
|
162
|
+
|
|
163
|
+
if kind == "map":
|
|
164
|
+
m = node["map"]
|
|
165
|
+
over = evaluate_expression(m["over"], base_scope())
|
|
166
|
+
if not isinstance(over, list):
|
|
167
|
+
_bfail("MAP_OVER_NOT_ARRAY", f"map '{op['id']}': 'over' did not evaluate to an array")
|
|
168
|
+
handler = handlers.get(m["component"])
|
|
169
|
+
if handler is None:
|
|
170
|
+
_bfail("UNKNOWN_COMPONENT", f"component '{m['component']}' has no handler (fail-closed)")
|
|
171
|
+
ctx = {"nodeId": op["id"], "component": m["component"]}
|
|
172
|
+
when = m.get("when")
|
|
173
|
+
|
|
174
|
+
def keep(scope: Dict[str, Value]) -> bool:
|
|
175
|
+
# per-element guard(v2): `{cond:[when,true,false]}` へ lower(strict-bool)。
|
|
176
|
+
if when is None and "when" not in m:
|
|
177
|
+
return True
|
|
178
|
+
return evaluate_expression({"cond": [when, True, False]}, scope) is True
|
|
179
|
+
|
|
180
|
+
kept_idx: List[int] = [] # guard を通過した over 内 index(into の整列用)
|
|
181
|
+
|
|
182
|
+
if m.get("batched") is True:
|
|
183
|
+
# batched(v2): guard 通過全要素の ports を先に評価し、handler を 1 回だけ呼ぶ。
|
|
184
|
+
items: List[Value] = []
|
|
185
|
+
for i, el in enumerate(over):
|
|
186
|
+
scope = {**base_scope(), m["as"]: el}
|
|
187
|
+
if not keep(scope):
|
|
188
|
+
continue
|
|
189
|
+
items.append(_eval_ports(m["ports"], scope))
|
|
190
|
+
kept_idx.append(i)
|
|
191
|
+
if not items:
|
|
192
|
+
collected: List[Value] = [] # guard 全落ち: handler は呼ばれない
|
|
193
|
+
else:
|
|
194
|
+
outcome = handler({"items": items}, ctx)
|
|
195
|
+
if "error" in outcome:
|
|
196
|
+
return outcome
|
|
197
|
+
r = outcome["ok"]
|
|
198
|
+
if not isinstance(r, list) or len(r) != len(items):
|
|
199
|
+
_bfail(
|
|
200
|
+
"MAP_BATCH_RESULT_MISMATCH",
|
|
201
|
+
f"map '{op['id']}': batched handler must return a list aligned to items "
|
|
202
|
+
f"(want {len(items)}, got {len(r) if isinstance(r, list) else type(r).__name__})",
|
|
203
|
+
)
|
|
204
|
+
collected = r
|
|
205
|
+
else:
|
|
206
|
+
collected = []
|
|
207
|
+
for i, el in enumerate(over):
|
|
208
|
+
scope = {**base_scope(), m["as"]: el}
|
|
209
|
+
if not keep(scope):
|
|
210
|
+
continue
|
|
211
|
+
ports = _eval_ports(m["ports"], scope)
|
|
212
|
+
outcome = handler(ports, {**ctx, "bound": el})
|
|
213
|
+
if "error" in outcome:
|
|
214
|
+
return outcome
|
|
215
|
+
collected.append(outcome["ok"])
|
|
216
|
+
kept_idx.append(i)
|
|
217
|
+
|
|
218
|
+
into = m.get("into")
|
|
219
|
+
if into is None and "into" not in m:
|
|
220
|
+
return {"ok": collected}
|
|
221
|
+
|
|
222
|
+
# into(v2): over と同じ長さ・順序の augment 済みリスト(skip 要素は無変更)。
|
|
223
|
+
augmented: List[Value] = []
|
|
224
|
+
k = 0
|
|
225
|
+
for i, el in enumerate(over):
|
|
226
|
+
if k < len(kept_idx) and kept_idx[k] == i:
|
|
227
|
+
if not isinstance(el, dict):
|
|
228
|
+
_bfail(
|
|
229
|
+
"MAP_INTO_ELEMENT_NOT_OBJECT",
|
|
230
|
+
f"map '{op['id']}': 'into' requires object elements (element {i} is not an object)",
|
|
231
|
+
)
|
|
232
|
+
augmented.append({**el, into: collected[k]})
|
|
233
|
+
k += 1
|
|
234
|
+
else:
|
|
235
|
+
augmented.append(el)
|
|
236
|
+
return {"ok": augmented}
|
|
237
|
+
|
|
238
|
+
# componentRef
|
|
239
|
+
handler = handlers.get(node["component"])
|
|
240
|
+
if handler is None:
|
|
241
|
+
_bfail("UNKNOWN_COMPONENT", f"component '{node['component']}' has no handler (fail-closed)")
|
|
242
|
+
ports = _eval_ports(node.get("ports", {}), base_scope())
|
|
243
|
+
return handler(ports, {"nodeId": op["id"], "component": node["component"]})
|
|
244
|
+
|
|
245
|
+
def wrapped_exec(op: Mapping[str, Any], bound: Optional[Value]) -> Mapping[str, Any]:
|
|
246
|
+
outcome = exec_node(op, bound)
|
|
247
|
+
if "ok" in outcome:
|
|
248
|
+
with results_lock:
|
|
249
|
+
results[op["id"]] = outcome["ok"]
|
|
250
|
+
return outcome
|
|
251
|
+
|
|
252
|
+
plan = comp.get("plan")
|
|
253
|
+
run = run_plan(plan, ops, wrapped_exec)
|
|
254
|
+
|
|
255
|
+
for i, s in enumerate(run["states"]):
|
|
256
|
+
if s is not None and s.get("status") == "skipped":
|
|
257
|
+
rk = _node_relation_kind(body[i])
|
|
258
|
+
results[ops[i]["id"]] = {"items": [], "cursor": None} if rk == "connection" else None
|
|
259
|
+
|
|
260
|
+
return evaluate_expression(comp["output"], base_scope())
|
|
@@ -19,15 +19,19 @@ from __future__ import annotations
|
|
|
19
19
|
import json
|
|
20
20
|
import os
|
|
21
21
|
import sys
|
|
22
|
+
import threading
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
from typing import Any, Dict, List, Optional
|
|
24
25
|
|
|
25
26
|
from . import (
|
|
26
27
|
SPEC_VERSIONS,
|
|
28
|
+
BehaviorFailure,
|
|
27
29
|
CanonicalFailure,
|
|
28
30
|
ExprFailure,
|
|
29
31
|
PlanFailure,
|
|
32
|
+
PortabilityError,
|
|
30
33
|
TemplateFailure,
|
|
34
|
+
assert_portable_component_graph,
|
|
31
35
|
canonical_json,
|
|
32
36
|
canonical_value,
|
|
33
37
|
decode_value,
|
|
@@ -37,6 +41,7 @@ from . import (
|
|
|
37
41
|
final_tree,
|
|
38
42
|
py_float_repr,
|
|
39
43
|
render_template,
|
|
44
|
+
run_behavior,
|
|
40
45
|
run_plan,
|
|
41
46
|
)
|
|
42
47
|
|
|
@@ -85,6 +90,9 @@ def _preflight(vectors_dir: Path) -> Dict[str, Any]:
|
|
|
85
90
|
("template.json", "template", "templateVersion", SPEC_VERSIONS["template"]),
|
|
86
91
|
("plan.json", "plan", "planVersion", SPEC_VERSIONS["plan"]),
|
|
87
92
|
("canonical.json", "canonical", "canonicalVersion", SPEC_VERSIONS["canonical"]),
|
|
93
|
+
("behavior.json", "behavior", "behaviorVersion", SPEC_VERSIONS["behavior"]),
|
|
94
|
+
("guard.json", "guard", "guardVersion", SPEC_VERSIONS["guard"]),
|
|
95
|
+
("c2-catalog-swap.json", "c2", "c2Version", SPEC_VERSIONS["c2"]),
|
|
88
96
|
]
|
|
89
97
|
loaded = [(f, suite, vk, want, _load_json(vectors_dir, f)) for (f, suite, vk, want) in specs]
|
|
90
98
|
mismatches = [(suite, doc.get(vk), want) for (_, suite, vk, want, doc) in loaded if doc.get(vk) != want]
|
|
@@ -247,6 +255,177 @@ def _run_plan(t: _Tally, doc: Any) -> None:
|
|
|
247
255
|
_bump(t, ok)
|
|
248
256
|
|
|
249
257
|
|
|
258
|
+
# ── behavior suite ────────────────────────────────────────────────────────────
|
|
259
|
+
def _build_handlers(spec: Dict[str, Any]):
|
|
260
|
+
"""handlers[name] = FIFO の scripted outcome 列(配列でなければ「毎回同じ 1 要素」の短縮形)。
|
|
261
|
+
|
|
262
|
+
v2 の観測用 outcome(PROTOCOL.md §3.5):
|
|
263
|
+
``{"echo":"ctx"}`` → ``{ok: {nodeId, component}}``(handler ctx の中身を返す)
|
|
264
|
+
``{"echo":"items"}`` → ``{ok: ports["items"]}``(batched handler が受け取った items)
|
|
265
|
+
"""
|
|
266
|
+
handlers = {}
|
|
267
|
+
for name, raw in spec.items():
|
|
268
|
+
queue = list(raw) if isinstance(raw, list) else None
|
|
269
|
+
single = None if isinstance(raw, list) else raw
|
|
270
|
+
|
|
271
|
+
def make(queue=queue, single=single):
|
|
272
|
+
# run_behavior は concurrency > 1 の stage を ThreadPoolExecutor で並列 dispatch
|
|
273
|
+
# し得るため、FIFO pop(check-then-act)は per-queue lock で原子化する
|
|
274
|
+
# (PROTOCOL.md §3.5「runner の FIFO pop は並行呼び出しに対して原子的」)。
|
|
275
|
+
lock = threading.Lock()
|
|
276
|
+
|
|
277
|
+
def handler(ports, ctx):
|
|
278
|
+
if single is not None:
|
|
279
|
+
o = single
|
|
280
|
+
else:
|
|
281
|
+
with lock:
|
|
282
|
+
o = queue.pop(0) if len(queue) > 1 else queue[0]
|
|
283
|
+
if o.get("echo") == "ctx":
|
|
284
|
+
return {"ok": {"nodeId": ctx["nodeId"], "component": ctx["component"]}}
|
|
285
|
+
if o.get("echo") == "items":
|
|
286
|
+
return {"ok": ports.get("items")}
|
|
287
|
+
if "ok" in o:
|
|
288
|
+
return {"ok": decode_value(o["ok"])}
|
|
289
|
+
return {"error": o["error"]}
|
|
290
|
+
|
|
291
|
+
return handler
|
|
292
|
+
|
|
293
|
+
handlers[name] = make()
|
|
294
|
+
return handlers
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _run_behavior(t: _Tally, doc: Any) -> None:
|
|
298
|
+
print(f"\nbehavior.json (v{doc['behaviorVersion']}) — {len(doc['vectors'])} vectors")
|
|
299
|
+
for v in doc["vectors"]:
|
|
300
|
+
ok = False
|
|
301
|
+
detail = ""
|
|
302
|
+
ir = v["ir"]
|
|
303
|
+
input_scope = {k: decode_value(val) for k, val in (v.get("input") or {}).items()}
|
|
304
|
+
handlers = _build_handlers(v.get("handlers") or {})
|
|
305
|
+
try:
|
|
306
|
+
result = run_behavior(ir, handlers, input_scope, v.get("entry"))
|
|
307
|
+
if "value" in v["expect"]:
|
|
308
|
+
ok = deep_equals(result, decode_value(v["expect"]["value"]))
|
|
309
|
+
if not ok:
|
|
310
|
+
detail = (
|
|
311
|
+
f"expected value {json.dumps(v['expect']['value'])}, "
|
|
312
|
+
f"got {json.dumps(encode_value(result))}"
|
|
313
|
+
)
|
|
314
|
+
else:
|
|
315
|
+
detail = f"expected Failure({v['expect']['failure']}), got a value"
|
|
316
|
+
except (BehaviorFailure, PlanFailure, ExprFailure) as e:
|
|
317
|
+
if "failure" in v["expect"]:
|
|
318
|
+
ok = e.code == v["expect"]["failure"]
|
|
319
|
+
if not ok:
|
|
320
|
+
detail = f"expected Failure({v['expect']['failure']}), got Failure({e.code})"
|
|
321
|
+
else:
|
|
322
|
+
detail = f"expected a value, got Failure({e.code})"
|
|
323
|
+
_line(ok, v["name"], detail)
|
|
324
|
+
_bump(t, ok)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ── guard suite(assert_portable_component_graph, bc#25)────────────────────────
|
|
328
|
+
def _run_guard(t: _Tally, doc: Any) -> None:
|
|
329
|
+
"""`ir` は RAW JSON のまま guard へ渡す(§2 の型付き decode 不適用 — 静的構造検査)。
|
|
330
|
+
|
|
331
|
+
reject は PortabilityError を固定コード PORTABILITY に map し、`expect.path` を
|
|
332
|
+
文字列完全一致で照合する(メッセージ本文は判定対象外。PROTOCOL.md §3.6)。
|
|
333
|
+
関数残存 reject は JSON vector に載らないため unit test(test_public_api.py)で pin。
|
|
334
|
+
"""
|
|
335
|
+
print(f"\nguard.json (v{doc['guardVersion']}) — {len(doc['vectors'])} vectors")
|
|
336
|
+
for v in doc["vectors"]:
|
|
337
|
+
ok = False
|
|
338
|
+
detail = ""
|
|
339
|
+
try:
|
|
340
|
+
assert_portable_component_graph(v["ir"])
|
|
341
|
+
if "ok" in v["expect"]:
|
|
342
|
+
ok = True
|
|
343
|
+
else:
|
|
344
|
+
detail = (
|
|
345
|
+
f"expected Failure({v['expect']['failure']} at {v['expect']['path']}), "
|
|
346
|
+
f"guard passed"
|
|
347
|
+
)
|
|
348
|
+
except PortabilityError as e:
|
|
349
|
+
if "failure" in v["expect"]:
|
|
350
|
+
ok = v["expect"]["failure"] == "PORTABILITY" and e.path == v["expect"]["path"]
|
|
351
|
+
if not ok:
|
|
352
|
+
detail = f"expected reject path {v['expect']['path']}, got {e.path}"
|
|
353
|
+
else:
|
|
354
|
+
detail = f"expected guard to pass, got PORTABILITY at {e.path}"
|
|
355
|
+
_line(ok, v["name"], detail)
|
|
356
|
+
_bump(t, ok)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ── c2 suite(c2-catalog-swap: catalog-swap 実行 + IR 構造一致, bc#28)──────────
|
|
360
|
+
def _c2_normalize(decoded_ir: Any, role_by_name: Dict[str, str]) -> Any:
|
|
361
|
+
"""component leaf 名を role に置換(構造は不変)。unmapped は ValueError。"""
|
|
362
|
+
for c in decoded_ir["components"]:
|
|
363
|
+
for n in c["body"]:
|
|
364
|
+
ref = n.get("map", n)
|
|
365
|
+
if "component" in ref:
|
|
366
|
+
name = ref["component"]
|
|
367
|
+
if name not in role_by_name:
|
|
368
|
+
raise ValueError(f"unmapped component '{name}'")
|
|
369
|
+
ref["component"] = role_by_name[name]
|
|
370
|
+
return decoded_ir
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _run_c2(t: _Tally, doc: Any) -> None:
|
|
374
|
+
"""各 consumer の vector を behavior-suite 形式で実行し、構造検査
|
|
375
|
+
(正規化後 deep-equal / 正規化前は不一致 = catalog が唯一の差分)を行う。
|
|
376
|
+
PROTOCOL.md §3.7。
|
|
377
|
+
"""
|
|
378
|
+
consumer_names = sorted(doc["consumers"].keys())
|
|
379
|
+
n_vec = sum(len(doc["consumers"][c]["vectors"]) for c in consumer_names)
|
|
380
|
+
print(f"\nc2-catalog-swap.json (v{doc['c2Version']}) — {n_vec} vectors + structural")
|
|
381
|
+
|
|
382
|
+
# 1) execution: 同一 runBehavior + consumer 固有 handlers → consumer 固有の期待出力。
|
|
383
|
+
for cname in consumer_names:
|
|
384
|
+
for v in doc["consumers"][cname]["vectors"]:
|
|
385
|
+
ok = False
|
|
386
|
+
detail = ""
|
|
387
|
+
try:
|
|
388
|
+
assert_portable_component_graph(v["ir"]) # 共有 guard が全 consumer IR を accept
|
|
389
|
+
input_scope = {k: decode_value(val) for k, val in (v.get("input") or {}).items()}
|
|
390
|
+
handlers = _build_handlers(v.get("handlers") or {})
|
|
391
|
+
result = run_behavior(v["ir"], handlers, input_scope, v.get("entry"))
|
|
392
|
+
ok = deep_equals(result, decode_value(v["expect"]["value"]))
|
|
393
|
+
if not ok:
|
|
394
|
+
detail = (
|
|
395
|
+
f"expected value {json.dumps(v['expect']['value'])}, "
|
|
396
|
+
f"got {json.dumps(encode_value(result))}"
|
|
397
|
+
)
|
|
398
|
+
except (BehaviorFailure, PlanFailure, ExprFailure, PortabilityError) as e:
|
|
399
|
+
detail = f"expected a value, got {type(e).__name__}({e})"
|
|
400
|
+
_line(ok, f"{cname}: {v['name']}", detail)
|
|
401
|
+
_bump(t, ok)
|
|
402
|
+
|
|
403
|
+
# 2) structural: 正規化(component 名 → role)後の IR が consumer 間で deep-equal、
|
|
404
|
+
# 正規化前は不一致であること。
|
|
405
|
+
a, b = consumer_names
|
|
406
|
+
role_of = lambda c: {name: role for role, name in doc["consumers"][c]["catalog"].items()} # noqa: E731
|
|
407
|
+
va = doc["consumers"][a]["vectors"]
|
|
408
|
+
vb = doc["consumers"][b]["vectors"]
|
|
409
|
+
for i in range(min(len(va), len(vb))):
|
|
410
|
+
ok = False
|
|
411
|
+
detail = ""
|
|
412
|
+
try:
|
|
413
|
+
da = decode_value(va[i]["ir"])
|
|
414
|
+
db = decode_value(vb[i]["ir"])
|
|
415
|
+
if deep_equals(da, db):
|
|
416
|
+
detail = "raw IRs must differ before normalization"
|
|
417
|
+
else:
|
|
418
|
+
na = _c2_normalize(da, role_of(a))
|
|
419
|
+
nb = _c2_normalize(db, role_of(b))
|
|
420
|
+
ok = deep_equals(na, nb)
|
|
421
|
+
if not ok:
|
|
422
|
+
detail = "IRs must be deep-equal modulo catalog names"
|
|
423
|
+
except ValueError as e:
|
|
424
|
+
detail = str(e)
|
|
425
|
+
_line(ok, f"structural[{i}]: IR equal modulo catalog ({va[i]['name']})", detail)
|
|
426
|
+
_bump(t, ok)
|
|
427
|
+
|
|
428
|
+
|
|
250
429
|
def _same_set(a: List[str], b: List[str]) -> bool:
|
|
251
430
|
return sorted(a) == sorted(b)
|
|
252
431
|
|
|
@@ -271,9 +450,12 @@ def run(vectors_dir: Optional[Path] = None) -> int:
|
|
|
271
450
|
_run_template(t, docs["template"])
|
|
272
451
|
_run_plan(t, docs["plan"])
|
|
273
452
|
_run_canonical(t, docs["canonical"])
|
|
453
|
+
_run_behavior(t, docs["behavior"])
|
|
454
|
+
_run_guard(t, docs["guard"])
|
|
455
|
+
_run_c2(t, docs["c2"])
|
|
274
456
|
|
|
275
457
|
total = t.passed + t.failed
|
|
276
|
-
print(f"\n{t.passed} passed, {t.failed} failed / {total} vectors across
|
|
458
|
+
print(f"\n{t.passed} passed, {t.failed} failed / {total} vectors across 7 suites")
|
|
277
459
|
return t.failed
|
|
278
460
|
|
|
279
461
|
|