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.
Files changed (26) hide show
  1. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/PKG-INFO +3 -3
  2. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/README.md +2 -2
  3. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/pyproject.toml +1 -1
  4. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/__init__.py +25 -0
  5. behavior_contracts-0.2.0/src/behavior_contracts/behavior.py +260 -0
  6. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/conformance.py +183 -1
  7. behavior_contracts-0.2.0/src/behavior_contracts/fingerprint.py +89 -0
  8. behavior_contracts-0.2.0/src/behavior_contracts/guard.py +189 -0
  9. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/plan.py +102 -21
  10. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/PKG-INFO +3 -3
  11. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/SOURCES.txt +4 -0
  12. behavior_contracts-0.2.0/tests/test_codegen_generated.py +126 -0
  13. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/tests/test_conformance.py +1 -1
  14. behavior_contracts-0.2.0/tests/test_plan_parallel.py +215 -0
  15. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/tests/test_public_api.py +145 -1
  16. behavior_contracts-0.1.3/src/behavior_contracts/guard.py +0 -54
  17. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/setup.cfg +0 -0
  18. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/canonical.py +0 -0
  19. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/codec.py +0 -0
  20. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/envelope.py +0 -0
  21. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/expr_eval.py +0 -0
  22. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts/template.py +0 -0
  23. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/dependency_links.txt +0 -0
  24. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/entry_points.txt +0 -0
  25. {behavior_contracts-0.1.3 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/requires.txt +0 -0
  26. {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.1.3
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 4 shared suites (105 vectors) from `../conformance/vectors/*.json`
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 # → "105 passed, 0 failed / 105 vectors across 4 suites"
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 4 shared suites (105 vectors) from `../conformance/vectors/*.json`
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 # → "105 passed, 0 failed / 105 vectors across 4 suites"
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.1.3"
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 4 suites")
458
+ print(f"\n{t.passed} passed, {t.failed} failed / {total} vectors across 7 suites")
277
459
  return t.failed
278
460
 
279
461