arxo-codegen 0.4.0__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 (152) hide show
  1. arxo_codegen/__init__.py +5 -0
  2. arxo_codegen/__main__.py +7 -0
  3. arxo_codegen/active-outcomes.json +6837 -0
  4. arxo_codegen/check.py +158 -0
  5. arxo_codegen/check_active_canaries.py +262 -0
  6. arxo_codegen/check_api.py +76 -0
  7. arxo_codegen/check_conformance.py +414 -0
  8. arxo_codegen/check_matrix.py +131 -0
  9. arxo_codegen/check_packages.py +281 -0
  10. arxo_codegen/check_semantic_probes.py +293 -0
  11. arxo_codegen/check_xlsx.py +320 -0
  12. arxo_codegen/consumer_runner.py +316 -0
  13. arxo_codegen/core_support.py +203 -0
  14. arxo_codegen/coverage.py +341 -0
  15. arxo_codegen/deliver.py +180 -0
  16. arxo_codegen/examples/xlsx/pricing.lawir.json +409 -0
  17. arxo_codegen/examples/xlsx/pricing.xlsx.json +52 -0
  18. arxo_codegen/excel_plan.py +602 -0
  19. arxo_codegen/harness/verify/ci/gates/backends/check_backends.py +1248 -0
  20. arxo_codegen/harness/verify/ci/gates/backends/l1_fixtures.py +463 -0
  21. arxo_codegen/inert-nodes.json +62 -0
  22. arxo_codegen/inert.py +91 -0
  23. arxo_codegen/inputs.py +59 -0
  24. arxo_codegen/package.py +565 -0
  25. arxo_codegen/plan.py +4053 -0
  26. arxo_codegen/recertify_registry.py +100 -0
  27. arxo_codegen/runtime-cert.json +5259 -0
  28. arxo_codegen/runtime_cert.py +233 -0
  29. arxo_codegen/survey.py +272 -0
  30. arxo_codegen/target_runner.py +101 -0
  31. arxo_codegen/targets/bsl/cert.bsl +113 -0
  32. arxo_codegen/targets/bsl/emit.py +356 -0
  33. arxo_codegen/targets/bsl/json.bsl +243 -0
  34. arxo_codegen/targets/bsl/runner.bsl +210 -0
  35. arxo_codegen/targets/bsl/runtime.bsl +858 -0
  36. arxo_codegen/targets/cs/Cert.cs +90 -0
  37. arxo_codegen/targets/cs/Runner.cs +154 -0
  38. arxo_codegen/targets/cs/Runtime.cs +243 -0
  39. arxo_codegen/targets/cs/emit.py +223 -0
  40. arxo_codegen/targets/dart/cert.dart +99 -0
  41. arxo_codegen/targets/dart/emit.py +206 -0
  42. arxo_codegen/targets/dart/json.dart +166 -0
  43. arxo_codegen/targets/dart/runner.dart +154 -0
  44. arxo_codegen/targets/dart/runtime.dart +293 -0
  45. arxo_codegen/targets/erl/cert.erl +64 -0
  46. arxo_codegen/targets/erl/emit.py +216 -0
  47. arxo_codegen/targets/erl/rt.erl +238 -0
  48. arxo_codegen/targets/erl/runner.erl +130 -0
  49. arxo_codegen/targets/ex/cert.exs +62 -0
  50. arxo_codegen/targets/ex/emit.py +204 -0
  51. arxo_codegen/targets/ex/runner.exs +124 -0
  52. arxo_codegen/targets/ex/runtime.ex +227 -0
  53. arxo_codegen/targets/go/apiBase.go +483 -0
  54. arxo_codegen/targets/go/apicheck.go +97 -0
  55. arxo_codegen/targets/go/cert.go +218 -0
  56. arxo_codegen/targets/go/emit.py +1806 -0
  57. arxo_codegen/targets/go/evaluate.go +1826 -0
  58. arxo_codegen/targets/go/runner.go +38 -0
  59. arxo_codegen/targets/go/runtime.go +2612 -0
  60. arxo_codegen/targets/java/Cert.java +91 -0
  61. arxo_codegen/targets/java/Json.java +148 -0
  62. arxo_codegen/targets/java/Runner.java +151 -0
  63. arxo_codegen/targets/java/Runtime.java +243 -0
  64. arxo_codegen/targets/java/emit.py +198 -0
  65. arxo_codegen/targets/kt/Cert.kt +67 -0
  66. arxo_codegen/targets/kt/Json.kt +103 -0
  67. arxo_codegen/targets/kt/Runner.kt +126 -0
  68. arxo_codegen/targets/kt/Runtime.kt +196 -0
  69. arxo_codegen/targets/kt/emit.py +195 -0
  70. arxo_codegen/targets/lua/cert.lua +82 -0
  71. arxo_codegen/targets/lua/emit.py +250 -0
  72. arxo_codegen/targets/lua/json.lua +133 -0
  73. arxo_codegen/targets/lua/runner.lua +167 -0
  74. arxo_codegen/targets/lua/runtime.lua +541 -0
  75. arxo_codegen/targets/php/cert.php +84 -0
  76. arxo_codegen/targets/php/emit.py +229 -0
  77. arxo_codegen/targets/php/json.php +144 -0
  78. arxo_codegen/targets/php/runner.php +189 -0
  79. arxo_codegen/targets/php/runtime.php +660 -0
  80. arxo_codegen/targets/py/api_base.py +334 -0
  81. arxo_codegen/targets/py/apicheck.py +51 -0
  82. arxo_codegen/targets/py/cert.py +140 -0
  83. arxo_codegen/targets/py/emit.py +1140 -0
  84. arxo_codegen/targets/py/evaluate.py +980 -0
  85. arxo_codegen/targets/py/runner.py +38 -0
  86. arxo_codegen/targets/py/runtime.py +1634 -0
  87. arxo_codegen/targets/rb/cert.rb +73 -0
  88. arxo_codegen/targets/rb/emit.py +203 -0
  89. arxo_codegen/targets/rb/runner.rb +130 -0
  90. arxo_codegen/targets/rb/runtime.rb +255 -0
  91. arxo_codegen/targets/rs/Cargo.lock +142 -0
  92. arxo_codegen/targets/rs/Cargo.toml +16 -0
  93. arxo_codegen/targets/rs/cert.rs +106 -0
  94. arxo_codegen/targets/rs/emit.py +233 -0
  95. arxo_codegen/targets/rs/prefetch.rs +1 -0
  96. arxo_codegen/targets/rs/runner.rs +181 -0
  97. arxo_codegen/targets/rs/runtime.rs +448 -0
  98. arxo_codegen/targets/scala/Cert.scala +77 -0
  99. arxo_codegen/targets/scala/Json.scala +140 -0
  100. arxo_codegen/targets/scala/Runner.scala +121 -0
  101. arxo_codegen/targets/scala/Runtime.scala +269 -0
  102. arxo_codegen/targets/scala/emit.py +193 -0
  103. arxo_codegen/targets/swift/cert.swift +105 -0
  104. arxo_codegen/targets/swift/emit.py +231 -0
  105. arxo_codegen/targets/swift/json.swift +208 -0
  106. arxo_codegen/targets/swift/runner.swift +133 -0
  107. arxo_codegen/targets/swift/runtime.swift +528 -0
  108. arxo_codegen/targets/ts/apiBase.ts +250 -0
  109. arxo_codegen/targets/ts/apicheck.ts +37 -0
  110. arxo_codegen/targets/ts/cert.ts +96 -0
  111. arxo_codegen/targets/ts/emit.py +1214 -0
  112. arxo_codegen/targets/ts/evaluate.ts +894 -0
  113. arxo_codegen/targets/ts/package-lock.json +45 -0
  114. arxo_codegen/targets/ts/package.json +9 -0
  115. arxo_codegen/targets/ts/runner.ts +31 -0
  116. arxo_codegen/targets/ts/runtime.ts +1369 -0
  117. arxo_codegen/targets/xlsx/README.ru.md +176 -0
  118. arxo_codegen/targets/xlsx/emit.py +198 -0
  119. arxo_codegen/temporal.py +59 -0
  120. arxo_codegen/test_alias_types.py +171 -0
  121. arxo_codegen/test_api_contract.py +89 -0
  122. arxo_codegen/test_calendar_policy.py +48 -0
  123. arxo_codegen/test_check.py +49 -0
  124. arxo_codegen/test_check_active_canaries.py +96 -0
  125. arxo_codegen/test_check_conformance.py +132 -0
  126. arxo_codegen/test_check_semantic_probes.py +85 -0
  127. arxo_codegen/test_check_xlsx.py +52 -0
  128. arxo_codegen/test_consumer_runner.py +175 -0
  129. arxo_codegen/test_core_support.py +80 -0
  130. arxo_codegen/test_e0212_decision_time.py +63 -0
  131. arxo_codegen/test_head_aggregate.py +71 -0
  132. arxo_codegen/test_inert.py +143 -0
  133. arxo_codegen/test_instant_types.py +113 -0
  134. arxo_codegen/test_key_conflict_scope.py +92 -0
  135. arxo_codegen/test_key_conflict_targets.py +17 -0
  136. arxo_codegen/test_package_layout.py +163 -0
  137. arxo_codegen/test_plan_language_version.py +44 -0
  138. arxo_codegen/test_positions_refusal.py +43 -0
  139. arxo_codegen/test_recertify_registry.py +77 -0
  140. arxo_codegen/test_ruby_target.py +41 -0
  141. arxo_codegen/test_runtime_cert.py +83 -0
  142. arxo_codegen/test_secondary_key_conflict_scope.py +17 -0
  143. arxo_codegen/test_stage_r.py +108 -0
  144. arxo_codegen/test_standalone_inputs.py +71 -0
  145. arxo_codegen/test_support.py +150 -0
  146. arxo_codegen/test_survey_inputs.py +115 -0
  147. arxo_codegen-0.4.0.dist-info/LICENSE +202 -0
  148. arxo_codegen-0.4.0.dist-info/METADATA +1673 -0
  149. arxo_codegen-0.4.0.dist-info/RECORD +152 -0
  150. arxo_codegen-0.4.0.dist-info/WHEEL +5 -0
  151. arxo_codegen-0.4.0.dist-info/entry_points.txt +13 -0
  152. arxo_codegen-0.4.0.dist-info/top_level.txt +1 -0
