pycforge 0.15.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. pycforge/__init__.py +8 -0
  2. pycforge/__main__.py +2 -0
  3. pycforge/_version.py +1 -0
  4. pycforge/converter/__init__.py +0 -0
  5. pycforge/converter/analysis/__init__.py +5 -0
  6. pycforge/converter/analysis/functions.py +723 -0
  7. pycforge/converter/analysis/model.py +158 -0
  8. pycforge/converter/analysis/planning.py +464 -0
  9. pycforge/converter/analysis/stage.py +1976 -0
  10. pycforge/converter/analysis/symbols.py +237 -0
  11. pycforge/converter/analysis/validation.py +2166 -0
  12. pycforge/converter/c_output/__init__.py +2 -0
  13. pycforge/converter/c_output/conformance.py +297 -0
  14. pycforge/converter/c_output/renderer.py +220 -0
  15. pycforge/converter/conditional_regions/__init__.py +71 -0
  16. pycforge/converter/conditional_regions/analysis.py +481 -0
  17. pycforge/converter/conditional_regions/lowering.py +579 -0
  18. pycforge/converter/conditional_regions/model.py +243 -0
  19. pycforge/converter/conditional_regions/validation.py +690 -0
  20. pycforge/converter/containers/__init__.py +19 -0
  21. pycforge/converter/containers/analysis.py +542 -0
  22. pycforge/converter/containers/lowering.py +290 -0
  23. pycforge/converter/containers/model.py +146 -0
  24. pycforge/converter/contracts/__init__.py +0 -0
  25. pycforge/converter/contracts/configuration.py +178 -0
  26. pycforge/converter/contracts/identifiers.py +68 -0
  27. pycforge/converter/contracts/versions.py +71 -0
  28. pycforge/converter/core/__init__.py +0 -0
  29. pycforge/converter/core/artifact_io.py +87 -0
  30. pycforge/converter/core/cancellation.py +7 -0
  31. pycforge/converter/core/canonicalization.py +254 -0
  32. pycforge/converter/core/context.py +13 -0
  33. pycforge/converter/core/diagnostics.py +95 -0
  34. pycforge/converter/core/enums.py +23 -0
  35. pycforge/converter/core/fingerprint.py +22 -0
  36. pycforge/converter/core/pipeline.py +8 -0
  37. pycforge/converter/core/progress.py +25 -0
  38. pycforge/converter/core/request.py +50 -0
  39. pycforge/converter/core/resource_policy.py +31 -0
  40. pycforge/converter/core/result.py +23 -0
  41. pycforge/converter/core/serialization.py +36 -0
  42. pycforge/converter/core/stage.py +10 -0
  43. pycforge/converter/core/stage_artifact.py +80 -0
  44. pycforge/converter/core/stage_outcome.py +15 -0
  45. pycforge/converter/decision_trace/__init__.py +0 -0
  46. pycforge/converter/decision_trace/recorder.py +52 -0
  47. pycforge/converter/facade.py +459 -0
  48. pycforge/converter/frontend/__init__.py +4 -0
  49. pycforge/converter/frontend/normalizer.py +59 -0
  50. pycforge/converter/frontend/parser.py +20 -0
  51. pycforge/converter/frontend/source_document.py +93 -0
  52. pycforge/converter/frontend/stages.py +518 -0
  53. pycforge/converter/frontend/validation.py +76 -0
  54. pycforge/converter/io/__init__.py +0 -0
  55. pycforge/converter/io/atomic_writer.py +33 -0
  56. pycforge/converter/ir/__init__.py +1 -0
  57. pycforge/converter/ir/c_ir/__init__.py +4 -0
  58. pycforge/converter/ir/c_ir/builder.py +26 -0
  59. pycforge/converter/ir/c_ir/model.py +318 -0
  60. pycforge/converter/ir/c_ir/serialization.py +88 -0
  61. pycforge/converter/ir/c_ir/validation.py +834 -0
  62. pycforge/converter/ir/python_ir/__init__.py +17 -0
  63. pycforge/converter/ir/python_ir/nodes.py +86 -0
  64. pycforge/converter/keyword_calls/__init__.py +68 -0
  65. pycforge/converter/keyword_calls/analysis.py +458 -0
  66. pycforge/converter/keyword_calls/lowering.py +271 -0
  67. pycforge/converter/keyword_calls/model.py +304 -0
  68. pycforge/converter/keyword_calls/validation.py +802 -0
  69. pycforge/converter/keyword_only_calls/__init__.py +68 -0
  70. pycforge/converter/keyword_only_calls/analysis.py +640 -0
  71. pycforge/converter/keyword_only_calls/lowering.py +376 -0
  72. pycforge/converter/keyword_only_calls/model.py +328 -0
  73. pycforge/converter/keyword_only_calls/validation.py +1357 -0
  74. pycforge/converter/lowering.py +1000 -0
  75. pycforge/converter/modules/__init__.py +13 -0
  76. pycforge/converter/modules/analysis.py +1250 -0
  77. pycforge/converter/modules/lowering.py +200 -0
  78. pycforge/converter/modules/model.py +73 -0
  79. pycforge/converter/modules/stage.py +192 -0
  80. pycforge/converter/numeric_semantics/__init__.py +23 -0
  81. pycforge/converter/numeric_semantics/analysis.py +420 -0
  82. pycforge/converter/numeric_semantics/lowering.py +153 -0
  83. pycforge/converter/numeric_semantics/model.py +123 -0
  84. pycforge/converter/records/__init__.py +34 -0
  85. pycforge/converter/records/analysis.py +1394 -0
  86. pycforge/converter/records/lowering.py +393 -0
  87. pycforge/converter/records/model.py +319 -0
  88. pycforge/converter/rules/__init__.py +3 -0
  89. pycforge/converter/support_templates/__init__.py +49 -0
  90. pycforge/converter/support_templates/assembly.py +106 -0
  91. pycforge/converter/support_templates/factories.py +348 -0
  92. pycforge/converter/support_templates/model.py +159 -0
  93. pycforge/converter/support_templates/registry.py +388 -0
  94. pycforge/converter/telemetry/__init__.py +0 -0
  95. pycforge/converter/telemetry/sink.py +39 -0
  96. pycforge/ide/__init__.py +19 -0
  97. pycforge/ide/__main__.py +5 -0
  98. pycforge/ide/_worker_event_codec.py +276 -0
  99. pycforge/ide/_worker_protocol_json.py +197 -0
  100. pycforge/ide/_worker_protocol_types.py +90 -0
  101. pycforge/ide/_worker_request_codec.py +200 -0
  102. pycforge/ide/_worker_request_data.py +260 -0
  103. pycforge/ide/_worker_result_data.py +343 -0
  104. pycforge/ide/action_contract.py +469 -0
  105. pycforge/ide/action_validation.py +229 -0
  106. pycforge/ide/command_palette.py +223 -0
  107. pycforge/ide/controller.py +904 -0
  108. pycforge/ide/controller_conversion.py +546 -0
  109. pycforge/ide/controller_io.py +205 -0
  110. pycforge/ide/editor.py +951 -0
  111. pycforge/ide/editor_commands_qt.py +274 -0
  112. pycforge/ide/editor_lexical.py +43 -0
  113. pycforge/ide/editor_sidebars.py +236 -0
  114. pycforge/ide/editor_syntax.py +243 -0
  115. pycforge/ide/find_replace.py +535 -0
  116. pycforge/ide/icons.py +91 -0
  117. pycforge/ide/io_service.py +196 -0
  118. pycforge/ide/model.py +164 -0
  119. pycforge/ide/panels.py +903 -0
  120. pycforge/ide/positions.py +220 -0
  121. pycforge/ide/process_worker.py +130 -0
  122. pycforge/ide/qt.py +230 -0
  123. pycforge/ide/qt_actions.py +418 -0
  124. pycforge/ide/qt_close.py +169 -0
  125. pycforge/ide/qt_command_palette.py +297 -0
  126. pycforge/ide/qt_contract.py +220 -0
  127. pycforge/ide/qt_documents.py +590 -0
  128. pycforge/ide/qt_editor_buffers.py +113 -0
  129. pycforge/ide/qt_editor_surfaces.py +112 -0
  130. pycforge/ide/qt_menus.py +324 -0
  131. pycforge/ide/qt_projection.py +500 -0
  132. pycforge/ide/qt_shell.py +348 -0
  133. pycforge/ide/qt_shell_interactions.py +320 -0
  134. pycforge/ide/qt_state.py +393 -0
  135. pycforge/ide/qt_workspace_features.py +398 -0
  136. pycforge/ide/qt_workspace_navigation.py +208 -0
  137. pycforge/ide/qt_workspace_observers.py +173 -0
  138. pycforge/ide/qt_workspace_panels.py +545 -0
  139. pycforge/ide/qt_workspace_widgets.py +228 -0
  140. pycforge/ide/resources/icons/about.svg +1 -0
  141. pycforge/ide/resources/icons/add-document.svg +1 -0
  142. pycforge/ide/resources/icons/brand-mark.svg +1 -0
  143. pycforge/ide/resources/icons/bundle-search.svg +1 -0
  144. pycforge/ide/resources/icons/cancel.svg +1 -0
  145. pycforge/ide/resources/icons/check.svg +1 -0
  146. pycforge/ide/resources/icons/chevron-right.svg +1 -0
  147. pycforge/ide/resources/icons/close.svg +1 -0
  148. pycforge/ide/resources/icons/collapse-all.svg +1 -0
  149. pycforge/ide/resources/icons/command-palette.svg +1 -0
  150. pycforge/ide/resources/icons/convert.svg +1 -0
  151. pycforge/ide/resources/icons/copy.svg +1 -0
  152. pycforge/ide/resources/icons/cut.svg +1 -0
  153. pycforge/ide/resources/icons/decision-trace.svg +1 -0
  154. pycforge/ide/resources/icons/details.svg +1 -0
  155. pycforge/ide/resources/icons/diagnostics.svg +1 -0
  156. pycforge/ide/resources/icons/duplicate-line.svg +1 -0
  157. pycforge/ide/resources/icons/exit.svg +1 -0
  158. pycforge/ide/resources/icons/expand-all.svg +1 -0
  159. pycforge/ide/resources/icons/export.svg +1 -0
  160. pycforge/ide/resources/icons/find.svg +1 -0
  161. pycforge/ide/resources/icons/go-to-line.svg +1 -0
  162. pycforge/ide/resources/icons/go-to-output.svg +1 -0
  163. pycforge/ide/resources/icons/go-to-source.svg +1 -0
  164. pycforge/ide/resources/icons/history.svg +1 -0
  165. pycforge/ide/resources/icons/indent.svg +1 -0
  166. pycforge/ide/resources/icons/link-c.svg +1 -0
  167. pycforge/ide/resources/icons/mappings.svg +1 -0
  168. pycforge/ide/resources/icons/module.svg +1 -0
  169. pycforge/ide/resources/icons/move-down.svg +1 -0
  170. pycforge/ide/resources/icons/move-line-down.svg +1 -0
  171. pycforge/ide/resources/icons/move-line-up.svg +1 -0
  172. pycforge/ide/resources/icons/move-up.svg +1 -0
  173. pycforge/ide/resources/icons/next.svg +1 -0
  174. pycforge/ide/resources/icons/open.svg +1 -0
  175. pycforge/ide/resources/icons/outdent.svg +1 -0
  176. pycforge/ide/resources/icons/outline.svg +1 -0
  177. pycforge/ide/resources/icons/paste.svg +1 -0
  178. pycforge/ide/resources/icons/previous.svg +1 -0
  179. pycforge/ide/resources/icons/primary-module.svg +1 -0
  180. pycforge/ide/resources/icons/redo.svg +1 -0
  181. pycforge/ide/resources/icons/remove-document.svg +1 -0
  182. pycforge/ide/resources/icons/replace.svg +1 -0
  183. pycforge/ide/resources/icons/save-as.svg +1 -0
  184. pycforge/ide/resources/icons/save.svg +1 -0
  185. pycforge/ide/resources/icons/select-all.svg +1 -0
  186. pycforge/ide/resources/icons/settings.svg +1 -0
  187. pycforge/ide/resources/icons/show-c.svg +1 -0
  188. pycforge/ide/resources/icons/split-view.svg +1 -0
  189. pycforge/ide/resources/icons/summary.svg +1 -0
  190. pycforge/ide/resources/icons/telemetry.svg +1 -0
  191. pycforge/ide/resources/icons/toggle-comment.svg +1 -0
  192. pycforge/ide/resources/icons/toggle-fold.svg +1 -0
  193. pycforge/ide/resources/icons/undo.svg +1 -0
  194. pycforge/ide/resources/icons/whitespace.svg +1 -0
  195. pycforge/ide/revisions.py +400 -0
  196. pycforge/ide/search_service.py +280 -0
  197. pycforge/ide/session_history.py +150 -0
  198. pycforge/ide/source_editing.py +417 -0
  199. pycforge/ide/source_structure.py +558 -0
  200. pycforge/ide/source_structure_async.py +191 -0
  201. pycforge/ide/supervisor.py +503 -0
  202. pycforge/ide/theme.py +119 -0
  203. pycforge/ide/theme_stylesheet.py +598 -0
  204. pycforge/ide/theme_workspace_stylesheet.py +155 -0
  205. pycforge/ide/visual_tokens.py +142 -0
  206. pycforge/ide/worker_protocol.py +80 -0
  207. pycforge/ide/workspace_search.py +462 -0
  208. pycforge/ide/workspace_session.py +256 -0
  209. pycforge/laboratory/__init__.py +6 -0
  210. pycforge/laboratory/audits.py +2244 -0
  211. pycforge/laboratory/checkpoint_e.py +2685 -0
  212. pycforge/laboratory/cli.py +119 -0
  213. pycforge/laboratory/exit_codes.py +11 -0
  214. pycforge/laboratory/keyword_audit.py +661 -0
  215. pycforge/laboratory/keyword_only_audit.py +481 -0
  216. pycforge-0.15.2.dist-info/METADATA +209 -0
  217. pycforge-0.15.2.dist-info/RECORD +221 -0
  218. pycforge-0.15.2.dist-info/WHEEL +5 -0
  219. pycforge-0.15.2.dist-info/entry_points.txt +5 -0
  220. pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
  221. pycforge-0.15.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,802 @@
