behavior-contracts 0.1.2__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.2 → behavior_contracts-0.2.0}/PKG-INFO +3 -3
  2. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/README.md +2 -2
  3. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/pyproject.toml +1 -1
  4. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts/__init__.py +29 -2
  5. behavior_contracts-0.2.0/src/behavior_contracts/behavior.py +260 -0
  6. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts/canonical.py +19 -1
  7. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts/codec.py +12 -1
  8. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts/conformance.py +186 -2
  9. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts/expr_eval.py +17 -1
  10. behavior_contracts-0.2.0/src/behavior_contracts/fingerprint.py +89 -0
  11. behavior_contracts-0.2.0/src/behavior_contracts/guard.py +189 -0
  12. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts/plan.py +102 -21
  13. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/PKG-INFO +3 -3
  14. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/SOURCES.txt +4 -0
  15. behavior_contracts-0.2.0/tests/test_codegen_generated.py +126 -0
  16. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/tests/test_conformance.py +2 -2
  17. behavior_contracts-0.2.0/tests/test_plan_parallel.py +215 -0
  18. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/tests/test_public_api.py +145 -1
  19. behavior_contracts-0.1.2/src/behavior_contracts/guard.py +0 -54
  20. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/setup.cfg +0 -0
  21. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts/envelope.py +0 -0
  22. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts/template.py +0 -0
  23. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/dependency_links.txt +0 -0
  24. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/entry_points.txt +0 -0
  25. {behavior_contracts-0.1.2 → behavior_contracts-0.2.0}/src/behavior_contracts.egg-info/requires.txt +0 -0
  26. {behavior_contracts-0.1.2 → 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.2
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 (93 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 # → "93 passed, 0 failed / 93 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 (93 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 # → "93 passed, 0 failed / 93 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.2"
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"
@@ -18,10 +18,13 @@ from __future__ import annotations
18
18
 
19
19
  # ── 対応する仕様バージョン(IR/vector の spec version。ライブラリ semver とは別管理)──
20
20
  SPEC_VERSIONS = {
21
- "expression": 1,
21
+ "expression": 2, # v2: obj の __proto__ own key を FORBIDDEN_KEY に(fail-closed)
22
22
  "template": 1,
23
23
  "plan": 1,
24
- "canonical": 1,
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>"``)。
@@ -29,6 +32,7 @@ ENVELOPE_SPEC_VERSION = "1.1"
29
32
 
30
33
  # ── expression(evaluate_expression) ──────────────────────────────────────────
31
34
  from .expr_eval import ( # noqa: E402
35
+ FORBIDDEN_OBJECT_KEY,
32
36
  ExprFailure,
33
37
  cmp_code_points,
34
38
  encode_value,
@@ -65,8 +69,16 @@ from .envelope import ( # noqa: E402
65
69
 
66
70
  # ── guard(Portability Guard) ────────────────────────────────────────────────
67
71
  from .guard import ( # noqa: E402
72
+ PORTABLE_EXPR_OPERATORS,
68
73
  PortabilityError,
69
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,
70
82
  )
71
83
 
72
84
  # ── conformance runner adapter 共通部 ─────────────────────────────────────────
@@ -75,6 +87,12 @@ from .codec import ( # noqa: E402
75
87
  deep_equals,
76
88
  )
77
89
 
90
+ # ── generator 支援(共通 Generator の生成モジュールが使う fingerprint, bc#13)──
91
+ from .fingerprint import ( # noqa: E402
92
+ FingerprintFailure,
93
+ fingerprint_component_graph,
94
+ )
95
+
78
96
  __all__ = [
79
97
  "SPEC_VERSIONS",
80
98
  "ENVELOPE_SPEC_VERSION",
@@ -83,6 +101,7 @@ __all__ = [
83
101
  "encode_value",
84
102
  "cmp_code_points",
85
103
  "ExprFailure",
104
+ "FORBIDDEN_OBJECT_KEY",
86
105
  # template
87
106
  "render_template",
88
107
  "resolve_partial",
@@ -101,8 +120,16 @@ __all__ = [
101
120
  "EnvelopeFailure",
102
121
  # guard
103
122
  "assert_portable",
123
+ "assert_portable_component_graph",
124
+ "PORTABLE_EXPR_OPERATORS",
104
125
  "PortabilityError",
126
+ # behavior
127
+ "run_behavior",
128
+ "BehaviorFailure",
105
129
  # codec
106
130
  "decode_value",
107
131
  "deep_equals",
132
+ # generator 支援(fingerprint)
133
+ "fingerprint_component_graph",
134
+ "FingerprintFailure",
108
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())
@@ -22,9 +22,11 @@ import json
22
22
  import math
23
23
  from typing import Any
24
24
 
25
+ from .expr_eval import FORBIDDEN_OBJECT_KEY
26
+
25
27
  Value = Any
26
28
 
27
- _CANONICAL_FAILURE_CODES = frozenset({"NAN_OR_INF", "INVALID_VALUE"})
29
+ _CANONICAL_FAILURE_CODES = frozenset({"NAN_OR_INF", "INVALID_VALUE", "FORBIDDEN_KEY"})
28
30
 
29
31
 
30
32
  class CanonicalFailure(Exception):
@@ -47,6 +49,19 @@ def _is_plain_object(v: Value) -> bool:
47
49
  return isinstance(v, dict)
48
50
 
49
51
 
52
+ def _guard_keys(keys) -> None:
53
+ """obj 値の own key に "__proto__" があれば fail-closed(FORBIDDEN_KEY)。
54
+
55
+ canonical_value/canonical_json/_encode_nested の全経路で適用し、expression の obj ノードと
56
+ 同一の禁止キー規律を持つ(expression-ir.md §2.3/§8)。
57
+ """
58
+ if FORBIDDEN_OBJECT_KEY in keys:
59
+ _fail(
60
+ "FORBIDDEN_KEY",
61
+ f'object key "{FORBIDDEN_OBJECT_KEY}" is forbidden (fail-closed)',
62
+ )
63
+
64
+
50
65
  # ── §4.1 float の正準10進(CPython repr(float))────────────────────────────────
51
66
  def py_float_repr(n: float) -> str:
52
67
  """CPython の ``repr(float)`` / ``json.dumps(float)`` と byte 一致する 10 進表現。
@@ -157,6 +172,7 @@ def canonical_value(v: Value) -> str:
157
172
  (canonical-serialization.md §2)。
158
173
  """
159
174
  if _is_plain_object(v):
175
+ _guard_keys(v.keys())
160
176
  keys = sorted(v.keys(), key=_code_point_sort_key)
161
177
  parts = [f"{_json_string(k)}:{_encode_nested(v[k])}" for k in keys]
162
178
  return "{" + ",".join(parts) + "}"
@@ -167,6 +183,7 @@ def canonical_value(v: Value) -> str:
167
183
  def canonical_json(v: Value) -> str:
168
184
  """fingerprint 用。全 object 階層のキーを昇順ソート(配列は順序保持)。"""
169
185
  if _is_plain_object(v):
186
+ _guard_keys(v.keys())
170
187
  keys = sorted(v.keys(), key=_code_point_sort_key)
171
188
  parts = [f"{_json_string(k)}:{canonical_json(v[k])}" for k in keys]
172
189
  return "{" + ",".join(parts) + "}"
@@ -178,6 +195,7 @@ def canonical_json(v: Value) -> str:
178
195
  # ── 内部: ネスト値の直列化(canonical_value 用。ネスト object は挿入順)──────────────
179
196
  def _encode_nested(v: Value) -> str:
180
197
  if _is_plain_object(v):
198
+ _guard_keys(v.keys())
181
199
  parts = [f"{_json_string(k)}:{_encode_nested(v[k])}" for k in v.keys()]
182
200
  return "{" + ",".join(parts) + "}"
183
201
  if isinstance(v, list):
@@ -16,6 +16,8 @@ from __future__ import annotations
16
16
  import math
17
17
  from typing import Any
18
18
 
19
+ from .expr_eval import FORBIDDEN_OBJECT_KEY, ExprFailure
20
+
19
21
  Value = Any
20
22
 
21
23
 
@@ -53,7 +55,16 @@ def decode_value(x: Any) -> Value:
53
55
  return float("nan")
54
56
  if k == "inf":
55
57
  return float("-inf") if x["inf"] < 0 else float("inf")
56
- return {k: decode_value(v) for k, v in x.items()}
58
+ out = {}
59
+ for k, v in x.items():
60
+ # fail-closed: own key "__proto__" は言語間で発散する(expression-ir.md §2.3/§8)。
61
+ if k == FORBIDDEN_OBJECT_KEY:
62
+ raise ExprFailure(
63
+ "FORBIDDEN_KEY",
64
+ f'object key "{FORBIDDEN_OBJECT_KEY}" is forbidden (fail-closed)',
65
+ )
66
+ out[k] = decode_value(v)
67
+ return out
57
68
  raise ValueError(f"cannot decode value: {x!s}")
58
69
 
59
70