arxo_codegen/plan.py ADDED
@@ -0,0 +1,4053 @@
1
+ #!/usr/bin/env python3
2
+ """CLIR -> план исполнения, НЕ ЗАВИСЯЩИЙ от целевого языка.
3
+
4
+ Здесь живёт вся семантика среза: какие таблицы завести, что перебирать, где
5
+ соединение, где гард, какой терм посчитать, что отказать. Целевой язык об
6
+ этом не знает: он получает готовый план и печатает синтаксис.
7
+
8
+ Отказ — обязателен и громок. Незнакомое объявление, поле узла или форма
9
+ терма дают не «меньше ответа», а ДРУГОЙ ответ, поэтому пропускать нельзя.
10
+ """
11
+ import hashlib
12
+ import json
13
+ import re
14
+ import unicodedata
15
+ from fractions import Fraction
16
+ if __package__:
17
+ from . import temporal
18
+ else:
19
+ import temporal
20
+ from pathlib import Path
21
+
22
+ # Версия формата плана. Состав полей описан в README; его изменение меняет
23
+ # версию, и эмиттер, не знающий версии, обязан отказать, а не печатать.
24
+ PLAN_VERSION = "law-backend-plan/0.7"
25
+ # План с возможностями поражаемого слоя (DECISION-0237): `capabilities`,
26
+ # опоры обеих полярностей, страты и расписание фаз §111. План без
27
+ # возможностей остаётся байтово прежним и носит PLAN_VERSION.
28
+ PLAN_VERSION_L1 = "law-backend-plan/0.8"
29
+ # План с агрегатами §59 в правилах и замыканиями §70 (DECISION-0239): подпланы
30
+ # comprehension, порядок шагов тела, фаза closure с уровнями E-0113. План без
31
+ # этих возможностей остаётся 0.8 либо 0.7 байтово.
32
+ PLAN_VERSION_09 = "law-backend-plan/0.9"
33
+ AGG_OPS = ("count", "sum", "min", "max", "only")
34
+ # План с нормативными позициями L2 (DECISION-0242): норм-правила, позиции,
35
+ # эффекты полномочий §127. План без возможности `norm` остаётся прежним.
36
+ PLAN_VERSION_10 = "law-backend-plan/0.10"
37
+ # План с календарными функциями §84–§86 (DECISION-0245): возможности функций,
38
+ # политики `deadline_policy`, снимки `calendar_snapshot`, версия семантики.
39
+ PLAN_VERSION_11 = "law-backend-plan/0.11"
40
+ # План с Rational §35/§58 (DECISION-0252): возможность `value:rational`,
41
+ # статические виды операндов арифметики, фоллибельные гарды `err` и сверка
42
+ # вида головы `kindChecks`. План, где Rational не возникает, остаётся прежним.
43
+ PLAN_VERSION_12 = "law-backend-plan/0.12"
44
+ # План с Quantity §49 v1 и счётчиком-связывателем §86.1 (DECISION-0256):
45
+ # возможность `value:quantity`, вид операнда `q`, терм `qtyvar`.
46
+ PLAN_VERSION_13 = "law-backend-plan/0.13"
47
+ # План с политикой округления §50 (DECISION-0259): возможности `value:round` и
48
+ # `value:div_round`, статические виды аргументов вызова `ak`.
49
+ PLAN_VERSION_14 = "law-backend-plan/0.14"
50
+ # План с Boolean и Natural как значениями (DECISION-0266), границами месяца
51
+ # §84.2 (DECISION-0267) и подстановочным `_` §98 (DECISION-0268):
52
+ # возможности `value:boolean`, `value:natural`, `calendar:month_bound`,
53
+ # `wildcard`. Модель они не меняют — план без них остаётся прежней версии
54
+ # байтово; версия нужна эмиттеру, чтобы незнакомый литерал, вызов или
55
+ # столбец атома не печатались молча.
56
+ PLAN_VERSION_15 = "law-backend-plan/0.15"
57
+ # План с датированными приоритетами §92.3 (DECISION-0269): возможность
58
+ # `priority:dated`, поля `datedPriorities` и `datedNormPriorities` — пары
59
+ # транзитивного замыкания с окнами `[start, end)` оси права; активное
60
+ # множество рёбер модуль готовит один раз на дело (`prepare`, подготовленный
61
+ # контекст). План без датированных рёбер остаётся прежней версии байтово.
62
+ PLAN_VERSION_16 = "law-backend-plan/0.16"
63
+ # План с вычисляемым аргументом атома тела и условным термом §56
64
+ # (DECISION-0270, E-0180): возможности `term:body` и `term:if`; модель 0.9 —
65
+ # ошибка значения аргумента есть issue правила, как у гардов; аргумент
66
+ # считается до перебора таблицы (`pre`), генератор с таким аргументом берётся
67
+ # только после связывания его переменных (`solver.solve`, pick).
68
+ PLAN_VERSION_17 = "law-backend-plan/0.17"
69
+ # План с функциями §46 «функция на функцию», параметрами A3 §A3.2, `let` и
70
+ # `match` §55.1 (DECISION-0273): возможности `function`, `param:a3`,
71
+ # `term:let`, `term:match`; модель 0.9. Функция печатается функцией цели по
72
+ # позиционным параметрам; параметр A3 — окна `[start, end)` оси права со
73
+ # значением, активное значение готовит `prepare` (подготовленный контекст),
74
+ # разрыв — MISSING_PARAMETER_VALUE правила; `match` над перечислением с
75
+ # вариантными образцами без полей и `_`, неполный — NON_EXHAUSTIVE_MATCH.
76
+ PLAN_VERSION_18 = "law-backend-plan/0.18"
77
+ # План с каналом суждения §47.3 (DECISION-0274): возможность `judgment` —
78
+ # программа объявляет отношение суждения (`external judgment relation`).
79
+ # Модель не меняется: запрос судье — обход по готовому состоянию модели
80
+ # (порт `whynot.judgment_blockers` и соседей в рантайме цели), для него план
81
+ # несёт `judgments` (орган и форма запроса), `backchain` с силой правил и
82
+ # гардами, а модель L1 — применения дефитеров и поражённых кандидатов.
83
+ PLAN_VERSION_19 = "law-backend-plan/0.19"
84
+ FN_CAPS = {"function", "param:a3", "term:let", "term:match"}
85
+ TERM_CAPS = {"term:body", "term:if"}
86
+ A3_OK = "urn:law:a3#Result/Ok"
87
+ A3_MISSING = "urn:law:a3#MissingParameterValue"
88
+ A3_RESULT = "urn:law:std#Result"
89
+ V15_CAPS = {"value:boolean", "value:natural", "calendar:month_bound", "wildcard"}
90
+ # §84.2 (E-0084): границы месяца — чистые функции над Date; ни политика §86,
91
+ # ни снимок §85 не читаются, ошибки значения над слотом Date нет.
92
+ MONTH_FNS = {"urn:law:std#month_start_of": "month_start", "urn:law:std#month_end_of": "month_end"}
93
+ # §84.1 (E-0028, E-0058, E-0185; W2 часть 2): физическая длительность между
94
+ # мгновениями и шаг мгновения на длительность — чистые функции над Instant.
95
+ # Не Instant у оракула всегда TYPE_ERROR — отказ плана; смешение мгновений со
96
+ # смещением и без, не-ISO запись — TYPE_ERROR по значению, названный отказ
97
+ # рантайма. Результат `add_duration` — Instant в UTC (`…Z`), дробные секунды
98
+ # отбрасываются, как у оракула (`add_duration_value`).
99
+ DUR_FNS = {"urn:law:std#hours_between": "hours_between", "urn:law:std#minutes_between": "minutes_between",
100
+ "urn:law:std#add_duration": "add_duration"}
101
+ DURATION_UNITS = ("second", "minute", "hour", "day")
102
+ INST = "urn:law:std#Instant"
103
+ # Функции std календаря → (имя в плане, возможность). `days_between` чистая
104
+ # и модели не меняет; остальные берут политику §86 и снимок §85 из
105
+ # окружения и дают ошибку значения, поэтому требуют накопления выводов.
106
+ CAL_FNS = {"urn:law:std#days_between": ("days_between", "calendar:days_between"),
107
+ "urn:law:std#add_calendar_period": ("add_calendar_period", "calendar:period"),
108
+ "urn:law:std#add_business_days": ("add_business_days", "calendar:business"),
109
+ "urn:law:std#add_legal_term": ("add_legal_term", None)}
110
+ CAL_CAPS = {"calendar:days_between", "calendar:period", "calendar:business"}
111
+ # Единицы счётчика (`values.py`): E-0029/E-0109 разводят операции по единице.
112
+ DAY_UNITS = ("business_day", "calendar_day")
113
+ CALENDAR_PERIOD_UNITS = ("calendar_day", "calendar_month", "calendar_year", "calendar_week")
114
+ DEADLINE_UNITS = ("business_day", "calendar_day", "calendar_month", "calendar_year", "calendar_week")
115
+ STD = "urn:law:std#"
116
+ # §140: институциональные литералы, которые публикует жизненный цикл позиций,
117
+ # с арностью; первый аргумент — позиция.
118
+ STD_POSITION = {"created": 1, "instance_of": 2, "bearer_of": 2, "beneficiary_of": 2,
119
+ "pending": 1, "active": 1, "satisfied": 1, "violated": 1, "expired": 1,
120
+ "undetermined": 1, "invalidly_exercised": 1}
121
+ POSITION_TYPE = "urn:law:backend#Position"
122
+ MODALITIES = ("duty", "power", "immunity", "liberty")
123
+ # Поля payload шаблона: читаемые оракулом и те, что оракул не читает вовсе
124
+ # (политики §123, `completenessPolicy`, `over`, `subject`, `before`) — у
125
+ # последних ответ от их значения не зависит, поэтому они принимаются.
126
+ PAYLOAD_FIELDS = {
127
+ "duty": {"kind", "bearer", "holder", "beneficiary", "goal", "window", "allocationPolicy",
128
+ "dischargePolicy", "violationPolicy", "over", "subject"},
129
+ "power": {"kind", "holder", "bearer", "beneficiary", "window", "exercise", "effect", "validWhen",
130
+ "over", "subject"},
131
+ "immunity": {"kind", "holder", "bearer", "beneficiary", "window", "protectedEffect", "against",
132
+ "over", "subject"},
133
+ "liberty": {"kind", "holder", "bearer", "beneficiary", "window", "action", "against", "over", "subject"},
134
+ }
135
+ GOAL_FIELDS = {"kind", "condition", "window", "action", "completenessPolicy", "termination"}
136
+ TEMPLATE_FIELDS = {"id", "kind", "contentHash", "package", "name", "label", "labels", "provenance",
137
+ "anchors", "sourceAnchors", "origin", "modality", "payload", "variables", "parameters"}
138
+ NORM_REFUSAL = "обратная связь L2 вне среза генератора (DECISION-0242 п. 1)"
139
+ # E-0116: пары агрегат/сравнение монотонного гарда и зеркало сравнения §206.
140
+ MONOTONE_GUARD_OPS = {"count": ("ge", "gt"), "sum": ("ge", "gt"), "max": ("ge", "gt"), "min": ("le", "lt")}
141
+ MIRROR_OP = {"ge": "le", "gt": "lt", "le": "ge", "lt": "gt"}
142
+ CLOSURE_FIELDS = {"id", "kind", "contentHash", "package", "name", "label", "labels", "provenance",
143
+ "anchors", "sourceAnchors", "origin", "predicate", "domain", "snapshot",
144
+ "completeAsOf", "deriveExplicitNegative", "effective"}
145
+
146
+ # Статусные тесты §65 в срезе L1 и сырые статусы §62 положительного атома,
147
+ # при которых конъюнкт выполнен, по полярности литерала (§63 зеркалит
148
+ # отрицательный). `supported` вычислительно тождествен `monotone` (E-0018),
149
+ # различие — слой спецификации, поэтому возможности у них раздельные.
150
+ STATUS_TESTS = ("established", "monotone", "supported", "not_known", "unknown", "refuted")
151
+ GENERATING = ("established", "monotone", "supported")
152
+ ACCEPT = {
153
+ ("established", "positive"): ["TRUE_ONLY"], ("established", "negative"): ["FALSE_ONLY"],
154
+ ("monotone", "positive"): ["TRUE_ONLY", "BOTH"], ("monotone", "negative"): ["FALSE_ONLY", "BOTH"],
155
+ ("supported", "positive"): ["TRUE_ONLY", "BOTH"], ("supported", "negative"): ["FALSE_ONLY", "BOTH"],
156
+ # §67/§113: not_known истинен без positive-опоры — при NEITHER и FALSE_ONLY.
157
+ ("not_known", "positive"): ["NEITHER", "FALSE_ONLY"], ("not_known", "negative"): ["NEITHER", "TRUE_ONLY"],
158
+ ("unknown", "positive"): ["NEITHER"], ("unknown", "negative"): ["NEITHER"],
159
+ ("refuted", "positive"): ["FALSE_ONLY"], ("refuted", "negative"): ["TRUE_ONLY"],
160
+ }
161
+ PRIORITY_FIELDS = {"id", "kind", "contentHash", "package", "name", "label", "labels", "provenance",
162
+ "anchors", "sourceAnchors", "origin", "condition", "higher", "lower",
163
+ "priorityKind", "reason"}
164
+ TRUE_FORMULA = {"kind": "boolean", "value": True}
165
+ UNSTRATIFIED = "программа не стратифицируема (§110, NON_EXECUTABLE_UNSTRATIFIED)"
166
+ LATE = "поздний производитель (§111, LDC-E4103)"
167
+
168
+ STD_FN = {"urn:law:std#round": "round", "urn:law:std#min": "min", "urn:law:std#max": "max",
169
+ "urn:law:std#abs": "abs", "urn:law:std#pow": "pow", "urn:law:std#div_round": "div_round"}
170
+ TEXT_URN = "urn:law:std#Text"
171
+ # §50 (DECISION-0259): режимы и предел точности `div_round` — порт `values`.
172
+ ROUNDING_MODES = ("CEILING", "DOWN", "FLOOR", "HALF_DOWN", "HALF_EVEN", "HALF_UP", "UP")
173
+ DIV_ROUND_MAX_PRECISION = 34
174
+ BINOP = {"mul": "mul", "add": "add", "sub": "sub", "div": "div", "mod": "mod"}
175
+ INT, DEC, MON = "urn:law:std#Integer", "urn:law:std#Decimal", "urn:law:std#Money"
176
+ # DECISION-0266: Natural — неотрицательное целое; у оракула значение вида
177
+ # `integer` (`values._TYPE_KINDS`), а тип-строка §189 своя: Natural не
178
+ # подходит слоту Integer и наоборот (`facts._conforms`).
179
+ NATURAL = "urn:law:std#Natural"
180
+ BOOL = "urn:law:std#Boolean"
181
+ # DECISION-0252: Rational §35 и динамический вид значение-зависимого частного
182
+ # §58 (Decimal/Decimal, Money/Money) — Decimal либо Rational по значению.
183
+ RAT, DYN = "urn:law:std#Rational", "urn:law:backend#DecimalOrRational"
184
+ # DECISION-0256: Quantity §49 v1 — вид без тега; тег — свойство значения.
185
+ QTY = "urn:law:std#Quantity"
186
+ NUMERIC = {INT, DEC, MON, RAT, DYN, QTY, NATURAL}
187
+ # Статический вид операнда в терме `bin` плана 0.12: рантайм не различает
188
+ # Integer и Decimal по значению, и пара §58 решается видами.
189
+ KIND_CODE = {INT: "i", DEC: "d", MON: "m", RAT: "r", DYN: "n", QTY: "q", NATURAL: "i"}
190
+
191
+
192
+ def _num(urn):
193
+ """Вид арифметики §58 по типу слота: Natural считает как Integer
194
+ (DECISION-0266), тип-строка при этом остаётся своей для §189."""
195
+ return INT if urn == NATURAL else urn
196
+ # §59 (errata E-0195): имена типов, у которых ВИД есть, — порт
197
+ # `values._TYPE_KINDS` плюс `_OPAQUE_KINDS`. С видом элемента, объявленным
198
+ # проекцией comprehension §52.1, сверяется вид политики пустой коллекции; имя
199
+ # вне набора (сущность, перечисление, чужой символ) вида не предъявляет, и
200
+ # сверка молчит: Unknown есть «утверждать нечего», а не «совместимо со всем».
201
+ EMPTY_KIND_NAMES = frozenset({INT, NATURAL, DEC, MON, TEXT_URN, "urn:law:std#Date", BOOL, RAT, QTY,
202
+ "urn:law:std#Instant", "urn:law:std#Magnitude", "urn:law:std#Bounds"})
203
+ CMP = {"lt", "le", "gt", "ge", "eq", "ne"}
204
+
205
+ TYPES = {
206
+ "urn:law:std#Money": "money",
207
+ "urn:law:std#Text": "text",
208
+ "urn:law:std#Decimal": "dec",
209
+ "urn:law:std#Integer": "dec",
210
+ "urn:law:std#Boolean": "bool",
211
+ # Natural (DECISION-0266): в ячейке — точное целое, как Integer.
212
+ "urn:law:std#Natural": "dec",
213
+ # Date §2.15: хранится и сравнивается по календарному дню; канон — ISO
214
+ # `YYYY-MM-DD`, поэтому порядок записи и есть порядок дня. Календарные
215
+ # функции §85 остаются вне среза.
216
+ "urn:law:std#Date": "date",
217
+ # Instant §2.15 (W2 часть 2): момент, а не запись (E-0048) — порядок и равенство
218
+ # по моменту, ключ атома по записи (E-0214); с Date не сравнивается (§57).
219
+ "urn:law:std#Instant": "inst",
220
+ # Rational §35 (DECISION-0252): несократимая дробь; ключ и сравнение — свои.
221
+ "urn:law:std#Rational": "rat",
222
+ # Quantity §49 v1 (DECISION-0256): число с тегом единицы.
223
+ "urn:law:std#Quantity": "qty",
224
+ }
225
+
226
+ #: §261 `law.std.actors` и корни событий §79–§80: сущности стандартной
227
+ #: библиотеки — идентичности, как сущности пакета (`entity_ref` дела типа не
228
+ #: несёт, §189 их не сверяет). Событие в слоте — ссылка `entity_ref` на
229
+ #: закреплённый снимок (§79.1); конструктор-запись события в факте остаётся
230
+ #: названным отказом раннера («аргумент вне среза раннера: record») до W7,
231
+ #: где свёртка §161.1 читает его поля. Instant §2.15 — своё значение (W2, часть 2).
232
+ #: `LegalContext`, `LegalRole` (§261) и `Jurisdiction` (§257, E-0039) — того же
233
+ #: рода: у оракула нет ни вида значения (`values._TYPE_KINDS`), ни литерала,
234
+ #: дело подаёт их `entity_ref`, и сверка §189 их не трогает (W2, релиз 0.1.5).
235
+ STD_ENTITY_TYPES = frozenset(f"urn:law:std#{n}" for n in (
236
+ "Actor", "NaturalPerson", "LegalPerson", "Organization",
237
+ "PublicAuthority", "Official", "Court", "Collective", "Event", "Action",
238
+ "LegalContext", "LegalRole", "Jurisdiction"))
239
+ #: `Interval<T>` §37 в слоте отношения — непрозрачная идентичность: литерала
240
+ #: такого вида у оракула нет (`values.from_literal_term`), `entity_ref` в слоте
241
+ #: §189 не сверяет, а интервальный терм дела раннер отвергает поимённо
242
+ #: («аргумент вне среза раннера: interval»). Пакет, лишь объявляющий такое
243
+ #: отношение (окно льготы `variance_granted` FNMA), получает план; дело с
244
+ #: интервалом — названный отказ, а не иной ответ; литерал-интервал в теле
245
+ #: правила — отказ плана («терм в аргументе тела вне среза»).
246
+ STD_OPAQUE_SLOT_TYPES = frozenset({"urn:law:std#Interval"})
247
+
248
+ RULE_META = {"id", "kind", "contentHash", "package", "name", "label", "labels",
249
+ "provenance", "anchors", "sourceAnchors", "variables"}
250
+ RULE_FIELDS = RULE_META | {"strength", "head", "body", "effective"}
251
+ RULE_EMPTY_OK = {"scope", "interpretations", "governs"}
252
+
253
+ EXTERNAL_JUDGMENT_FIELDS = {"kind", "id", "package", "symbol", "effect", "authority", "capability",
254
+ "requestType", "contentHash", "labels", "anchors"}
255
+ FUNCTION_FIELDS = {"kind", "id", "package", "symbol", "parameters", "body", "effect", "anchors",
256
+ "provenance", "contentHash", "labels", "visibility"}
257
+ KNOWN_NODES = {("symbol_decl", "relation"), ("symbol_decl", "constant"),
258
+ ("type_decl", None), ("rule", None),
259
+ ("unit_decl", None), ("assertion", None),
260
+ # Объявление функции само по себе ничего не считает: вызов вне
261
+ # STD_FN отвергает `term`, ссылка через const_ref — `const_value`.
262
+ ("symbol_decl", "function"), ("function_decl", None)}
263
+ ALWAYS = "urn:law:backend#always"
264
+
265
+ # Инертные конструкции (CODEGEN-COVERAGE-PLAN §1.1, W1): присутствуют в пакете,
266
+ # но на ответ поддержанных видов вопросов не влияют — план их ПРОПУСКАЕТ, а
267
+ # не отвергает. Каждая строка манифеста `inert-nodes.json` держится парой
268
+ # векторов «с конструкцией / без неё» с одним документом оракула; строку без
269
+ # такого свидетеля роняют ворота check_conformance (модуль `inert`). Здесь
270
+ # манифест только читается: план не решает, что инертно, — он исполняет
271
+ # закреплённое.
272
+ _INERT_MANIFEST = json.loads((Path(__file__).resolve().with_name("inert-nodes.json")).read_text(encoding="utf-8"))
273
+ INERT_NODE_KINDS = frozenset(c.removeprefix("node:") for c in _INERT_MANIFEST["constructs"] if c.startswith("node:"))
274
+ INERT_RULE_FIELDS = frozenset(c.removeprefix("rulefield:") for c in _INERT_MANIFEST["constructs"]
275
+ if c.startswith("rulefield:"))
276
+
277
+
278
+ class Refused(Exception):
279
+ """Отказ среза с названной причиной."""
280
+
281
+
282
+ def _reject_withdrawn_language_version(ir: dict) -> None:
283
+ """Stage R accepts one exact semantic release and one IR schema."""
284
+ version, schema = ir.get("semanticVersion"), ir.get("schemaVersion")
285
+ if version != "0.2.11" or schema != "law.core.ir/0.7":
286
+ raise Refused("SEMANTIC_RELEASE_UNAVAILABLE: требуется law.core/0.2.11 "
287
+ f"и law.core.ir/0.7; получено {version!r} / {schema!r}")
288
+
289
+
290
+ def _reject_withdrawn_language_versions(ir: dict, world: list | None = None) -> None:
291
+ """Проверить корневой IR и каждый IR мира до их семантического связывания."""
292
+ _reject_withdrawn_language_version(ir)
293
+ for dependency in world or ():
294
+ _reject_withdrawn_language_version(dependency)
295
+
296
+
297
+ # --- редакции §31/§92.3 (E-0095, DECISION-0236) --------------------------------
298
+ # Порт `lawref.applicability.edition_state`: состояние редакции на момент —
299
+ # последнее наступившее событие, чей extent покрывает момент. План не знает
300
+ # даты права, поэтому считает состояние во всех точках излома (начала и
301
+ # концы событий) и сворачивает применимость в интервалы; интервал — то же
302
+ # окно `window`, что у `effective`, и эмиттеры о редакциях не знают.
303
+ OPENING = ("in_force", "restored")
304
+ SUSPENDING = ("suspended",)
305
+ CLOSING = ("repealed", "expired", "invalidated")
306
+ NORMATIVE_KINDS = ("assertion", "constraint", "norm_template", "priority_rule", "rule")
307
+ T0 = "0000-01-01"
308
+ NO_START = "\uffff"
309
+
310
+
311
+ def _is_date(t) -> bool:
312
+ return isinstance(t, str) and len(t) == 10 and t[4] == "-" and t[7] == "-"
313
+
314
+
315
+ def _event_start(extent):
316
+ if isinstance(extent, dict):
317
+ start = extent.get("start")
318
+ return None if start in (None, "-infinity") else str(start)
319
+ return None if extent is None else str(extent)
320
+
321
+
322
+ def _ordered_events(edition: dict) -> list:
323
+ events = [e for e in edition.get("lifecycle") or [] if e.get("status")]
324
+ return [e for _, e in sorted(enumerate(events),
325
+ key=lambda pair: (_event_start(pair[1].get("extent")) or NO_START, pair[0]))]
326
+
327
+
328
+ def _covers(extent, t: str) -> bool:
329
+ """Покрывает ли extent момент t; границы обязаны быть днями §2.15 —
330
+ иную гранулярность вызывающий отсеял до сюда."""
331
+ if isinstance(extent, dict):
332
+ start, end = extent.get("start"), extent.get("end")
333
+ if start not in (None, "-infinity"):
334
+ if not (start <= t if extent.get("startInclusive", True) else start < t):
335
+ return False
336
+ if end not in (None, "infinity"):
337
+ if not (t <= end if extent.get("endInclusive", False) else t < end):
338
+ return False
339
+ return True
340
+ return extent <= t
341
+
342
+
343
+ def edition_applicable(edition: dict, t: str) -> bool:
344
+ if edition.get("applicability") == "timeless":
345
+ return True
346
+ state, closed = "not_yet", False
347
+ for event in _ordered_events(edition):
348
+ status = event["status"]
349
+ if status not in OPENING + SUSPENDING + CLOSING:
350
+ continue
351
+ extent = event.get("extent")
352
+ start = _event_start(extent)
353
+ if start is not None and start > t:
354
+ continue
355
+ if status in CLOSING:
356
+ closed, state = True, "closed"
357
+ continue
358
+ if closed:
359
+ continue
360
+ covers = _covers(extent, t)
361
+ if status in SUSPENDING:
362
+ state = "suspended" if covers else "applicable"
363
+ else:
364
+ state = "applicable" if covers else "closed"
365
+ return state == "applicable"
366
+
367
+
368
+ def edition_intervals(edition: dict):
369
+ """Применимость редакции как список окон `[start, end)`; строка —
370
+ причина, по которой окно не вычислимо (граница не дневной гранулярности:
371
+ оракул такую редакцию читает как вне датировки с предупреждением).
372
+ Редакция без событий применимости §31 и без `timeless` — пустой список:
373
+ по §27.2 она `not_yet` на любую дату и узел датирует (§92 усл. 3,
374
+ errata E-0350, шаг 2)."""
375
+ if edition.get("applicability") == "timeless":
376
+ return [(None, None)]
377
+ events = [e for e in edition.get("lifecycle") or []
378
+ if e.get("status") in OPENING + SUSPENDING + CLOSING]
379
+ if not events:
380
+ return []
381
+ points = set()
382
+ for e in events:
383
+ extent = e.get("extent")
384
+ bounds = [extent.get("start"), extent.get("end")] if isinstance(extent, dict) else [extent]
385
+ for b in bounds:
386
+ if b in (None, "-infinity", "infinity"):
387
+ continue
388
+ if not _is_date(b):
389
+ return f"граница lifecycle не дневной гранулярности: {b}"
390
+ points.add(str(b))
391
+ marks = [T0] + sorted(points)
392
+ out, cur = [], None
393
+ for i, m in enumerate(marks):
394
+ on = edition_applicable(edition, m)
395
+ if on and cur is None:
396
+ cur = None if m == T0 else m
397
+ cur = (cur,)
398
+ elif not on and cur is not None:
399
+ out.append((cur[0], m))
400
+ cur = None
401
+ if cur is not None:
402
+ out.append((cur[0], None))
403
+ return out
404
+
405
+
406
+ def _intersect(a, b):
407
+ """Пересечение двух окон `[start, end)`; None — пусто."""
408
+ start = a[0] if b[0] is None else (b[0] if a[0] is None else max(a[0], b[0]))
409
+ end = a[1] if b[1] is None else (b[1] if a[1] is None else min(a[1], b[1]))
410
+ if start is not None and end is not None and start >= end:
411
+ return None
412
+ return (start, end)
413
+
414
+
415
+ def _contains(window, seg) -> bool:
416
+ """Лежит ли отрезок `seg` целиком в окне `window` (`None` — бесконечность)."""
417
+ s, e = window
418
+ a, b = seg
419
+ return (s is None or (a is not None and s <= a)) and (e is None or (b is not None and b <= e))
420
+
421
+
422
+ def _union(intervals: list) -> list:
423
+ """Объединение окон в непересекающиеся, по возрастанию."""
424
+ items = sorted(intervals, key=lambda w: (w[0] is not None, w[0] or ""))
425
+ out: list = []
426
+ for w in items:
427
+ if out and (out[-1][1] is None or (w[0] is not None and w[0] <= out[-1][1])):
428
+ last = out.pop()
429
+ end = None if last[1] is None or w[1] is None else max(last[1], w[1])
430
+ out.append((last[0], end))
431
+ else:
432
+ out.append(w)
433
+ return out
434
+
435
+
436
+ def _payload_vars(value, out: list | None = None) -> list:
437
+ """Все переменные payload шаблона — порт `norms._payload_vars`: payload не
438
+ формула, и `_free` по видам формул его узлов не видит."""
439
+ out = [] if out is None else out
440
+ if isinstance(value, dict):
441
+ if value.get("kind") == "var":
442
+ if value["var"] not in out:
443
+ out.append(value["var"])
444
+ else:
445
+ for item in value.values():
446
+ _payload_vars(item, out)
447
+ elif isinstance(value, list):
448
+ for item in value:
449
+ _payload_vars(item, out)
450
+ return out
451
+
452
+
453
+ def _next_day(day: str) -> str:
454
+ """Следующий календарный день ISO §2.15 — граница полуинтервала."""
455
+ import datetime
456
+ return (datetime.date.fromisoformat(day) + datetime.timedelta(days=1)).isoformat()
457
+
458
+
459
+ class _Excluded(Exception):
460
+ """Узел исключён проекцией §92.3 на всей оси: в плане его нет."""
461
+
462
+
463
+ def _slug(urn: str) -> str:
464
+ """Внутреннее имя порождённого кода из StableId (таблица, функция, правило).
465
+
466
+ ASCII-локальная часть — с заменой символов вне `[A-Za-z0-9_]` на `_`, как
467
+ прежде. Замена букв вне ASCII (кириллица и другие письменности) стирала
468
+ различие имён: у отношений одного пакета получалось одно имя таблицы.
469
+ Такому слагу добавляются 8 знаков SHA-256 локальной части — имя остаётся
470
+ ASCII для всех целей и различимым (DECISION-0423 §7)."""
471
+ local = urn.split("#")[-1]
472
+ slug = re.sub(r"[^A-Za-z0-9_]", "_", local)
473
+ if local.isascii():
474
+ return slug
475
+ digest = hashlib.sha256(local.encode("utf-8")).hexdigest()[:8]
476
+ compact = re.sub(r"_+", "_", slug).strip("_")
477
+ return f"{compact}_{digest}" if compact else f"u_{digest}"
478
+
479
+
480
+ def _api_slug(urn: str) -> str:
481
+ """Нейтральное имя вопроса или входа API из StableId (DECISION-0423 §2.7).
482
+
483
+ ASCII-имя совпадает с `_slug`. Имя с буквами вне ASCII их сохраняет (NFC):
484
+ знаки, не являющиеся буквой, цифрой или `_`, сворачиваются в `_`. Слова и
485
+ регистр по языку (snake_case, camelCase, PascalCase) выводит эмиттер цели."""
486
+ local = urn.split("#")[-1]
487
+ if local.isascii():
488
+ return re.sub(r"[^A-Za-z0-9_]", "_", local)
489
+ name = re.sub(r"[^\w]+|_+", "_", unicodedata.normalize("NFC", local)).strip("_")
490
+ return name or "u_" + hashlib.sha256(local.encode("utf-8")).hexdigest()[:8]
491
+
492
+
493
+ LABEL_ORDER = {"official": 0, "plain": 1, "unofficial": 2, "translation": 3}
494
+
495
+
496
+ def _labels(node: dict) -> list:
497
+ """Метки §218 узла для комментариев API: официальная первой, затем
498
+ прочие и переводы; текст и язык — как в CLIR, без правки."""
499
+ out = [{"language": l.get("language"), "status": l.get("status"), "text": l.get("text")}
500
+ for l in (node.get("labels") or []) if l.get("text")]
501
+ return sorted(out, key=lambda l: (LABEL_ORDER.get(l["status"], 9), l["language"] or ""))
502
+
503
+
504
+ def _sccs(vertices: list, edges: list) -> dict:
505
+ """Компоненты сильной связности (Тарьян, итеративно): вершина → номер."""
506
+ succ: dict = {v: [] for v in vertices}
507
+ for a, b in edges:
508
+ succ.setdefault(a, []).append(b)
509
+ succ.setdefault(b, [])
510
+ index, low, comp, stack, on = {}, {}, {}, [], set()
511
+ counter = [0]
512
+ for root in sorted(succ):
513
+ if root in index:
514
+ continue
515
+ work = [(root, 0)]
516
+ while work:
517
+ v, i = work.pop()
518
+ if i == 0:
519
+ index[v] = low[v] = counter[0]
520
+ counter[0] += 1
521
+ stack.append(v)
522
+ on.add(v)
523
+ recurse = False
524
+ for j in range(i, len(succ[v])):
525
+ w = succ[v][j]
526
+ if w not in index:
527
+ work.append((v, j + 1))
528
+ work.append((w, 0))
529
+ recurse = True
530
+ break
531
+ if w in on:
532
+ low[v] = min(low[v], index[w])
533
+ if recurse:
534
+ continue
535
+ if low[v] == index[v]:
536
+ while True:
537
+ w = stack.pop()
538
+ on.discard(w)
539
+ comp[w] = index[v]
540
+ if w == v:
541
+ break
542
+ if work:
543
+ parent = work[-1][0]
544
+ low[parent] = min(low[parent], low[v])
545
+ return comp
546
+
547
+
548
+ def _covered(inner, outer: list) -> bool:
549
+ """Покрывает ли объединение окон `outer` окно `inner` (`None` — вся ось)."""
550
+ need = [(None, None)] if inner is None else inner
551
+ # `_union` сливает пересекающиеся и смежные окна, поэтому окно внутри
552
+ # объединения лежит целиком в одном его окне.
553
+ have = [(None, None)] if outer is None else _union(outer)
554
+ return all(any((h[0] is None or (w[0] is not None and h[0] <= w[0]))
555
+ and (h[1] is None or (w[1] is not None and w[1] <= h[1])) for h in have)
556
+ for w in need)
557
+
558
+
559
+ def _admit_current_slice(ir, world):
560
+ _reject_withdrawn_language_versions(ir, world)
561
+ for document in [ir, *(world or [])]:
562
+ temporal.validate_ir(document, Refused)
563
+ if any(n.get("kind") == "norm_template" or
564
+ (n.get("head") or {}).get("kind") == "norm_template_ref"
565
+ for n in document.get("nodes") or []):
566
+ raise Refused("NORM_RANKS_UNAVAILABLE: norm runtime requires current ranks (W6)")
567
+
568
+
569
+ class Builder:
570
+ def __init__(self, ir: dict, world: list | None = None, want: set | None = None,
571
+ resources: dict | None = None):
572
+ """`world` — CLIR зависимостей (замыкание импортов). Мир собирается
573
+ как у `resolver.link_world`: корень, затем зависимости по имени;
574
+ узел с уже занятым StableId пропускается, если его contentHash тот же
575
+ (копии фактов `facts pkg::Group` у потребителя), иначе — отказ.
576
+
577
+ `want` — возможности, которых требует не программа, а её вызывающий:
578
+ `negation` для дела с отрицательными фактами (DECISION-0237)."""
579
+ _admit_current_slice(ir, world)
580
+ self.linked = world is not None
581
+ self.world_passport = []
582
+ self.dated_facts: list = []
583
+ if world:
584
+ ir = self._link(ir, world)
585
+ self.ir = ir
586
+ self.consts: dict = {}
587
+ self.entities: set = set()
588
+ self.aliases: dict = {}
589
+ self.types: dict = {}
590
+ self.want = set(want or ())
591
+ self.caps: set = set()
592
+ self.l1 = False
593
+ self._meta: dict = {}
594
+ self._prio: list = []
595
+ self.v09 = False
596
+ self.v10 = False
597
+ self.v11 = False
598
+ self.v12 = False
599
+ self.v13 = False
600
+ self.v14 = False
601
+ self.v15 = False
602
+ self.v16 = False
603
+ self.v17 = False
604
+ self.v18 = False
605
+ # DECISION-0274: отношения суждения §47.3 объявлены.
606
+ self.v19 = False
607
+ self.judgments: dict = {}
608
+ # DECISION-0273: функции §46, параметры A3, `let`, `match` встретились.
609
+ self._function_seen = False
610
+ self._a3_seen = False
611
+ self._let_seen = False
612
+ self._match_seen = False
613
+ self._fn_plan: dict = {}
614
+ self._fn_stack: list = []
615
+ self._a3params: dict = {}
616
+ self._let_n = 0
617
+ # DECISION-0270: вычисляемый аргумент тела и условный терм встретились.
618
+ self._body_term_seen = False
619
+ self._if_seen = False
620
+ # DECISION-0269: датированное ребро приоритета встретилось при разборе.
621
+ self._dated_prio_seen = False
622
+ # DECISION-0266–0268: Boolean, Natural, границы месяца и `_` встретились
623
+ # при компиляции — `build_plan` строит план заново с возможностями.
624
+ self._boolean_seen = False
625
+ self._natural_seen = False
626
+ self._month_seen = False
627
+ self._wildcard_seen = False
628
+ # DECISION-0259 п. 1: `round` с политикой для рантайма и `div_round`.
629
+ self._round_seen = False
630
+ self._div_round_seen = False
631
+ # DECISION-0256 п. 1: Quantity первым классом встретилась при компиляции.
632
+ self._quantity_seen = False
633
+ # W2 часть 2: Instant первым классом встретилась при компиляции.
634
+ self._instant_seen = False
635
+ # DECISION-0252 п. 1: Rational либо значение-зависимое частное встретились
636
+ # при компиляции — `build_plan` строит план заново с `value:rational`.
637
+ self._rational_seen = False
638
+ # DECISION-0252 п. 4: правила с ошибкой значения над незавершённым входом —
639
+ # ошибку не ловят, она становится названным отказом раннера.
640
+ self._value_refuse: set = set()
641
+ # Байты ресурсов по `resourceUri` (снимки §85, DECISION-0245); без
642
+ # записи — явный MISSING_CALENDAR; исходное дерево не читается.
643
+ self.resources = dict(resources or {})
644
+ # Explicit maps (including an empty map) never read repository resources.
645
+ # Standalone planning never discovers a source checkout.
646
+ self.repository_resources = False
647
+ self._norms: list = []
648
+ self._norm_meta: dict = {}
649
+ self._atoms: list = []
650
+ self._aggs: list = []
651
+ self._agg_cache: dict = {}
652
+ # E-0105: в плане есть агрегат с ошибкой значения — нужен `backchain`.
653
+ self._agg_fallible = False
654
+ self._agg_reads: list = []
655
+ self._agg_inner: list = []
656
+ self._closures: list = []
657
+
658
+ def _link(self, root: dict, world: list) -> dict:
659
+ nodes, owner = [], {}
660
+ parts = [((root.get("package") or {}).get("name", "<root>"), root)]
661
+ parts += sorted(((d.get("package") or {}).get("name", "?"), d) for d in world)
662
+ for name, ir in parts:
663
+ for n in ir.get("nodes", []):
664
+ nid = n.get("id", "")
665
+ prev = owner.get(nid)
666
+ if prev is not None:
667
+ if prev.get("contentHash") != n.get("contentHash"):
668
+ raise Refused(f"коллизия StableId между пакетами мира с разным содержимым: {nid}")
669
+ continue
670
+ owner[nid] = n
671
+ nodes.append(n)
672
+ if ir is not root:
673
+ self.world_passport.append({"name": name, "namespace": (ir.get("package") or {}).get("namespace"),
674
+ "version": (ir.get("package") or {}).get("version")})
675
+ return dict(root, nodes=nodes, imports=[])
676
+
677
+ def _foreign_type_aliases(self, ir: dict, rels: list) -> None:
678
+ """Чужой тип в форме `имя.пакета#Тип` (так понижается
679
+ `algo.description::Algorithm`, lawc `lower/decls.rs`): вид слота
680
+ берётся у объявления `namespace#Тип`, где namespace — пакета мира с
681
+ ТОЧНО этим именем. Строка типа в сигнатурах, константах и
682
+ наследовании не меняется: сверка §189 у оракула (`facts._conforms`)
683
+ сравнивает строки, и нормализация приняла бы то, что он отвергает.
684
+ Имя без пакета в мире или без объявления остаётся вне среза."""
685
+ names = {(ir.get("package") or {}).get("name"): (ir.get("package") or {}).get("namespace")}
686
+ names.update({w["name"]: w["namespace"] for w in self.world_passport})
687
+ raw = {t for r in rels for t in self.types[r["id"]] if t}
688
+ raw |= {c["type"] for c in self.consts.values() if c["type"]}
689
+ raw |= {b for b in self.bases.values() if b}
690
+ for name in sorted(raw):
691
+ if name.startswith("urn:") or name.count("#") != 1:
692
+ continue
693
+ package, local = name.split("#")
694
+ ns = names.get(package)
695
+ if not ns:
696
+ continue
697
+ # Цель сама бывает алиасом §40 (`kz.corpus.osms#Person` →
698
+ # `kz.corpus.civilcode#Person`): вид берётся за цепочкой, строка
699
+ # ссылки при этом не меняется (W2, часть 1).
700
+ urn = self._t(f"{ns}#{local}")
701
+ if urn in self.entities:
702
+ self.entities.add(name)
703
+ elif urn in self.enums:
704
+ self.enums[name] = self.enums[urn]
705
+
706
+ def _t(self, name):
707
+ """Имя типа за цепочкой алиасов §40 (E-0097/E-0098) — как
708
+ `facts._unalias` оракула: обе стороны сверки §189 берутся за цепочкой,
709
+ цикл обрывается на повторе. `None` проходит как есть."""
710
+ seen: set = set()
711
+ current = name
712
+ while current is not None and current not in seen:
713
+ seen.add(current)
714
+ nxt = self.aliases.get(current)
715
+ if nxt is None:
716
+ break
717
+ current = nxt
718
+ return current
719
+
720
+ def ts(self, name):
721
+ name = self._t(name)
722
+ if name == "urn:law:std#Number":
723
+ raise Refused("NUMBER_UNAVAILABLE: exact Number and slot narrowing (E-0316/E-0319/E-0334) are outside this slice")
724
+ if name == RAT:
725
+ self._rational_seen = True
726
+ if name == QTY:
727
+ self._quantity_seen = True
728
+ if name == INST:
729
+ self._instant_seen = True
730
+ if name == BOOL:
731
+ self._boolean_seen = True
732
+ if name == NATURAL:
733
+ self._natural_seen = True
734
+ if name in TYPES:
735
+ return TYPES[name]
736
+ if name in self.entities:
737
+ return "text"
738
+ if name in self.enums:
739
+ return "enum"
740
+ raise Refused(f"тип аргумента вне среза генератора: {name}")
741
+
742
+ def term(self, node: dict, binds: dict) -> dict:
743
+ k = node["kind"]
744
+ if k == "var":
745
+ if node["var"] not in binds:
746
+ raise Refused(f"несвязанная переменная в голове: {node['var']}")
747
+ bind = binds[node["var"]]
748
+ if bind[0] == "p":
749
+ # Параметр подплана агрегата: значение внешней переменной (§52.1).
750
+ return {"k": "param", "i": bind[1], "t": bind[2]}
751
+ if bind[0] == "f":
752
+ # DECISION-0273: параметр функции §46 — позиционный аргумент.
753
+ return {"k": "fvar", "i": bind[1], "t": bind[2]}
754
+ if bind[0] == "l":
755
+ # DECISION-0273: переменная `let` — локальное связывание.
756
+ return {"k": "lvar", "i": bind[1], "t": bind[2]}
757
+ i, j, t = bind
758
+ return {"k": "var", "row": i, "col": j, "t": t}
759
+ if k == "aggregate" and self.v09:
760
+ return self._aggregate(node, binds)[0]
761
+ if k == "let":
762
+ return self._let_term(node, binds)
763
+ if k == "match":
764
+ return self._match_term(node, binds)
765
+ if k == "value":
766
+ t = (node.get("type") or {}).get("name", "")
767
+ if t == "urn:law:std#Number":
768
+ raise Refused("NUMBER_UNAVAILABLE: exact Number and slot narrowing (E-0316/E-0319/E-0334) are outside this slice")
769
+ if t == "urn:law:std#Money":
770
+ return {"k": "lit", "t": "money", "v": str(node["value"]), "cur": node["currency"]}
771
+ if t in ("urn:law:std#Decimal", "urn:law:std#Integer"):
772
+ return {"k": "lit", "t": "dec", "v": str(node["value"])}
773
+ if t == "urn:law:std#Text":
774
+ return {"k": "lit", "t": "text", "v": node["value"]}
775
+ if t == "urn:law:std#Date":
776
+ if not temporal.date_valid(node.get("value")):
777
+ raise Refused("TEMPORAL_VALUE_INVALID: Date requires ASCII Gregorian YYYY-MM-DD")
778
+ return {"k": "lit", "t": "date", "v": str(node["value"])}
779
+ if t == INST:
780
+ if not temporal.instant_valid(node.get("value")):
781
+ raise Refused("TEMPORAL_VALUE_INVALID: Instant requires an explicit valid offset")
782
+ # Запись мгновения — идентичность (E-0214), сравнение — по моменту (E-0048).
783
+ self._instant_seen = True
784
+ return {"k": "lit", "t": "inst", "v": str(node["value"])}
785
+ if t == RAT:
786
+ # §35 (E-0005): запись литерала бывает сократимой (`5/100` в датасете
787
+ # A3), оракул её сокращает (`from_literal_term`); рантайм цели принимает
788
+ # только канон §168.1 — сокращаем здесь. Не `n/d` с целыми, ноль в
789
+ # знаменателе — у оракула TYPE_ERROR/DIVISION_BY_ZERO всегда: отказ.
790
+ #
791
+ # Errata E-0213 сокращение НЕ отменяет, и это замер, а не осторожность.
792
+ # Канон §208 теперь печатает несократимую дробь, и порождаемый CLIR
793
+ # корпуса после перевыпуска несократим весь (0 из 1 281 снимка), — но
794
+ # план читает CLIR КАК ЗАПИСАН, а не канонизирует его: сократимая
795
+ # запись остаётся ЗАКОННЫМ входом (§208, раздел лексики E-0203),
796
+ # схема `LiteralTerm.value` намеренно не сужена, и рукописный либо
797
+ # чужой CLIR вправе прийти с `5/100`. Правило здесь то же самое:
798
+ # `Fraction` даёт взаимно простую пару с положительным знаменателем —
799
+ # ровно канон `canonical_rational`, — и `5/1` остаётся дробью.
800
+ raw = str(node["value"])
801
+ try:
802
+ num, den = (int(x) for x in raw.split("/"))
803
+ except ValueError as exc:
804
+ raise Refused(f"литерал Rational не 'n/d' вне среза генератора (§35): {raw}") from exc
805
+ if den == 0:
806
+ raise Refused("литерал Rational со знаменателем 0 вне среза генератора (§35)")
807
+ frac = Fraction(num, den)
808
+ self._rational_seen = True
809
+ return {"k": "lit", "t": "rat", "v": f"{frac.numerator}/{frac.denominator}"}
810
+ if t == QTY and node.get("unit"):
811
+ self._quantity_seen = True
812
+ return {"k": "lit", "t": "qty", "v": str(node["value"]), "unit": node["unit"]}
813
+ if t == BOOL:
814
+ # DECISION-0266: литерал обязан быть JSON-булевым — иное у
815
+ # оракула всегда TYPE_ERROR (`values.from_literal_term`).
816
+ if not isinstance(node.get("value"), bool):
817
+ raise Refused("литерал Boolean не true/false вне среза генератора (§48)")
818
+ self._boolean_seen = True
819
+ return {"k": "lit", "t": "bool", "v": node["value"]}
820
+ if t == NATURAL:
821
+ # DECISION-0266: неотрицательное целое; иное у оракула всегда TYPE_ERROR.
822
+ v = node.get("value")
823
+ if isinstance(v, bool) or not isinstance(v, int) or v < 0:
824
+ raise Refused("литерал Natural не неотрицательное целое вне среза генератора (§48)")
825
+ self._natural_seen = True
826
+ return {"k": "lit", "t": "dec", "v": str(v)}
827
+ raise Refused(f"тип значения вне среза генератора: {t}")
828
+ if k == "entity_ref":
829
+ return {"k": "lit", "t": "text", "v": node["id"]}
830
+ if k == "const_ref":
831
+ if self.is_member(node["id"]):
832
+ return {"k": "lit", "t": "enum", "v": node["id"]}
833
+ if self._is_a3(node["id"]):
834
+ return self._a3_term(node["id"])
835
+ return self.term(self.const_value(node["id"]), binds)
836
+ if k == "binary":
837
+ op = BINOP.get(node["op"])
838
+ if op is None:
839
+ raise Refused(f"операция вне среза генератора: {node['op']}")
840
+ out = {"k": "bin", "op": op, "l": self.term(node["left"], binds),
841
+ "r": self.term(node["right"], binds)}
842
+ self.static_type(node, binds) # таблицы видов §58 — статически
843
+ # Current arithmetic always carries operand kinds (i64 boundary included).
844
+ # DECISION-0252 п. 3: статические виды операндов для `arith` рантайма
845
+ # (DECISION-0270: и у планов с вычисляемыми аргументами — код ошибки).
846
+ out["lk"] = KIND_CODE[self.static_type(node["left"], binds)]
847
+ out["rk"] = KIND_CODE[self.static_type(node["right"], binds)]
848
+ return out
849
+ if k == "call" and node.get("function") in CAL_FNS:
850
+ return self._calendar_call(node, binds)
851
+ if k == "call" and node.get("function") in MONTH_FNS:
852
+ return self._month_call(node, binds)
853
+ if k == "call" and node.get("function") in DUR_FNS:
854
+ return self._duration_call(node, binds)
855
+ if k == "if":
856
+ return self._if_term(node, binds)
857
+ if k == "call" and node.get("function") in self.fdecls:
858
+ return self._fcall(node, binds)
859
+ if k == "call":
860
+ fn = STD_FN.get(node["function"])
861
+ if fn is None:
862
+ raise Refused(f"функция вне среза генератора: {node['function']}")
863
+ out = {"k": "call", "fn": fn, "args": [self.term(a, binds) for a in node["args"]]}
864
+ self.static_type(node, binds)
865
+ if fn in ("round", "div_round"):
866
+ ak = self._policy(node, binds)
867
+ if self.v14:
868
+ # DECISION-0259 п. 2: вид аргумента — из плана, не из значения.
869
+ out["ak"] = ak
870
+ return out
871
+ raise Refused(f"узел терма вне среза генератора: {k}")
872
+
873
+ # --- DECISION-0273: функции §46, параметры A3, let, match -----------------
874
+
875
+ def _is_a3(self, cid: str) -> bool:
876
+ """Функция объявлена параметром-датасетом A3 (`calendar.is_a3_parameter`)."""
877
+ decl = self.fdecls.get(cid)
878
+ return decl is not None and any((e.get("attributes") or {}).get("a3Kind") == "parameter-dataset"
879
+ for e in decl.get("provenance") or [])
880
+
881
+ def _a3_range(self, cond: dict) -> tuple:
882
+ """Условие строки параметра (`a3._range_condition`): `true`, `v0 >= start`,
883
+ `v0 < end` либо их конъюнкция; иная форма — отказ."""
884
+ k = (cond or {}).get("kind")
885
+ if k == "boolean" and cond.get("value") is True:
886
+ return None, None
887
+ if k == "and":
888
+ items = cond.get("items") or []
889
+ if len(items) == 2:
890
+ s1, e1 = self._a3_range(items[0])
891
+ s2, e2 = self._a3_range(items[1])
892
+ if s1 is not None and e1 is None and s2 is None and e2 is not None:
893
+ return s1, e2
894
+ raise Refused("форма параметра A3 вне среза генератора (DECISION-0273)")
895
+ if k == "comparison" and (cond.get("left") or {}).get("kind") == "var" \
896
+ and (cond.get("right") or {}).get("kind") == "value" \
897
+ and ((cond["right"].get("type") or {}).get("name") == "urn:law:std#Date"):
898
+ if cond.get("op") == "ge":
899
+ return str(cond["right"]["value"]), None
900
+ if cond.get("op") == "lt":
901
+ return None, str(cond["right"]["value"])
902
+ raise Refused("форма параметра A3 вне среза генератора (DECISION-0273)")
903
+
904
+ def _a3_term(self, cid: str) -> dict:
905
+ """Параметр A3 §A3.2 через `const_ref`: оракул вызывает функцию датасета на
906
+ `legalTime` (`values.eval_term`), строки — окна `[start, end)` на оси
907
+ права (`a3.lower_parameter`). План разбирает тело структурно: цепочка
908
+ `if` по окнам → вызов функции строки → `Result/Ok [литерал]`, хвост —
909
+ `MissingParameterValue`. Значение готовит `prepare` один раз на дело;
910
+ разрыв — MISSING_PARAMETER_VALUE правила (молчание права, статус
911
+ NEITHER/COMPUTED, обход E-0105 код пропускает). Ось Instant — отказ."""
912
+ if cid in self._a3params:
913
+ p = self._a3params[cid]
914
+ return {"k": "a3", "id": cid, "t": p["kind"]}
915
+ decl, sym = self.fdecls[cid], self.fsyms.get(cid)
916
+ if sym is None:
917
+ raise Refused(f"параметр A3 без объявления символа: {cid}")
918
+ params = decl.get("parameters") or []
919
+ if len(params) != 1 or self._t((params[0].get("type") or {}).get("name")) != "urn:law:std#Date":
920
+ raise Refused("параметр A3 по оси Instant вне среза генератора (DECISION-0273)")
921
+ rt = sym.get("returnType") or {}
922
+ if rt.get("name") != A3_RESULT or not rt.get("args"):
923
+ raise Refused("форма параметра A3 вне среза генератора (DECISION-0273)")
924
+ vt = self._t((rt["args"][0] or {}).get("name"))
925
+ kind = self.ts(vt)
926
+ windows, body = [], decl.get("body") or {}
927
+ while True:
928
+ if body.get("kind") == "variant_value" and body.get("constructor") == A3_MISSING:
929
+ break
930
+ if body.get("kind") != "if":
931
+ raise Refused("форма параметра A3 вне среза генератора (DECISION-0273)")
932
+ start, end = self._a3_range(body.get("condition"))
933
+ then = body.get("then") or {}
934
+ row = self.fdecls.get(then.get("function")) if then.get("kind") == "call" and not then.get("args") else None
935
+ rb = (row or {}).get("body") or {}
936
+ if rb.get("kind") != "variant_value" or rb.get("constructor") != A3_OK or len(rb.get("fields") or []) != 1:
937
+ raise Refused("форма параметра A3 вне среза генератора (DECISION-0273)")
938
+ lit = rb["fields"][0]
939
+ if self.static_type(lit, {}) != vt:
940
+ raise Refused("тип строки параметра A3 не совпадает с типом значения (DECISION-0273)")
941
+ windows.append([start, end, self.term(lit, {})])
942
+ body = body.get("else") or {}
943
+ if not windows:
944
+ raise Refused("параметр A3 без строк вне среза генератора (DECISION-0273)")
945
+ self._a3params[cid] = {"id": cid, "type": vt, "kind": kind, "windows": windows}
946
+ self._a3_seen = True
947
+ return {"k": "a3", "id": cid, "t": kind}
948
+
949
+ def _fn_sig(self, fn: str) -> dict:
950
+ """Скомпилированная функция §46: тело по позиционным параметрам,
951
+ статический тип результата против объявленного (`_require_type`
952
+ оракула), фоллибельность тела. Рекурсия у оракула —
953
+ NON_EXECUTABLE_FUNCTION_RECURSION всегда: отказ плана."""
954
+ if fn in self._fn_plan:
955
+ return self._fn_plan[fn]
956
+ if fn in self._fn_stack:
957
+ raise Refused(f"рекурсия функции §46 (NON_EXECUTABLE_FUNCTION_RECURSION) вне среза генератора: {fn}")
958
+ if self._is_a3(fn):
959
+ raise Refused(f"вызов параметра A3 как функции вне среза генератора: {fn}")
960
+ decl, sym = self.fdecls[fn], self.fsyms.get(fn)
961
+ if sym is None:
962
+ raise Refused(f"функция без объявления символа: {fn}")
963
+ for f in sorted(set(decl) - FUNCTION_FIELDS):
964
+ raise Refused(f"поле функции вне среза генератора: {f}")
965
+ params = decl.get("parameters") or []
966
+ urns = [self._t((p.get("type") or {}).get("name")) for p in params]
967
+ kinds = [self.ts(u) for u in urns]
968
+ binds = {p["id"]: ("f", i, kinds[i], urns[i]) for i, p in enumerate(params)}
969
+ declared = self._t((sym.get("returnType") or {}).get("name"))
970
+ returns = self.ts(declared)
971
+ name = _slug(fn)
972
+ if any(v["name"] == name for v in self._fn_plan.values()):
973
+ raise Refused(f"совпадающие имена функций в мире вне среза генератора: {name}")
974
+ self._fn_stack.append(fn)
975
+ try:
976
+ body = self.term(decl["body"], binds)
977
+ actual = self.static_type(decl["body"], binds)
978
+ if actual != declared:
979
+ raise Refused("тип результата функции §46 не совпадает с объявленным вне среза генератора (DECISION-0273)")
980
+ fallible = self._fallible_value(decl["body"], binds)
981
+ finally:
982
+ self._fn_stack.pop()
983
+ out = {"name": name, "id": fn, "params": kinds, "paramTypes": urns, "returns": returns,
984
+ "returnType": declared, "body": body}
985
+ if fallible:
986
+ out["fallible"] = True
987
+ self._fn_plan[fn] = out
988
+ self._function_seen = True
989
+ return out
990
+
991
+ def _fcall(self, node: dict, binds: dict) -> dict:
992
+ """Вызов функции §46: арность и типы аргументов статически (у оракула —
993
+ TYPE_ERROR при исполнении, здесь отказ плана)."""
994
+ sig = self._fn_sig(node["function"])
995
+ self._function_seen = True
996
+ args = node.get("args") or []
997
+ if len(args) != len(sig["params"]):
998
+ raise Refused("арность вызова функции §46 не совпадает с объявлением вне среза генератора")
999
+ for a, want in zip(args, sig["paramTypes"]):
1000
+ actual = self.static_type(a, binds)
1001
+ if actual != want:
1002
+ raise Refused("тип аргумента функции §46 не совпадает с параметром вне среза генератора (DECISION-0273)")
1003
+ return {"k": "fcall", "fn": sig["name"], "args": [self.term(a, binds) for a in args]}
1004
+
1005
+ def _let_binds(self, node: dict, binds: dict, i: int) -> dict:
1006
+ var = node.get("variable") or {}
1007
+ vt = self._t((var.get("type") or {}).get("name"))
1008
+ kind = self.ts(vt)
1009
+ actual = self.static_type(node.get("value") or {}, binds)
1010
+ if actual != vt:
1011
+ raise Refused("тип значения `let` не совпадает с объявленным вне среза генератора (DECISION-0273)")
1012
+ out = dict(binds)
1013
+ out[str(var.get("id"))] = ("l", i, kind, vt)
1014
+ return out
1015
+
1016
+ def _let_term(self, node: dict, binds: dict) -> dict:
1017
+ """`let` §55: значение вычисляется один раз, тело — с локальным связыванием;
1018
+ тип значения обязан совпасть с объявленным (`_require_type`)."""
1019
+ i = self._let_n
1020
+ self._let_n += 1
1021
+ inner = self._let_binds(node, binds, i)
1022
+ self._let_seen = True
1023
+ return {"k": "let", "i": i, "v": self.term(node["value"], binds), "b": self.term(node["body"], inner)}
1024
+
1025
+ def _match_arms(self, node: dict, binds: dict) -> tuple:
1026
+ """Образцы `match` §55.1 над перечислением: вариант без полей —
1027
+ идентичность члена (`_match_pattern`, E-0067), `_` — любой; иные образцы
1028
+ и не-перечисление — отказ. Возвращает (enum, [(член|None, значение)],
1029
+ исчерпывающий ли)."""
1030
+ st = self.static_type(node.get("subject") or {}, binds)
1031
+ if st not in self.enums:
1032
+ raise Refused("match §55.1 над не-перечислением вне среза генератора (DECISION-0273)")
1033
+ members = self.enums[st]
1034
+ arms, covered, wild = [], set(), False
1035
+ for arm in node.get("arms") or []:
1036
+ p = arm.get("pattern") or {}
1037
+ if p.get("kind") == "wildcard":
1038
+ m, wild = None, True
1039
+ elif p.get("kind") == "variant":
1040
+ if p.get("fields"):
1041
+ raise Refused("вариантный образец с полями над перечислением вне среза генератора (§38)")
1042
+ enum_urn, _, member = str(p.get("constructor")).rpartition("/")
1043
+ if enum_urn != st:
1044
+ raise Refused("образец match другого перечисления вне среза генератора (DECISION-0273)")
1045
+ m = next((x for x in members if x.rsplit("#", 1)[-1] == member), None)
1046
+ if m is None:
1047
+ raise Refused(f"член перечисления в образце не объявлен: {p.get('constructor')}")
1048
+ covered.add(m)
1049
+ else:
1050
+ raise Refused(f"образец match вне среза генератора: {p.get('kind')}")
1051
+ arms.append((m, arm.get("value") or {}))
1052
+ if not arms:
1053
+ raise Refused("match без ветвей вне среза генератора")
1054
+ return st, arms, wild or covered >= set(members)
1055
+
1056
+ def _match_type(self, node: dict, binds: dict) -> str | None:
1057
+ _st, arms, _ex = self._match_arms(node, binds)
1058
+ kinds = {self.static_type(v, binds) for _m, v in arms}
1059
+ if len(kinds) != 1:
1060
+ raise Refused("ветви match §55.1 разных видов вне среза генератора (DECISION-0273)")
1061
+ return kinds.pop()
1062
+
1063
+ def _match_term(self, node: dict, binds: dict) -> dict:
1064
+ self._match_type(node, binds)
1065
+ _st, arms, exhaustive = self._match_arms(node, binds)
1066
+ self._match_seen = True
1067
+ out = {"k": "match", "s": self.term(node["subject"], binds),
1068
+ "arms": [{"m": m, "v": self.term(v, binds)} for m, v in arms]}
1069
+ if not exhaustive:
1070
+ # §55.1: ни один образец не совпал — NON_EXHAUSTIVE_MATCH по подстановке.
1071
+ out["x"] = False
1072
+ return out
1073
+
1074
+ def _if_term(self, node: dict, binds: dict) -> dict:
1075
+ """Условный терм §56 (E-0180, DECISION-0270): условие — чистое
1076
+ data-условие (`values.is_data_condition`: boolean, comparison, and,
1077
+ or); статусный тест в условии читает опору, не данные — отказ. Ветви
1078
+ одного статического вида; иное — отказ (у оракула вид по значению
1079
+ ветви, срез видами ветвей расходился бы с ключом атома). Ветвь
1080
+ вычисляется лениво — только выбранная (`select_conditionals`)."""
1081
+ cond = self._data_cond(node.get("condition"), binds)
1082
+ kt = self.static_type(node.get("then") or {}, binds)
1083
+ ke = self.static_type(node.get("else") or {}, binds)
1084
+ if kt != ke:
1085
+ raise Refused("ветви условного терма §56 разных видов вне среза генератора (DECISION-0270)")
1086
+ self._if_seen = True
1087
+ return {"k": "if", "c": cond, "t": self.term(node["then"], binds), "e": self.term(node["else"], binds)}
1088
+
1089
+ def _data_cond(self, f: dict, binds: dict) -> dict:
1090
+ k = (f or {}).get("kind")
1091
+ if k == "boolean":
1092
+ return {"k": "bool", "v": bool(f.get("value"))}
1093
+ if k == "comparison":
1094
+ if f.get("op") not in CMP:
1095
+ raise Refused(f"сравнение вне среза генератора: {f.get('op')}")
1096
+ self.guard_enums(f, binds)
1097
+ self.guard_kinds(f, binds)
1098
+ self.guard_instant(f, binds)
1099
+ return {"k": "cmp", "op": f["op"], "l": self.term(f["left"], binds), "r": self.term(f["right"], binds)}
1100
+ if k in ("and", "or"):
1101
+ items = f.get("items") or []
1102
+ if not items:
1103
+ raise Refused("пустое условие терма §56 вне среза генератора")
1104
+ return {"k": k, "items": [self._data_cond(x, binds) for x in items]}
1105
+ raise Refused(f"условие терма §56 вне data-условия вне среза генератора (DECISION-0270): {k}")
1106
+
1107
+ def _body_arg(self, ai: int, j: int, t: str, arg: dict, expected: str, binds: dict) -> dict:
1108
+ """Фильтр атома тела по аргументу. Константа — как прежде (`body_const`);
1109
+ вычисляемый терм (E-0180, DECISION-0270) — тип обязан совпасть с типом
1110
+ слота статически (оракул сопоставляет канонические байты, значение И
1111
+ тип), значение считается ДО перебора таблицы (`pre`), ошибка значения —
1112
+ issue правила (`err`), как у гарда."""
1113
+ if arg["kind"] in ("value", "entity_ref", "const_ref"):
1114
+ return {"row": ai, "col": j, "t": t, "term": self.body_const(arg, expected)}
1115
+ if arg["kind"] not in ("binary", "call", "if", "let", "match"):
1116
+ raise Refused(f"терм в аргументе тела вне среза генератора: {arg['kind']}")
1117
+ missing = sorted(set(self._free(arg)) - set(binds))
1118
+ if missing:
1119
+ raise Refused(f"переменная без позитивного генератора (§190/§113): {', '.join(missing)}")
1120
+ actual = self.static_type(arg, binds)
1121
+ if actual != expected and (expected in TYPES or expected in self.enums):
1122
+ raise Refused("тип аргумента тела не совпадает с параметром §189")
1123
+ self._body_term_seen = True
1124
+ out = {"row": ai, "col": j, "t": t, "term": self.term(arg, binds), "pre": True}
1125
+ if self._fallible_calendar(arg) or self._fallible_value(arg, binds):
1126
+ out["err"] = True
1127
+ return out
1128
+
1129
+ def _month_call(self, node: dict, binds: dict) -> dict:
1130
+ """§84.2 (E-0084, DECISION-0267): граница месяца — чистая функция над
1131
+ Date. Не Date у оракула всегда TYPE_ERROR — отказ плана; над слотом
1132
+ Date ошибки значения нет, поэтому вызов не фоллибелен и модели не
1133
+ меняет (как `days_between`, DECISION-0245 п. 1)."""
1134
+ name = MONTH_FNS[node["function"]]
1135
+ args = node.get("args") or []
1136
+ if len(args) != 1:
1137
+ raise Refused(f"{name}: арность вне среза генератора (§84.2)")
1138
+ out = self.term(args[0], binds)
1139
+ if self.static_type(args[0], binds) != "urn:law:std#Date":
1140
+ raise Refused(f"{name}: аргумент не Date вне среза генератора (§57/§2.15)")
1141
+ self._month_seen = True
1142
+ return {"k": "call", "fn": name, "args": [out]}
1143
+
1144
+ def _duration_call(self, node: dict, binds: dict) -> dict:
1145
+ """§84.1 (W2 часть 2): `hours_between`/`minutes_between` — полных единиц
1146
+ между мгновениями со знаком; `add_duration` — мгновение через целую
1147
+ Quantity секунд/минут/часов/суток, канон UTC. Виды аргументов
1148
+ проверяются статически, как `values` при исполнении: не Instant, не
1149
+ Quantity, чужая единица, дробный счётчик — TYPE_ERROR оракула всегда,
1150
+ здесь отказ плана. Смешение записи со смещением и без — отказ рантайма."""
1151
+ name = DUR_FNS[node["function"]]
1152
+ args = node.get("args") or []
1153
+
1154
+ def moment(a):
1155
+ out = self.term(a, binds)
1156
+ if self.static_type(a, binds) != INST:
1157
+ raise Refused(f"{name}: аргумент не Instant вне среза генератора (§84.1/§2.15)")
1158
+ return out
1159
+
1160
+ self._instant_seen = True
1161
+ if name != "add_duration":
1162
+ if len(args) != 2:
1163
+ raise Refused(f"{name}: арность вне среза генератора (§84.1)")
1164
+ return {"k": "call", "fn": name, "args": [moment(args[0]), moment(args[1])]}
1165
+ if len(args) != 2:
1166
+ raise Refused("add_duration: арность вне среза генератора (§84.1)")
1167
+ count = args[1]
1168
+ if count.get("kind") != "value" or (count.get("type") or {}).get("name") != QTY:
1169
+ raise Refused("add_duration: длительность не литерал Quantity вне среза генератора (§84.1)")
1170
+ unit = count.get("unit")
1171
+ if unit not in DURATION_UNITS:
1172
+ raise Refused(f"add_duration: единица {unit} вне среза генератора (§84.1)")
1173
+ try:
1174
+ value = float(str(count.get("value")))
1175
+ except ValueError:
1176
+ raise Refused("add_duration: длительность не число вне среза генератора (§84.1)")
1177
+ if value != int(value):
1178
+ raise Refused("add_duration: дробная длительность вне среза генератора (§84.1)")
1179
+ return {"k": "call", "fn": "add_duration", "args": [moment(args[0]), {"k": "qty", "v": int(value), "unit": unit}]}
1180
+
1181
+ def _calendar_call(self, node: dict, binds: dict) -> dict:
1182
+ """Календарная функция §84–§86 (DECISION-0245). Аргументы проверяются
1183
+ статически так, как `values` проверяет их при исполнении: то, что у
1184
+ оракула всегда TYPE_ERROR (не Date, чужая единица, дробный счётчик),
1185
+ здесь отказ плана; ошибки политики и снимка — исполнения."""
1186
+ name = CAL_FNS[node["function"]][0]
1187
+ args = node.get("args") or []
1188
+ date = "urn:law:std#Date"
1189
+
1190
+ def dated(a):
1191
+ out = self.term(a, binds)
1192
+ if self.static_type(a, binds) != date:
1193
+ raise Refused(f"{name}: аргумент не Date вне среза генератора (§57/§2.15)")
1194
+ return out
1195
+
1196
+ if name == "days_between":
1197
+ if len(args) != 2:
1198
+ raise Refused("days_between: арность вне среза генератора (§84.4)")
1199
+ return {"k": "call", "fn": name, "args": [dated(args[0]), dated(args[1])]}
1200
+ if len(args) not in (2, 3) or (len(args) == 3 and name != "add_business_days"):
1201
+ raise Refused(f"{name}: арность вне среза генератора (§86)")
1202
+ count = args[1]
1203
+ if count.get("kind") != "value" and self.static_type(count, binds) == QTY:
1204
+ # DECISION-0256 п. 6: связыватель Quantity — вид, единица и целочисленность
1205
+ # на деле (`counter` рантайма); у `add_legal_term` ветвь выбирает единица.
1206
+ self._quantity_seen = True
1207
+ out = {"k": "call", "fn": name,
1208
+ "args": [dated(args[0]), {"k": "qtyvar", "fn": name, "term": self.term(count, binds)}]}
1209
+ if len(args) == 3:
1210
+ if self.static_type(args[2], binds) != "urn:law:std#Text":
1211
+ raise Refused("add_business_days: after_time не Text вне среза генератора (§86 cutoff)")
1212
+ out["args"].append(self.term(args[2], binds))
1213
+ return out
1214
+ if count.get("kind") != "value" or (count.get("type") or {}).get("name") != "urn:law:std#Quantity":
1215
+ raise Refused(f"{name}: счётчик не литерал Quantity вне среза генератора (DECISION-0245 п. 2)")
1216
+ unit = count.get("unit")
1217
+ allowed = {"add_calendar_period": CALENDAR_PERIOD_UNITS, "add_business_days": DAY_UNITS,
1218
+ "add_legal_term": DEADLINE_UNITS}[name]
1219
+ if unit not in allowed:
1220
+ raise Refused(f"{name}: единица счётчика {unit} вне среза генератора (E-0029/E-0109)")
1221
+ try:
1222
+ value = float(str(count.get("value")))
1223
+ except ValueError:
1224
+ raise Refused(f"{name}: счётчик не число вне среза генератора (§84)")
1225
+ if value != int(value):
1226
+ raise Refused(f"{name}: дробный счётчик вне среза генератора (§84)")
1227
+ if name == "add_legal_term":
1228
+ # §86.1: единица выбирает одну из двух нормативных операций.
1229
+ name = "add_business_days" if unit in DAY_UNITS else "add_calendar_period"
1230
+ out = {"k": "call", "fn": name, "args": [dated(args[0]), {"k": "qty", "v": int(value), "unit": unit}]}
1231
+ if len(args) == 3:
1232
+ if self.static_type(args[2], binds) != "urn:law:std#Text":
1233
+ raise Refused("add_business_days: after_time не Text вне среза генератора (§86 cutoff)")
1234
+ out["args"].append(self.term(args[2], binds))
1235
+ return out
1236
+
1237
+ @staticmethod
1238
+ def _fallible_calendar(node) -> bool:
1239
+ """Терм содержит календарную функцию с ошибкой значения (§86)."""
1240
+ stack = [node]
1241
+ while stack:
1242
+ x = stack.pop()
1243
+ if isinstance(x, list):
1244
+ stack.extend(x)
1245
+ elif isinstance(x, dict):
1246
+ if x.get("kind") == "call" and x.get("function") in CAL_FNS \
1247
+ and x["function"] != "urn:law:std#days_between":
1248
+ return True
1249
+ stack.extend(v for v in x.values() if isinstance(v, (dict, list)))
1250
+ return False
1251
+
1252
+ def static_type(self, node: dict, binds: dict) -> str | None:
1253
+ """Тип терма головы, известный статически: URN типа `std` либо None,
1254
+ если проверять нечего (сущность — у `entity_ref` носителя типа нет)."""
1255
+ k = node["kind"]
1256
+ if k == "var":
1257
+ if node["var"] not in binds:
1258
+ raise Refused(f"несвязанная переменная в голове: {node['var']}")
1259
+ bind = binds[node["var"]]
1260
+ if bind[0] in ("p", "f", "l"):
1261
+ urn = bind[3]
1262
+ return urn if urn in TYPES or urn in self.enums else None
1263
+ pred = self._atoms[bind[0]]["predicate"]
1264
+ return self._slot_type(pred, self._var_col(node["var"], binds))
1265
+ if k == "value":
1266
+ return (node.get("type") or {}).get("name")
1267
+ if k == "const_ref":
1268
+ if self.is_member(node["id"]):
1269
+ return self.members[node["id"]]
1270
+ if self._is_a3(node["id"]):
1271
+ return self._a3params[node["id"]]["type"] if node["id"] in self._a3params \
1272
+ else self._a3params[self._a3_term(node["id"])["id"]]["type"]
1273
+ return self.static_type(self.const_value(node["id"]), binds)
1274
+ if k == "let":
1275
+ return self.static_type(node.get("body") or {}, self._let_binds(node, binds, -1))
1276
+ if k == "match":
1277
+ return self._match_type(node, binds)
1278
+ if k == "call" and node.get("function") in self.fdecls:
1279
+ return self._fn_sig(node["function"])["returnType"]
1280
+ if k == "binary":
1281
+ return self.arith_kind(node["op"], self.static_type(node["left"], binds),
1282
+ self.static_type(node["right"], binds))
1283
+ if k == "call" and node.get("function") in CAL_FNS:
1284
+ return INT if node["function"] == "urn:law:std#days_between" else "urn:law:std#Date"
1285
+ if k == "call" and node.get("function") in MONTH_FNS:
1286
+ return "urn:law:std#Date"
1287
+ if k == "call" and node.get("function") in DUR_FNS:
1288
+ return INST if DUR_FNS[node["function"]] == "add_duration" else INT
1289
+ if k == "if":
1290
+ kt = self.static_type(node.get("then") or {}, binds)
1291
+ ke = self.static_type(node.get("else") or {}, binds)
1292
+ if kt != ke:
1293
+ raise Refused("ветви условного терма §56 разных видов вне среза генератора (DECISION-0270)")
1294
+ return kt
1295
+ if k == "call":
1296
+ return self.call_kind(node["function"], [self.static_type(a, binds) for a in node["args"]])
1297
+ if k == "aggregate" and self.v09:
1298
+ return self._aggregate(node, binds)[1]
1299
+ return None
1300
+
1301
+ def arith_kind(self, op: str, l: str | None, r: str | None) -> str | None:
1302
+ """Таблицы видов §58 для среза (Integer, Decimal, Money; Rational и
1303
+ динамический Decimal|Rational — DECISION-0252). Пара вне таблицы —
1304
+ отказ плана, как TYPE_ERROR у оракула; вид, зависящий от значения,
1305
+ план несёт динамическим, а ошибку решает рантайм (`arith`, issue
1306
+ модели 0.12). Операция вне BINOP сюда не доходит."""
1307
+ if op not in BINOP:
1308
+ return None
1309
+ l, r = _num(l), _num(r)
1310
+ if l not in NUMERIC or r not in NUMERIC:
1311
+ raise Refused(f"арифметика вне среза (§58): не числовой операнд {op}")
1312
+ if RAT in (l, r) or DYN in (l, r) or QTY in (l, r):
1313
+ if QTY in (l, r):
1314
+ self._quantity_seen = True
1315
+ kind = self._arith_rational(op, l, r)
1316
+ if RAT in (l, r) or DYN in (l, r) or kind in (RAT, DYN):
1317
+ self._rational_seen = True
1318
+ return kind
1319
+ pair = {l, r}
1320
+ if op in ("add", "sub"):
1321
+ if l != r:
1322
+ raise Refused(f"арифметика вне среза (§58): виды операндов не совпадают у {op}")
1323
+ return l
1324
+ if op == "mul":
1325
+ if pair == {MON}:
1326
+ raise Refused("арифметика вне среза (§58): произведение двух размерных величин не определено")
1327
+ if INT in pair:
1328
+ return l if r == INT else r # скаляр §49 не меняет вида
1329
+ return MON if MON in pair else DEC
1330
+ if op == "div":
1331
+ if (l, r) == (DEC, DEC) or (l, r) == (MON, MON):
1332
+ # E-0005/E-0083: вид частного зависит от значения (DECISION-0252 п. 2).
1333
+ self._rational_seen = True
1334
+ return DYN
1335
+ if (l, r) == (MON, DEC):
1336
+ return MON
1337
+ if (l, r) == (INT, INT):
1338
+ # E-0020: единственный источник Rational из целых.
1339
+ self._rational_seen = True
1340
+ return RAT
1341
+ raise Refused("арифметика вне среза (§58): пара вне таблицы делимых")
1342
+ if op == "mod":
1343
+ if (l, r) != (INT, INT):
1344
+ raise Refused("арифметика вне среза (§58): остаток только Integer mod Integer")
1345
+ return INT
1346
+ return None
1347
+
1348
+ def call_kind(self, fn: str, kinds: list) -> str | None:
1349
+ name = STD_FN.get(fn)
1350
+ kinds = [_num(k) for k in kinds]
1351
+ if name in ("min", "max"):
1352
+ if len(kinds) == 2 and (RAT in kinds or DYN in kinds or QTY in kinds):
1353
+ # DECISION-0252/0256: выбор операнда требует совпадения видов, как
1354
+ # сложение; динамический операнд совпадает своим исходом.
1355
+ if QTY in kinds:
1356
+ self._quantity_seen = True
1357
+ if RAT in kinds or DYN in kinds:
1358
+ self._rational_seen = True
1359
+ return self._arith_rational("add", kinds[0], kinds[1])
1360
+ if len(kinds) != 2 or kinds[0] not in NUMERIC or kinds[0] != kinds[1]:
1361
+ raise Refused(f"арифметика вне среза (§58): виды операндов не совпадают у {name}")
1362
+ return kinds[0]
1363
+ if name == "abs":
1364
+ if len(kinds) != 1 or kinds[0] not in NUMERIC:
1365
+ raise Refused("арифметика вне среза (§58): abs над нечисловым видом")
1366
+ if kinds[0] in (RAT, DYN):
1367
+ self._rational_seen = True
1368
+ if kinds[0] == QTY:
1369
+ self._quantity_seen = True
1370
+ return kinds[0]
1371
+ if name == "pow":
1372
+ if kinds != [INT, INT]:
1373
+ raise Refused("арифметика вне среза (§58): pow только Integer pow Integer")
1374
+ return INT
1375
+ if name == "round":
1376
+ # §50, E-0110: над Integer при precision >= 0 — тождество того же вида.
1377
+ if kinds and kinds[0] == RAT:
1378
+ self._rational_seen = True
1379
+ raise Refused("round над Rational вне среза генератора (§50: round не берёт Rational)")
1380
+ if kinds and kinds[0] == QTY:
1381
+ self._quantity_seen = True # §50: round сохраняет тег (DECISION-0256)
1382
+ if kinds and kinds[0] == DYN:
1383
+ # DECISION-0259 п. 3: Rational по значению — TYPE_ERROR рантайма.
1384
+ self._rational_seen = self._round_seen = True
1385
+ elif not kinds or kinds[0] not in (MON, DEC, INT, QTY):
1386
+ raise Refused("round вне среза генератора: вид операнда не числовой (§50)")
1387
+ self._policy_kinds("round", kinds[1:])
1388
+ return DEC if kinds[0] == DYN else kinds[0]
1389
+ if name == "div_round":
1390
+ # §50/§58 (E-0036, E-0170, DECISION-0259 п. 3): четыре пары; иная —
1391
+ # у оракула всегда TYPE_ERROR, отказ плана.
1392
+ if len(kinds) != 4:
1393
+ raise Refused("div_round вне среза генератора: ожидаются делимое, делитель, точность и режим (§50)")
1394
+ self._div_round_seen = True
1395
+ if RAT in kinds[:2]:
1396
+ self._rational_seen = True
1397
+ raise Refused("div_round над Rational вне среза генератора (§50/§58, E-0036)")
1398
+ if DYN in kinds[:2]:
1399
+ self._rational_seen = True
1400
+ if QTY in kinds[:2]:
1401
+ self._quantity_seen = True
1402
+ pair = tuple(DEC if k == DYN else k for k in kinds[:2])
1403
+ if pair not in ((MON, DEC), (QTY, DEC), (DEC, DEC), (MON, MON)):
1404
+ raise Refused("div_round вне среза генератора: пара видов вне E-0036/E-0170 (§50/§58)")
1405
+ self._policy_kinds("div_round", kinds[2:])
1406
+ return {(MON, DEC): MON, (QTY, DEC): QTY}.get(pair, DEC)
1407
+ return None
1408
+
1409
+ @staticmethod
1410
+ def _policy_kinds(fn: str, kinds: list) -> None:
1411
+ """§50: точность — Integer либо Decimal, режим — Text; иной вид у оракула
1412
+ всегда TYPE_ERROR (DECISION-0259 п. 3)."""
1413
+ if len(kinds) != 2:
1414
+ raise Refused(f"{fn} вне среза генератора: ожидаются точность и режим (§50)")
1415
+ if kinds[0] not in (INT, DEC):
1416
+ raise Refused(f"{fn} вне среза генератора: точность не Integer и не Decimal (§50)")
1417
+ if kinds[1] != TEXT_URN:
1418
+ raise Refused(f"{fn} вне среза генератора: режим не Text (§50)")
1419
+
1420
+ @staticmethod
1421
+ def _pair_kinds(op: str, a: str, b: str) -> set:
1422
+ """Виды исхода конкретной пары §58 — порт `values.binary`; пустое
1423
+ множество — у оракула TYPE_ERROR. Decimal/Decimal и Money/Money дают
1424
+ Decimal либо Rational по значению (E-0005, E-0083)."""
1425
+ if op in ("add", "sub"):
1426
+ return {a} if a == b else set()
1427
+ if op == "mul":
1428
+ if a == b == INT:
1429
+ return {INT}
1430
+ if INT in (a, b) and {a, b} & {MON, DEC, QTY}:
1431
+ return {a, b} - {INT}
1432
+ if {a, b} == {INT, RAT} or a == b == RAT:
1433
+ return {RAT}
1434
+ if {a, b} == {MON, RAT}:
1435
+ return {MON} # E-0085: доля масштабирует деньги
1436
+ if RAT in (a, b) or a == b == MON or QTY in (a, b) and {a, b} != {QTY, DEC}:
1437
+ return set() # Q×Q, Q×Money, Q×Rational — TYPE_ERROR
1438
+ return {QTY} if QTY in (a, b) else {MON} if MON in (a, b) else {DEC}
1439
+ if op == "div":
1440
+ if a == b == RAT or a == b == INT:
1441
+ return {RAT}
1442
+ if RAT in (a, b):
1443
+ return set()
1444
+ if (a, b) in ((DEC, DEC), (MON, MON), (QTY, QTY)):
1445
+ return {DEC, RAT} # E-0068: частное одной единицы безразмерно
1446
+ return {MON} if (a, b) == (MON, DEC) else {QTY} if (a, b) == (QTY, DEC) else set()
1447
+ if op == "mod":
1448
+ return {INT} if a == b == INT else set()
1449
+ return set()
1450
+
1451
+ def _arith_rational(self, op: str, l: str, r: str) -> str:
1452
+ """Пара §58 с Rational либо динамическим операндом (DECISION-0252 п. 2):
1453
+ вид — у исходов, которые не ошибка; пара без таких исходов у оракула
1454
+ всегда TYPE_ERROR и отвергается планом."""
1455
+ outcomes = set()
1456
+ for a in ((DEC, RAT) if l == DYN else (l,)):
1457
+ for b in ((DEC, RAT) if r == DYN else (r,)):
1458
+ outcomes |= self._pair_kinds(op, a, b)
1459
+ if not outcomes:
1460
+ raise Refused(f"арифметика вне среза (§58): {l.split('#')[-1]} {op} {r.split('#')[-1]} "
1461
+ "у оракула всегда TYPE_ERROR")
1462
+ if outcomes == {DEC, RAT}:
1463
+ return DYN
1464
+ if len(outcomes) != 1:
1465
+ raise Refused(f"арифметика вне среза (§58): вид исхода {op} не определён статически")
1466
+ return outcomes.pop()
1467
+
1468
+ def _lit(self, node: dict) -> dict | None:
1469
+ """Литерал за ссылками на константы §54 либо None."""
1470
+ while node.get("kind") == "const_ref" and not self.is_member(node["id"]):
1471
+ node = self.const_value(node["id"])
1472
+ return node if node.get("kind") == "value" else None
1473
+
1474
+ def _policy(self, node: dict, binds: dict) -> list:
1475
+ """DECISION-0259 п. 1–3: политика `round`/`div_round`. Литеральная
1476
+ точность либо режим, у оракула всегда TYPE_ERROR, — отказ плана.
1477
+ `value:div_round` — любой `div_round`; `value:round` — `round` с
1478
+ нелитеральной политикой, режимом не HALF_UP либо динамическим видом.
1479
+ Результат — статические виды аргументов `ak` (`t` — Text)."""
1480
+ fn = STD_FN[node["function"]]
1481
+ args = node["args"]
1482
+ kinds = [self.static_type(a, binds) for a in args]
1483
+ prec, mode = self._lit(args[-2]), self._lit(args[-1])
1484
+ if prec is not None:
1485
+ raw = str(prec["value"])
1486
+ if "." in raw or "e" in raw.lower():
1487
+ raise Refused(f"{fn} вне среза генератора: точность не целое (§50)")
1488
+ scale = int(raw)
1489
+ if scale < 0 or (fn == "div_round" and scale > DIV_ROUND_MAX_PRECISION):
1490
+ raise Refused(f"{fn} вне среза генератора: точность вне диапазона (§50, E-0036)")
1491
+ if mode is not None and mode.get("value") not in ROUNDING_MODES:
1492
+ raise Refused(f"{fn} вне среза генератора: режим вне семи (§50)")
1493
+ if fn == "div_round":
1494
+ self._div_round_seen = True
1495
+ elif prec is None or mode is None or mode["value"] != "HALF_UP" or kinds[0] == DYN:
1496
+ self._round_seen = True
1497
+ return [KIND_CODE.get(k, "t") for k in kinds]
1498
+
1499
+ def _policy_fallible(self, node: dict, binds: dict) -> bool:
1500
+ """DECISION-0259 п. 5: вызов может дать ошибку значения. `round` — при
1501
+ нелитеральной политике либо динамическом виде; `div_round` — кроме
1502
+ ненулевого литерального делителя при литеральной политике и паре не
1503
+ Money/Money."""
1504
+ args = node["args"]
1505
+ if self._lit(args[-2]) is None or self._lit(args[-1]) is None:
1506
+ return True
1507
+ kinds = [self.static_type(a, binds) for a in args[:-2]]
1508
+ if DYN in kinds:
1509
+ return True
1510
+ if STD_FN[node["function"]] == "round":
1511
+ return False
1512
+ divisor = self._lit(args[1])
1513
+ return divisor is None or kinds == [MON, MON] or \
1514
+ not any(ch in "123456789" for ch in str(divisor["value"]))
1515
+
1516
+ def kind_checks(self, head: dict, binds: dict) -> list:
1517
+ """Позиции головы с динамическим видом в слоте Decimal либо Rational —
1518
+ сверка E-0077 рантаймом по значению (DECISION-0252 п. 5)."""
1519
+ params = self.types.get(head["predicate"]) or []
1520
+ return [[pos, KIND_CODE[expected]]
1521
+ for pos, (expected, arg) in enumerate(zip(params, head["args"]))
1522
+ if expected in (DEC, RAT) and self.static_type(arg, binds) == DYN]
1523
+
1524
+ def _fallible_value(self, node, binds: dict, gens: list | None = None) -> bool:
1525
+ """Терм может дать ошибку значения §58 (DECISION-0252 п. 4): деление и
1526
+ остаток (ноль, неточность), арифметика над Rational и динамическим
1527
+ видом, сравнение с динамическим видом. Расхождение валют у денег сюда
1528
+ не входит: как и до плана 0.12, это названный отказ рантайма. Внутрь
1529
+ агрегата обход не спускается: его ошибки — G3."""
1530
+ saved = self._atoms
1531
+ if gens is not None:
1532
+ self._atoms = gens
1533
+ try:
1534
+ stack = [node]
1535
+ while stack:
1536
+ x = stack.pop()
1537
+ if isinstance(x, list):
1538
+ stack.extend(x)
1539
+ continue
1540
+ if not isinstance(x, dict) or x.get("kind") in ("aggregate", "comprehension"):
1541
+ continue
1542
+ if x.get("kind") == "let":
1543
+ # DECISION-0273: тело — с локальным связыванием.
1544
+ if self._fallible_value(x.get("value"), binds) \
1545
+ or self._fallible_value(x.get("body"), self._let_binds(x, binds, -1)):
1546
+ return True
1547
+ continue
1548
+ if x.get("kind") == "match":
1549
+ _st, arms, exhaustive = self._match_arms(x, binds)
1550
+ if not exhaustive:
1551
+ return True # NON_EXHAUSTIVE_MATCH по подстановке
1552
+ stack.append(x.get("subject"))
1553
+ stack.extend(v for _m, v in arms)
1554
+ continue
1555
+ if x.get("kind") == "const_ref" and self._is_a3(x["id"]):
1556
+ return True # MISSING_PARAMETER_VALUE на разрыве датасета
1557
+ if x.get("kind") == "call" and x.get("function") in self.fdecls \
1558
+ and self._fn_sig(x["function"]).get("fallible"):
1559
+ return True # ошибка значения в теле функции §46
1560
+ if x.get("kind") in ("binary", "comparison"):
1561
+ kinds = {self.static_type(x["left"], binds), self.static_type(x["right"], binds)}
1562
+ if x["kind"] == "binary" and (x.get("op") in ("div", "mod") or kinds & {RAT, DYN, QTY}):
1563
+ return True
1564
+ if x["kind"] == "comparison" and (DYN in kinds or kinds == {QTY}):
1565
+ return True # DECISION-0256 п. 4: разные единицы
1566
+ if x.get("kind") == "call" and STD_FN.get(x.get("function")) in ("round", "div_round") \
1567
+ and self._policy_fallible(x, binds):
1568
+ return True # DECISION-0259 п. 5
1569
+ stack.extend(v for v in x.values() if isinstance(v, (dict, list)))
1570
+ return False
1571
+ finally:
1572
+ self._atoms = saved
1573
+
1574
+ def guard_instant(self, g: dict, binds: dict) -> None:
1575
+ """§57/§2.15 (W2 часть 2): Instant сравнивается только с Instant; с Date у
1576
+ оракула DATE_INSTANT_MISMATCH, с прочими видами TYPE_ERROR — всегда,
1577
+ поэтому отказ плана. Стоит отдельно от `guard_kinds`: L0-путь строгих
1578
+ правил её не звал, и расширять там сверку Rational значило бы менять
1579
+ закреплённые исходы."""
1580
+ kinds = []
1581
+ for side in ("left", "right"):
1582
+ try:
1583
+ kinds.append(self.static_type(g[side], binds))
1584
+ except Refused:
1585
+ return
1586
+ for x, y in ((kinds[0], kinds[1]), (kinds[1], kinds[0])):
1587
+ if x == INST and y is not None and y != INST:
1588
+ raise Refused("сравнение Instant с иным видом вне среза генератора (§57/§2.15)")
1589
+
1590
+ def guard_kinds(self, g: dict, binds: dict) -> None:
1591
+ """§57/§48 (DECISION-0252 п. 2): Rational сравнивается только с Rational;
1592
+ сравнение, которое у оракула всегда TYPE_ERROR, — отказ плана."""
1593
+ kinds = []
1594
+ for side in ("left", "right"):
1595
+ try:
1596
+ kinds.append(_num(self.static_type(g[side], binds)))
1597
+ except Refused:
1598
+ return
1599
+ for x, y in ((kinds[0], kinds[1]), (kinds[1], kinds[0])):
1600
+ if (x == RAT and y in (INT, DEC, MON)) or (x == DYN and y in (INT, MON, QTY)) \
1601
+ or (x == QTY and y in (INT, DEC, MON, RAT)):
1602
+ raise Refused("сравнение Rational с иным видом вне среза генератора (§57/§48)")
1603
+
1604
+ def _var_col(self, name: str, binds: dict) -> int:
1605
+ return binds[name][1]
1606
+
1607
+ def _slot_type(self, pred: str, col: int) -> str | None:
1608
+ t = self.types[pred][col]
1609
+ return t if t in TYPES or t in self.enums else None
1610
+
1611
+ def head_types(self, head: dict, binds: dict) -> None:
1612
+ """Типы аргументов ВЫВЕДЕННОЙ головы против сигнатуры §189 (E-0077).
1613
+
1614
+ Оракул отвергает вывод с Integer в Decimal-слоте при исполнении
1615
+ (`check_derived_types`), а рантайм среза сравнивает числа по величине
1616
+ и ответил бы TRUE_ONLY там, где оракул даёт NEITHER. Здесь это отказ
1617
+ плана: сущности не проверяются (у `entity_ref` типа нет — как у
1618
+ оракула), остальное обязано совпасть с объявленным типом слота."""
1619
+ params = self.types.get(head["predicate"])
1620
+ if params is None:
1621
+ raise Refused(f"предикат головы не объявлен в пакете: {head['predicate']}")
1622
+ if len(params) != len(head["args"]):
1623
+ raise Refused("арность головы правила не совпадает с сигнатурой §189")
1624
+ for arg in head["args"]:
1625
+ if arg.get("kind") == "const_ref" and self._is_a3(arg["id"]):
1626
+ # DECISION-0273: голый параметр в голове — позиция идентичности у оракула
1627
+ # (`a3_runner`: «голый const_ref в голове … не вычисляется»).
1628
+ raise Refused("голый `const_ref` параметра A3 в позиции идентичности (голова, аргумент атома) вне среза генератора: у оракула он не вычисляется, читайте вызовом (DECISION-0273)")
1629
+ for expected, arg in zip(params, head["args"]):
1630
+ actual = self.static_type(arg, binds)
1631
+ if actual is None or (expected not in TYPES and expected not in self.enums):
1632
+ continue
1633
+ if actual == DYN and expected in (DEC, RAT):
1634
+ continue # DECISION-0252 п. 5: сверка рантаймом (`kindChecks`)
1635
+ if actual != expected:
1636
+ raise Refused("тип аргумента головы не совпадает с параметром §189 (E-0077)")
1637
+
1638
+ def guard_enums(self, g: dict, binds: dict) -> None:
1639
+ """§57/E-0067 для гарда: порядок над перечислением не определён, члены
1640
+ разных перечислений несравнимы — у оракула это TYPE_ERROR при
1641
+ исполнении, рантайм среза ответил бы false. Здесь — отказ плана.
1642
+ То же для Boolean (DECISION-0266): `values.binary` сравнивает виды
1643
+ (`_check_same_kind`), а порядок над `boolean` — TYPE_ERROR."""
1644
+ kinds = []
1645
+ for side in ("left", "right"):
1646
+ try:
1647
+ kinds.append(self.static_type(g[side], binds))
1648
+ except Refused:
1649
+ kinds.append(None)
1650
+ if BOOL in kinds:
1651
+ if kinds[0] != kinds[1]:
1652
+ raise Refused("сравнение Boolean с иным видом вне среза генератора (§57/§48)")
1653
+ if g["op"] not in ("eq", "ne"):
1654
+ raise Refused("порядок над Boolean не определён §57 вне среза генератора")
1655
+ enum_sides = [t for t in kinds if t in self.enums]
1656
+ if not enum_sides:
1657
+ return
1658
+ if g["op"] not in ("eq", "ne"):
1659
+ raise Refused("порядок над перечислением не определён §57 (E-0067)")
1660
+ if len(set(enum_sides)) > 1:
1661
+ raise Refused("члены разных перечислений несравнимы §57 (E-0067)")
1662
+
1663
+ def is_member(self, cid: str) -> bool:
1664
+ """`const_ref` — член перечисления §38, если это не объявленная константа
1665
+ со значением: таблица разыменования §54 знает объявленные константы и
1666
+ применяется первой, а одно имя бывает и константой, и членом
1667
+ перечисления того же пакета (`kz.corpus.mortgage`,
1668
+ `ObrashchenieVSobstvennost`)."""
1669
+ c = self.consts.get(cid)
1670
+ return cid in self.members and not (c is not None and c.get("value") is not None)
1671
+
1672
+ def const_value(self, cid: str) -> dict:
1673
+ """Значение константы §54; ссылка на функцию или параметр A3 через
1674
+ `const_ref` — отдельный отказ: обзор корпуса иначе читал их как
1675
+ «константа не найдена» и обещал дешёвый рычаг, которого нет."""
1676
+ c = self.consts.get(cid)
1677
+ if c is not None and c.get("value") is not None:
1678
+ return c["value"]
1679
+ if cid in self.functions:
1680
+ raise Refused(f"ссылка на функцию или параметр через const_ref вне среза генератора: {cid}")
1681
+ if c is not None:
1682
+ raise Refused(f"константа без значения вне среза генератора: {cid}")
1683
+ raise Refused(f"константа не найдена в CLIR: {cid}")
1684
+
1685
+ def body_const(self, arg: dict, expected: str) -> dict:
1686
+ """Константа в аргументе атома тела: оракул сопоставляет её с ground-
1687
+ атомом по каноническим байтам терма (`solver.solve`), то есть по
1688
+ значению И типу. Рантайм сравнивает `eq` — по значению, поэтому тип
1689
+ литерала обязан совпасть с объявленным типом слота статически: литерал
1690
+ Integer в Decimal-слоте у оракула не совпадает ни с одной строкой, а
1691
+ рантайм по величине ответил бы иначе. Сущности типа не несут, как у
1692
+ оракула. Подстановочный `_` §98 и терм с вычислением — отказ."""
1693
+ k = arg["kind"]
1694
+ if k == "wildcard":
1695
+ raise Refused("подстановочный аргумент `_` §98 вне среза генератора")
1696
+ if k in ("binary", "call", "if", "let", "match"):
1697
+ # DECISION-0270: вычисляемый аргумент требует модели 0.9 — признак
1698
+ # для второго прохода `build_plan`, план строится заново с `term:body`.
1699
+ self._body_term_seen = True
1700
+ if k not in ("value", "entity_ref", "const_ref"):
1701
+ raise Refused(f"терм в аргументе тела вне среза генератора: {k}")
1702
+ if k == "const_ref" and self._is_a3(arg["id"]):
1703
+ # DECISION-0273: голый параметр в атоме — идентичность у оракула (как член
1704
+ # перечисления), не значение; корпус читает параметр вызовом (`round(MRP, …)`).
1705
+ raise Refused("голый `const_ref` параметра A3 в позиции идентичности (голова, аргумент атома) вне среза генератора: у оракула он не вычисляется, читайте вызовом (DECISION-0273)")
1706
+ if k == "const_ref" and self.is_member(arg["id"]):
1707
+ # Член перечисления — идентичность `{namespace}#{Член}` (§38); одно
1708
+ # имя члена бывает у нескольких перечислений пакета, поэтому
1709
+ # проверяется принадлежность слоту, а не «тип члена».
1710
+ if expected not in self.enums or arg["id"] not in self.enums[expected]:
1711
+ raise Refused("тип аргумента тела не совпадает с параметром §189")
1712
+ return self.term(arg, {})
1713
+ actual = self.static_type(arg, {})
1714
+ if actual is not None and (expected in TYPES or expected in self.enums) and actual != expected:
1715
+ raise Refused("тип аргумента тела не совпадает с параметром §189")
1716
+ return self.term(arg, {})
1717
+
1718
+ def parts(self, body: dict, atoms: list, guards: list) -> None:
1719
+ k = body.get("kind")
1720
+ if k == "status":
1721
+ if body.get("status") != "established":
1722
+ raise Refused(f"статусный тест вне L0-среза: {body.get('status')}")
1723
+ atoms.append(body["formula"])
1724
+ elif k == "literal":
1725
+ atoms.append(body)
1726
+ elif k == "comparison":
1727
+ if body["op"] not in CMP:
1728
+ raise Refused(f"сравнение вне среза генератора: {body['op']}")
1729
+ guards.append(body)
1730
+ elif k in ("and", "conjunction"):
1731
+ subs = body.get("items") or body.get("args") or body.get("operands")
1732
+ if not subs:
1733
+ raise Refused("конъюнкция без членов в CLIR")
1734
+ for s in subs:
1735
+ self.parts(s, atoms, guards)
1736
+ else:
1737
+ raise Refused(f"форма тела вне среза генератора: {k}")
1738
+
1739
+ # --- Единицы проверки. `build` останавливается на первом отказе, `survey`
1740
+ # проходит все единицы и собирает КАЖДЫЙ отказ: карта причин по корпусу
1741
+ # нужна для выбора следующего шага среза, а первая причина скрывает
1742
+ # остальные. Внутри одной единицы (правило, факт) отказ по-прежнему первый.
1743
+
1744
+ def _node(self, n: dict) -> None:
1745
+ sig = (n.get("kind"), n.get("symbolKind"))
1746
+ if sig == ("source_edition", None):
1747
+ # Жизненный цикл редакции §31/§92.3 решает, участвует ли
1748
+ # заякоренное правило в вычислении: это НЕ провенанс. План
1749
+ # сворачивает применимость в окно по дате права (`_dating`).
1750
+ return
1751
+ if sig in (("source_publication", None), ("publication", None), ("fragment", None)):
1752
+ # Провенанс §194: закреплённый текст, его хэши и адрес. На
1753
+ # вычисление не влияет ни в одной реализации — как source_work.
1754
+ return
1755
+ if sig == ("norm_template", None) and self.v10:
1756
+ # Шаблон нормы §122 разбирает `_norm_v10` у ссылающегося правила.
1757
+ for f in sorted(set(n) - TEMPLATE_FIELDS):
1758
+ raise Refused(f"поле шаблона нормы вне среза генератора: {f}")
1759
+ return
1760
+ if sig in (("deadline_policy", None), ("calendar_snapshot", None)) and self.v11:
1761
+ # Политика §86 и снимок §85 выбираются делом (DECISION-0245).
1762
+ return
1763
+ if sig == ("closure_policy", None) and self.v09:
1764
+ # Замыкание §70 разбирает `_closure` (DECISION-0239 G4).
1765
+ return
1766
+ if sig == ("external_decl", None) and n.get("effect") == "judgment":
1767
+ # DECISION-0274: носитель органа канала суждения §47.3 — читается
1768
+ # в `judgments` (`queries.judgment_declarations`).
1769
+ for f in sorted(set(n) - EXTERNAL_JUDGMENT_FIELDS):
1770
+ raise Refused(f"поле external_decl канала суждения вне среза генератора: {f}")
1771
+ return
1772
+ if sig == ("priority_rule", None) and self.l1:
1773
+ # Приоритет §106 разбирает `_priority`: в срезе только безусловное
1774
+ # недатированное ребро между правилами (DECISION-0237 F4).
1775
+ return
1776
+ if sig[1] is None and sig[0] in INERT_NODE_KINDS:
1777
+ # Инертное объявление: свидетель — пара векторов манифеста
1778
+ # `inert-nodes.json` (source_work §28, anchor §33, revision §32,
1779
+ # entrenchment §92.3, factor_vocabulary/precedent_decl §201,
1780
+ # counterfactual_decl §186).
1781
+ return
1782
+ if sig not in KNOWN_NODES:
1783
+ raise Refused(f"объявление вне среза генератора: {n.get('kind')}/{n.get('symbolKind')}")
1784
+ if n.get("kind") == "rule":
1785
+ for f in sorted(set(n) - RULE_FIELDS):
1786
+ if f in RULE_EMPTY_OK and not n[f]:
1787
+ continue
1788
+ if f in INERT_RULE_FIELDS:
1789
+ # Инертное поле правила (`visibility` §23.2, DECISION-0386):
1790
+ # свидетель — пара манифеста инертности.
1791
+ continue
1792
+ if f == "scope" and n[f] == {"kind": "boolean", "value": True}:
1793
+ continue
1794
+ raise Refused(f"поле правила вне среза генератора: {f}")
1795
+
1796
+ def _index(self) -> list[dict]:
1797
+ ir = self.ir
1798
+ rels = [n for n in ir["nodes"] if n.get("symbolKind") == "relation"]
1799
+ self.root_ns = (ir.get("package") or {}).get("namespace", "")
1800
+ by_id = {n["id"]: n for n in ir["nodes"] if "id" in n}
1801
+ self.editions = {n["id"]: edition_intervals(n) for n in ir["nodes"] if n.get("kind") == "source_edition"}
1802
+ self.frag_edition = {n["id"]: n.get("edition") for n in ir["nodes"] if n.get("kind") == "fragment"}
1803
+ self.by_id = by_id
1804
+ self.entities = {n["id"] for n in ir["nodes"] if n.get("typeKind") == "entity"}
1805
+ self.consts = {n["id"]: {"type": (n.get("returnType") or {}).get("name"),
1806
+ "value": n.get("value") if isinstance(n.get("value"), dict) else None}
1807
+ for n in ir["nodes"] if n.get("symbolKind") in ("constant", "const")}
1808
+ self.functions = {n["id"] for n in ir["nodes"] if n.get("symbolKind") == "function"}
1809
+ # DECISION-0273: тела функций §46 по символу и их объявления.
1810
+ self.fdecls = {n["symbol"]: n for n in ir["nodes"] if n.get("kind") == "function_decl" and n.get("symbol")}
1811
+ self.fsyms = {n["id"]: n for n in ir["nodes"] if n.get("symbolKind") == "function"}
1812
+ # Перечисления §38: член — значение со stable qualified identity
1813
+ # `{namespace}#{Член}`; узла у члена нет, ссылка на него — `const_ref`,
1814
+ # который оракул оставляет в ключе атома как есть (§54: таблица
1815
+ # разыменования знает только объявленные константы).
1816
+ # Пространство имён члена — пакет-владелец перечисления (в мире
1817
+ # импортов узлы разных пакетов лежат рядом; `package` узла — его URN).
1818
+ ns = (ir.get("package") or {}).get("namespace", "")
1819
+ self.enums = {n["id"]: [f"{n.get('package') or ns}#{v}" for v in (n.get("variants") or [])]
1820
+ for n in ir["nodes"] if n.get("kind") == "type_decl" and n.get("typeKind") == "enum"}
1821
+ self.members = {m: e for e, ms in self.enums.items() for m in ms}
1822
+ # Наследование сущностей (`entity A : B`): константа подтипа
1823
+ # подходит параметру базового типа — как у оракула (`_conforms`).
1824
+ self.bases = {n["id"]: (n.get("base") or {}).get("name") for n in ir["nodes"]
1825
+ if n.get("kind") == "type_decl" and (n.get("base") or {}).get("name")}
1826
+ self.types = {r["id"]: [(p.get("type") or {}).get("name") for p in r["parameters"]] for r in rels}
1827
+ # §40 (E-0097/E-0098): алиас типа прозрачен на исполнении — как у оракула
1828
+ # (`facts._unalias`: обе стороны сверки §189 берутся за цепочкой алиасов).
1829
+ # Цель алиаса компилятор пишет в форме `имя.пакета#Тип`; вид слота берётся
1830
+ # у объявления пакета мира с ТОЧНО этим именем. Имя без пакета в мире
1831
+ # остаётся строкой цели и уходит в отказ с этим именем (CODEGEN-COVERAGE-PLAN W2).
1832
+ # Нормализуются ТОЛЬКО ссылки на объявленные алиасы: сырые строки
1833
+ # `имя.пакета#Тип` в сигнатурах оракул сравнивает как есть, их не трогает
1834
+ # и `_foreign_type_aliases` ниже.
1835
+ names = {(ir.get("package") or {}).get("name"): ns}
1836
+ names.update({w["name"]: w["namespace"] for w in self.world_passport})
1837
+ self.aliases = {}
1838
+ for n in ir["nodes"]:
1839
+ if n.get("kind") != "type_decl" or n.get("typeKind") != "alias":
1840
+ continue
1841
+ target = (n.get("target") or {}).get("name")
1842
+ if not target:
1843
+ continue
1844
+ if not target.startswith("urn:") and target.count("#") == 1:
1845
+ package, local = target.split("#")
1846
+ if names.get(package):
1847
+ target = f"{names[package]}#{local}"
1848
+ self.aliases[n["id"]] = target
1849
+ if self.aliases:
1850
+ self.types = {r: [self._t(t) for t in ts] for r, ts in self.types.items()}
1851
+ for c in self.consts.values():
1852
+ c["type"] = self._t(c["type"])
1853
+ self.bases = {k: self._t(v) for k, v in self.bases.items()}
1854
+ # §261: сущности стандартной библиотеки — идентичности, как сущности пакета;
1855
+ # слот `Interval<T>` — непрозрачная идентичность (релиз 0.1.5).
1856
+ self.entities |= STD_ENTITY_TYPES | STD_OPAQUE_SLOT_TYPES
1857
+ self._foreign_type_aliases(ir, rels)
1858
+ self.public = {r["id"] for r in rels if r.get("visibility") == "public"}
1859
+ self.rel_kind = {r["id"]: r.get("relationKind") for r in rels}
1860
+ # DECISION-0274: отношения суждения §47.3 — орган и форма запроса
1861
+ # (`queries.judgment_declarations`): `external_decl` перекрывает
1862
+ # исторический объект `judgment` символа; без обоих орган — сам символ.
1863
+ self.judgments = {}
1864
+ for r in rels:
1865
+ if r.get("relationKind") == "judgment":
1866
+ legacy = r.get("judgment") or {}
1867
+ self.judgments[r["id"]] = {"authority": legacy.get("authority", r["id"])}
1868
+ if legacy.get("requestSchema") is not None:
1869
+ self.judgments[r["id"]]["requestSchema"] = legacy["requestSchema"]
1870
+ for n in ir["nodes"]:
1871
+ if n.get("kind") == "external_decl" and n.get("effect") == "judgment" and n.get("symbol") in self.judgments:
1872
+ d = self.judgments[n["symbol"]]
1873
+ authority = (n.get("authority") or {}).get("id")
1874
+ if authority:
1875
+ d["authority"] = authority
1876
+ if "requestType" in n:
1877
+ d["requestSchema"] = n["requestType"]
1878
+ if self.judgments:
1879
+ influenced = set(self.judgments)
1880
+ dependencies = []
1881
+ for rule in self.ir["nodes"]:
1882
+ if rule.get("kind") != "rule":
1883
+ continue
1884
+ body = rule.get("body") or {}
1885
+ stack, reads, variables = [body], set(), set()
1886
+ while stack:
1887
+ part = stack.pop()
1888
+ if isinstance(part, list): stack.extend(part)
1889
+ elif isinstance(part, dict):
1890
+ if part.get("kind") == "literal": reads.add(part.get("predicate"))
1891
+ if part.get("kind") == "var": variables.add(part["var"])
1892
+ stack.extend(v for v in part.values() if isinstance(v, (dict, list)))
1893
+ head = rule.get("head") or {}
1894
+ headvars = {a["var"] for a in head.get("args") or [] if a.get("kind") == "var"}
1895
+ dependencies.append((head.get("predicate"), reads, variables - headvars))
1896
+ while True:
1897
+ more = {head for head, reads, _ in dependencies if reads & influenced}
1898
+ if more <= influenced: break
1899
+ influenced |= more
1900
+ if any(hidden and reads & influenced for _, reads, hidden in dependencies):
1901
+ raise Refused("JUDGMENT_WITNESS_UNAVAILABLE: witness-bound backchaining requires E-0189")
1902
+ if self.judgments and "aggregate" in self.caps:
1903
+ raise Refused("AGGREGATE_JUDGMENT_UNAVAILABLE: open aggregate members require E-0357")
1904
+ self.rel_labels = {r["id"]: _labels(r) for r in rels}
1905
+ # Провенанс для комментариев API: фрагмент → локатор и редакция §194.
1906
+ editions = {n["id"]: _labels(n) for n in ir["nodes"] if n.get("kind") == "source_edition"}
1907
+ self.fragments = {n["id"]: {"locator": n.get("locator"),
1908
+ "edition": (editions.get(n.get("edition")) or [{}])[0].get("text")}
1909
+ for n in ir["nodes"] if n.get("kind") == "fragment"}
1910
+ self.rule_nodes = {n["id"]: n for n in ir["nodes"] if n.get("kind") == "rule"}
1911
+ return rels
1912
+
1913
+ def table_name(self, pred: str) -> str:
1914
+ """Имя таблицы в порождённом коде: локальное имя; при коллизии между
1915
+ пакетами мира — с префиксом пространства имён."""
1916
+ if not hasattr(self, "_names"):
1917
+ self._names = {}
1918
+ name = _slug(pred)
1919
+ if self._names.get(name, pred) != pred:
1920
+ name = _slug(pred.split("#")[0]) + "__" + name
1921
+ if self._names.get(name, pred) != pred:
1922
+ # Два отношения с одним именем таблицы и после префикса — иначе
1923
+ # порождённый код молча свёл бы их в одну таблицу.
1924
+ raise Refused(f"совпадающие имена таблиц в мире вне среза генератора: {name}")
1925
+ self._names[name] = pred
1926
+ return name
1927
+
1928
+ def _table(self, r: dict) -> dict:
1929
+ # Тип КАЖДОГО параметра каждого отношения проверяется здесь, а не только
1930
+ # у атомов правил: таблица с параметром вне среза (Rational, Instant,
1931
+ # Date) не может хранить значения дела, и отказ обязан жить в плане, а
1932
+ # не всплывать в раннере одной из целей (T151/T155 до этой проверки).
1933
+ # Объявленные типы уезжают в план: раннер сверяет с ними аргументы
1934
+ # дела по §189/§48 (errata E-0046) так же, как оракул.
1935
+ # §45.1: ключевая проекция. Два различных accepted-кортежа при одной
1936
+ # проекции на ключ — KEY_CONFLICT (§196); evaluator ничего не выбирает
1937
+ # и не удаляет, ответ от этого не меняется. Ключи уезжают в план
1938
+ # как позиции столбцов: раннер считает конфликты после насыщения —
1939
+ # у оракула в счёт идут и утверждения, и strict-выводы.
1940
+ arity = len(r["parameters"])
1941
+ keys = []
1942
+ for key in r.get("keys") or []:
1943
+ if not key or any(not isinstance(i, int) or i < 0 or i >= arity for i in key):
1944
+ raise Refused("ключ отношения §45.1 вне арности")
1945
+ keys.append(list(key))
1946
+ for t in self.types[r["id"]]:
1947
+ self.ts(t)
1948
+ return {"pred": r["id"], "name": self.table_name(r["id"]), "arity": arity,
1949
+ "params": list(self.types[r["id"]]), "keys": keys,
1950
+ "kinds": [self.ts(t) for t in self.types[r["id"]]]}
1951
+
1952
+ def _dating(self, node: dict):
1953
+ """Окна применимости узла по его якорям (§92.3): `None` — узел вне
1954
+ датировки (якорь не разрешается), иначе объединение окон датирующих
1955
+ редакций. Редакция без событий применимости даёт пустое окно (§27.2,
1956
+ errata E-0350): узел, все якоря которого ведут на такие редакции,
1957
+ снят на всей оси. Достаточно одной применимой редакции; граница не
1958
+ дневной гранулярности читается как вне датировки — как у оракула
1959
+ (узел остаётся, предупреждение)."""
1960
+ windows, dated = [], False
1961
+ for anchor in node.get("anchors") or []:
1962
+ target = self.by_id.get(anchor)
1963
+ eid = anchor if target is not None and target.get("kind") == "source_edition" \
1964
+ else self.frag_edition.get(anchor)
1965
+ iv = self.editions.get(eid) if eid else None
1966
+ if iv is None or isinstance(iv, str):
1967
+ return None
1968
+ dated = True
1969
+ windows += iv
1970
+ return _union(windows) if dated else None
1971
+
1972
+ def _rule(self, i: int, rule: dict) -> dict:
1973
+ if rule.get("strength") not in (None, "strict"):
1974
+ raise Refused(f"сила правила вне L0-среза: {rule.get('strength')}")
1975
+ atoms, guards = [], []
1976
+ self.parts(rule["body"], atoms, guards)
1977
+ if not atoms:
1978
+ raise Refused("тело без перебираемых атомов вне среза генератора")
1979
+ binds, joins, filters, plan_atoms = {}, [], [], []
1980
+ self._atoms = atoms
1981
+ for ai, atom in enumerate(atoms):
1982
+ if atom.get("polarity") != "positive":
1983
+ raise Refused("отрицательный литерал в теле вне L0-среза")
1984
+ params = self.types.get(atom["predicate"])
1985
+ if params is None:
1986
+ raise Refused(f"предикат тела не объявлен в пакете: {atom['predicate']}")
1987
+ if len(params) != len(atom["args"]):
1988
+ raise Refused("арность атома тела не совпадает с сигнатурой §189")
1989
+ plan_atoms.append({"pred": atom["predicate"]})
1990
+ for j, arg in enumerate(atom["args"]):
1991
+ t = self.ts(params[j])
1992
+ if self._wild(plan_atoms[-1], arg, j, len(params)):
1993
+ continue
1994
+ if arg["kind"] != "var":
1995
+ filters.append({"row": ai, "col": j, "t": t, "term": self.body_const(arg, params[j])})
1996
+ continue
1997
+ cell = (ai, j, t)
1998
+ prev = binds.get(arg["var"])
1999
+ if prev is None:
2000
+ binds[arg["var"]] = cell
2001
+ else:
2002
+ joins.append([list(prev), list(cell)])
2003
+ head = rule["head"]
2004
+ if head.get("polarity") != "positive":
2005
+ raise Refused("отрицательная голова правила вне L0-среза")
2006
+ self.head_types(head, binds)
2007
+ window, dating = self._window(rule)
2008
+ for g in guards:
2009
+ self.guard_enums(g, binds)
2010
+ self.guard_instant(g, binds)
2011
+ out = {
2012
+ "name": f"{i}_{_slug(rule['id'])}", "id": rule["id"], "window": window,
2013
+ "atoms": plan_atoms, "joins": joins, "filters": filters,
2014
+ "guards": [{"op": g["op"], "l": self.term(g["left"], binds), "r": self.term(g["right"], binds)}
2015
+ for g in guards],
2016
+ "head": {"pred": head["predicate"], "args": [self.term(a, binds) for a in head["args"]]},
2017
+ }
2018
+ self._remember(out, rule, "strict", [("established", a["predicate"], "positive") for a in atoms], dating)
2019
+ return out
2020
+
2021
+ def _wild(self, atom: dict, arg: dict, j: int, arity: int) -> bool:
2022
+ """§98 (E-0165, DECISION-0268): `_` в аргументе положительного литерала
2023
+ тела — существование: позиция совпадает с любым значением, ничего не
2024
+ связывает, и строки, различающиеся только в ней, дают ОДНУ
2025
+ подстановку (`solver.solve`: дедупликация по остальным столбцам).
2026
+ План отмечает у атома столбцы `wild` и `keep`; эмиттер печатает
2027
+ дедупликацию по проекции на `keep`. Свидетель у оракула — наименьший
2028
+ по ключу — влияет только на proof, которого срез не несёт."""
2029
+ if arg.get("kind") != "wildcard":
2030
+ return False
2031
+ self._wildcard_seen = True
2032
+ atom.setdefault("wild", []).append(j)
2033
+ atom["keep"] = [c for c in range(arity) if c not in atom["wild"]]
2034
+ return True
2035
+
2036
+ @staticmethod
2037
+ def _has_wild(lit: dict) -> bool:
2038
+ return any(isinstance(a, dict) and a.get("kind") == "wildcard" for a in lit.get("args") or [])
2039
+
2040
+ def _window(self, rule: dict):
2041
+ """Окно действия §31 правила и окна датирующих редакций §92.3."""
2042
+ eff = rule.get("effective")
2043
+ window = None
2044
+ if eff:
2045
+ if eff.get("axis") != "legal_time":
2046
+ raise Refused(f"ось окна effective вне среза генератора: {eff.get('axis')}")
2047
+ if eff.get("startInclusive") is False or eff.get("endInclusive") is True:
2048
+ raise Refused("полузакрытость окна effective вне среза генератора")
2049
+ window = {"start": eff.get("start"), "end": eff.get("end")}
2050
+ # Редакции §92.3: окно правила — пересечение `effective` с
2051
+ # применимостью датирующих редакций. Несколько окон в одном языке
2052
+ # не выразимы прежним планом — названный отказ; пустое окно — правило
2053
+ # исключено (у оракула — issue `EDITION_NOT_APPLICABLE`, ответ тот же).
2054
+ dating = self._dating(rule)
2055
+ if dating is not None:
2056
+ base = (window or {}).get("start"), (window or {}).get("end")
2057
+ cut = [w for w in (_intersect(base, d) for d in dating) if w is not None]
2058
+ if not cut:
2059
+ raise _Excluded(rule["id"])
2060
+ if len(cut) > 1:
2061
+ raise Refused("прерывистая применимость редакции §92.3 вне среза генератора")
2062
+ window = {"start": cut[0][0], "end": cut[0][1]}
2063
+ return window, dating
2064
+
2065
+ def _remember(self, out: dict, rule: dict, strength: str, reads: list, dating) -> None:
2066
+ """Сведения о правиле для структурного анализа (не входят в план):
2067
+ сила, голова, чтения `(статус, предикат, полярность)` и датировка
2068
+ редакцией — у оракула проекция §92.3 снимает узел до подготовки слоёв."""
2069
+ self._meta[out["name"]] = {
2070
+ "name": out["name"], "id": rule["id"], "strength": strength,
2071
+ "head": out["head"]["pred"], "polarity": out["head"].get("polarity", "positive"),
2072
+ "reads": reads, "dating": dating,
2073
+ "computed": any(a["k"] in ("bin", "call") for a in out["head"]["args"])}
2074
+
2075
+ # --- поражаемый слой L1 (DECISION-0237) ------------------------------------
2076
+
2077
+ def _capabilities(self) -> set:
2078
+ """Возможности, которых требует программа: сила правила §104,
2079
+ отрицательные головы, литералы и факты §99/§113, приоритеты §106,
2080
+ статусные тесты §65 кроме `established`. Пустое множество — L0-план."""
2081
+ caps = set(self.want)
2082
+
2083
+ def walk(f) -> None:
2084
+ if not isinstance(f, dict):
2085
+ return
2086
+ k = f.get("kind")
2087
+ if k == "literal" and f.get("polarity") == "negative":
2088
+ caps.add("negation")
2089
+ if k == "status":
2090
+ if f.get("status") in STATUS_TESTS and f.get("status") != "established":
2091
+ caps.add(f"status:{f['status']}")
2092
+ walk(f.get("formula"))
2093
+ for s in f.get("items") or f.get("args") or f.get("operands") or []:
2094
+ if isinstance(s, dict) and s.get("kind") in ("literal", "status", "and", "conjunction"):
2095
+ walk(s)
2096
+
2097
+ for n in self.ir["nodes"]:
2098
+ k = n.get("kind")
2099
+ if k == "rule":
2100
+ if n.get("strength") in ("defeasible", "defeater"):
2101
+ caps.add("defeasible")
2102
+ if (n.get("head") or {}).get("polarity") == "negative":
2103
+ caps.add("negation")
2104
+ walk(n.get("body"))
2105
+ elif k == "priority_rule":
2106
+ caps.add("priority")
2107
+ elif k == "closure_policy":
2108
+ caps.add("closure")
2109
+ elif k == "norm_template":
2110
+ caps.add("norm")
2111
+ elif k == "symbol_decl" and n.get("relationKind") == "judgment":
2112
+ # DECISION-0274: объявленное отношение суждения — запрос судье
2113
+ # возможен из любого носителя канала (E-0100), не только тела.
2114
+ caps.add("judgment")
2115
+ if k == "rule" and (n.get("head") or {}).get("kind") == "norm_template_ref":
2116
+ caps.add("norm")
2117
+ elif k == "assertion" and (n.get("literal") or {}).get("polarity") == "negative":
2118
+ caps.add("negation")
2119
+ if k in ("rule", "norm_template"):
2120
+ # DECISION-0245: календарные функции в термах правил и в
2121
+ # границах окон шаблонов позиций.
2122
+ stack = [n.get("body"), n.get("head"), n.get("payload")]
2123
+ while stack:
2124
+ x = stack.pop()
2125
+ if isinstance(x, list):
2126
+ stack.extend(x)
2127
+ elif isinstance(x, dict):
2128
+ if x.get("kind") == "call" and x.get("function") in CAL_FNS:
2129
+ caps |= self._cal_caps(x)
2130
+ stack.extend(v for v in x.values() if isinstance(v, (dict, list)))
2131
+ if k == "rule":
2132
+ # DECISION-0239: агрегат §59 в теле или голове; генератор его
2133
+ # comprehension несёт свои статусы и отрицания.
2134
+ stack = [n.get("body"), n.get("head")]
2135
+ while stack:
2136
+ x = stack.pop()
2137
+ if isinstance(x, list):
2138
+ stack.extend(x)
2139
+ elif isinstance(x, dict):
2140
+ if x.get("kind") == "aggregate":
2141
+ caps.add("aggregate")
2142
+ walk((x.get("input") or {}).get("generator"))
2143
+ stack.extend(v for v in x.values() if isinstance(v, (dict, list)))
2144
+ return caps
2145
+
2146
+ @staticmethod
2147
+ def _cal_caps(node: dict) -> set:
2148
+ """Возможность вызова календарной функции; `add_legal_term` §86.1 —
2149
+ по единице литерала счётчика (иначе отказ плана при разборе терма)."""
2150
+ name, cap = CAL_FNS[node["function"]]
2151
+ if cap is not None:
2152
+ return {cap}
2153
+ args = node.get("args") or []
2154
+ if len(args) > 1 and isinstance(args[1], dict) and args[1].get("kind") != "value":
2155
+ # DECISION-0256 п. 6: единица связывателя известна лишь на деле — обе ветви.
2156
+ return {"calendar:period", "calendar:business"}
2157
+ unit = args[1].get("unit") if len(args) > 1 and isinstance(args[1], dict) else None
2158
+ return {"calendar:business"} if unit in DAY_UNITS else {"calendar:period"}
2159
+
2160
+ def parts_l1(self, body: dict, out: list) -> None:
2161
+ """Тело правила L1 → конъюнкты `(статус, литерал)` и гарды `("guard", …)`."""
2162
+ k = body.get("kind")
2163
+ if k == "status":
2164
+ status = body.get("status")
2165
+ if status not in STATUS_TESTS:
2166
+ # conflicted/opposed оракул не исполняет (`flatten_body`):
2167
+ # правило у него пропускается с NON_EXECUTABLE_RULE.
2168
+ raise Refused(f"статусный тест вне среза генератора: {status}")
2169
+ if (body.get("formula") or {}).get("kind") != "literal":
2170
+ raise Refused("статусный тест над нелитералом вне среза генератора")
2171
+ out.append((status, body["formula"]))
2172
+ elif k == "literal":
2173
+ out.append(("established", body))
2174
+ elif k == "comparison":
2175
+ if body["op"] not in CMP:
2176
+ raise Refused(f"сравнение вне среза генератора: {body['op']}")
2177
+ out.append(("guard", body))
2178
+ elif k in ("and", "conjunction"):
2179
+ subs = body.get("items") or body.get("args") or body.get("operands")
2180
+ if not subs:
2181
+ raise Refused("конъюнкция без членов в CLIR")
2182
+ for s in subs:
2183
+ self.parts_l1(s, out)
2184
+ elif k == "boolean" and body.get("value") is True:
2185
+ return
2186
+ else:
2187
+ raise Refused(f"форма тела вне среза генератора: {k}")
2188
+
2189
+ def _rule_l1(self, i: int, rule: dict) -> dict:
2190
+ """Правило L1: генераторы — положительные `established`/`monotone`
2191
+ литералы в порядке тела; прочие литералы и статусные тесты —
2192
+ проверки над уже связанными переменными (§190: отсутствие не
2193
+ связывает, §113); ground-тело без генератора допускается."""
2194
+ strength = rule.get("strength") or "strict"
2195
+ if strength not in ("strict", "defeasible", "defeater"):
2196
+ raise Refused(f"сила правила вне среза генератора: {strength}")
2197
+ conj: list = []
2198
+ self.parts_l1(rule["body"], conj)
2199
+ gens = [lit for st, lit in conj if st in GENERATING and lit.get("polarity") == "positive"]
2200
+ binds, joins, filters, plan_atoms = {}, [], [], []
2201
+ self._atoms = gens
2202
+ for ai, atom in enumerate(gens):
2203
+ params = self.types.get(atom["predicate"])
2204
+ if params is None:
2205
+ raise Refused(f"предикат тела не объявлен в пакете: {atom['predicate']}")
2206
+ if len(params) != len(atom["args"]):
2207
+ raise Refused("арность атома тела не совпадает с сигнатурой §189")
2208
+ status = next(st for st, lit in conj if lit is atom)
2209
+ plan_atoms.append({"pred": atom["predicate"], "accept": ACCEPT[(status, "positive")]})
2210
+ for j, arg in enumerate(atom["args"]):
2211
+ t = self.ts(params[j])
2212
+ if self._wild(plan_atoms[-1], arg, j, len(params)):
2213
+ continue
2214
+ if arg["kind"] != "var":
2215
+ filters.append({"row": ai, "col": j, "t": t, "term": self.body_const(arg, params[j])})
2216
+ continue
2217
+ cell = (ai, j, t)
2218
+ prev = binds.get(arg["var"])
2219
+ if prev is None:
2220
+ binds[arg["var"]] = cell
2221
+ else:
2222
+ joins.append([list(prev), list(cell)])
2223
+ checks, reads = [], []
2224
+ for st, lit in conj:
2225
+ if st == "guard":
2226
+ continue
2227
+ pol = lit.get("polarity")
2228
+ if pol not in ("positive", "negative"):
2229
+ raise Refused("полярность литерала тела вне среза генератора")
2230
+ reads.append((st, lit["predicate"], pol))
2231
+ if any(lit is g for g in gens):
2232
+ continue
2233
+ if self._has_wild(lit):
2234
+ # §98/E-0041: `_` законно только в положительном литерале тела;
2235
+ # под отрицанием и в статусном тесте компилятор его отвергает (LDC-E0207).
2236
+ raise Refused("подстановочный аргумент `_` §98 вне положительного литерала тела вне среза генератора")
2237
+ params = self.types.get(lit["predicate"])
2238
+ if params is None:
2239
+ raise Refused(f"предикат тела не объявлен в пакете: {lit['predicate']}")
2240
+ if len(params) != len(lit["args"]):
2241
+ raise Refused("арность атома тела не совпадает с сигнатурой §189")
2242
+ args = []
2243
+ for j, arg in enumerate(lit["args"]):
2244
+ if arg["kind"] == "var":
2245
+ if arg["var"] not in binds:
2246
+ raise Refused(f"переменная без позитивного генератора (§190/§113): {arg['var']}")
2247
+ args.append(self.term(arg, binds))
2248
+ else:
2249
+ args.append(self.body_const(arg, params[j]))
2250
+ checks.append({"pred": lit["predicate"], "accept": ACCEPT[(st, pol)], "args": args})
2251
+ head = rule["head"]
2252
+ if (head or {}).get("kind") != "literal":
2253
+ raise Refused(f"голова правила вне среза генератора: {(head or {}).get('kind')}")
2254
+ if head.get("polarity") not in ("positive", "negative"):
2255
+ raise Refused("полярность головы правила вне среза генератора")
2256
+ self.head_types(head, binds)
2257
+ window, dating = self._window(rule)
2258
+ guards = [g for st, g in conj if st == "guard"]
2259
+ for g in guards:
2260
+ self.guard_enums(g, binds)
2261
+ self.guard_instant(g, binds)
2262
+ out = {
2263
+ "name": f"{i}_{_slug(rule['id'])}", "id": rule["id"], "strength": strength, "window": window,
2264
+ "atoms": plan_atoms, "joins": joins, "filters": filters, "checks": checks,
2265
+ "guards": [{"op": g["op"], "l": self.term(g["left"], binds), "r": self.term(g["right"], binds)}
2266
+ for g in guards],
2267
+ "head": {"pred": head["predicate"], "polarity": head["polarity"],
2268
+ "args": [self.term(a, binds) for a in head["args"]]},
2269
+ # Идентичность применения (DECISION-0237 п. 8): StableId правила
2270
+ # плюс типизированная подстановка — значения всех переменных тела.
2271
+ "subst": [self.term({"kind": "var", "var": v}, binds) for v in binds],
2272
+ }
2273
+ self._remember(out, rule, strength, reads, dating)
2274
+ return out
2275
+
2276
+ # --- агрегаты §59 и замыкания §70 (DECISION-0239) ---------------------------
2277
+
2278
+ @staticmethod
2279
+ def _free(item, out: list | None = None) -> list:
2280
+ """Свободные переменные терма или формулы в порядке встречи — порт
2281
+ `solver._term_vars`/`_formula_vars` для форм среза: у comprehension
2282
+ свои переменная и связки не свободны."""
2283
+ out = [] if out is None else out
2284
+ if isinstance(item, list):
2285
+ for x in item:
2286
+ Builder._free(x, out)
2287
+ return out
2288
+ if not isinstance(item, dict):
2289
+ return out
2290
+ k = item.get("kind")
2291
+ if k == "var":
2292
+ if item["var"] not in out:
2293
+ out.append(item["var"])
2294
+ elif k == "comprehension":
2295
+ inner = Builder._free([item.get("element"), item.get("generator")])
2296
+ bound = {(item.get("variable") or {}).get("id")}
2297
+ bound |= {b.get("id") for b in item.get("binders") or [] if b.get("id")}
2298
+ for v in inner:
2299
+ if v not in bound and v not in out:
2300
+ out.append(v)
2301
+ elif k == "aggregate":
2302
+ Builder._free(item.get("input"), out)
2303
+ elif k in ("literal",):
2304
+ Builder._free(item.get("args"), out)
2305
+ elif k == "status":
2306
+ Builder._free(item.get("formula"), out)
2307
+ elif k in ("and", "conjunction"):
2308
+ Builder._free(item.get("items") or item.get("args") or item.get("operands"), out)
2309
+ elif k in ("binary", "comparison"):
2310
+ Builder._free([item.get("left"), item.get("right")], out)
2311
+ elif k == "call":
2312
+ Builder._free(item.get("args"), out)
2313
+ elif k == "if":
2314
+ # §56: переменные условия и ОБЕИХ ветвей (`solver._term_vars`).
2315
+ Builder._free([item.get("condition"), item.get("then"), item.get("else")], out)
2316
+ elif k == "or":
2317
+ Builder._free(item.get("items"), out)
2318
+ elif k == "let":
2319
+ # DECISION-0273: переменная `let` связана в теле.
2320
+ Builder._free(item.get("value"), out)
2321
+ inner = Builder._free(item.get("body"))
2322
+ bound = str((item.get("variable") or {}).get("id"))
2323
+ for v in inner:
2324
+ if v != bound and v not in out:
2325
+ out.append(v)
2326
+ elif k == "match":
2327
+ Builder._free([item.get("subject")] + [a.get("value") for a in item.get("arms") or []], out)
2328
+ return out
2329
+
2330
+ def _body09(self, formula: dict, outer: dict) -> dict:
2331
+ """Тело правила или генератор comprehension в порядке выбора
2332
+ `solver.solve`: сначала первый полностью связанный конъюнкт (гард или
2333
+ проверка статуса), иначе первый позитивный генератор. От порядка
2334
+ зависит, вычислится ли гард с ошибкой агрегата (DECISION-0239 п. 4)."""
2335
+ conj: list = []
2336
+ self.parts_l1(formula, conj)
2337
+ binds = dict(outer)
2338
+ atoms, joins, filters, checks, guards, steps, reads, gens = [], [], [], [], [], [], [], []
2339
+ saved = self._atoms
2340
+ self._atoms = gens
2341
+ remaining = list(conj)
2342
+ try:
2343
+ while remaining:
2344
+ idx = next((n for n, (_st, it) in enumerate(remaining)
2345
+ if set(self._free(it)) <= set(binds)), None)
2346
+ if idx is not None and self._has_wild(remaining[idx][1]):
2347
+ # §98 (DECISION-0268): полностью связанный литерал с `_` у
2348
+ # оракула — свидетель существования на том же месте; в
2349
+ # плане это генератор с дедупликацией, все столбцы которого
2350
+ # соединения либо фильтры, и он берётся на своём шаге.
2351
+ st, it = remaining[idx]
2352
+ if st not in GENERATING or it.get("polarity") != "positive":
2353
+ raise Refused("подстановочный аргумент `_` §98 вне положительного литерала тела вне среза генератора")
2354
+ remaining.pop(idx)
2355
+ remaining.insert(0, (st, it))
2356
+ idx = None
2357
+ if idx is not None:
2358
+ st, it = remaining.pop(idx)
2359
+ if st == "guard":
2360
+ self.guard_enums(it, binds)
2361
+ self.guard_kinds(it, binds)
2362
+ self.guard_instant(it, binds)
2363
+ steps.append({"guard": len(guards)})
2364
+ guards.append({"op": it["op"], "l": self.term(it["left"], binds),
2365
+ "r": self.term(it["right"], binds), "_node": it})
2366
+ continue
2367
+ pol = it.get("polarity")
2368
+ if pol not in ("positive", "negative"):
2369
+ raise Refused("полярность литерала тела вне среза генератора")
2370
+ reads.append((st, it["predicate"], pol))
2371
+ params = self.types.get(it["predicate"])
2372
+ if params is None:
2373
+ raise Refused(f"предикат тела не объявлен в пакете: {it['predicate']}")
2374
+ if len(params) != len(it["args"]):
2375
+ raise Refused("арность атома тела не совпадает с сигнатурой §189")
2376
+ args, err = [], False
2377
+ for j, a in enumerate(it["args"]):
2378
+ if a["kind"] == "var":
2379
+ args.append(self.term(a, binds))
2380
+ elif a["kind"] in ("binary", "call", "if", "let", "match"):
2381
+ # DECISION-0270: вычисляемый аргумент проверки статуса —
2382
+ # значение считается до проверки; ошибка значения — issue
2383
+ # правила по подстановкам, как у гарда (`err`).
2384
+ f = self._body_arg(0, j, self.ts(params[j]), a, params[j], binds)
2385
+ err = err or bool(f.get("err"))
2386
+ args.append(f["term"])
2387
+ else:
2388
+ args.append(self.body_const(a, params[j]))
2389
+ steps.append({"check": len(checks)})
2390
+ check = {"pred": it["predicate"], "accept": ACCEPT[(st, pol)], "args": args}
2391
+ if err:
2392
+ check["err"] = True
2393
+ checks.append(check)
2394
+ continue
2395
+ # `solver.solve` (pick): генератор с вычисляемым аргументом
2396
+ # берётся, лишь когда переменные аргумента связаны (E-0180).
2397
+ idx = next((n for n, (st, it) in enumerate(remaining)
2398
+ if st in GENERATING and it.get("polarity") == "positive"
2399
+ and all(a.get("kind") in ("var", "wildcard") or set(self._free(a)) <= set(binds)
2400
+ for a in it.get("args") or [])), None)
2401
+ if idx is None:
2402
+ missing = sorted(set(self._free(remaining[0][1])) - set(binds))
2403
+ raise Refused(f"переменная без позитивного генератора (§190/§113): {', '.join(missing)}")
2404
+ st, atom = remaining.pop(idx)
2405
+ reads.append((st, atom["predicate"], "positive"))
2406
+ params = self.types.get(atom["predicate"])
2407
+ if params is None:
2408
+ raise Refused(f"предикат тела не объявлен в пакете: {atom['predicate']}")
2409
+ if len(params) != len(atom["args"]):
2410
+ raise Refused("арность атома тела не совпадает с сигнатурой §189")
2411
+ ai = len(atoms)
2412
+ gens.append(atom)
2413
+ atoms.append({"pred": atom["predicate"], "accept": ACCEPT[(st, "positive")]})
2414
+ steps.append({"gen": ai})
2415
+ for j, arg in enumerate(atom["args"]):
2416
+ t = self.ts(params[j])
2417
+ if self._wild(atoms[-1], arg, j, len(params)):
2418
+ continue
2419
+ if arg["kind"] != "var":
2420
+ filters.append(self._body_arg(ai, j, t, arg, params[j], binds))
2421
+ continue
2422
+ prev = binds.get(arg["var"])
2423
+ if prev is None:
2424
+ binds[arg["var"]] = (ai, j, t)
2425
+ elif prev[0] == "p":
2426
+ filters.append({"row": ai, "col": j, "t": t,
2427
+ "term": {"k": "param", "i": prev[1], "t": prev[2]}})
2428
+ else:
2429
+ joins.append([list(prev), [ai, j, t]])
2430
+ finally:
2431
+ self._atoms = saved
2432
+ return {"atoms": atoms, "joins": joins, "filters": filters, "checks": checks, "guards": guards,
2433
+ "steps": steps, "reads": reads, "binds": binds, "gens": gens}
2434
+
2435
+ def _aggregate(self, node: dict, binds: dict) -> tuple:
2436
+ """Агрегат §59 → подплан comprehension §52.1 с параметрами — внешними
2437
+ переменными. Возвращает (терм, URN вида результата, фоллибелен ли)."""
2438
+ key = id(node)
2439
+ if key in self._agg_cache:
2440
+ return self._agg_cache[key]
2441
+ op = node.get("op")
2442
+ if op not in AGG_OPS:
2443
+ raise Refused(f"агрегат вне среза генератора: {op}")
2444
+ if op == "only" and node.get("options"):
2445
+ # §51 (errata E-0256): у функционального lookup политики нет, и
2446
+ # оракул отвечает на `options` TYPE_ERROR — другой ответ, а не меньший.
2447
+ raise Refused("агрегат only с options вне среза генератора (§51, errata E-0256)")
2448
+ comp = node.get("input") or {}
2449
+ if comp.get("kind") != "comprehension" or "distinct" not in comp:
2450
+ raise Refused("вход агрегата не comprehension §52.1 вне среза генератора")
2451
+ free = self._free(comp)
2452
+ outer, ptypes = {}, []
2453
+ for n, v in enumerate(free):
2454
+ if v not in binds:
2455
+ raise Refused(f"несвязанная переменная в голове: {v}")
2456
+ b = binds[v]
2457
+ if b[0] == "p":
2458
+ urn, t = b[3], b[2]
2459
+ else:
2460
+ urn = self.types[self._atoms[b[0]]["predicate"]][b[1]]
2461
+ t = b[2]
2462
+ outer[v] = ("p", n, t, urn)
2463
+ ptypes.append(t)
2464
+ args = [self.term({"kind": "var", "var": v}, binds) for v in free]
2465
+ body = self._body09(comp["generator"], outer)
2466
+ if not body["atoms"]:
2467
+ raise Refused("comprehension без перебираемых атомов вне среза генератора")
2468
+ if any(f.get("err") for f in body["filters"]) or any(c.get("err") for c in body["checks"]):
2469
+ # DECISION-0270: у подплана агрегата нет кодов по подстановкам (G3 —
2470
+ # ошибка агрегата целиком); ошибка аргумента атома comprehension — отказ.
2471
+ raise Refused("ошибка значения в аргументе атома comprehension вне среза генератора (DECISION-0270)")
2472
+ element = comp.get("element") or {}
2473
+ saved = self._atoms
2474
+ self._atoms = body["gens"]
2475
+ try:
2476
+ if element.get("kind") not in ("var", "value", "entity_ref", "const_ref"):
2477
+ raise Refused("вычисляемый элемент comprehension вне среза генератора (§52.1)")
2478
+ el = self.term(element, body["binds"])
2479
+ kind = self.static_type(element, body["binds"])
2480
+ finally:
2481
+ self._atoms = saved
2482
+ if op != "count" and kind not in NUMERIC:
2483
+ raise Refused(f"агрегат {op} над нечисловым элементом вне среза генератора (§59)")
2484
+ # DECISION-0266: сумма и выбор над Natural у оракула — Integer (`Typed integer`).
2485
+ result = INT if op == "count" else _num(kind)
2486
+ empty = (node.get("options") or {}).get("empty")
2487
+ empty_term = None
2488
+ if empty is not None and op != "count":
2489
+ policy = _num(self.static_type(empty, {}))
2490
+ # errata E-0195: у оракула вид политики сверяется с видом элемента
2491
+ # ПУСТОЙ коллекции, а его несёт ОБЪЯВЛЕННЫЙ тип проекции §52.1
2492
+ # (`values.aggregate_element_type`), а не вид, выведенный планом из
2493
+ # терма элемента: в законной программе они совпадают, в рукописном
2494
+ # CLIR — нет, и там оракул даёт TYPE_ERROR. Сверяются ОБА носителя:
2495
+ # расхождение любого — отказ, потому что ответ у оракула другой,
2496
+ # а не меньший.
2497
+ declared = self._t(((comp.get("variable") or {}).get("type") or {}).get("name"))
2498
+ if policy != result or (declared in EMPTY_KIND_NAMES and policy != _num(declared)):
2499
+ raise Refused("вид empty-политики агрегата не совпадает с видом элемента коллекции "
2500
+ "(§59, errata E-0195)")
2501
+ empty_term = self.term(empty, {})
2502
+ name = f"agg_{len(self._aggs)}"
2503
+ for g in body["guards"]:
2504
+ g.pop("_node", None)
2505
+ self._aggs.append({"name": name, "op": op, "params": ptypes, "atoms": body["atoms"],
2506
+ "joins": body["joins"], "filters": body["filters"], "checks": body["checks"],
2507
+ "guards": body["guards"], "steps": body["steps"], "element": el,
2508
+ "distinct": bool(comp["distinct"]), "empty": empty_term})
2509
+ # E-0112: перечисление — ребро completion над всеми чтениями генератора.
2510
+ self._agg_reads += [("completion", pred, pol) for _st, pred, pol in body["reads"]]
2511
+ self._agg_inner += body["reads"]
2512
+ out = ({"k": "agg", "fn": name, "args": args, "t": TYPES[result]}, result,
2513
+ op != "count" and empty_term is None)
2514
+ if out[2]:
2515
+ # E-0105: ошибка агрегата правила-кандидата доезжает до статуса
2516
+ # вопроса `truth` — раннеру нужен обход от цели (`backchain`).
2517
+ self._agg_fallible = True
2518
+ self._agg_cache[key] = out
2519
+ return out
2520
+
2521
+ def _monotone_guard(self, g: dict):
2522
+ """Агрегат монотонного гарда E-0116 либо None — порт
2523
+ `rules._monotone_guard_aggregate` с `_monotone_generator`."""
2524
+ op = g.get("op")
2525
+ for agg, threshold, effective in ((g.get("left") or {}, g.get("right") or {}, op),
2526
+ (g.get("right") or {}, g.get("left") or {}, MIRROR_OP.get(op))):
2527
+ if agg.get("kind") != "aggregate":
2528
+ continue
2529
+ if effective not in MONOTONE_GUARD_OPS.get(agg.get("op"), ()):
2530
+ return None
2531
+ comp = agg.get("input") or {}
2532
+ if comp.get("kind") != "comprehension" or '"comprehension"' in json.dumps(threshold):
2533
+ return None
2534
+ conj: list = []
2535
+ try:
2536
+ self.parts_l1(comp.get("generator") or {}, conj)
2537
+ except Refused:
2538
+ return None
2539
+ for st, lit in conj:
2540
+ if st == "guard" or st in ("monotone", "supported"):
2541
+ continue
2542
+ if st != "established" or lit.get("polarity") == "negative" \
2543
+ or lit.get("predicate") in self._derived:
2544
+ return None
2545
+ return agg
2546
+ return None
2547
+
2548
+ def _rule_v09(self, i: int, rule: dict) -> dict:
2549
+ """Правило плана 0.9: шаги тела в порядке `solve`, агрегаты подпланами."""
2550
+ strength = rule.get("strength") or "strict"
2551
+ if strength not in ("strict", "defeasible", "defeater"):
2552
+ raise Refused(f"сила правила вне среза генератора: {strength}")
2553
+ body, fallible, addends, first_agg = self._rule_body09(rule)
2554
+ binds = body["binds"]
2555
+ head = rule["head"]
2556
+ if (head or {}).get("kind") != "literal":
2557
+ raise Refused(f"голова правила вне среза генератора: {(head or {}).get('kind')}")
2558
+ if head.get("polarity") not in ("positive", "negative"):
2559
+ raise Refused("полярность головы правила вне среза генератора")
2560
+ saved = self._atoms
2561
+ self._atoms = body["gens"]
2562
+ try:
2563
+ self.head_types(head, binds)
2564
+ kind_checks = self.kind_checks(head, binds) if self.v12 or self.v13 or self.v14 or self.v17 or self.v18 else []
2565
+ head_args = [self.term(a, binds) for a in head["args"]]
2566
+ subst = [self.term({"kind": "var", "var": v}, binds) for v in binds]
2567
+ head_value = (self.v12 or self.v13 or self.v14 or self.v17 or self.v18) and self._fallible_value(head["args"], binds)
2568
+ finally:
2569
+ self._atoms = saved
2570
+ # W5 (errata E-0265): агрегат `sum`/`min`/`max`/`only` без политики `empty`
2571
+ # в голове снимает только свою подстановку — issue одна на (правило, код),
2572
+ # остальные подстановки применяются. До errata ответ оракула зависел от
2573
+ # порядка подстановок, и здесь стоял отказ плана (DECISION-0239 п. 4).
2574
+ head_agg = any(self._agg_cache.get(id(x), (None, None, False))[2] for x in self._walk(head))
2575
+ head_fallible = self._fallible_calendar(head) or head_value or head_agg
2576
+ window, dating = self._window(rule)
2577
+ out = {
2578
+ "name": f"{i}_{_slug(rule['id'])}", "id": rule["id"], "strength": strength, "window": window,
2579
+ "atoms": body["atoms"], "joins": body["joins"], "filters": body["filters"], "checks": body["checks"],
2580
+ "guards": body["guards"], "steps": body["steps"],
2581
+ "head": {"pred": head["predicate"], "polarity": head["polarity"], "args": head_args},
2582
+ "subst": subst, "fallible": fallible, "addends": addends,
2583
+ }
2584
+ if head_fallible:
2585
+ # §175 (errata E-0265): ошибка головы снимает только свою подстановку;
2586
+ # issue — по каждому коду однажды, прошедшие подстановки применяются.
2587
+ out["headFallible"] = True
2588
+ if head_agg:
2589
+ # Ошибка агрегата головы (EMPTY_AGGREGATE, CARDINALITY_CONFLICT) ловится
2590
+ # на подстановке, а не всем правилом, как ошибка гарда (G3).
2591
+ out["headAgg"] = True
2592
+ if kind_checks:
2593
+ # DECISION-0252 п. 5: вид значения в слоте Decimal/Rational — E-0077.
2594
+ out["kindChecks"] = kind_checks
2595
+ self._remember(out, rule, strength, body["reads"] + self._agg_reads, dating)
2596
+ self._meta[out["name"]]["fallible"] = fallible or head_fallible
2597
+ if self._fallible_calendar(head) or any(g.get("cal") for g in body["guards"]):
2598
+ self._meta[out["name"]]["calendar"] = True
2599
+ if head_value or any(g.get("err") for g in body["guards"]) \
2600
+ or any(f.get("err") for f in body["filters"]) or any(c.get("err") for c in body["checks"]):
2601
+ self._meta[out["name"]]["value"] = True
2602
+ # Статусы чтений внутри генераторов: для замыкания важно, читает ли
2603
+ # перечисление негатив (§70/E-0113), а не сам факт перечисления.
2604
+ self._meta[out["name"]]["inner"] = list(self._agg_inner)
2605
+ self._meta[out["name"]]["aggregates"] = [a["name"] for a in self._aggs[first_agg:]]
2606
+ self._meta[out["name"]]["closure_reads"] = self._closure_reads(rule)
2607
+ return out
2608
+
2609
+ def _rule_body09(self, rule: dict) -> tuple:
2610
+ """Тело правила плана 0.9: шаги `solve`, гарды E-0116 и фоллибельность —
2611
+ общее у правил с литеральной головой и у норм-правил (DECISION-0242)."""
2612
+ self._agg_reads = []
2613
+ self._agg_inner = []
2614
+ first_agg = len(self._aggs)
2615
+ body = self._body09(rule["body"], {})
2616
+ addends, fallible = [], False
2617
+ # DECISION-0270: ошибка значения вычисляемого аргумента атома (до перебора
2618
+ # таблицы) либо проверки статуса — та же механика кодов, что у гарда.
2619
+ if any(f.get("err") for f in body["filters"]) or any(c.get("err") for c in body["checks"]):
2620
+ fallible = True
2621
+ for g in body["guards"]:
2622
+ node = g.pop("_node")
2623
+ if self._fallible_calendar(node):
2624
+ # DECISION-0245 п. 6: ошибка календаря в гарде — issue и снятие
2625
+ # правила на проход; коды ловятся по подстановкам.
2626
+ g["cal"] = True
2627
+ fallible = True
2628
+ elif (self.v12 or self.v13 or self.v14 or self.v17 or self.v18) and self._fallible_value(node, body["binds"], body["gens"]):
2629
+ # DECISION-0252 п. 4: ошибка значения §58 в гарде — та же механика.
2630
+ g["err"] = True
2631
+ fallible = True
2632
+ mono = self._monotone_guard(node)
2633
+ if mono is not None:
2634
+ conj: list = []
2635
+ self.parts_l1(mono["input"]["generator"], conj)
2636
+ derived = sorted({lit["predicate"] for st, lit in conj
2637
+ if st != "guard" and lit["predicate"] in self._derived})
2638
+ if derived:
2639
+ raise Refused("монотонный агрегатный гард (E-0116) над выводимым предикатом "
2640
+ f"вне среза генератора (DECISION-0239 G2): {', '.join(derived)}")
2641
+ if mono.get("op") == "sum":
2642
+ # E-0116: знак слагаемых проверяется на исполнении гарда.
2643
+ addends.append(self._agg_cache[id(mono)][0])
2644
+ for side in (node.get("left") or {}, node.get("right") or {}):
2645
+ stack = [side]
2646
+ while stack:
2647
+ x = stack.pop()
2648
+ if isinstance(x, dict):
2649
+ if x.get("kind") == "aggregate" and id(x) in self._agg_cache and self._agg_cache[id(x)][2]:
2650
+ fallible = True
2651
+ stack.extend(v for v in x.values() if isinstance(v, (dict, list)))
2652
+ elif isinstance(x, list):
2653
+ stack.extend(x)
2654
+ return body, fallible, addends, first_agg
2655
+
2656
+ def _closure_reads(self, rule: dict) -> list:
2657
+ """Чтения правила для ожидания замыкания §70 (E-0174) —
2658
+ `(статус, предикат, полярность, перечисление)`: порт разбора
2659
+ `rules.closure_waits`. Литералы тела; генераторы монотонного гарда
2660
+ E-0116 — не перечисление; генераторы прочих агрегатов тела и головы —
2661
+ перечисление (`_split_generator_literals`)."""
2662
+ conj: list = []
2663
+ self.parts_l1(rule["body"], conj)
2664
+ reads: list = []
2665
+ for st, item in conj:
2666
+ if st == "guard":
2667
+ mono = self._monotone_guard(item)
2668
+ if mono is not None:
2669
+ reads += [(s, lit, False) for s, lit in self._generator_literals(mono["input"])]
2670
+ continue
2671
+ else:
2672
+ reads.append((st, item, False))
2673
+ reads += [(s, lit, True) for s, lit in self._generator_literals(item)]
2674
+ reads += [(s, lit, True) for s, lit in self._generator_literals(rule["head"])]
2675
+ return [(s, lit["predicate"], lit.get("polarity", "positive"), enum) for s, lit, enum in reads]
2676
+
2677
+ def _generator_literals(self, formula) -> list:
2678
+ """Порт `rules._aggregate_generator_literals`: литералы генераторов
2679
+ comprehension со статусом; внутрь генератора обход не спускается."""
2680
+ out: list = []
2681
+ stack = [formula]
2682
+ while stack:
2683
+ x = stack.pop(0)
2684
+ if isinstance(x, list):
2685
+ stack[:0] = x
2686
+ elif isinstance(x, dict):
2687
+ if x.get("kind") == "comprehension":
2688
+ conj: list = []
2689
+ self.parts_l1(x.get("generator") or {}, conj)
2690
+ out += [(st, lit) for st, lit in conj if st != "guard" and lit.get("predicate")]
2691
+ continue
2692
+ stack[:0] = [v for v in x.values() if isinstance(v, (dict, list))]
2693
+ return out
2694
+
2695
+ @staticmethod
2696
+ def _walk(x):
2697
+ stack = [x]
2698
+ while stack:
2699
+ y = stack.pop()
2700
+ if isinstance(y, dict):
2701
+ yield y
2702
+ stack.extend(v for v in y.values() if isinstance(v, (dict, list)))
2703
+ elif isinstance(y, list):
2704
+ stack.extend(y)
2705
+
2706
+ def _closure(self, n: dict) -> None:
2707
+ """Замыкание §70 (E-0087, E-0104): негатив члену домена без
2708
+ strict/asserted positive-опоры. Неисполнимое у оракула замыкание
2709
+ (`NON_EXECUTABLE_CLOSURE`) — отказ среза."""
2710
+ for f in sorted(set(n) - CLOSURE_FIELDS):
2711
+ raise Refused(f"поле замыкания вне среза генератора: {f}")
2712
+ if not n.get("deriveExplicitNegative"):
2713
+ return # оракул такое замыкание пропускает молча
2714
+ pred, domain = n.get("predicate"), n.get("domain") or {}
2715
+ width = len(self.types.get(pred) or [])
2716
+ if not width or domain.get("kind") != "const_ref" or domain.get("id") not in self.types \
2717
+ or self.types[domain["id"]] != self.types[pred]:
2718
+ raise Refused(f"замыкание §70 вне среза генератора (NON_EXECUTABLE_CLOSURE): {n.get('id')}")
2719
+ eff = n.get("effective")
2720
+ window = None
2721
+ if eff:
2722
+ if eff.get("axis") != "legal_time":
2723
+ raise Refused(f"ось окна effective вне среза генератора: {eff.get('axis')}")
2724
+ start, end = eff.get("start"), eff.get("end")
2725
+ start = None if start in (None, "-infinity") else start
2726
+ end = None if end in (None, "infinity") else end
2727
+ # E-0087: окно замыкания проверяется `within_effective` с флагами
2728
+ # включения. Граница-день §2.15 переводится в полуинтервал
2729
+ # `[start, end)` сдвигом на следующий день; иная гранулярность — отказ.
2730
+ for bound, inclusive, shift in ((start, eff.get("startInclusive", True), False),
2731
+ (end, eff.get("endInclusive", False), True)):
2732
+ if bound is not None and not _is_date(bound) and inclusive is shift:
2733
+ raise Refused("полузакрытость окна effective вне среза генератора")
2734
+ if start is not None and eff.get("startInclusive") is False:
2735
+ start = _next_day(start)
2736
+ if end is not None and eff.get("endInclusive") is True:
2737
+ end = _next_day(end)
2738
+ window = {"start": start, "end": end}
2739
+ self._closures.append({"name": f"closure_{len(self._closures)}_{_slug(n['id'])}", "id": n["id"],
2740
+ "pred": pred, "domain": domain["id"], "window": window})
2741
+
2742
+ # --- нормативные позиции L2 (DECISION-0242) ---------------------------------
2743
+
2744
+ def _std_tables(self) -> list:
2745
+ """Таблицы литералов §140, которые публикует жизненный цикл позиций.
2746
+ Позиция — сущность вида `urn:position:…`; шаблон во втором аргументе
2747
+ `instance_of` — `const_ref` (идентичность). Объявленный пакетом
2748
+ std-предикат остаётся объявлением пакета."""
2749
+ out = []
2750
+ self.entities |= {POSITION_TYPE, "urn:law:backend#Template"}
2751
+ for name, arity in STD_POSITION.items():
2752
+ pred = STD + name
2753
+ if pred in self.types:
2754
+ continue
2755
+ params = [POSITION_TYPE] + (["urn:law:backend#Template" if name == "instance_of" else POSITION_TYPE]
2756
+ if arity == 2 else [])
2757
+ if name in ("bearer_of", "beneficiary_of"):
2758
+ params[1] = POSITION_TYPE
2759
+ self.types[pred] = params
2760
+ out.append({"pred": pred, "name": self.table_name(pred), "arity": arity, "params": params,
2761
+ "keys": [], "kinds": ["text"] * arity})
2762
+ return out
2763
+
2764
+ def _deref(self, value):
2765
+ """Разыменование констант §54 в payload шаблона — оракул делает его над
2766
+ программой до исполнения; члены перечислений остаются `const_ref`."""
2767
+ if isinstance(value, list):
2768
+ return [self._deref(v) for v in value]
2769
+ if not isinstance(value, dict):
2770
+ return value
2771
+ if value.get("kind") == "const_ref" and not self.is_member(value.get("id", "")):
2772
+ c = self.consts.get(value.get("id"))
2773
+ if c is not None and c.get("value") is not None:
2774
+ return c["value"]
2775
+ return {k: self._deref(v) for k, v in value.items()}
2776
+
2777
+ def _norm_v10(self, i: int, rule: dict) -> dict:
2778
+ """Норм-правило §135 с шаблоном §122: подстановки тела создают позиции,
2779
+ статус §134 — лестница оракула (`norms._summary_at`), эффект power —
2780
+ `effects.materialize_effects`. Всё, что оракул исполняет иначе или
2781
+ не исполняет, — названный отказ."""
2782
+ strength = rule.get("strength") or "strict"
2783
+ if strength == "defeater":
2784
+ raise Refused("дефитер норм-правила вне среза генератора (DECISION-0242 K6)")
2785
+ if strength not in ("strict", "defeasible"):
2786
+ raise Refused(f"сила правила вне среза генератора: {strength}")
2787
+ tid = (rule.get("head") or {}).get("id")
2788
+ tpl = self.by_id.get(tid)
2789
+ if tpl is None or tpl.get("kind") != "norm_template":
2790
+ raise Refused(f"шаблон нормы не разрешается (§135): {tid}")
2791
+ # §92.3: проекция редакции снимает у оракула шаблон на дату права, и
2792
+ # ссылающееся правило позиций не создаёт (NON_EXECUTABLE_RULE — issue
2793
+ # вне сверки среза). То же даёт окно правила, пересечённое с окнами
2794
+ # шаблона; несколько окон — названный отказ, как у правил.
2795
+ rule_window, _dating = self._window(rule)
2796
+ tdating = self._dating(tpl)
2797
+ if tdating is not None and tdating != [(None, None)]:
2798
+ base = (rule_window or {}).get("start"), (rule_window or {}).get("end")
2799
+ cut = [w for w in (_intersect(base, d) for d in tdating) if w is not None]
2800
+ if not cut:
2801
+ raise _Excluded(rule["id"])
2802
+ if len(cut) > 1:
2803
+ raise Refused("прерывистая применимость редакции §92.3 вне среза генератора")
2804
+ rule_window = {"start": cut[0][0], "end": cut[0][1]}
2805
+ modality = tpl.get("modality")
2806
+ payload = self._deref(tpl.get("payload") or {})
2807
+ if modality not in MODALITIES or payload.get("kind") != modality:
2808
+ raise Refused(f"модальность шаблона вне среза генератора: {modality}")
2809
+ for f in sorted(set(payload) - PAYLOAD_FIELDS[modality]):
2810
+ raise Refused(f"поле payload шаблона вне среза генератора (DECISION-0242 п. 4): {f}")
2811
+ body, fallible, addends, first_agg = self._rule_body09(rule)
2812
+ if any(g.get("cal") for g in body["guards"]):
2813
+ raise Refused("календарная функция с ошибкой значения в теле норм-правила вне среза генератора "
2814
+ "(DECISION-0245)")
2815
+ if any(g.get("err") for g in body["guards"]):
2816
+ raise Refused("терм с ошибкой значения §58 в теле норм-правила вне среза генератора (DECISION-0252)")
2817
+ binds = body["binds"]
2818
+ free = set(_payload_vars(payload))
2819
+ if not free <= set(binds):
2820
+ raise Refused(f"переменная payload не связана телом норм-правила (DECISION-0242 п. 3): "
2821
+ f"{', '.join(sorted(free - set(binds)))}")
2822
+ saved = self._atoms
2823
+ self._atoms = body["gens"]
2824
+ reads_extra: list = []
2825
+ try:
2826
+ def literal(node, what):
2827
+ if node is None:
2828
+ return None
2829
+ if node.get("kind") == "status" and what in ("условие цели", "validWhen"):
2830
+ if what == "условие цели" and node.get("status") != "established":
2831
+ raise Refused(f"{what} §124 в форме status:{node.get('status')} вне среза генератора")
2832
+ node = node.get("formula") or {}
2833
+ if node.get("kind") == "call" and what == "exercise":
2834
+ node = {"kind": "literal", "polarity": "positive", "predicate": node.get("function"),
2835
+ "args": node.get("args") or []}
2836
+ if node.get("kind") != "literal" or node.get("polarity") not in ("positive", "negative"):
2837
+ raise Refused(f"{what} вне среза генератора (DECISION-0242 п. 4): {node.get('kind')}")
2838
+ params = self.types.get(node["predicate"])
2839
+ if params is None or len(params) != len(node.get("args") or []):
2840
+ raise Refused(f"{what}: предикат не объявлен либо арность не совпадает (§189)")
2841
+ reads_extra.append((what, node["predicate"]))
2842
+ return {"pred": node["predicate"], "polarity": node["polarity"],
2843
+ "args": [self.term(a, binds) for a in node["args"]]}
2844
+
2845
+ def actor(term):
2846
+ if term is None:
2847
+ return None
2848
+ k = term.get("kind")
2849
+ if k in ("entity_ref", "const_ref"):
2850
+ return {"k": "lit", "t": "text" if k == "entity_ref" else "enum", "v": term["id"]}
2851
+ if k == "var":
2852
+ t = self.term(term, binds)
2853
+ return t if t["t"] in ("text", "enum") else {"k": "fallback"}
2854
+ return {"k": "fallback"}
2855
+
2856
+ def bound(term):
2857
+ if term is None:
2858
+ return None
2859
+ if term.get("kind") == "value":
2860
+ tname = (term.get("type") or {}).get("name")
2861
+ if tname == "urn:law:std#Text" and term.get("value") in ("infinity", "+infinity", "-infinity"):
2862
+ return {"k": "inf", "v": term["value"]}
2863
+ if tname == "urn:law:std#Date":
2864
+ return {"k": "lit", "t": "date", "v": str(term["value"])}
2865
+ if term.get("kind") == "var":
2866
+ t = self.term(term, binds)
2867
+ if t["t"] == "date":
2868
+ return t
2869
+ if term.get("kind") == "call" and term.get("function") in CAL_FNS:
2870
+ # DECISION-0245 п. 8: граница — календарный терм над
2871
+ # переменными тела; ошибка — issue и граница «нет». Не
2872
+ # Date (`days_between`) — у оракула TYPE_ERROR на каждой
2873
+ # позиции (`norms._window_bounds`).
2874
+ compiled = self.term(term, binds)
2875
+ if self.static_type(term, binds) != "urn:law:std#Date":
2876
+ return {"k": "calbound", "error": "TYPE_ERROR"}
2877
+ return {"k": "calbound", "term": compiled}
2878
+ raise Refused("граница окна позиции вне среза генератора (DECISION-0242 п. 2): "
2879
+ f"{term.get('kind')}")
2880
+
2881
+ def window(w):
2882
+ if w is None:
2883
+ return None
2884
+ if w.get("kind") != "interval":
2885
+ raise Refused(f"окно позиции вне среза генератора: {w.get('kind')}")
2886
+ return {"start": bound(w.get("start")), "end": bound(w.get("end")),
2887
+ "startInclusive": bool(w.get("startInclusive", True)),
2888
+ "endInclusive": bool(w.get("endInclusive", True))}
2889
+
2890
+ goal = payload.get("goal")
2891
+ goal_kind, condition, goal_window, completeness = None, None, None, []
2892
+ if modality == "duty":
2893
+ if not isinstance(goal, dict) or goal.get("kind") not in ("achievement", "maintenance", "forbearance"):
2894
+ raise Refused("вид цели §124 вне среза генератора: "
2895
+ f"{(goal or {}).get('kind') if isinstance(goal, dict) else goal}")
2896
+ for f in sorted(set(goal) - GOAL_FIELDS):
2897
+ raise Refused(f"поле цели §124 вне среза генератора: {f}")
2898
+ goal_kind = goal["kind"]
2899
+ goal_window = window(goal.get("window"))
2900
+ if goal_kind == "forbearance":
2901
+ action = goal.get("action") or {}
2902
+ if action.get("kind") != "call":
2903
+ raise Refused("действие цели §124.3 вне среза генератора: требуется вызов отношения")
2904
+ condition = literal({"kind": "literal", "polarity": "negative",
2905
+ "predicate": action.get("function"), "args": action.get("args") or []},
2906
+ "условие цели")
2907
+ else:
2908
+ if goal.get("condition") is None:
2909
+ raise Refused("условие цели §124 отсутствует (NON_EXECUTABLE_GOAL)")
2910
+ condition = literal(goal.get("condition"), "условие цели")
2911
+ if goal_kind in ("maintenance", "forbearance"):
2912
+ for c in sorted((n for n in self.ir["nodes"] if n.get("kind") == "closure_policy"
2913
+ and n.get("deriveExplicitNegative") and n.get("predicate") == condition["pred"]),
2914
+ key=lambda n: n["id"]):
2915
+ dom = (c.get("domain") or {})
2916
+ if dom.get("kind") == "const_ref" and dom.get("id") in self.types:
2917
+ completeness.append({"domain": dom["id"],
2918
+ "completeAsOf": str(c.get("completeAsOf", ""))[:10]})
2919
+ elif goal is not None:
2920
+ raise Refused(f"цель у модальности {modality} вне среза генератора")
2921
+ holder_term = payload.get("bearer") if payload.get("bearer") is not None else payload.get("holder")
2922
+ out = {
2923
+ "name": f"{i}_{_slug(rule['id'])}", "id": rule["id"], "strength": strength,
2924
+ "window": rule_window, "template": tid, "modality": modality,
2925
+ "atoms": body["atoms"], "joins": body["joins"], "filters": body["filters"],
2926
+ "checks": body["checks"], "guards": body["guards"], "steps": body["steps"],
2927
+ "fallible": fallible, "addends": addends,
2928
+ "subst": [self.term({"kind": "var", "var": v}, binds) for v in binds],
2929
+ "holder": actor(holder_term),
2930
+ "beneficiary": actor(payload.get("beneficiary")),
2931
+ "goalKind": goal_kind, "condition": condition, "goalWindow": goal_window,
2932
+ "payloadWindow": window(payload.get("window")), "completeness": completeness,
2933
+ "payload": payload,
2934
+ "payloadVars": [{"var": v, "term": self.term({"kind": "var", "var": v}, binds),
2935
+ "pred": body["gens"][binds[v][0]]["predicate"], "col": binds[v][1]}
2936
+ for v in sorted(free)],
2937
+ }
2938
+ if modality == "power":
2939
+ effect = payload.get("effect") or {}
2940
+ if effect.get("kind") != "create" or not isinstance(effect.get("after"), dict):
2941
+ raise Refused("эффект полномочия §127 вне среза генератора: только create")
2942
+ out.update({"exercise": literal(payload.get("exercise"), "exercise"),
2943
+ "validWhen": literal(payload.get("validWhen"), "validWhen"),
2944
+ "effect": literal(effect["after"], "эффект"),
2945
+ "powerHolder": actor(payload.get("holder"))})
2946
+ if out["exercise"] is None:
2947
+ raise Refused("exercise полномочия §127 отсутствует")
2948
+ if modality == "immunity":
2949
+ protected = (payload.get("protectedEffect") or {}).get("after")
2950
+ out.update({"protected": literal(protected, "защищённый эффект") if protected else None,
2951
+ "against": actor(payload.get("against"))})
2952
+ finally:
2953
+ self._atoms = saved
2954
+ self._norm_meta[out["name"]] = {
2955
+ "id": rule["id"], "name": out["name"], "reads": body["reads"] + self._agg_reads,
2956
+ "extra": reads_extra, "modality": modality, "goal": goal_kind, "template": tid,
2957
+ "action": json.dumps((goal or {}).get("action") if modality == "duty" else payload.get("action"),
2958
+ sort_keys=True),
2959
+ "condition": (condition or {}).get("pred"),
2960
+ "effect": (out.get("effect") or {}).get("pred"), "aggregates": [a["name"] for a in self._aggs[first_agg:]]}
2961
+ return out
2962
+
2963
+ def _structure10(self, metas: list, strict: list, drules: list, reach: dict) -> dict:
2964
+ """Отказы DECISION-0242: обратная связь L2 (п. 1), конфликт позиций
2965
+ §105.1 (K5); приоритеты норм-правил для power/immunity (п. 5)."""
2966
+ if "urn:law:std#incompatible" in json.dumps(self.ir["nodes"]):
2967
+ raise Refused("конфликт позиций §105.1 (incompatible) вне среза генератора (DECISION-0242 K5)")
2968
+ norms = list(self._norm_meta.values())
2969
+ libs = {n["action"] for n in norms if n["modality"] == "liberty" and n["action"] != "null"}
2970
+ forb = {n["action"] for n in norms if n["goal"] == "forbearance" and n["action"] != "null"}
2971
+ if libs & forb:
2972
+ raise Refused("конфликт позиций §105.1 (forbearance/liberty) вне среза генератора (DECISION-0242 K5)")
2973
+ keyed = sorted({n["condition"] for n in norms if n["goal"] in ("achievement", "maintenance")
2974
+ and n["condition"] and (self.by_id.get(n["condition"]) or {}).get("keys")})
2975
+ if keyed:
2976
+ raise Refused("конфликт позиций §105.1 по ключу §45.1 вне среза генератора "
2977
+ f"(DECISION-0242 K5, POSSIBLE_NORM_CONFLICT): {', '.join(keyed)}")
2978
+ # п. 1: выходы L2 и их strict-следствия.
2979
+ out = {STD + s for s in STD_POSITION} | {n["effect"] for n in norms if n["effect"]}
2980
+ changed = True
2981
+ while changed:
2982
+ changed = False
2983
+ for m in strict:
2984
+ if m["head"] not in out and any(pred in out for _st, pred, _p in m["reads"]):
2985
+ out.add(m["head"])
2986
+ changed = True
2987
+ for n in norms:
2988
+ hit = sorted({pred for _st, pred, _p in n["reads"] if pred in out}
2989
+ # Эффект и защищённый эффект — образцы, а не чтения хранилища.
2990
+ | {pred for _w, pred in n["extra"] if pred in out and _w not in ("эффект", "защищённый эффект")})
2991
+ if hit:
2992
+ raise Refused(f"{NORM_REFUSAL}: норм-правило {n['id']} читает {', '.join(hit)}")
2993
+ for m in drules:
2994
+ if m["head"] in out or any(pred in out for _st, pred, _p in m["reads"]):
2995
+ raise Refused(f"{NORM_REFUSAL}: поражаемое правило {m['id']} над выходом L2")
2996
+ for c in self._closures:
2997
+ if c["pred"] in out or c["domain"] in out:
2998
+ raise Refused(f"{NORM_REFUSAL}: замыкание {c['id']} над выходом L2")
2999
+ negative_heads = {m["head"] for m in strict if m["polarity"] == "negative"}
3000
+ for m in strict:
3001
+ for st, pred, pol in m["reads"] + m.get("inner", []):
3002
+ if pred not in out:
3003
+ continue
3004
+ if st not in ("established", "monotone", "supported") or pol == "negative" \
3005
+ or (st == "established" and pred in negative_heads):
3006
+ raise Refused(f"{NORM_REFUSAL}: strict-правило {m['id']} читает {st} {pred} "
3007
+ "до исполнения L2")
3008
+ names = {n["id"]: n["name"] for n in norms}
3009
+ return {"normPriorities": sorted([names[a], names[b]] for a in reach for b in reach[a]
3010
+ if a in names and b in names)}
3011
+
3012
+ def _priority(self, n: dict) -> None:
3013
+ """Приоритет §106 в срезе: безусловное (`condition == true`)
3014
+ недатированное ребро между правилами. Условный §117/§118,
3015
+ датированный редакцией и неизвестные поля — названный отказ."""
3016
+ if n.get("condition") not in (None, TRUE_FORMULA) or n.get("priorityKind") not in (None, "direct"):
3017
+ raise Refused(f"условный приоритет (§117/§118) вне среза генератора: {n.get('id')}")
3018
+ for f in sorted(set(n) - PRIORITY_FIELDS):
3019
+ raise Refused(f"поле приоритета вне среза генератора: {f}")
3020
+ higher, lower = n.get("higher") or {}, n.get("lower") or {}
3021
+ if higher.get("kind") != "const_ref" or lower.get("kind") != "const_ref":
3022
+ raise Refused(f"сторона приоритета не правило (§106) вне среза генератора: {n.get('id')}")
3023
+ dating = self._dating(n)
3024
+ window = None
3025
+ if dating is not None and dating != [(None, None)]:
3026
+ if not dating:
3027
+ return # §92.3: узел снят проекцией на всей оси
3028
+ if len(dating) != 1:
3029
+ # DECISION-0269 п. 3: прерывистое окно исходного ребра — отказ первого среза.
3030
+ raise Refused(f"прерывистое окно приоритета (§92.3) вне среза генератора: {n.get('id')}")
3031
+ self._dated_prio_seen = True
3032
+ window = dating[0]
3033
+ self._prio.append((higher["id"], lower["id"], n["id"], window))
3034
+
3035
+ def _fact_l1(self, a: dict) -> dict:
3036
+ lit = a.get("literal") or {}
3037
+ pol = lit.get("polarity")
3038
+ if pol not in ("positive", "negative"):
3039
+ raise Refused("полярность собственного факта вне среза генератора")
3040
+ dating = self._dating(a)
3041
+ if dating is not None and dating != [(None, None)]:
3042
+ if not dating:
3043
+ raise _Excluded(a["id"])
3044
+ if len(dating) > 1:
3045
+ raise Refused("прерывистая применимость редакции §92.3 вне среза генератора")
3046
+ self.dated_facts.append({
3047
+ "name": f"dated_{len(self.dated_facts)}_{_slug(a['id'])}", "id": a["id"], "strength": "strict",
3048
+ "window": {"start": dating[0][0], "end": dating[0][1]},
3049
+ "atoms": [{"pred": ALWAYS, "accept": ["TRUE_ONLY"]}], "joins": [], "filters": [], "checks": [],
3050
+ "guards": [], "subst": [],
3051
+ "head": {"pred": lit["predicate"], "polarity": pol, "args": [self.term(x, {}) for x in lit["args"]]}})
3052
+ raise _Excluded(None)
3053
+ return {"pred": lit["predicate"], "polarity": pol, "args": [self.term(x, {}) for x in lit["args"]]}
3054
+
3055
+ def _structure(self, rules: list) -> dict:
3056
+ """Структурный анализ программы: циклы §110, завершимость, а у L1 —
3057
+ барьер полноты §110 п. 5, приоритеты §106, ранги обоих слоёв, поздние
3058
+ производители §111 и расписание фаз. Всё за срезом — названный отказ."""
3059
+ metas = sorted((self._meta[r["name"]] for r in rules if r["name"] in self._meta),
3060
+ key=lambda m: m["id"])
3061
+ # §110 п. 1–2: цикл допускает только монотонные рёбра. Единственное
3062
+ # поражаемое правило, читающее свою голову, — тоже цикл: до errata
3063
+ # E-0173 оракул исполнял его одним проходом, теперь оба оценщика
3064
+ # отвечают NON_EXECUTABLE_UNSTRATIFIED, и срез отказывает с ними.
3065
+ edges = []
3066
+ for m in metas:
3067
+ for st, pred, _pol in m["reads"]:
3068
+ if st == "completion":
3069
+ kind = "completion"
3070
+ elif st in ("not_known", "unknown"):
3071
+ kind = "default"
3072
+ elif m["strength"] != "strict":
3073
+ kind = "defeasible"
3074
+ elif st in ("monotone", "supported"):
3075
+ kind = "monotone"
3076
+ else:
3077
+ kind = "status"
3078
+ edges.append((m["head"], pred, kind))
3079
+ # E-0113: замыкание — вершина `producers(P ∪ domain) → closure(P) →
3080
+ # readers(¬P)`; все его рёбра немонотонны.
3081
+ closed: dict = {}
3082
+ for c in self._closures:
3083
+ closed.setdefault(c["pred"], []).append(c)
3084
+ edges += [(c["id"], c["pred"], "closure"), (c["id"], c["domain"], "closure")]
3085
+ for m in metas:
3086
+ for st, pred, pol in m["reads"] + m.get("inner", []):
3087
+ if pred in closed and st != "completion" and (pol == "negative" or st in ("refuted", "not_known", "unknown")):
3088
+ edges += [(m["head"], c["id"], "closure") for c in closed[pred]]
3089
+ # §47.3 (DECISION-0274): чтение отношения суждения — обычное чтение
3090
+ # таблицы; запрос судье строит обход раннера по `backchain`.
3091
+ comp = _sccs(sorted({e[0] for e in edges} | {e[1] for e in edges}), [(a, b) for a, b, _ in edges])
3092
+ for a, b, kind in edges:
3093
+ if kind != "monotone" and comp[a] == comp[b]:
3094
+ raise Refused(f"{UNSTRATIFIED}: цикл через {a} → {b} с ребром [{kind}]")
3095
+ # DECISION-0237 п. 11: рекурсия, порождающая значения арифметикой, не
3096
+ # обязана завершиться, а частичный результат не выдаётся как ответ.
3097
+ for m in metas:
3098
+ if m["computed"] and any(comp.get(m["head"]) == comp.get(pred) for _st, pred, _p in m["reads"]):
3099
+ raise Refused(f"рекурсия с порождением значений арифметикой (DECISION-0237 п. 11) "
3100
+ f"вне среза генератора: {m['head']}")
3101
+ if not self.l1:
3102
+ return {}
3103
+ strict =[m for m in metas if m["strength"] == "strict"]
3104
+ drules = [m for m in metas if m["strength"] != "strict"]
3105
+ # §110 п. 5, §107.3: дефитер действует только при обычном кандидате
3106
+ # той же головы и полярности; у оракула набор узлов проецирован на
3107
+ # дату права §92.3, поэтому окна производителей обязаны покрыть окно
3108
+ # дефитера — иначе в непокрытую дату оракул гасит документ.
3109
+ # Считается по узлам CLIR, а не по скомпилированным правилам: обзор
3110
+ # продолжает после отказа правила, и производитель, отвергнутый своей
3111
+ # причиной, иначе делал бы дефитер «сиротой».
3112
+ heads = []
3113
+ for n in self.ir["nodes"]:
3114
+ head = n.get("head") or {}
3115
+ if n.get("kind") != "rule" or n.get("strength") not in ("defeasible", "defeater") \
3116
+ or head.get("kind") != "literal":
3117
+ continue
3118
+ dating = self._dating(n)
3119
+ if dating == []:
3120
+ continue # §92.3: узел снят проекцией на всей оси
3121
+ heads.append({"id": n["id"], "strength": n["strength"], "head": head.get("predicate"),
3122
+ "polarity": head.get("polarity"),
3123
+ "dating": None if dating == [(None, None)] else dating})
3124
+ for d in heads:
3125
+ if d["strength"] != "defeater":
3126
+ continue
3127
+ prods = [m for m in heads if m["strength"] == "defeasible"
3128
+ and (m["head"], m["polarity"]) == (d["head"], d["polarity"])]
3129
+ outer = None if any(m["dating"] is None for m in prods) else [w for m in prods for w in m["dating"]]
3130
+ if not prods or not _covered(d["dating"], outer):
3131
+ raise Refused("дефитер без обычного кандидата той же полярности "
3132
+ f"(§110 п. 5, NON_EXECUTABLE_STRATUM_BARRIER): {d['id']}")
3133
+ # §106: транзитивное замыкание безусловных рёбер; цикл — отказ среза
3134
+ # (у оракула PRIORITY_CONFLICT группы, DECISION-0237 п. 13).
3135
+ # DECISION-0269: у оракула проекция §92.3 сначала снимает неприменимые
3136
+ # узлы приоритета, потом строится замыкание (`editions.project_editions`,
3137
+ # `defeasible._priority_closure`), поэтому окно ребра участвует в
3138
+ # замыкании: по отрезкам оси между границами окон — пересечение вдоль
3139
+ # пути, объединение между путями; цикл на отрезке — отказ.
3140
+ pairs = self._priority_windows()
3141
+ reach: dict = {}
3142
+ for (a, b), ws in pairs.items():
3143
+ if ws == [(None, None)]:
3144
+ reach.setdefault(a, set()).add(b)
3145
+ dnames = {m["id"]: m["name"] for m in drules}
3146
+ priorities = sorted([dnames[a], dnames[b]] for a in reach for b in reach[a]
3147
+ if a in dnames and b in dnames)
3148
+ dated_priorities = sorted([dnames[a], dnames[b], [list(w) for w in ws]]
3149
+ for (a, b), ws in pairs.items()
3150
+ if ws != [(None, None)] and a in dnames and b in dnames)
3151
+ srank = self._strict_ranks(strict)
3152
+ drank = self._defeasible_ranks(drules)
3153
+ late = self._late(strict, drules, drank)
3154
+ levels_09, wait_level = (self._structure09(metas, strict, drules, srank, late, closed, edges)
3155
+ if self.v09 else ([], {}))
3156
+ strict_strata = [[m["name"] for m in strict if srank[m["id"]] == k]
3157
+ for k in sorted(set(srank.values()))]
3158
+ if self.dated_facts:
3159
+ # Датированный факт у оракула — вход, а не правило: его голова
3160
+ # полна до любого вывода, поэтому страта печатается первой.
3161
+ strict_strata.insert(0, [r["name"] for r in self.dated_facts])
3162
+ levels = sorted(set(drank.values()))
3163
+ dstrata = [[m["name"] for m in drules if drank[m["id"]] == k] for k in levels]
3164
+
3165
+ def strict_phase(done: int) -> dict:
3166
+ # E-0174: `waiting` — правила, чьи замыкания ещё не пройдены после
3167
+ # `done` уровней; strict-проход их не исполняет. Поле печатается
3168
+ # только непустым: у прочих планов расписание байтово прежнее.
3169
+ waiting = sorted(name for name, k in wait_level.items() if k > done)
3170
+ return {"phase": "strict", "waiting": waiting} if waiting else {"phase": "strict"}
3171
+
3172
+ schedule = [strict_phase(0)]
3173
+ if levels_09:
3174
+ for done, names in enumerate(levels_09, start=1):
3175
+ schedule += [{"phase": "closure", "policies": names}, strict_phase(done)]
3176
+ else:
3177
+ schedule.append({"phase": "closure", "policies": []})
3178
+ for k, names in zip(levels, dstrata):
3179
+ schedule += [{"phase": "candidates", "stratum": k, "rules": names},
3180
+ {"phase": "defeat", "stratum": k},
3181
+ {"phase": "surviving", "stratum": k},
3182
+ {"phase": "strict"},
3183
+ {"phase": "status", "stratum": k}]
3184
+ result = {"strata": {"strict": [s for s in strict_strata if s], "defeasible": dstrata},
3185
+ "priorities": priorities, "schedule": schedule}
3186
+ if self.v16:
3187
+ result["datedPriorities"] = dated_priorities
3188
+ if self.v10:
3189
+ # DECISION-0242 K3: один проход L2 после поражаемого слоя —
3190
+ # позиции §135, эффекты полномочий §127, strict-замыкание.
3191
+ result.update(self._structure10(metas, strict, drules, reach))
3192
+ if self.v16:
3193
+ names = {n["id"]: n["name"] for n in self._norm_meta.values()}
3194
+ result["datedNormPriorities"] = sorted(
3195
+ [names[a], names[b], [list(w) for w in ws]] for (a, b), ws in pairs.items()
3196
+ if ws != [(None, None)] and a in names and b in names)
3197
+ schedule += [{"phase": "norms"}, {"phase": "effects"}, {"phase": "strict"}]
3198
+ return result
3199
+
3200
+ def _priority_windows(self) -> dict:
3201
+ """Транзитивное замыкание приоритетов §106 с окнами §92.3
3202
+ (DECISION-0269): пара «выше, ниже» → объединение окон `[start, end)`,
3203
+ на которых она достижима; `[(None, None)]` — вся ось (недатированная
3204
+ пара, как прежде). Отрезки оси — между границами окон датированных
3205
+ рёбер; на каждом отрезке замыкание Уоршелла по активным рёбрам, цикл
3206
+ на отрезке — названный отказ (у оракула PRIORITY_CONFLICT на дату)."""
3207
+ edges = [(hi, lo, w) for hi, lo, _pid, w in self._prio]
3208
+ bounds = sorted({b for _hi, _lo, w in edges if w is not None for b in w if b is not None})
3209
+ segments = []
3210
+ prev = None
3211
+ for b in bounds:
3212
+ segments.append((prev, b))
3213
+ prev = b
3214
+ segments.append((prev, None))
3215
+ pairs: dict = {}
3216
+ for seg in segments:
3217
+ active = [(hi, lo) for hi, lo, w in edges if w is None or _contains(w, seg)]
3218
+ reach: dict = {}
3219
+ for hi, lo in active:
3220
+ reach.setdefault(hi, set()).add(lo)
3221
+ changed = True
3222
+ while changed:
3223
+ changed = False
3224
+ for a in sorted(reach):
3225
+ for b in sorted(reach[a]):
3226
+ extra = reach.get(b, set()) - reach[a]
3227
+ if extra:
3228
+ reach[a] |= extra
3229
+ changed = True
3230
+ cyc = sorted(a for a in reach if a in reach[a])
3231
+ if cyc:
3232
+ if seg == (None, None):
3233
+ raise Refused(f"цикл приоритетов (§106, PRIORITY_CONFLICT) вне среза генератора: {', '.join(cyc)}")
3234
+ raise Refused("цикл приоритетов (§106, PRIORITY_CONFLICT) на отрезке "
3235
+ f"[{seg[0] or '-∞'}, {seg[1] or '∞'}) вне среза генератора (DECISION-0269): {', '.join(cyc)}")
3236
+ for a in reach:
3237
+ for b in reach[a]:
3238
+ pairs.setdefault((a, b), []).append(seg)
3239
+ return {pair: _union(segs) for pair, segs in pairs.items()}
3240
+
3241
+ def _structure09(self, metas, strict, drules, srank, late, closed, edges) -> list:
3242
+ """Структура плана 0.9: отказы DECISION-0239 и уровни замыканий E-0113."""
3243
+ dp = {m["head"] for m in drules}
3244
+ # п. 8 (G5): замыкание над предикатом или доменом, чей производитель
3245
+ # поражаем либо поздний через строгую цепочку, — отказ: оракул
3246
+ # материализует замыкания до поражаемого слоя, проза §70 — после
3247
+ # производителей.
3248
+ for c in self._closures:
3249
+ for pred in (c["pred"], c["domain"]):
3250
+ if pred in dp or late.get(pred):
3251
+ raise Refused(f"замыкание §70 над поражаемым производителем вне среза генератора "
3252
+ f"(DECISION-0239 G5): {c['id']}")
3253
+ # п. 7 (errata E-0174): strict-читатель, чей тест опровергается
3254
+ # негативом замыкания, ждёт замыкание — подъём над вершиной, а не отказ.
3255
+ waits = _closure_waits(strict, closed)
3256
+ # п. 4: фоллибельный гард strict-правила обязан читать только
3257
+ # завершённое ниже его страты — иначе момент ошибки зависел бы от хода
3258
+ # фикспойнта.
3259
+ producers: dict = {}
3260
+ for m in strict:
3261
+ producers.setdefault(m["head"], []).append(m)
3262
+ for m in strict:
3263
+ if not m.get("fallible"):
3264
+ continue
3265
+ cal = m.get("calendar")
3266
+ value = m.get("value")
3267
+ for _st, pred, _pol in m["reads"]:
3268
+ if pred in dp or late.get(pred) or pred in closed or any(
3269
+ srank[p["id"]] >= srank[m["id"]] for p in producers.get(pred, [])
3270
+ if p["id"] != m["id"] or cal or value):
3271
+ if cal:
3272
+ raise Refused("календарная функция с ошибкой значения над незавершённым входом вне "
3273
+ f"среза генератора (DECISION-0245 п. 7): {m['id']} читает {pred}")
3274
+ if value:
3275
+ # DECISION-0252 п. 4: момент ошибки значения зависел бы от хода
3276
+ # фикспойнта — правило ошибку не ловит, и она становится
3277
+ # названным отказом раннера, а не issue и не отказом плана.
3278
+ self._value_refuse.add(m["name"])
3279
+ break
3280
+ raise Refused("агрегат с ошибкой значения над незавершённым входом вне среза генератора "
3281
+ f"(DECISION-0239 п. 4): {m['id']} читает {pred}")
3282
+ if not self._closures:
3283
+ return [], {}
3284
+ # E-0113: уровень замыкания — на единицу выше уровней замыканий,
3285
+ # достижимых из его вершины по зависимостям.
3286
+ adj: dict = {}
3287
+ for a, b, _k in edges:
3288
+ adj.setdefault(a, set()).add(b)
3289
+ ids = [c["id"] for c in self._closures]
3290
+ deps = {}
3291
+ for cid in ids:
3292
+ seen, stack = {cid}, [cid]
3293
+ while stack:
3294
+ for nxt in adj.get(stack.pop(), ()):
3295
+ if nxt not in seen:
3296
+ seen.add(nxt)
3297
+ stack.append(nxt)
3298
+ deps[cid] = [o for o in ids if o != cid and o in seen]
3299
+ level: dict = {}
3300
+ for _ in range(len(ids) + 1):
3301
+ changed = False
3302
+ for cid in ids:
3303
+ need = 1 + max((level.get(o, 1) for o in deps[cid]), default=0)
3304
+ if need > level.get(cid, 0):
3305
+ level[cid] = need
3306
+ changed = True
3307
+ if not changed:
3308
+ break
3309
+ else:
3310
+ raise Refused(f"{UNSTRATIFIED}: цикл между замыканиями")
3311
+ groups = [[c["name"] for c in sorted(self._closures, key=lambda c: c["id"]) if level[c["id"]] == k]
3312
+ for k in sorted(set(level.values()))]
3313
+ # Ждущее правило исполняется в strict-проходе после уровня своего
3314
+ # старшего замыкания: замыкания материализуются по уровням.
3315
+ ranks = sorted(set(level.values()))
3316
+ wait_level = {m["name"]: 1 + ranks.index(max(level[c] for c in waits[m["id"]]))
3317
+ for m in strict if waits.get(m["id"])}
3318
+ return groups, wait_level
3319
+
3320
+ def _strict_ranks(self, strict: list) -> dict:
3321
+ """Порт `strict.strict_ranks`: ранги §110 строгого слоя (DECISION-0088)."""
3322
+ rank = {m["id"]: 0 for m in strict}
3323
+ heads = {m["head"] for m in strict}
3324
+ needs = any(m["polarity"] == "negative" or any(st in ("not_known", "unknown", "refuted")
3325
+ or (st == "completion" and p in heads)
3326
+ for st, p, _q in m["reads"]) for m in strict)
3327
+ if not needs:
3328
+ return rank
3329
+ producers: dict = {}
3330
+ for m in strict:
3331
+ producers.setdefault(m["head"], []).append(m["id"])
3332
+ for _ in range(len(strict) + 1):
3333
+ changed = False
3334
+ for m in strict:
3335
+ need = 0
3336
+ for st, pred, _pol in m["reads"]:
3337
+ bump = 0 if st in ("supported", "monotone") else 1
3338
+ for pid in producers.get(pred, []):
3339
+ if pid != m["id"]:
3340
+ need = max(need, rank[pid] + bump)
3341
+ if need > rank[m["id"]]:
3342
+ rank[m["id"]] = need
3343
+ changed = True
3344
+ head_max: dict = {}
3345
+ for m in strict:
3346
+ head_max[m["head"]] = max(head_max.get(m["head"], 0), rank[m["id"]])
3347
+ for m in strict:
3348
+ if head_max[m["head"]] > rank[m["id"]]:
3349
+ rank[m["id"]] = head_max[m["head"]]
3350
+ changed = True
3351
+ if not changed:
3352
+ return rank
3353
+ raise Refused(f"{UNSTRATIFIED}: ранги строгого слоя не сходятся")
3354
+
3355
+ def _defeasible_ranks(self, drules: list) -> dict:
3356
+ """Порт `defeasible.stratify`: ранг §110 поражаемого правила — глубина
3357
+ через поражаемые головы; правила одной головы обеих полярностей и
3358
+ дефитеры — в одной страте (E-0078/E-0080)."""
3359
+ heads: dict = {}
3360
+ for m in drules:
3361
+ heads.setdefault(m["head"], []).append(m)
3362
+ rank = {m["id"]: 0 for m in drules}
3363
+ for _ in range(len(drules) + 1):
3364
+ changed = False
3365
+ for m in drules:
3366
+ deps = [(pred, 1) for _st, pred, _pol in m["reads"]]
3367
+ if m["strength"] == "defeater":
3368
+ deps.append((m["head"], 1))
3369
+ need = 0
3370
+ for pred, bump in deps:
3371
+ same = m["strength"] == "defeater" and pred == m["head"]
3372
+ for p in heads.get(pred, []):
3373
+ if p["id"] != m["id"]:
3374
+ need = max(need, rank[p["id"]] + (0 if same else bump))
3375
+ if need > rank[m["id"]]:
3376
+ rank[m["id"]] = need
3377
+ changed = True
3378
+ head_max: dict = {}
3379
+ for m in drules:
3380
+ head_max[m["head"]] = max(head_max.get(m["head"], 0), rank[m["id"]])
3381
+ for m in drules:
3382
+ if head_max[m["head"]] > rank[m["id"]]:
3383
+ rank[m["id"]] = head_max[m["head"]]
3384
+ changed = True
3385
+ if not changed:
3386
+ return rank
3387
+ raise Refused(f"{UNSTRATIFIED}: цикл через поражаемые зависимости")
3388
+
3389
+ def _late(self, strict: list, drules: list, drank: dict) -> None:
3390
+ """Поздние производители §111 (LDC-E4103). Strict-замыкание повторяется
3391
+ после каждой поражаемой страты, поэтому предикат со строгим
3392
+ производителем, читающим поражаемый вывод, полон только после страты
3393
+ этого вывода. С errata E-0172 оба оценщика поднимают поражаемого
3394
+ читателя цепочки рангом, а строгого status-sensitive читателя
3395
+ пропускают (lawc — E4103); срез печатает страты без этого подъёма и
3396
+ потому по-прежнему отказывает — снятие отказа есть расширение среза."""
3397
+ dp = {m["head"] for m in drules}
3398
+ level = {}
3399
+ for m in drules:
3400
+ level[m["head"]] = max(level.get(m["head"], 0), drank[m["id"]])
3401
+ late: dict = {m["head"]: set() for m in strict}
3402
+ per_rule: dict = {}
3403
+ changed = True
3404
+ while changed:
3405
+ changed = False
3406
+ for m in strict:
3407
+ acc = set()
3408
+ for _st, pred, _pol in m["reads"]:
3409
+ if pred in dp:
3410
+ acc.add(pred)
3411
+ acc |= late.get(pred, set())
3412
+ per_rule[m["id"]] = acc
3413
+ if not acc <= late[m["head"]]:
3414
+ late[m["head"]] |= acc
3415
+ changed = True
3416
+ for m in strict:
3417
+ for st, pred, pol in m["reads"]:
3418
+ if st in ("monotone", "supported"):
3419
+ continue
3420
+ if pred in dp:
3421
+ raise Refused(f"{LATE}: strict-правило {m['id']} читает {st} {pred} "
3422
+ "с поражаемым производителем")
3423
+ if not late.get(pred):
3424
+ continue
3425
+ opposite = [s for s in strict if s["head"] == pred and s["polarity"] != pol and per_rule[s["id"]]]
3426
+ if st != "established" or opposite:
3427
+ raise Refused(f"{LATE}: strict-правило {m['id']} читает {st} {pred}, "
3428
+ "производимый строгой цепочкой над поражаемым выводом")
3429
+ for m in drules:
3430
+ for pred in [p for _st, p, _pol in m["reads"]] + [m["head"]]:
3431
+ if late.get(pred) and max(level[q] for q in late[pred]) >= drank[m["id"]]:
3432
+ raise Refused(f"{LATE}: правило {m['id']} страты {drank[m['id']]} читает {pred}, "
3433
+ "полный лишь после страты своего поражаемого источника")
3434
+ return {pred: qs | ({pred} if pred in dp else set()) for pred, qs in late.items()} | \
3435
+ {pred: {pred} for pred in dp if pred not in late}
3436
+
3437
+ def _api(self, rels: list, rules: list) -> dict:
3438
+ """Поверхность API §api (DECISION-0234): вопросы и входы модуля.
3439
+
3440
+ Правила те же, что у `@arxo/law-forms`, и пакет их не объявляет:
3441
+ ВОПРОС — `pub`-отношение, у которого есть производящее правило,
3442
+ читающее в теле ДРУГОЙ предикат (самозамыкание вроде симметрии
3443
+ вопросом не делает); ВХОД — предикат, достижимый из вопросов по
3444
+ телам правил и либо эмпирический, либо без производителя. Печатается
3445
+ только это; остальные отношения остаются таблицами модели."""
3446
+ # DECISION-0237 п. 9: дефитер опоры не создаёт и производителем
3447
+ # вопроса не становится, но его входы и входы отрицательных и
3448
+ # статусных чтений (`checks`) — входы вопроса.
3449
+ producers: dict = {}
3450
+ reads: dict = {}
3451
+ for r in rules:
3452
+ body = {a["pred"] for a in r["atoms"]} | {c["pred"] for c in r.get("checks", [])}
3453
+ if self.v09 and r["name"] in self._meta:
3454
+ # DECISION-0239: входы генераторов агрегатов — входы вопроса.
3455
+ body |= {pred for _st, pred, _pol in self._meta[r["name"]]["reads"]}
3456
+ reads.setdefault(r["head"]["pred"], set()).update(body)
3457
+ if r.get("strength") != "defeater":
3458
+ producers.setdefault(r["head"]["pred"], set()).update(body)
3459
+ questions = sorted(p for p in self.public
3460
+ if (self.by_id.get(p) or {}).get("package", self.root_ns) == self.root_ns
3461
+ and any(q != p for q in producers.get(p, ())))
3462
+ reach: set = set()
3463
+ stack = list(questions)
3464
+ while stack:
3465
+ p = stack.pop()
3466
+ for q in reads.get(p, ()):
3467
+ if q not in reach:
3468
+ reach.add(q)
3469
+ stack.append(q)
3470
+ inputs = sorted(p for p in reach
3471
+ if p != ALWAYS and (p not in producers or self.rel_kind.get(p) == "empirical"))
3472
+ names: dict = {}
3473
+
3474
+ def unique(pred: str) -> str:
3475
+ base = _api_slug(pred)
3476
+ name, n = base, 1
3477
+ while name in names and names[name] != pred:
3478
+ n += 1
3479
+ name = f"{base}_{n}"
3480
+ names[name] = pred
3481
+ return name
3482
+
3483
+ # Источники вопроса: производящие правила с их метками и якорями
3484
+ # §194 — то, что печатается комментарием над функцией.
3485
+ by_head: dict = {}
3486
+ for r in rules:
3487
+ if r.get("strength") != "defeater":
3488
+ by_head.setdefault(r["head"]["pred"], []).append(r["id"])
3489
+
3490
+ def sources(pred: str) -> list:
3491
+ out = []
3492
+ for rid in by_head.get(pred, []):
3493
+ node = self.rule_nodes.get(rid, {})
3494
+ anchors = [dict(self.fragments.get(a) or {"locator": None, "edition": None}, id=a)
3495
+ for a in (node.get("anchors") or [])]
3496
+ out.append({"rule": _slug(rid), "labels": _labels(node), "anchors": anchors})
3497
+ return out
3498
+
3499
+ return {"questions": [{"pred": p, "name": unique(p), "params": list(self.types[p]),
3500
+ "kinds": [self.ts(t) for t in self.types[p]],
3501
+ "labels": self.rel_labels.get(p, []),
3502
+ "inputs": sorted(q for q in inputs if q in self._deps(p, reads)),
3503
+ "sources": sources(p)}
3504
+ for p in questions],
3505
+ "inputs": [{"pred": p, "name": unique(p), "params": list(self.types[p]),
3506
+ "kinds": [self.ts(t) for t in self.types[p]],
3507
+ "labels": self.rel_labels.get(p, [])} for p in inputs]}
3508
+
3509
+ @staticmethod
3510
+ def _deps(pred: str, producers: dict) -> set:
3511
+ out: set = set()
3512
+ stack = [pred]
3513
+ while stack:
3514
+ p = stack.pop()
3515
+ for q in producers.get(p, ()):
3516
+ if q not in out:
3517
+ out.add(q)
3518
+ stack.append(q)
3519
+ return out
3520
+
3521
+ def _fact(self, a: dict) -> dict:
3522
+ lit = a.get("literal") or {}
3523
+ if lit.get("polarity") != "positive":
3524
+ raise Refused("отрицательный собственный факт вне L0-среза")
3525
+ dating = self._dating(a)
3526
+ if dating is not None and dating != [(None, None)]:
3527
+ if not dating:
3528
+ raise _Excluded(a["id"])
3529
+ if len(dating) > 1:
3530
+ raise Refused("прерывистая применимость редакции §92.3 вне среза генератора")
3531
+ # Датированный факт печатается правилом с окном над служебной
3532
+ # таблицей `always` (одна строка): та же механика окна §31, что у
3533
+ # правил, и ни один эмиттер не узнаёт о редакциях.
3534
+ self.dated_facts.append({
3535
+ "name": f"dated_{len(self.dated_facts)}_{_slug(a['id'])}", "id": a["id"],
3536
+ "window": {"start": dating[0][0], "end": dating[0][1]},
3537
+ "atoms": [{"pred": ALWAYS}], "joins": [], "filters": [], "guards": [],
3538
+ "head": {"pred": lit["predicate"], "args": [self.term(x, {}) for x in lit["args"]]}})
3539
+ raise _Excluded(None)
3540
+ return {"pred": lit["predicate"], "args": [self.term(x, {}) for x in lit["args"]]}
3541
+
3542
+ def _setup(self) -> None:
3543
+ self.caps = self._capabilities()
3544
+ # DECISION-0245: `days_between` модели не меняет; фоллибельные
3545
+ # календарные функции требуют накопления выводов модели 0.9.
3546
+ self.l1 = bool(self.caps - {"calendar:days_between", "judgment"} - V15_CAPS)
3547
+ self.v10 = "norm" in self.caps
3548
+ self.v09 = bool(self.caps & {"aggregate", "closure", "norm", "calendar:period", "calendar:business",
3549
+ "value:rational", "value:quantity", "value:round", "value:div_round"}
3550
+ or self.caps & TERM_CAPS or self.caps & FN_CAPS)
3551
+ self.v11 = bool(self.caps & CAL_CAPS)
3552
+ # DECISION-0252 п. 1: ошибка значения §58 требует накопления выводов 0.9.
3553
+ self.v12 = "value:rational" in self.caps
3554
+ self.v13 = "value:quantity" in self.caps
3555
+ # DECISION-0259 п. 1: политика округления для рантайма.
3556
+ self.v14 = bool(self.caps & {"value:round", "value:div_round"})
3557
+ # DECISION-0266–0268: значения и формы, не меняющие модель.
3558
+ self.v15 = bool(self.caps & V15_CAPS)
3559
+ # DECISION-0269: датированные приоритеты §92.3.
3560
+ self.v16 = "priority:dated" in self.caps
3561
+ # DECISION-0270: вычисляемые аргументы тела и условные термы.
3562
+ self.v17 = bool(self.caps & TERM_CAPS)
3563
+ # DECISION-0273: функции, параметры A3, `let`, `match`.
3564
+ self.v18 = bool(self.caps & FN_CAPS)
3565
+ # DECISION-0274: канал суждения §47.3.
3566
+ self.v19 = "judgment" in self.caps
3567
+ self._derived = {(n.get("head") or {}).get("predicate") for n in self.ir["nodes"]
3568
+ if n.get("kind") == "rule" and (n.get("head") or {}).get("kind") == "literal"}
3569
+
3570
+ def build(self) -> dict:
3571
+ ir = self.ir
3572
+ if ir.get("imports") and not self.linked:
3573
+ raise Refused("импорт чужого пакета без мира зависимостей вне среза генератора")
3574
+ self._setup()
3575
+ for n in ir["nodes"]:
3576
+ self._node(n)
3577
+ rels = self._index()
3578
+ self._export_functions()
3579
+ tables = [self._table(r) for r in rels]
3580
+ if self.v10:
3581
+ tables += self._std_tables()
3582
+ rules, excluded = [], []
3583
+ compile_rule = self._rule_v09 if self.v09 else self._rule_l1 if self.l1 else self._rule
3584
+ for i, rule in enumerate([n for n in ir["nodes"] if n.get("kind") == "rule"]):
3585
+ try:
3586
+ if self.v10 and (rule.get("head") or {}).get("kind") == "norm_template_ref":
3587
+ self._norms.append(self._norm_v10(i, rule))
3588
+ else:
3589
+ rules.append(compile_rule(i, rule))
3590
+ except _Excluded as exc:
3591
+ excluded.append(str(exc))
3592
+ if self.l1:
3593
+ for n in ir["nodes"]:
3594
+ if n.get("kind") == "priority_rule":
3595
+ self._priority(n)
3596
+ if n.get("kind") == "closure_policy" and self.v09:
3597
+ self._closure(n)
3598
+ facts = []
3599
+ compile_fact = self._fact_l1 if self.l1 else self._fact
3600
+ for a in ir["nodes"]:
3601
+ if a.get("kind") != "assertion":
3602
+ continue
3603
+ try:
3604
+ facts.append(compile_fact(a))
3605
+ except _Excluded as exc:
3606
+ if str(exc) != "None":
3607
+ excluded.append(str(exc))
3608
+ structure = self._structure(rules)
3609
+ for r in rules:
3610
+ if r["name"] in self._value_refuse:
3611
+ if r.get("headAgg"):
3612
+ # Без перехвата ошибка агрегата головы ушла бы в обработчик G3
3613
+ # и сняла правило целиком — иной ответ, чем у оракула.
3614
+ raise Refused("агрегат в голове с ошибкой значения над незавершённым входом "
3615
+ "вне среза генератора (DECISION-0252 п. 4)")
3616
+ # DECISION-0252 п. 4: без перехвата ошибка значения — отказ раннера.
3617
+ r.pop("headFallible", None)
3618
+ for g in r["guards"]:
3619
+ g.pop("err", None)
3620
+ if self.dated_facts:
3621
+ tables.append({"pred": ALWAYS, "name": "always", "arity": 1, "params": ["urn:law:std#Text"],
3622
+ "keys": [], "kinds": ["text"]})
3623
+ always = {"pred": ALWAYS, "args": [{"k": "lit", "t": "text", "v": "on"}]}
3624
+ facts.append(dict(always, polarity="positive") if self.l1 else always)
3625
+ rules += self.dated_facts
3626
+ plan = {"planVersion": PLAN_VERSION, "package": ir.get("package"),
3627
+ "world": self.world_passport,
3628
+ "tables": tables, "rules": rules, "facts": facts,
3629
+ "excluded": sorted(excluded),
3630
+ "consts": self._consts(), "bases": dict(sorted(self.bases.items())),
3631
+ "api": self._api(rels, rules)}
3632
+ if not self.l1:
3633
+ if self.v11 or self.v15 or self._fn_plan or self.v19:
3634
+ # DECISION-0245 п. 1: `days_between` в модели L0; DECISION-0266–0268
3635
+ # тоже не меняют модели — версия 0.15 и возможности при тех же полях.
3636
+ # DECISION-0273: функции §46 для вопросов `term` — модель L0 версии 0.18.
3637
+ out = dict({"planVersion": PLAN_VERSION_19 if self.v19 else PLAN_VERSION_18 if self._fn_plan
3638
+ else PLAN_VERSION_15 if self.v15 else PLAN_VERSION_11,
3639
+ "capabilities": sorted(self.caps)},
3640
+ **{k: v for k, v in plan.items() if k != "planVersion"})
3641
+ if self.v11:
3642
+ out.update(self._calendar_env())
3643
+ if self._fn_plan:
3644
+ out["functions"] = [self._fn_plan[k] for k in sorted(self._fn_plan)]
3645
+ if self.v19:
3646
+ # DECISION-0274: канал суждения у модели L0 — обход раннера
3647
+ # по правилам с силой и гардами, органы по отношению.
3648
+ out["judgments"] = dict(sorted(self.judgments.items()))
3649
+ out["backchain"] = self._backchain(rules)
3650
+ out["backchainConsts"] = {cid: c["value"] for cid, c in sorted(self.consts.items())
3651
+ if c["value"] is not None}
3652
+ return out
3653
+ return plan
3654
+ if self.v09:
3655
+ structure = dict(structure, aggregates=self._aggs, closures=self._closures)
3656
+ if self.v10:
3657
+ structure = dict(structure, norms=self._norms)
3658
+ if self.v18 or self._fn_plan or self._a3params:
3659
+ # DECISION-0273: функции §46 и параметры A3 в порядке идентификаторов.
3660
+ structure = dict(structure, functions=[self._fn_plan[k] for k in sorted(self._fn_plan)])
3661
+ if self._a3params:
3662
+ structure["a3Params"] = [self._a3params[k] for k in sorted(self._a3params)]
3663
+ self.v18 = True
3664
+ if self.v11:
3665
+ structure = dict(structure, **self._calendar_env())
3666
+ if self.v19:
3667
+ structure = dict(structure, judgments=dict(sorted(self.judgments.items())))
3668
+ if self.caps & {"calendar:period", "calendar:business"} or self.v12 or self.v13 or self.v14 or self.v17 \
3669
+ or self.v18 or self.v19 or self._agg_fallible:
3670
+ # E-0105: обход ошибок терма от цели вопроса (DECISION-0245, 0252, 0270);
3671
+ # DECISION-0274: тот же обход — запрос судье §47.3.
3672
+ structure["backchain"] = self._backchain(rules)
3673
+ # Разыменование констант §54 аргументов вопроса для обхода.
3674
+ structure["backchainConsts"] = {cid: c["value"] for cid, c in sorted(self.consts.items())
3675
+ if c["value"] is not None}
3676
+ return dict({"planVersion": PLAN_VERSION_19 if self.v19 else PLAN_VERSION_18 if self.v18
3677
+ else PLAN_VERSION_17 if self.v17 else PLAN_VERSION_16 if self.v16
3678
+ else PLAN_VERSION_15 if self.v15
3679
+ else PLAN_VERSION_14 if self.v14
3680
+ else PLAN_VERSION_13 if self.v13
3681
+ else PLAN_VERSION_12 if self.v12
3682
+ else PLAN_VERSION_11 if self.v11
3683
+ else PLAN_VERSION_10 if self.v10 else PLAN_VERSION_09 if self.v09 else PLAN_VERSION_L1,
3684
+ "capabilities": sorted(self.caps)},
3685
+ **{k: v for k, v in plan.items() if k != "planVersion"}, **structure)
3686
+
3687
+ def _export_functions(self) -> None:
3688
+ """DECISION-0273: функции §46 модуля доступны вопросу `term` раннера и
3689
+ без правил (T071), поэтому компилируются заранее. Функция вне среза
3690
+ (запись, список, рекурсия) пакету не отказывает, пока её не читает
3691
+ правило: она не попадает в план, вопрос к ней — названный отказ
3692
+ раннера. Признаки значений неиспользуемой функции не двигают версию."""
3693
+ flags = ("_rational_seen", "_quantity_seen", "_round_seen", "_div_round_seen", "_boolean_seen",
3694
+ "_natural_seen", "_month_seen", "_wildcard_seen", "_if_seen", "_let_seen", "_match_seen",
3695
+ "_body_term_seen", "_function_seen")
3696
+ saved = {f: getattr(self, f) for f in flags}
3697
+ for fn in sorted(self.fdecls):
3698
+ if self._is_a3(fn):
3699
+ continue
3700
+ try:
3701
+ self._fn_sig(fn)
3702
+ except Refused:
3703
+ continue
3704
+ for f, v in saved.items():
3705
+ setattr(self, f, v)
3706
+
3707
+ def _backchain(self, rules: list) -> list:
3708
+ """Обход ошибок терма E-0105 (`whynot.term_error_blockers`): правила
3709
+ с литеральной головой по id — окно, голова и литеральные конъюнкты
3710
+ тела с разыменованными константами §54, как их видит оракул. Раннер
3711
+ по нему называет статусом NEITHER, за которым стоит ошибка терма."""
3712
+ compiled = {r["id"]: r for r in rules if "id" in r}
3713
+ out = []
3714
+ for n in sorted((n for n in self.ir["nodes"] if n.get("kind") == "rule"
3715
+ and (n.get("head") or {}).get("kind") == "literal" and n["id"] in compiled),
3716
+ key=lambda n: n["id"]):
3717
+ conj: list = []
3718
+
3719
+ def walk(f):
3720
+ k = f.get("kind") if isinstance(f, dict) else None
3721
+ if k == "status" and (f.get("formula") or {}).get("kind") == "literal":
3722
+ conj.append([f.get("status"), self._deref(f["formula"])])
3723
+ elif k == "literal":
3724
+ conj.append(["established", self._deref(f)])
3725
+ elif k == "comparison":
3726
+ # DECISION-0274: гард §57–§58 — живость правила на пути
3727
+ # запроса судье (E-0101, `_rule_is_dead`).
3728
+ conj.append(["guard", self._deref(f)])
3729
+ elif k in ("and", "conjunction"):
3730
+ for x in f.get("items") or f.get("args") or f.get("operands") or []:
3731
+ walk(x)
3732
+
3733
+ walk(n.get("body") or {})
3734
+ r = compiled[n["id"]]
3735
+ out.append({"id": n["id"], "name": r["name"], "window": r["window"],
3736
+ "strength": n.get("strength") or "strict",
3737
+ "head": self._deref(n["head"]), "body": conj})
3738
+ return out
3739
+
3740
+ def _calendar_env(self) -> dict:
3741
+ """Окружение календаря для раннера (DECISION-0245 п. 3): версия
3742
+ семантики CLIR (у 0.1 политика — объект контекста), политики
3743
+ `deadline_policy` по id и снимки `calendar_snapshot` с датасетом либо
3744
+ кодом ошибки загрузки — порт `calendar.load_calendar`."""
3745
+ import hashlib
3746
+ from pathlib import Path
3747
+ policies, calendars = {}, []
3748
+ for n in self.ir["nodes"]:
3749
+ if n.get("kind") == "deadline_policy" and isinstance(n.get("policy"), dict):
3750
+ policies[n["id"]] = n["policy"]
3751
+ if n.get("kind") != "calendar_snapshot":
3752
+ continue
3753
+ uri = str(n.get("resourceUri", ""))
3754
+ data = self.resources.get(uri)
3755
+ entry = {"id": n["id"]}
3756
+ if data is None:
3757
+ entry["error"] = "MISSING_CALENDAR"
3758
+ elif "sha256:" + hashlib.sha256(data).hexdigest() != n.get("datasetHash", ""):
3759
+ entry["error"] = "CALENDAR_DATASET_HASH_MISMATCH"
3760
+ else:
3761
+ entry.update(_calendar_dataset(data))
3762
+ calendars.append(entry)
3763
+ return {"semanticVersion": self.ir["semanticVersion"],
3764
+ "deadlinePolicies": dict(sorted(policies.items())),
3765
+ "calendars": sorted(calendars, key=lambda c: c["id"])}
3766
+
3767
+ def _consts(self) -> dict:
3768
+ """Константы со значением для раннера: разыменование §54 аргументов
3769
+ дела и запроса и тип константы для проверки §189. Константа с типом
3770
+ вне среза не отказывает пакету, пока на неё никто не ссылается:
3771
+ она просто не попадает в план."""
3772
+ out = {}
3773
+ for cid, c in sorted(self.consts.items()):
3774
+ if c["value"] is None:
3775
+ continue
3776
+ if c["type"] == RAT and not self.v12:
3777
+ continue # DECISION-0252: значение Rational несёт только план 0.12
3778
+ if c["type"] == QTY and not self.v13:
3779
+ continue # DECISION-0256: значение Quantity несёт только план 0.13
3780
+ try:
3781
+ out[cid] = {"type": c["type"], "term": self.term(c["value"], {})}
3782
+ except Refused:
3783
+ continue
3784
+ return out
3785
+
3786
+ def survey(self) -> list[str]:
3787
+ """Все отказы пакета по единицам проверки; пустой список — план строится."""
3788
+ ir = self.ir
3789
+ out: list[str] = []
3790
+
3791
+ def attempt(fn, *args) -> None:
3792
+ try:
3793
+ fn(*args)
3794
+ except Refused as exc:
3795
+ out.append(str(exc))
3796
+ except _Excluded:
3797
+ pass
3798
+
3799
+ if ir.get("imports") and not self.linked:
3800
+ out.append("импорт чужого пакета без мира зависимостей вне среза генератора")
3801
+ self._setup()
3802
+ for n in ir["nodes"]:
3803
+ attempt(self._node, n)
3804
+ rels = self._index()
3805
+ for r in rels:
3806
+ attempt(self._table, r)
3807
+ if self.v10:
3808
+ attempt(self._std_tables)
3809
+ rules: list = []
3810
+ compile_rule = self._rule_v09 if self.v09 else self._rule_l1 if self.l1 else self._rule
3811
+ for i, rule in enumerate([n for n in ir["nodes"] if n.get("kind") == "rule"]):
3812
+ if self.v10 and (rule.get("head") or {}).get("kind") == "norm_template_ref":
3813
+ attempt(lambda i=i, rule=rule: self._norms.append(self._norm_v10(i, rule)))
3814
+ else:
3815
+ attempt(lambda i=i, rule=rule: rules.append(compile_rule(i, rule)))
3816
+ if self.l1:
3817
+ for n in ir["nodes"]:
3818
+ if n.get("kind") == "priority_rule":
3819
+ attempt(self._priority, n)
3820
+ if n.get("kind") == "closure_policy" and self.v09:
3821
+ attempt(self._closure, n)
3822
+ for a in [n for n in ir["nodes"] if n.get("kind") == "assertion"]:
3823
+ attempt(self._fact_l1 if self.l1 else self._fact, a)
3824
+ # Структура — одна единица над правилами, прошедшими свою проверку:
3825
+ # цикл или поздний производитель — свойство программы, не правила.
3826
+ attempt(self._structure, rules)
3827
+ return out
3828
+
3829
+ def capabilities(self) -> list[str]:
3830
+ """Возможности программы без построения плана (для обзора)."""
3831
+ return sorted(self._capabilities())
3832
+
3833
+
3834
+ def _closure_read_dependencies(status: str, predicate: str, polarity: str, enumeration: bool) -> tuple:
3835
+ """Порт `rules._closure_read_dependencies` (E-0174): зависимости одного
3836
+ чтения — (монотонные, немонотонные). Элемент `("rule", P, полярность)` —
3837
+ опоры строгих производителей головы `P`, `("closure", P)` — негативы
3838
+ замыкания над `P`. `established` опровергает противоположная опора,
3839
+ `not_known` и `refuted` — опора той же полярности, `unknown` — любая;
3840
+ перечисление немонотонно по всему, от чего зависит его тест."""
3841
+ negative = polarity == "negative"
3842
+ same = ("rule", predicate, polarity)
3843
+ other = ("rule", predicate, "positive" if negative else "negative")
3844
+ closure = ("closure", predicate)
3845
+ if status in ("supported", "monotone"):
3846
+ monotone, non_monotone = [same] + ([closure] if negative else []), []
3847
+ elif status == "not_known":
3848
+ monotone, non_monotone = [], [same] + ([closure] if negative else [])
3849
+ elif status == "refuted":
3850
+ monotone, non_monotone = [other] + ([] if negative else [closure]), [same]
3851
+ elif status == "unknown":
3852
+ monotone, non_monotone = [], [same, other, closure]
3853
+ else:
3854
+ monotone, non_monotone = [same] + ([closure] if negative else []), [other]
3855
+ if enumeration:
3856
+ return [], monotone + non_monotone
3857
+ return monotone, non_monotone
3858
+
3859
+
3860
+ def _closure_waits(strict: list, closed: dict) -> dict:
3861
+ """Порт `rules.closure_waits` (errata E-0174): id strict-правила →
3862
+ id замыканий, до материализации которых оно не исполняется. Ожидание
3863
+ транзитивно через строгих производителей: производитель завершён, когда
3864
+ завершены все его чтения. Пустой словарь — прежнее расписание."""
3865
+ if not closed:
3866
+ return {}
3867
+ reads_of = []
3868
+ for m in strict:
3869
+ monotone, non_monotone = [], []
3870
+ for st, pred, pol, enumeration in m.get("closure_reads", []):
3871
+ mono, non = _closure_read_dependencies(st, pred, pol, enumeration)
3872
+ monotone += mono
3873
+ non_monotone += non
3874
+ reads_of.append((m["id"], (m["head"], m["polarity"]), monotone, non_monotone))
3875
+ ready: dict = {}
3876
+
3877
+ def value(dep: tuple) -> frozenset:
3878
+ if dep[0] == "closure":
3879
+ return frozenset(c["id"] for c in closed.get(dep[1], []))
3880
+ return ready.get((dep[1], dep[2]), frozenset())
3881
+
3882
+ waits = {rule_id: frozenset() for rule_id, *_ in reads_of}
3883
+ changed = True
3884
+ while changed:
3885
+ changed = False
3886
+ for rule_id, head, monotone, non_monotone in reads_of:
3887
+ wait = frozenset().union(*(value(d) for d in non_monotone))
3888
+ complete = wait.union(*(value(d) for d in monotone))
3889
+ if wait != waits[rule_id]:
3890
+ waits[rule_id] = wait
3891
+ changed = True
3892
+ if not complete <= ready.get(head, frozenset()):
3893
+ ready[head] = ready.get(head, frozenset()) | complete
3894
+ changed = True
3895
+ return {rule_id: wait for rule_id, wait in waits.items() if wait}
3896
+
3897
+
3898
+ def build_plan(ir: dict, world: list | None = None, want: set | None = None,
3899
+ resources: dict | None = None) -> dict:
3900
+ """План программы. Rational §35 и значение-зависимое частное §58 видны
3901
+ только при компиляции термов, а возможность `value:rational` меняет модель
3902
+ (DECISION-0252 п. 1): встретив их, план строится заново с возможностью.
3903
+ Отказ первого прохода проверяется обзором: он доходит до всех правил."""
3904
+ _admit_current_slice(ir, world)
3905
+ builder = Builder(ir, world, want, resources)
3906
+ try:
3907
+ plan = builder.build()
3908
+ needed = value_caps(builder) - builder.caps
3909
+ except Refused:
3910
+ probe = Builder(ir, world, want, resources)
3911
+ probe.survey()
3912
+ needed = value_caps(probe) - probe.caps
3913
+ if not needed:
3914
+ raise
3915
+ want = set(want or ())
3916
+ # DECISION-0273: параметр A3, функция, `let`, `match` внутри вычисляемого
3917
+ # аргумента тела видны только проходу с `term:body` — возможности
3918
+ # добираются до неподвижной точки (каждый проход добавляет хотя бы одну).
3919
+ while needed:
3920
+ want |= needed
3921
+ builder = Builder(ir, world, want, resources)
3922
+ plan = builder.build()
3923
+ needed = value_caps(builder) - builder.caps
3924
+ return dict(plan, planVersion="law-backend-plan/0.20", basePlanVersion=plan["planVersion"],
3925
+ capabilities=sorted(set(plan.get("capabilities") or []) | {"admission:current"}),
3926
+ admission={"reservedAssertionIds": sorted({n["id"] for document in [ir, *(world or [])]
3927
+ for n in document.get("nodes") or [] if n.get("id") and n.get("kind") != "assertion"})})
3928
+
3929
+
3930
+ def value_caps(builder: Builder) -> set:
3931
+ """Возможности значений, встреченные проходом компиляции: Rational
3932
+ (DECISION-0252), Quantity (DECISION-0256), политика округления
3933
+ (DECISION-0259). Признаки отмечаются и без возможности, поэтому второго
3934
+ прохода достаточно."""
3935
+ return ({"value:rational"} if builder._rational_seen else set()) | \
3936
+ ({"value:quantity"} if builder._quantity_seen else set()) | \
3937
+ ({"value:instant"} if builder._instant_seen else set()) | \
3938
+ ({"value:round"} if builder._round_seen else set()) | \
3939
+ ({"value:div_round"} if builder._div_round_seen else set()) | \
3940
+ ({"value:boolean"} if builder._boolean_seen else set()) | \
3941
+ ({"value:natural"} if builder._natural_seen else set()) | \
3942
+ ({"calendar:month_bound"} if builder._month_seen else set()) | \
3943
+ ({"wildcard"} if builder._wildcard_seen else set()) | \
3944
+ ({"priority:dated"} if builder._dated_prio_seen else set()) | \
3945
+ ({"term:body"} if builder._body_term_seen else set()) | \
3946
+ ({"term:if"} if builder._if_seen else set()) | \
3947
+ ({"function"} if builder._function_seen else set()) | \
3948
+ ({"param:a3"} if builder._a3_seen else set()) | \
3949
+ ({"term:let"} if builder._let_seen else set()) | \
3950
+ ({"term:match"} if builder._match_seen else set())
3951
+
3952
+
3953
+ def survey_plan(ir: dict, world: list | None = None) -> list[str]:
3954
+ _admit_current_slice(ir, world)
3955
+ builder = Builder(ir, world)
3956
+ reasons = builder.survey()
3957
+ needed = value_caps(builder) - builder.caps
3958
+ if needed:
3959
+ return Builder(ir, world, needed).survey()
3960
+ return reasons
3961
+
3962
+
3963
+ WEEKDAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
3964
+
3965
+
3966
+ def _calendar_dataset(data: bytes) -> dict:
3967
+ """Датасет снимка §85 для модуля: покрытие, недельный график, исключения —
3968
+ инварианты `calendar.load_calendar`; нарушение — код ошибки загрузки,
3969
+ который оракул выпускает при первом обращении к календарю."""
3970
+ import datetime
3971
+ from pathlib import Path
3972
+ import jsonschema
3973
+ invalid = {"error": "CALENDAR_DATASET_INVALID"}
3974
+ try:
3975
+ dataset = json.loads(data.decode("utf-8"))
3976
+ # Схема датасета §85 — та же, что у `schemas.validate("calendar-dataset")`.
3977
+ from core_support import calendar_schema
3978
+ schema = calendar_schema()
3979
+ if any(True for _ in jsonschema.Draft202012Validator(schema).iter_errors(dataset)):
3980
+ return invalid
3981
+ valid = dataset["valid"]
3982
+ start = datetime.date.fromisoformat(valid["start"])
3983
+ end = datetime.date.fromisoformat(valid["endExclusive"])
3984
+ weekly = {e["day"]: bool(e["working"]) for e in dataset["weeklySchedule"]}
3985
+ if any(d not in weekly for d in WEEKDAYS):
3986
+ return invalid
3987
+ overrides = {}
3988
+ for e in dataset.get("overrides", []):
3989
+ day = datetime.date.fromisoformat(e["date"])
3990
+ if not (start <= day < end):
3991
+ return invalid
3992
+ overrides[day.isoformat()] = bool(e["working"])
3993
+ except (ValueError, KeyError, TypeError, AttributeError):
3994
+ return invalid
3995
+ return {"start": start.isoformat(), "endExclusive": end.isoformat(),
3996
+ "weekly": [weekly[d] for d in WEEKDAYS], "overrides": dict(sorted(overrides.items()))}
3997
+
3998
+
3999
+ # --- мир зависимостей ---------------------------------------------------------
4000
+
4001
+ def world_index(root) -> dict:
4002
+ """Снимки CLIR по имени пакета: `corpus/clir` и собственные снимки
4003
+ `packs/**/clir` (без `deps/` и `.build/` — копий чужих)."""
4004
+ from pathlib import Path
4005
+ root = Path(root)
4006
+ index = {}
4007
+ paths = sorted((root / "corpus" / "clir").glob("*.lawir.json"))
4008
+ for p in sorted((root / "packs").rglob("*.lawir.json")):
4009
+ if "deps" in p.parts or ".build" in p.parts:
4010
+ continue
4011
+ paths.append(p)
4012
+ for p in paths:
4013
+ try:
4014
+ ir = json.loads(p.read_text(encoding="utf-8"))
4015
+ except (OSError, ValueError):
4016
+ continue
4017
+ name = (ir.get("package") or {}).get("name")
4018
+ if name and name not in index:
4019
+ index[name] = p
4020
+ return index
4021
+
4022
+
4023
+ def resolve_world(ir: dict, index: dict, extra: list | None = None) -> list:
4024
+ """Замыкание импортов корня по индексу снимков; `extra` — CLIR, поданные
4025
+ явно (зависимости вектора). Импорт без снимка — отказ плана."""
4026
+ from pathlib import Path
4027
+ from inputs import dependency_index
4028
+ have = dependency_index(extra or [])
4029
+ out, queue = [], [i.get("package", {}).get("name") for i in ir.get("imports") or []]
4030
+ seen = set()
4031
+ while queue:
4032
+ name = queue.pop(0)
4033
+ if not name or name in seen:
4034
+ continue
4035
+ seen.add(name)
4036
+ dep = have.get(name)
4037
+ if dep is None:
4038
+ path = index.get(name)
4039
+ if path is None:
4040
+ raise Refused(f"импорт без снимка CLIR в мире: {name}")
4041
+ dep = json.loads(Path(path).read_text(encoding="utf-8"))
4042
+ out.append(dep)
4043
+ queue += [i.get("package", {}).get("name") for i in dep.get("imports") or []]
4044
+ return out
4045
+
4046
+
4047
+ if __name__ == "__main__":
4048
+ import sys
4049
+ from pathlib import Path
4050
+ plan = build_plan(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")))
4051
+ out = sys.argv[2] if len(sys.argv) > 2 else "-"
4052
+ text = json.dumps(plan, ensure_ascii=False, indent=1)
4053
+ print(text) if out == "-" else Path(out).write_text(text, encoding="utf-8")