1
+ """Independent reconstruction of exact direct keyword-call binding facts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ from pycforge.converter.ir.python_ir import python_ir_reference_ids
8
+
9
+ from .model import (
10
+ CUMULATIVE_KEYWORD_TARGET_DIAGNOSTIC_CODE,
11
+ CUMULATIVE_KEYWORD_TARGET_REASON,
12
+ KEYWORD_CALL_FACT_SCHEMA,
13
+ KEYWORD_CALL_KEY_DOMAIN,
14
+ KEYWORD_CALL_LOWERING_SHAPE,
15
+ KEYWORD_CALL_OBLIGATIONS,
16
+ KEYWORD_CALL_PROVENANCE_EVIDENCE,
17
+ KEYWORD_CALL_RULE_ID,
18
+ KEYWORD_CALL_RULE_VERSION,
19
+ KEYWORD_CALL_TABLE_DEPENDENCIES,
20
+ KEYWORD_CALL_TABLE_ID,
21
+ KeywordCallValidationCanceled,
22
+ keyword_call_binding_id,
23
+ )
24
+
25
+
26
+ class _MalformedKeywordEvidence(ValueError):
27
+ """Internal sentinel for hostile or structurally invalid evidence."""
28
+
29
+
30
+ def _sequence(value: Any) -> bool:
31
+ return isinstance(value, (list, tuple))
32
+
33
+
34
+ def _check(cancellation: Any) -> None:
35
+ if cancellation is not None and bool(getattr(cancellation, "is_canceled", False)):
36
+ raise KeywordCallValidationCanceled
37
+
38
+
39
+ def _category(value: Any) -> str:
40
+ return value.value if hasattr(value, "value") else str(value or "unknown")
41
+
42
+
43
+ def _validate_keyword_call_binding_facts(
44
+ payload: Mapping[str, Any],
45
+ tables: list[dict[str, Any]] | tuple[dict[str, Any], ...] | None = None,
46
+ *,
47
+ expected_fact_schema: str = KEYWORD_CALL_FACT_SCHEMA,
48
+ require_table: bool = True,
49
+ require_plans: bool = True,
50
+ ignored_call_node_ids: frozenset[str] = frozenset(),
51
+ cumulatively_ineligible_target_function_node_ids: frozenset[str] = frozenset(),
52
+ cancellation: Any = None,
53
+ ) -> tuple[bool, str]:
54
+ """Require exact table coverage, independently rebuilt facts, and plans."""
55
+
56
+ _check(cancellation)
57
+ raw_tables = tables if tables is not None else payload.get("fact_tables")
58
+ if not _sequence(raw_tables) or any(not isinstance(item, dict) for item in raw_tables):
59
+ return False, "keyword-call fact tables are malformed"
60
+ table_values = tuple(raw_tables)
61
+ table = next((item for item in table_values if item.get("table_id") == KEYWORD_CALL_TABLE_ID), None)
62
+ if table is None:
63
+ return (False, "keyword-call binding fact table is absent") if require_table else (True, "")
64
+ dependencies = table.get("invalidation_dependencies")
65
+ if (
66
+ table.get("schema_version") != expected_fact_schema
67
+ or table.get("producer_stage") != "analysis.plan"
68
+ or table.get("key_domain") != KEYWORD_CALL_KEY_DOMAIN
69
+ or table.get("completeness") != "complete"
70
+ or not _sequence(dependencies)
71
+ or tuple(dependencies) != KEYWORD_CALL_TABLE_DEPENDENCIES
72
+ ):
73
+ return False, "keyword-call binding fact schema is invalid"
74
+
75
+ module = payload.get("python_ir")
76
+ node_values = module.get("nodes") if isinstance(module, dict) else None
77
+ if not isinstance(node_values, list):
78
+ return False, "keyword-call validation lacks normalized Python IR"
79
+ nodes: dict[str, dict[str, Any]] = {}
80
+ node_order: dict[str, int] = {}
81
+ for ordinal, node in enumerate(node_values):
82
+ _check(cancellation)
83
+ node_id = node.get("node_id") if isinstance(node, dict) else None
84
+ if not isinstance(node_id, str) or node_id in nodes:
85
+ return False, "keyword-call Python IR identities are invalid"
86
+ nodes[node_id] = node
87
+ node_order[node_id] = ordinal
88
+
89
+ table_map: dict[str, dict[str, Any]] = {}
90
+ for item in table_values:
91
+ table_id = item.get("table_id")
92
+ if not isinstance(table_id, str) or table_id in table_map:
93
+ return False, "keyword-call fact-table identities are malformed"
94
+ table_map[table_id] = item
95
+ required = {
96
+ "binding-facts",
97
+ "function-signature-facts",
98
+ "value-category-facts",
99
+ "call-target-facts",
100
+ }
101
+ if not required.issubset(table_map):
102
+ return False, "keyword-call proof dependencies are absent"
103
+
104
+ def record_values(table_id: str) -> tuple[dict[str, Any], ...]:
105
+ result: list[dict[str, Any]] = []
106
+ records = table_map[table_id].get("records")
107
+ if not isinstance(records, list):
108
+ raise _MalformedKeywordEvidence(
109
+ f"keyword-call dependency {table_id!r} records are malformed"
110
+ )
111
+ for record in records:
112
+ _check(cancellation)
113
+ value = record.get("value") if isinstance(record, dict) else None
114
+ if not isinstance(value, dict):
115
+ raise _MalformedKeywordEvidence(
116
+ f"keyword-call dependency {table_id!r} contains a malformed record"
117
+ )
118
+ result.append(value)
119
+ return tuple(result)
120
+
121
+ occurrence_bindings: dict[str, str] = {}
122
+ for binding in record_values("binding-facts"):
123
+ _check(cancellation)
124
+ binding_id = binding.get("binding_id")
125
+ occurrences = binding.get("occurrence_node_ids")
126
+ if (
127
+ not isinstance(binding_id, str)
128
+ or not _sequence(occurrences)
129
+ or any(not isinstance(item, str) for item in occurrences)
130
+ ):
131
+ raise _MalformedKeywordEvidence("keyword-call binding evidence is malformed")
132
+ for occurrence in occurrences:
133
+ existing = occurrence_bindings.get(occurrence)
134
+ if existing is not None and existing != binding_id:
135
+ raise _MalformedKeywordEvidence(
136
+ "keyword-call occurrence has conflicting binding evidence"
137
+ )
138
+ occurrence_bindings[occurrence] = binding_id
139
+ signatures: dict[str, dict[str, Any]] = {}
140
+ for item in record_values("function-signature-facts"):
141
+ binding_id = item.get("binding_id")
142
+ parameters = item.get("parameters")
143
+ if (
144
+ not isinstance(binding_id, str)
145
+ or not _sequence(parameters)
146
+ or binding_id in signatures
147
+ ):
148
+ raise _MalformedKeywordEvidence("keyword-call signature evidence is malformed")
149
+ signatures[binding_id] = item
150
+ categories: dict[Any, str] = {}
151
+ category_records = table_map["value-category-facts"].get("records")
152
+ if not isinstance(category_records, list):
153
+ raise _MalformedKeywordEvidence("keyword-call category evidence is malformed")
154
+ for record in category_records:
155
+ _check(cancellation)
156
+ if not isinstance(record, dict) or not isinstance(record.get("key"), str):
157
+ raise _MalformedKeywordEvidence("keyword-call category record is malformed")
158
+ categories[record["key"]] = _category(record.get("value"))
159
+ call_targets: dict[str, dict[str, Any]] = {}
160
+ for item in record_values("call-target-facts"):
161
+ call_id = item.get("call_node_id")
162
+ if not isinstance(call_id, str) or call_id in call_targets:
163
+ raise _MalformedKeywordEvidence("keyword-call target evidence is malformed")
164
+ call_targets[call_id] = item
165
+
166
+ reconstruction = _IndependentKeywordReconstruction(
167
+ node_values,
168
+ nodes=nodes,
169
+ node_order=node_order,
170
+ occurrence_bindings=occurrence_bindings,
171
+ signatures=signatures,
172
+ categories=categories,
173
+ ignored_call_node_ids=ignored_call_node_ids,
174
+ cancellation=cancellation,
175
+ )
176
+ expected = reconstruction.expected()
177
+ for call_id, fact in tuple(expected.items()):
178
+ _check(cancellation)
179
+ if (
180
+ fact.get("supported") is True
181
+ and fact.get("target_function_node_id")
182
+ in cumulatively_ineligible_target_function_node_ids
183
+ ):
184
+ expected[call_id] = {
185
+ **fact,
186
+ "runtime_binding_failure": "compile-time-rejected",
187
+ "supported": False,
188
+ "diagnostic_code": CUMULATIVE_KEYWORD_TARGET_DIAGNOSTIC_CODE,
189
+ "reason": CUMULATIVE_KEYWORD_TARGET_REASON,
190
+ "rejection_node_id": fact["target_function_node_id"],
191
+ }
192
+
193
+ records = table.get("records")
194
+ if not isinstance(records, list):
195
+ return False, "keyword-call binding records are malformed"
196
+ keys = [record.get("key") if isinstance(record, dict) else None for record in records]
197
+ if (
198
+ any(not isinstance(key, str) for key in keys)
199
+ or any(keys[index - 1] >= keys[index] for index in range(1, len(keys)))
200
+ ):
201
+ return False, "keyword-call binding keys are not unique and sorted"
202
+ found: dict[str, dict[str, Any]] = {}
203
+ provenance: dict[str, Any] = {}
204
+ for record in records:
205
+ _check(cancellation)
206
+ key, value = record.get("key"), record.get("value")
207
+ if not isinstance(value, dict) or value.get("call_node_id") != key:
208
+ return False, "keyword-call binding record identity is malformed"
209
+ found[key] = value
210
+ provenance[key] = record.get("provenance")
211
+ if set(found) != set(expected):
212
+ return False, "keyword-call facts do not exactly cover eligible direct keyword calls"
213
+ for call_id, fact in expected.items():
214
+ _check(cancellation)
215
+ if found[call_id] != fact:
216
+ return False, "keyword-call fact disagrees with independent reconstruction"
217
+ proof = provenance.get(call_id)
218
+ if (
219
+ not isinstance(proof, dict)
220
+ or proof.get("source_node_ids") != _provenance_ids(fact)
221
+ or proof.get("evidence") != list(KEYWORD_CALL_PROVENANCE_EVIDENCE)
222
+ ):
223
+ return False, "keyword-call fact provenance is incomplete"
224
+ target = call_targets.get(call_id)
225
+ if not isinstance(target, dict) or any(
226
+ target.get(name) != value
227
+ for name, value in {
228
+ "callee_node_id": fact["callee_node_id"],
229
+ "target_function_node_id": fact["target_function_node_id"],
230
+ "target_binding_id": fact["target_binding_id"],
231
+ "argument_node_ids": fact["source_argument_node_ids"],
232
+ "argument_categories": fact["source_argument_categories"],
233
+ "evaluation_order": fact["evaluation_order"],
234
+ "arguments_evaluated_once": True,
235
+ }.items()
236
+ ):
237
+ return False, "keyword-call fact disagrees with call-target evidence"
238
+ recursive_override = bool(
239
+ fact["supported"]
240
+ and target.get("resolution") == "recursive-target"
241
+ and target.get("supported") is False
242
+ and target.get("diagnostic_code") == "PYC2920"
243
+ and target.get("reason")
244
+ == "Direct and mutual recursion are unsupported in Phase 9"
245
+ )
246
+ if not recursive_override and any(
247
+ target.get(name) != value
248
+ for name, value in {
249
+ "resolution": "understood-source-function" if fact["supported"] else "ineligible-source-function",
250
+ "supported": fact["supported"],
251
+ "diagnostic_code": fact["diagnostic_code"],
252
+ "reason": fact["reason"],
253
+ }.items()
254
+ ):
255
+ return False, "keyword-call fact disagrees with call-target support state"
256
+
257
+ if require_plans:
258
+ valid, reason = _validate_plans(payload, expected, cancellation)
259
+ if not valid:
260
+ return valid, reason
261
+ return True, ""
262
+
263
+
264
+ def _provenance_ids(fact: dict[str, Any]) -> list[str]:
265
+ values = (
266
+ fact["call_node_id"], fact["callee_node_id"], fact["target_function_node_id"],
267
+ *fact["parameter_node_ids"], *fact["positional_argument_node_ids"],
268
+ *fact["keyword_node_ids"], *fact["keyword_value_node_ids"],
269
+ )
270
+ return list(dict.fromkeys(values))
271
+
272
+
273
+ class _IndependentKeywordReconstruction:
274
+ """A producer-independent implementation of the closed binding algorithm."""
275
+
276
+ def __init__(
277
+ self,
278
+ node_values: list[dict[str, Any]],
279
+ *,
280
+ nodes: Mapping[str, dict[str, Any]],
281
+ node_order: Mapping[str, int],
282
+ occurrence_bindings: Mapping[str, str],
283
+ signatures: Mapping[str, dict[str, Any]],
284
+ categories: Mapping[str, str],
285
+ ignored_call_node_ids: frozenset[str],
286
+ cancellation: Any,
287
+ ) -> None:
288
+ self.node_values = node_values
289
+ self.nodes = nodes
290
+ self.node_order = node_order
291
+ self.occurrence_bindings = occurrence_bindings
292
+ self.signatures = signatures
293
+ self.categories = categories
294
+ self.ignored = ignored_call_node_ids
295
+ self.cancellation = cancellation
296
+
297
+ def expected(self) -> dict[str, dict[str, Any]]:
298
+ result: dict[str, dict[str, Any]] = {}
299
+ for call in self.node_values:
300
+ _check(self.cancellation)
301
+ if call.get("kind") != "Call" or call.get("node_id") in self.ignored:
302
+ continue
303
+ fields = call.get("fields")
304
+ if not isinstance(fields, dict) or not _sequence(fields.get("keywords")):
305
+ raise _MalformedKeywordEvidence("keyword-call Python IR fields are malformed")
306
+ if not fields["keywords"]:
307
+ continue
308
+ callee_id = fields.get("func")
309
+ callee = self.nodes.get(callee_id)
310
+ binding_id = self.occurrence_bindings.get(callee_id)
311
+ signature = self.signatures.get(binding_id)
312
+ if (
313
+ not callee or callee.get("kind") != "Name"
314
+ or not isinstance(signature, dict)
315
+ or signature.get("eligible") is not True
316
+ ):
317
+ continue
318
+ target = self.nodes.get(signature.get("function_node_id"))
319
+ target_fields = target.get("fields") if isinstance(target, dict) else None
320
+ arguments = (
321
+ self.nodes.get(target_fields.get("args"))
322
+ if isinstance(target_fields, dict)
323
+ else None
324
+ )
325
+ argument_fields = (
326
+ arguments.get("fields") if isinstance(arguments, dict) else None
327
+ )
328
+ if (
329
+ isinstance(argument_fields, dict)
330
+ and argument_fields.get("kwonlyargs")
331
+ ):
332
+ continue
333
+ result[call["node_id"]] = self._fact(call, signature, str(binding_id))
334
+ return result
335
+
336
+ def _fact(self, call: dict[str, Any], signature: dict[str, Any], target_binding_id: str) -> dict[str, Any]:
337
+ _check(self.cancellation)
338
+ fields, call_id = call.get("fields"), call.get("node_id")
339
+ if not isinstance(fields, dict) or not isinstance(call_id, str):
340
+ raise _MalformedKeywordEvidence("keyword-call Python IR call is malformed")
341
+ target_id = signature.get("function_node_id")
342
+ target = self.nodes.get(target_id)
343
+ target_fields = target.get("fields") if isinstance(target, dict) else None
344
+ if (
345
+ not isinstance(target_id, str)
346
+ or not isinstance(target, dict)
347
+ or target.get("kind") != "FunctionDef"
348
+ or not isinstance(target_fields, dict)
349
+ ):
350
+ raise _MalformedKeywordEvidence("keyword-call target evidence is malformed")
351
+ arguments = self.nodes.get(target_fields.get("args"))
352
+ argument_fields = arguments.get("fields") if isinstance(arguments, dict) else None
353
+ parameters_value = signature.get("parameters")
354
+ if (
355
+ not isinstance(arguments, dict)
356
+ or arguments.get("kind") != "arguments"
357
+ or not isinstance(argument_fields, dict)
358
+ or not _sequence(argument_fields.get("posonlyargs"))
359
+ or not _sequence(parameters_value)
360
+ ):
361
+ raise _MalformedKeywordEvidence("keyword-call signature evidence is malformed")
362
+ parameters = tuple(parameters_value)
363
+ if any(not isinstance(item, dict) for item in parameters):
364
+ raise _MalformedKeywordEvidence("keyword-call parameters are malformed")
365
+ parameter_node_ids = tuple(str(item.get("parameter_node_id", "")) for item in parameters)
366
+ parameter_names = tuple(str(item.get("source_name", "")) for item in parameters)
367
+ parameter_categories = tuple(_category(item.get("category")) for item in parameters)
368
+ if (
369
+ any(not item for item in parameter_node_ids)
370
+ or any(not item for item in parameter_names)
371
+ or len(parameter_names) != len(set(parameter_names))
372
+ or any(item not in self.nodes for item in parameter_node_ids)
373
+ ):
374
+ raise _MalformedKeywordEvidence("keyword-call parameter identities are malformed")
375
+ positional_only_count = len(argument_fields["posonlyargs"])
376
+ if positional_only_count > len(parameters):
377
+ raise _MalformedKeywordEvidence("keyword-call positional-only evidence is malformed")
378
+ if not _sequence(fields.get("args")) or not _sequence(fields.get("keywords")):
379
+ raise _MalformedKeywordEvidence("keyword-call argument sequences are malformed")
380
+ positional_ids = tuple(fields["args"])
381
+ keyword_ids = tuple(fields["keywords"])
382
+ if any(not isinstance(item, str) for item in (*positional_ids, *keyword_ids)):
383
+ raise _MalformedKeywordEvidence("keyword-call argument identities are malformed")
384
+ keyword_nodes: list[dict[str, Any]] = []
385
+ for keyword_id in keyword_ids:
386
+ keyword = self.nodes.get(keyword_id)
387
+ keyword_fields = keyword.get("fields") if isinstance(keyword, dict) else None
388
+ if (
389
+ not isinstance(keyword, dict)
390
+ or keyword.get("kind") != "keyword"
391
+ or not isinstance(keyword_fields, dict)
392
+ or not isinstance(keyword_fields.get("value"), str)
393
+ or keyword_fields["value"] not in self.nodes
394
+ ):
395
+ raise _MalformedKeywordEvidence("keyword-call keyword evidence is malformed")
396
+ keyword_nodes.append(keyword)
397
+
398
+ positional_entries: list[dict[str, Any]] = []
399
+ for position, argument_id in enumerate(positional_ids):
400
+ _check(self.cancellation)
401
+ if argument_id not in self.nodes:
402
+ raise _MalformedKeywordEvidence("keyword-call argument node is absent")
403
+ positional_entries.append({"kind": "positional", "position": position, "argument": argument_id, "keyword": None, "name": None, "order_node_id": argument_id})
404
+ keyword_entries: list[dict[str, Any]] = []
405
+ for position, keyword in enumerate(keyword_nodes):
406
+ _check(self.cancellation)
407
+ keyword_fields = keyword["fields"]
408
+ keyword_entries.append({"kind": "keyword", "position": position, "argument": keyword_fields["value"], "keyword": keyword["node_id"], "name": keyword_fields.get("arg"), "order_node_id": keyword["node_id"]})
409
+ entries = self._merge_source_entries(positional_entries, keyword_entries)
410
+ for ordinal, entry in enumerate(entries):
411
+ entry["ordinal"] = ordinal
412
+ by_position = {(item["kind"], item["position"]): item for item in entries}
413
+
414
+ parameter_by_name = {name: ordinal for ordinal, name in enumerate(parameter_names)}
415
+ parameter_arguments: list[str | None] = [None] * len(parameters)
416
+ parameter_sources: list[int | None] = [None] * len(parameters)
417
+ source_parameters: dict[tuple[str, int], int | None] = {}
418
+ unpacking: list[tuple[int, str]] = []
419
+ excess: list[tuple[int, str]] = []
420
+ problems: list[tuple[str, str]] = []
421
+ saw_starred = False
422
+ for position, argument_id in enumerate(positional_ids):
423
+ _check(self.cancellation)
424
+ entry = by_position[("positional", position)]
425
+ if self.nodes.get(argument_id, {}).get("kind") == "Starred":
426
+ saw_starred = True
427
+ source_parameters[("positional", position)] = None
428
+ unpacking.append((entry["ordinal"], argument_id))
429
+ elif saw_starred:
430
+ source_parameters[("positional", position)] = None
431
+ elif position >= len(parameters):
432
+ source_parameters[("positional", position)] = None
433
+ excess.append((entry["ordinal"], argument_id))
434
+ else:
435
+ source_parameters[("positional", position)] = position
436
+ parameter_arguments[position] = argument_id
437
+ parameter_sources[position] = entry["ordinal"]
438
+ for position, keyword in enumerate(keyword_nodes):
439
+ _check(self.cancellation)
440
+ entry = by_position[("keyword", position)]
441
+ keyword_id, name = keyword["node_id"], keyword.get("fields", {}).get("arg")
442
+ if name is None:
443
+ source_parameters[("keyword", position)] = None
444
+ unpacking.append((entry["ordinal"], keyword_id))
445
+ continue
446
+ parameter_ordinal = parameter_by_name.get(name)
447
+ if parameter_ordinal is None:
448
+ source_parameters[("keyword", position)] = None
449
+ problems.append((keyword_id, f"Unknown keyword argument: {name}"))
450
+ continue
451
+ source_parameters[("keyword", position)] = parameter_ordinal
452
+ if parameter_ordinal < positional_only_count:
453
+ problems.append((keyword_id, f"Positional-only parameter cannot be bound by keyword: {name}"))
454
+ elif parameter_arguments[parameter_ordinal] is not None:
455
+ prior = parameter_sources[parameter_ordinal]
456
+ prior_entry = entries[prior] if isinstance(prior, int) else None
457
+ reason = f"Keyword argument duplicates a positionally bound parameter: {name}" if prior_entry and prior_entry["kind"] == "positional" else f"Duplicate keyword argument: {name}"
458
+ problems.append((keyword_id, reason))
459
+ else:
460
+ parameter_arguments[parameter_ordinal] = keyword.get("fields", {}).get("value")
461
+ parameter_sources[parameter_ordinal] = entry["ordinal"]
462
+
463
+ argument_bindings: list[dict[str, Any]] = []
464
+ for entry in entries:
465
+ _check(self.cancellation)
466
+ parameter_ordinal = source_parameters.get((entry["kind"], entry["position"]))
467
+ valid = isinstance(parameter_ordinal, int) and 0 <= parameter_ordinal < len(parameters)
468
+ argument_id = entry["argument"]
469
+ argument_bindings.append({
470
+ "source_argument_node_id": argument_id,
471
+ "keyword_node_id": entry["keyword"], "keyword_name": entry["name"],
472
+ "source_ordinal": entry["ordinal"],
473
+ "parameter_node_id": parameter_node_ids[parameter_ordinal] if valid else None,
474
+ "parameter_name": parameter_names[parameter_ordinal] if valid else None,
475
+ "parameter_ordinal": parameter_ordinal,
476
+ "category": self.categories.get(argument_id, "unknown"),
477
+ "expected_category": parameter_categories[parameter_ordinal] if valid else None,
478
+ })
479
+ missing = [parameter_names[i] for i, value in enumerate(parameter_arguments) if value is None]
480
+ mismatches = [item for item in argument_bindings if item["parameter_ordinal"] is not None and item["expected_category"] is not None and item["category"] != item["expected_category"] and parameter_sources[item["parameter_ordinal"]] == item["source_ordinal"]]
481
+ coverage = bool(not unpacking and not excess and not problems and not missing and all(value is not None for value in parameter_arguments) and len(entries) == len(parameters))
482
+
483
+ code = reason = rejection = None
484
+ if unpacking:
485
+ _, rejection = min(unpacking, key=lambda item: item[0]); code, reason = "PYC2910", "Starred and unpacked call arguments are unsupported by the exact keyword profile"
486
+ elif excess:
487
+ _, rejection = min(excess, key=lambda item: item[0]); code, reason = "PYC2904", "Argument count exceeds the target signature"
488
+ elif problems:
489
+ rejection, reason = problems[0]; code = "PYC2912"
490
+ elif missing:
491
+ rejection, code, reason = call_id, "PYC2904", "Every target parameter must be supplied exactly once; missing: " + ", ".join(missing)
492
+ elif mismatches:
493
+ mismatch = mismatches[0]
494
+ rejection = mismatch["keyword_node_id"] or mismatch["source_argument_node_id"]
495
+ code, reason = "PYC2905", f"Argument representation does not match parameter {mismatch['parameter_name']}"
496
+ supported = code is None and coverage
497
+ if not supported and code is None:
498
+ rejection, code, reason = call_id, "PYC2904", "Keyword arguments do not exactly cover the target signature"
499
+ source_ids = tuple(item["source_argument_node_id"] for item in argument_bindings)
500
+ source_categories = tuple(item["category"] for item in argument_bindings)
501
+ return {
502
+ "binding_id": keyword_call_binding_id(call_id, target_binding_id),
503
+ "call_node_id": call_id, "callee_node_id": fields["func"],
504
+ "target_function_node_id": target_id, "target_binding_id": target_binding_id,
505
+ "target_name": str(signature.get("source_name", target_fields.get("name", ""))),
506
+ "positional_only_parameter_count": positional_only_count,
507
+ "parameter_node_ids": list(parameter_node_ids), "parameter_names": list(parameter_names),
508
+ "parameter_categories": list(parameter_categories),
509
+ "positional_argument_node_ids": list(positional_ids), "keyword_node_ids": list(keyword_ids),
510
+ "keyword_names": [item.get("fields", {}).get("arg") for item in keyword_nodes],
511
+ "keyword_value_node_ids": [item.get("fields", {}).get("value") for item in keyword_nodes],
512
+ "argument_bindings": argument_bindings,
513
+ "source_argument_node_ids": list(source_ids), "source_argument_categories": list(source_categories),
514
+ "source_to_parameter_ordinals": [item["parameter_ordinal"] for item in argument_bindings],
515
+ "parameter_argument_node_ids": list(parameter_arguments), "parameter_to_source_ordinals": list(parameter_sources),
516
+ "evaluation_order": list(source_ids), "arguments_evaluated_once": True,
517
+ "parameter_coverage_exact": coverage, "lowering_shape": KEYWORD_CALL_LOWERING_SHAPE,
518
+ "allocation_model": "none", "cleanup_model": "none",
519
+ "runtime_binding_failure": "proved-absent" if supported else "compile-time-rejected",
520
+ "supported": supported, "diagnostic_code": code, "reason": reason, "rejection_node_id": rejection,
521
+ }
522
+
523
+ def _merge_source_entries(
524
+ self,
525
+ positional: list[dict[str, Any]],
526
+ keywords: list[dict[str, Any]],
527
+ ) -> list[dict[str, Any]]:
528
+ """Linearly merge normalized source-ordered positional/keyword lists."""
529
+
530
+ merged: list[dict[str, Any]] = []
531
+ positional_index = 0
532
+ keyword_index = 0
533
+ while positional_index < len(positional) and keyword_index < len(keywords):
534
+ _check(self.cancellation)
535
+ positional_item = positional[positional_index]
536
+ keyword_item = keywords[keyword_index]
537
+ if self._source_key(positional_item["order_node_id"]) <= self._source_key(
538
+ keyword_item["order_node_id"]
539
+ ):
540
+ merged.append(positional_item)
541
+ positional_index += 1
542
+ else:
543
+ merged.append(keyword_item)
544
+ keyword_index += 1
545
+ merged.extend(positional[positional_index:])
546
+ merged.extend(keywords[keyword_index:])
547
+ return merged
548
+
549
+ def _source_key(self, node_id: str) -> tuple[int, int]:
550
+ node = self.nodes.get(node_id, {})
551
+ provenance = node.get("provenance")
552
+ span = provenance.get("source_span") if isinstance(provenance, dict) else None
553
+ start = span.get("start") if isinstance(span, dict) else None
554
+ offset = start.get("offset") if isinstance(start, dict) else None
555
+ return (
556
+ offset if isinstance(offset, int) else 2**63 - 1,
557
+ self.node_order.get(node_id, 2**63 - 1),
558
+ )
559
+
560
+ def _validate_plans(payload: Mapping[str, Any], expected: dict[str, dict[str, Any]], cancellation: Any) -> tuple[bool, str]:
561
+ plan_values = payload.get("rule_plans")
562
+ if not isinstance(plan_values, list):
563
+ return False, "keyword-call RulePlans are malformed"
564
+ plans_by_source: dict[str, dict[str, Any]] = {}
565
+ for item in plan_values:
566
+ _check(cancellation)
567
+ if not isinstance(item, dict):
568
+ return False, "keyword-call RulePlans are malformed"
569
+ if item.get("rule_id") != KEYWORD_CALL_RULE_ID:
570
+ continue
571
+ source_id = item.get("source_node_id")
572
+ if not isinstance(source_id, str) or source_id in plans_by_source:
573
+ return False, "keyword-call RulePlans have duplicate or invalid sources"
574
+ plans_by_source[source_id] = item
575
+
576
+ table_values = payload.get("fact_tables")
577
+ if not isinstance(table_values, list):
578
+ return False, "keyword-call RulePlan dependencies are malformed"
579
+ tables: dict[str, dict[str, Any]] = {}
580
+ for item in table_values:
581
+ if not isinstance(item, dict) or not isinstance(item.get("table_id"), str):
582
+ return False, "keyword-call RulePlan dependencies are malformed"
583
+ table_id = item["table_id"]
584
+ if table_id in tables:
585
+ return False, "keyword-call RulePlan dependencies have duplicate identities"
586
+ tables[table_id] = item
587
+
588
+ def values(table_id: str) -> tuple[dict[str, Any], ...]:
589
+ table = tables.get(table_id)
590
+ records = table.get("records") if isinstance(table, dict) else None
591
+ if not isinstance(records, list):
592
+ raise _MalformedKeywordEvidence(
593
+ f"keyword-call RulePlan dependency {table_id!r} is malformed"
594
+ )
595
+ result: list[dict[str, Any]] = []
596
+ for record in records:
597
+ value = record.get("value") if isinstance(record, dict) else None
598
+ if not isinstance(value, dict):
599
+ raise _MalformedKeywordEvidence(
600
+ f"keyword-call RulePlan dependency {table_id!r} is malformed"
601
+ )
602
+ result.append(value)
603
+ return tuple(result)
604
+
605
+ call_targets = {item.get("call_node_id"): item for item in values("call-target-facts")}
606
+ supported = {
607
+ key: value
608
+ for key, value in expected.items()
609
+ if value["supported"] and call_targets.get(key, {}).get("supported") is True
610
+ }
611
+ if set(plans_by_source) != set(supported):
612
+ return False, "keyword-call RulePlans do not exactly cover cumulatively supported facts"
613
+ decision_values = payload.get("support_decisions")
614
+ if not isinstance(decision_values, list):
615
+ return False, "keyword-call support decisions are malformed"
616
+ decisions: dict[str, dict[str, Any]] = {}
617
+ for item in decision_values:
618
+ node_id = item.get("node_id") if isinstance(item, dict) else None
619
+ if not isinstance(node_id, str) or node_id in decisions:
620
+ return False, "keyword-call support decisions are malformed"
621
+ decisions[node_id] = item
622
+ category_records = tables.get("value-category-facts", {}).get("records")
623
+ if not isinstance(category_records, list):
624
+ return False, "keyword-call RulePlan categories are malformed"
625
+ categories = {
626
+ record.get("key"): _category(record.get("value"))
627
+ for record in category_records
628
+ if isinstance(record, dict)
629
+ }
630
+ module_functions = {item.get("function_node_id"): item for item in values("module-function-facts")}
631
+ module = payload.get("python_ir")
632
+ node_values = module.get("nodes") if isinstance(module, dict) else None
633
+ if not isinstance(node_values, list):
634
+ return False, "keyword-call RulePlan Python IR is malformed"
635
+ nodes = {
636
+ item.get("node_id"): item
637
+ for item in node_values
638
+ if isinstance(item, dict) and isinstance(item.get("node_id"), str)
639
+ }
640
+ parents: dict[str, list[str]] = {}
641
+ for parent in node_values:
642
+ _check(cancellation)
643
+ fields = parent.get("fields") if isinstance(parent, dict) else None
644
+ if (
645
+ not isinstance(fields, dict)
646
+ or not isinstance(parent.get("kind"), str)
647
+ or not isinstance(parent.get("node_id"), str)
648
+ ):
649
+ return False, "keyword-call RulePlan Python IR is malformed"
650
+ for field, value in fields.items():
651
+ for child in python_ir_reference_ids(parent["kind"], field, value, nodes):
652
+ parents.setdefault(child, []).append(parent["node_id"])
653
+ def owner(node_id: str) -> str | None:
654
+ seen: set[str] = set()
655
+ current = node_id
656
+ while current not in seen:
657
+ seen.add(current)
658
+ candidates = parents.get(current, ())
659
+ if not candidates:
660
+ return None
661
+ current = candidates[0]
662
+ if nodes.get(current, {}).get("kind") == "FunctionDef":
663
+ return current
664
+ return None
665
+ for call_id, fact in supported.items():
666
+ _check(cancellation)
667
+ plan = plans_by_source[call_id]
668
+ feature_facts = {
669
+ f"keyword-call-binding:{fact['binding_id']}", f"keyword-call:{call_id}",
670
+ f"keyword-call-target:{fact['target_binding_id']}",
671
+ f"keyword-source-argument-count:{len(fact['source_argument_node_ids'])}",
672
+ f"keyword-parameter-count:{len(fact['parameter_node_ids'])}",
673
+ f"keyword-call-lowering-shape:{fact['lowering_shape']}",
674
+ }
675
+ facts_used = plan.get("facts_used")
676
+ if not isinstance(facts_used, list) or any(
677
+ not isinstance(item, str) for item in facts_used
678
+ ):
679
+ return False, "keyword-call RulePlan facts are malformed"
680
+ actual_feature_facts = {
681
+ item for item in facts_used if item.startswith("keyword-")
682
+ }
683
+ call = call_targets.get(call_id, {})
684
+ annotation_evidence = call.get("annotation_evidence")
685
+ argument_categories = call.get("argument_categories")
686
+ if not _sequence(annotation_evidence) or not _sequence(argument_categories):
687
+ return False, "keyword-call RulePlan call-target evidence is malformed"
688
+ expected_facts = {
689
+ f"value-category:{categories.get(call_id, 'unknown')}",
690
+ f"call-target:{fact['target_binding_id']}",
691
+ *(f"annotation-evidence:{item}" for item in annotation_evidence),
692
+ *(f"argument-category:{item}" for item in argument_categories),
693
+ *feature_facts,
694
+ }
695
+ expected_obligations = [
696
+ *KEYWORD_CALL_OBLIGATIONS,
697
+ "call-result-representation-known",
698
+ "source-output-call-mapping-required",
699
+ ]
700
+ expected_explanation = [
701
+ "selected", KEYWORD_CALL_RULE_ID, "for", "Call",
702
+ "resolved-target", fact["target_name"], "argument-order",
703
+ *fact["evaluation_order"],
704
+ ]
705
+ caller = module_functions.get(owner(call_id), {})
706
+ callee = module_functions.get(fact["target_function_node_id"], {})
707
+ caller_module = caller.get("module_id")
708
+ callee_module = callee.get("module_id")
709
+ if caller_module and callee_module and caller_module != callee_module:
710
+ expected_facts.update({
711
+ f"caller-module:{caller_module}",
712
+ f"callee-module:{callee_module}",
713
+ f"target-function:{fact['target_function_node_id']}",
714
+ })
715
+ expected_obligations.extend((
716
+ "cross-module-call-uses-target-binding",
717
+ "bundle-wide-recursion-policy-explicit",
718
+ ))
719
+ expected_explanation.extend((
720
+ "cross-module-direct-call", str(caller_module), "to", str(callee_module),
721
+ ))
722
+ expected_explanation.extend((
723
+ "keyword-call-binding", fact["target_name"],
724
+ "source-order-arguments", str(len(fact["source_argument_node_ids"])),
725
+ "formal-order-parameters", str(len(fact["parameter_node_ids"])),
726
+ "lowered-as", fact["lowering_shape"],
727
+ ))
728
+ decision = decisions.get(call_id)
729
+ facts_are_sorted_unique = all(
730
+ facts_used[index - 1] < facts_used[index]
731
+ for index in range(1, len(facts_used))
732
+ )
733
+ if (
734
+ plan.get("rule_version") != KEYWORD_CALL_RULE_VERSION
735
+ or plan.get("support_state") != "SupportedDirect"
736
+ or plan.get("helper_requirements") != []
737
+ or plan.get("unresolved_obligations") != []
738
+ or actual_feature_facts != feature_facts
739
+ or not facts_are_sorted_unique
740
+ or set(facts_used) != expected_facts
741
+ or plan.get("semantic_obligations") != expected_obligations
742
+ or plan.get("resolved_obligations") != expected_obligations
743
+ or plan.get("explanation_tokens") != expected_explanation
744
+ or not isinstance(decision, dict)
745
+ or decision.get("state") != "SupportedDirect"
746
+ or decision.get("rule_plan_id") != plan.get("plan_id")
747
+ ):
748
+ return False, "keyword-call RulePlan does not close its exact proof"
749
+ return True, ""
750
+
751
+
752
+ def validate_keyword_call_binding_facts(
753
+ payload: Mapping[str, Any],
754
+ tables: list[dict[str, Any]] | tuple[dict[str, Any], ...] | None = None,
755
+ *,
756
+ expected_fact_schema: str = KEYWORD_CALL_FACT_SCHEMA,
757
+ require_table: bool = True,
758
+ require_plans: bool = True,
759
+ ignored_call_node_ids: frozenset[str] = frozenset(),
760
+ cumulatively_ineligible_target_function_node_ids: frozenset[str] = frozenset(),
761
+ cancellation: Any = None,
762
+ ) -> tuple[bool, str]:
763
+ """Fail closed on malformed evidence while preserving cancellation."""
764
+
765
+ _check(cancellation)
766
+ if not isinstance(payload, Mapping):
767
+ return False, "keyword-call validation payload is malformed"
768
+ if tables is not None and not _sequence(tables):
769
+ return False, "keyword-call fact tables are malformed"
770
+ if (
771
+ not isinstance(ignored_call_node_ids, (set, frozenset))
772
+ or any(not isinstance(item, str) for item in ignored_call_node_ids)
773
+ or not isinstance(
774
+ cumulatively_ineligible_target_function_node_ids,
775
+ (set, frozenset),
776
+ )
777
+ or any(
778
+ not isinstance(item, str)
779
+ for item in cumulatively_ineligible_target_function_node_ids
780
+ )
781
+ ):
782
+ return False, "keyword-call validation exclusions are malformed"
783
+ try:
784
+ return _validate_keyword_call_binding_facts(
785
+ payload,
786
+ tables,
787
+ expected_fact_schema=expected_fact_schema,
788
+ require_table=require_table,
789
+ require_plans=require_plans,
790
+ ignored_call_node_ids=frozenset(ignored_call_node_ids),
791
+ cumulatively_ineligible_target_function_node_ids=frozenset(
792
+ cumulatively_ineligible_target_function_node_ids
793
+ ),
794
+ cancellation=cancellation,
795
+ )
796
+ except _MalformedKeywordEvidence as exc:
797
+ return False, str(exc) or "keyword-call evidence is malformed"
798
+ except (AttributeError, IndexError, KeyError, TypeError, ValueError):
799
+ return False, "keyword-call evidence is malformed"
800
+
801
+
802
+ __all__ = ["validate_keyword_call_binding_facts"]