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
@@ -0,0 +1,1634 @@
1
+ """Рантайм L0 для Python. Тот же контракт, что у runtime.ts и runtime.go:
2
+ точная десятичная арифметика §58, деньги с валютой, названный режим
3
+ округления, календарный день, член перечисления."""
4
+
5
+
6
+ import builtins as _b
7
+ import contextvars as _cv
8
+ import re
9
+ import datetime as _dt
10
+
11
+
12
+ class Refused(Exception):
13
+ """Названный отказ рантайма: то, что срез не считает, а не сбой.
14
+
15
+ Раннер печатает его как JSON с кодом выхода 3. Любое другое исключение —
16
+ крах, и ворота обязаны его отличать: молчаливое «не смог» и «упал»
17
+ неразличимы по одному ненулевому коду."""
18
+
19
+
20
+ class Dec:
21
+ __slots__ = ("u", "s")
22
+
23
+ def __init__(self, u: int, s: int):
24
+ self.u, self.s = u, s
25
+
26
+
27
+ class Money(Dec):
28
+ __slots__ = ("cur",)
29
+
30
+ def __init__(self, u: int, s: int, cur: str):
31
+ super().__init__(u, s)
32
+ self.cur = cur
33
+
34
+
35
+ class Qty(Dec):
36
+ """Quantity §49 v1 (DECISION-0256 п. 5): точное десятичное и тег единицы —
37
+ байты записи; реестра единиц и алиасов §49.2 здесь нет. Не Money: у ключа
38
+ и у сравнения свой вид, 1 KZT денег и величина 1 [KZT] — разные значения."""
39
+ __slots__ = ("unit",)
40
+
41
+ def __init__(self, u: int, s: int, unit: str):
42
+ super().__init__(u, s)
43
+ self.unit = unit
44
+
45
+
46
+ class Rat:
47
+ """Rational §35 (DECISION-0252 п. 6): несократимая дробь на целых
48
+ произвольной длины, знаменатель положителен, ноль — 0/1. Не Dec: у ключа
49
+ строки и у сравнения свой вид, 2/1 и Decimal 2 — разные значения §48."""
50
+ __slots__ = ("n", "d")
51
+
52
+ def __init__(self, n: int, d: int):
53
+ if d == 0:
54
+ raise TermError("DIVISION_BY_ZERO")
55
+ if d < 0:
56
+ n, d = -n, -d
57
+ g = _gcd(n, d) or 1
58
+ self.n, self.d = n // g, d // g
59
+
60
+
61
+ _DATE_RE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\Z")
62
+ _INSTANT_RE = re.compile(r"([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]+))?(Z|[+-][0-9]{2}:[0-9]{2})\Z")
63
+
64
+
65
+ def _date_parts(raw):
66
+ if not isinstance(raw, str) or not _DATE_RE.fullmatch(raw):
67
+ raise TermError("TYPE_ERROR")
68
+ try:
69
+ return _dt.date.fromisoformat(raw)
70
+ except ValueError as exc:
71
+ raise TermError("TYPE_ERROR") from exc
72
+
73
+
74
+ class Date:
75
+ """Strict ASCII Gregorian Date (§82.2)."""
76
+ __slots__ = ("v",)
77
+
78
+ def __init__(self, v: str):
79
+ _date_parts(v)
80
+ self.v = v
81
+
82
+
83
+ def date(lit: str) -> Date:
84
+ return Date(lit)
85
+
86
+
87
+ class Instant:
88
+ """Keep the record for atom identity; compare the exact moment (§82.2)."""
89
+ __slots__ = ("v",)
90
+
91
+ def __init__(self, v: str):
92
+ _moment_text(v)
93
+ self.v = v
94
+
95
+
96
+ def instant(lit: str) -> Instant:
97
+ return Instant(lit)
98
+
99
+
100
+ def _moment_text(raw):
101
+ m = _INSTANT_RE.fullmatch(raw) if isinstance(raw, str) else None
102
+ if not m:
103
+ raise TermError("TYPE_ERROR")
104
+ day = _date_parts(m[1])
105
+ h, mi, sec = map(int, (m[2], m[3], m[4]))
106
+ off = m[6]
107
+ if h > 23 or mi > 59 or sec > 59:
108
+ raise TermError("TYPE_ERROR")
109
+ offset = 0
110
+ if off != "Z":
111
+ oh, om = int(off[1:3]), int(off[4:6])
112
+ if oh > 23 or om > 59:
113
+ raise TermError("TYPE_ERROR")
114
+ offset = (-1 if off[0] == "-" else 1) * (oh * 3600 + om * 60)
115
+ seconds = (day.toordinal() - _dt.date(1970, 1, 1).toordinal()) * 86400 + h * 3600 + mi * 60 + sec - offset
116
+ return seconds, (m[5] or "").rstrip("0")
117
+
118
+
119
+ def _moment(value: Instant):
120
+ return _moment_text(value.v)
121
+
122
+
123
+ def _moment_pair(op: str, a: Instant, b: Instant):
124
+ return _moment(a), _moment(b)
125
+
126
+
127
+ def validate_temporal_case(case):
128
+ for i, assertion in enumerate(case.get("assertions") or []):
129
+ if "valid" not in assertion:
130
+ continue
131
+ interval = assertion["valid"]
132
+ if not isinstance(interval, dict):
133
+ raise Refused(f"TEMPORAL_BOUND_INVALID: request:/case/assertions/{i}/valid")
134
+ for side in ("start", "end"):
135
+ if side not in interval:
136
+ continue
137
+ value = interval[side]
138
+ if value == ("-infinity" if side == "start" else "infinity"):
139
+ continue
140
+ try:
141
+ _date_parts(value) if isinstance(value, str) and _DATE_RE.fullmatch(value) else _moment_text(value)
142
+ except TermError as exc:
143
+ raise Refused(f"TEMPORAL_BOUND_INVALID: request:/case/assertions/{i}/valid/{side}") from exc
144
+
145
+
146
+ class Sym:
147
+ """Член перечисления §38 либо иная ссылка `const_ref` без объявления:
148
+ значение — stable qualified identity, равенство — по ней (§57), порядок не
149
+ определён. В ключе атома у оракула это узел `const_ref`, не сущность."""
150
+ __slots__ = ("id",)
151
+
152
+ def __init__(self, id: str): # noqa: A002
153
+ self.id = id
154
+
155
+
156
+ def sym(id: str) -> Sym: # noqa: A002
157
+ return Sym(str(id))
158
+
159
+
160
+ def dec(lit: str) -> Dec:
161
+ return lit_dec(lit)
162
+
163
+
164
+ def money(lit: str, cur: str) -> Money:
165
+ d = dec(lit)
166
+ return Money(d.u, d.s, cur)
167
+
168
+
169
+ def rat_term(lit) -> Rat:
170
+ """Литерал Rational в вычислимом терме вопроса `term`: разбор оракула
171
+ (`values.from_literal_term`) — запись `n/d` с целыми частями по лексике
172
+ §208 (E-0203), сократимая и со знаком в знаменателе нормализуется; не
173
+ `n/d` — TYPE_ERROR, знаменатель 0 — DIVISION_BY_ZERO. Каноничность §168.1
174
+ требуется от записей дела и вопроса `truth`, не от операнда (ERR-E0005)."""
175
+ return lit_rat_term(lit)
176
+
177
+
178
+ def qty(lit: str, unit: str) -> Qty:
179
+ """Величина §49: значение и тег единицы как записаны."""
180
+ d = dec(lit)
181
+ return Qty(d.u, d.s, str(unit))
182
+
183
+
184
+ def rat(lit) -> Rat:
185
+ """Каноническая запись `n/d` (§168.1): иная — названный отказ, а не молчаливое
186
+ сокращение — оракул хранит запись как есть, и сокращённая дала бы другой ответ."""
187
+ text = lit_text(lit)
188
+ num, sep, den = text.partition("/")
189
+ if not sep or not (_FRACTION_PART_LEXIS.match(num) and _FRACTION_PART_LEXIS.match(den)):
190
+ raise Refused("§168.1: запись Rational не n/d: " + text)
191
+ n, d = int(num), int(den)
192
+ if d <= 0 or _gcd(n, d) != 1 or f"{n}/{d}" != text:
193
+ raise Refused("§168.1: неканоническая запись Rational: " + text)
194
+ return Rat(n, d)
195
+
196
+
197
+ # --- сырое поле литерала §208 ----------------------------------------------
198
+ # Правило одно на три цели (errata E-0197, E-0202, E-0203): сырое поле литерала
199
+ # читается НЕ средствами языка цели, а по грамматике §208. `int()`, `Decimal()`
200
+ # и `Fraction()` Python принимают пробелы, `_`, юникодную цифру и `Infinity`;
201
+ # `Number()`/`parseFloat` JavaScript принимают `1e2` и `Infinity` и теряют
202
+ # точность на i64; `strconv`/`math/big` Go имеют свои допуски. Норма у всех
203
+ # одна, поэтому алфавит назван явно и одинаково.
204
+
205
+ #: §18/§208 (errata E-0203): лексика ЗАПИСИ десятичного значения — дословный
206
+ #: порт `lawref/values.py::_DECIMAL_LEXIS`. Экспонента, ведущие и хвостовые
207
+ #: нули, знак `+`, `.5` и `5.` остаются законной записью входа и нормализуются;
208
+ #: сужены ровно пробельный символ, `_`, не-ASCII цифра и `Infinity`/`NaN`.
209
+ #: Якорь `\Z`, а не `$`: Python-овый `$` пропускает хвостовой перевод строки.
210
+ _DECIMAL_LEXIS = re.compile(r"^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\Z")
211
+
212
+ #: §35/§208 (errata E-0203): лексика ЦЕЛОГО внутри записи `<n>/<d>`.
213
+ _FRACTION_PART_LEXIS = re.compile(r"^[+-]?[0-9]+\Z")
214
+
215
+ #: §208 (errata E-0202): знаковый 64-битный носитель JSON-числа канона.
216
+ I64_MIN, I64_MAX = -(2 ** 63), 2 ** 63 - 1
217
+
218
+ #: Канон §208 отвергает JSON-float и целое вне i64 ОТКАЗОМ СОБРАТЬ ДОКУМЕНТ
219
+ #: (error-объект §172), а не issue. У порождённого модуля канала error-объекта
220
+ #: нет — профиль `core-executable` документа не строит, — поэтому тот же вход
221
+ #: даёт названный отказ раннера: «меньше ответа», а не другой ответ.
222
+ CANON_NUMBER = ("JSON-число вне канона §208 (только целое знакового 64-битного "
223
+ "диапазона) вне среза раннера")
224
+
225
+
226
+ def raw_lit(raw):
227
+ """Вид JSON-носителя сырого поля литерала и его текст: `s` — JSON-строка,
228
+ `i` — целое JSON-число канона, `x` — любая другая форма (в том числе
229
+ отсутствие поля: схема `LiteralTerm` не требует `value`, E-0197)."""
230
+ if isinstance(raw, str):
231
+ return "s", raw
232
+ if isinstance(raw, bool):
233
+ return "x", ""
234
+ if isinstance(raw, int):
235
+ if not (I64_MIN <= raw <= I64_MAX):
236
+ raise Refused(CANON_NUMBER)
237
+ return "i", str(raw)
238
+ if isinstance(raw, float):
239
+ raise Refused(CANON_NUMBER)
240
+ return "x", ""
241
+
242
+
243
+ def lit_text(raw) -> str:
244
+ """Поле, чей канон §208 — JSON-строка (`Text`, `Date`, `Instant`,
245
+ `Rational`, валюта, единица). Порт `values._json_string`: до E-0197 цели
246
+ приводили к строке ЛЮБОЙ JSON, и литерал без `value` выходил значением
247
+ `"undefined"` у ts и `""` у go — расхождение в результате, не в тексте."""
248
+ kind, text = raw_lit(raw)
249
+ if kind != "s":
250
+ raise TermError("TYPE_ERROR")
251
+ return text
252
+
253
+
254
+ def lit_int_text(raw) -> str:
255
+ """Поле, чей канон §208 — целое JSON-число (`Integer`). Порт `values._json_int`."""
256
+ kind, text = raw_lit(raw)
257
+ if kind != "i":
258
+ raise TermError("TYPE_ERROR")
259
+ return text
260
+
261
+
262
+ def lit_dec_text(raw) -> str:
263
+ """Поле `Decimal`/`Money`/`Quantity`: строка по лексике §208 либо целое
264
+ JSON-число (запись числом остаётся законной — E-0203, долг п. 3)."""
265
+ kind, text = raw_lit(raw)
266
+ if kind == "i":
267
+ return text
268
+ if kind != "s" or not _DECIMAL_LEXIS.match(text):
269
+ raise TermError("TYPE_ERROR")
270
+ return text
271
+
272
+
273
+ def dec_lexis(text: str) -> Dec:
274
+ """Точное десятичное из записи, уже прошедшей `_DECIMAL_LEXIS`: знак,
275
+ необязательная экспонента, `.5` и `5.`. Нормализация — как у
276
+ `Decimal(text)` оракула, чей канон §208 печатает non-exponent форму."""
277
+ body = text
278
+ neg = body[:1] == "-"
279
+ if body[:1] in ("+", "-"):
280
+ body = body[1:]
281
+ mant, sep, exp_text = body.partition("e")
282
+ if not sep:
283
+ mant, sep, exp_text = body.partition("E")
284
+ exp = int(exp_text) if sep else 0
285
+ int_part, _, frac = mant.partition(".")
286
+ digits = (int_part + frac) or "0"
287
+ scale = len(frac) - exp
288
+ unscaled = int(digits)
289
+ if unscaled == 0 and _b.abs(scale) > 4096:
290
+ return Dec(0, 0)
291
+ canonical_scale = scale - (len(digits) - len(digits.rstrip("0")))
292
+ if unscaled != 0 and (canonical_scale > 4096 or scale < -4096):
293
+ raise Refused("DECIMAL_EXPANSION_UNAVAILABLE: E-0320 limit 4096")
294
+ if scale < 0:
295
+ unscaled *= 10 ** (-scale)
296
+ scale = 0
297
+ return Dec(-unscaled if neg else unscaled, scale)
298
+
299
+
300
+ def lit_dec(raw) -> Dec:
301
+ return dec_lexis(lit_dec_text(raw))
302
+
303
+
304
+ def lit_int(raw) -> Dec:
305
+ return Dec(int(lit_int_text(raw)), 0)
306
+
307
+
308
+ def lit_money(raw, cur) -> Money:
309
+ d = lit_dec(raw)
310
+ return Money(d.u, d.s, lit_text(cur))
311
+
312
+
313
+ def lit_qty(raw, unit) -> Qty:
314
+ d = lit_dec(raw)
315
+ return Qty(d.u, d.s, lit_text(unit))
316
+
317
+
318
+ def lit_rat_term(raw) -> Rat:
319
+ """Литерал `Rational` вычислимого терма: порт ветви `RATIONAL`
320
+ `values.from_literal_term` в редакции E-0203 — `<n>/<d>` с частями по
321
+ `_FRACTION_PART_LEXIS`, сократимая запись нормализуется."""
322
+ parts = lit_text(raw).split("/")
323
+ if len(parts) != 2 or not (_FRACTION_PART_LEXIS.match(parts[0])
324
+ and _FRACTION_PART_LEXIS.match(parts[1])):
325
+ raise TermError("TYPE_ERROR")
326
+ n, d = int(parts[0]), int(parts[1])
327
+ if d == 0:
328
+ raise TermError("DIVISION_BY_ZERO")
329
+ return Rat(n, d)
330
+
331
+
332
+ # --- виды ------------------------------------------------------------------
333
+
334
+ def _is_num(v) -> bool:
335
+ return isinstance(v, Dec)
336
+
337
+
338
+ def _same(a, b, op: str) -> None:
339
+ """§48/§58: сложение, вычитание, min/max требуют совпадения видов;
340
+ у денег — и валюты, у величин — и единицы (DECISION-0256: ошибка значения)."""
341
+ if isinstance(a, Qty) or isinstance(b, Qty):
342
+ if not (isinstance(a, Qty) and isinstance(b, Qty)):
343
+ raise TermError("TYPE_ERROR")
344
+ if a.unit != b.unit:
345
+ raise TermError("DIMENSION_MISMATCH")
346
+ return
347
+ if not (_is_num(a) and _is_num(b)) or isinstance(a, Money) != isinstance(b, Money):
348
+ raise Refused(f"§58: виды операндов не совпадают: {op}")
349
+ if isinstance(a, Money) and a.cur != b.cur:
350
+ raise Refused(f"§58: разные валюты: {op}")
351
+
352
+
353
+ def _bounded_coefficient(u, s=0):
354
+ if u < -(1 << 127) or u >= (1 << 127) or (u and s > 4096):
355
+ raise Refused("NUMERIC_LIMIT_UNAVAILABLE: current i64/i128 arithmetic boundary")
356
+ return u
357
+
358
+
359
+ def _pair(a, b):
360
+ s = _b.max(a.s, b.s)
361
+ x, y = a.u * 10 ** (s - a.s), b.u * 10 ** (s - b.s)
362
+ _bounded_coefficient(x, s); _bounded_coefficient(y, s)
363
+ return x, y, s
364
+
365
+
366
+ def _make(u: int, s: int, like) -> Dec:
367
+ _bounded_coefficient(u, s)
368
+ if isinstance(like, Qty):
369
+ return Qty(u, s, like.unit)
370
+ return Money(u, s, like.cur) if isinstance(like, Money) else Dec(u, s)
371
+
372
+
373
+ def _integral(v) -> bool:
374
+ """Целое значение: дробная часть нулевая. Статика плана гарантирует вид
375
+ Integer у операндов mod/pow, здесь — защита в глубину."""
376
+ return _is_num(v) and not isinstance(v, (Money, Qty)) and v.u % (10 ** v.s) == 0
377
+
378
+
379
+ def _int(v) -> int:
380
+ return v.u // (10 ** v.s)
381
+
382
+
383
+ # --- арифметика §58 ---------------------------------------------------------
384
+
385
+ def add(a, b) -> Dec:
386
+ if isinstance(a, Rat) or isinstance(b, Rat):
387
+ return _rat_pair(a, b, "add")
388
+ _same(a, b, "add")
389
+ x, y, s = _pair(a, b)
390
+ return _make(x + y, s, a)
391
+
392
+
393
+ def sub(a, b) -> Dec:
394
+ if isinstance(a, Rat) or isinstance(b, Rat):
395
+ return _rat_pair(a, b, "sub")
396
+ _same(a, b, "sub")
397
+ x, y, s = _pair(a, b)
398
+ return _make(x - y, s, a)
399
+
400
+
401
+ def mul(a, b) -> Dec:
402
+ """Правило скаляра §49: безразмерное масштабирует размерное; масштаб
403
+ результата — сумма масштабов. Два размерных не перемножаются."""
404
+ if not (_is_num(a) and _is_num(b)):
405
+ raise Refused("§58: виды операндов не совпадают: mul")
406
+ if isinstance(a, (Money, Qty)) and isinstance(b, (Money, Qty)):
407
+ raise Refused("§58: произведение двух размерных величин не определено")
408
+ like = a if isinstance(a, (Money, Qty)) else b
409
+ return _make(a.u * b.u, a.s + b.s, like)
410
+
411
+
412
+ def _gcd(a: int, b: int) -> int:
413
+ a, b = _b.abs(a), _b.abs(b)
414
+ while b:
415
+ a, b = b, a % b
416
+ return a
417
+
418
+
419
+ def _exact(num: int, den: int):
420
+ """Точное десятичное частное num/den, если знаменатель 10-гладкий (§18),
421
+ иначе None. Масштаб — наименьший точный."""
422
+ g = _gcd(num, den) or 1
423
+ num, den = num // g, den // g
424
+ if den < 0:
425
+ num, den = -num, -den
426
+ twos = fives = 0
427
+ while den % 2 == 0:
428
+ den //= 2
429
+ twos += 1
430
+ while den % 5 == 0:
431
+ den //= 5
432
+ fives += 1
433
+ if den != 1:
434
+ return None
435
+ k = _b.max(twos, fives)
436
+ return num * 2 ** (k - twos) * 5 ** (k - fives), k
437
+
438
+
439
+ def div(a, b) -> Dec:
440
+ """Таблица делимых §58 в срезе: Decimal/Decimal, Money/Decimal,
441
+ Money/Money одной валюты. Integer/Integer даёт Rational — вне среза,
442
+ отвергается планом статически."""
443
+ if not (_is_num(a) and _is_num(b)):
444
+ raise Refused("§58: деление вне таблицы делимых")
445
+ if b.u == 0:
446
+ raise Refused("деление на ноль — evaluation error (§58)")
447
+ if isinstance(a, Money) and isinstance(b, Money):
448
+ if a.cur != b.cur:
449
+ raise Refused("CURRENCY_MISMATCH: §58 делит Money на Money только при тождественной валюте")
450
+ like = None
451
+ elif isinstance(a, Money):
452
+ like = a
453
+ elif isinstance(b, Money):
454
+ raise Refused("§58: деление вне таблицы делимых")
455
+ else:
456
+ like = None
457
+ q = _exact(a.u * 10 ** b.s, b.u * 10 ** a.s)
458
+ if q is None:
459
+ if like is not None:
460
+ raise Refused("INEXACT_DIVISION: округление только явной политикой (§50/§18)")
461
+ raise Refused("§58: частное не 10-гладкое — Rational вне среза")
462
+ u, s = q
463
+ return Money(u, s, like.cur) if like is not None else Dec(u, s)
464
+
465
+
466
+ def mod(a, b) -> Dec:
467
+ """Евклидов остаток §58: 0 <= r < |b| при любых знаках операндов."""
468
+ if not (_integral(a) and _integral(b)):
469
+ raise Refused("§58: mod определён только для Integer")
470
+ y = _int(b)
471
+ if y == 0:
472
+ raise Refused("остаток от деления на ноль — evaluation error (§58)")
473
+ return Dec(_int(a) % _b.abs(y), 0)
474
+
475
+
476
+ # --- Rational §35/§58 и арифметика по видам (DECISION-0252) ------------------
477
+
478
+ def _rat_pair(a, b, op: str):
479
+ """add/sub/min/max над Rational: оба операнда Rational, иначе TYPE_ERROR §48."""
480
+ if not (isinstance(a, Rat) and isinstance(b, Rat)):
481
+ raise TermError("TYPE_ERROR")
482
+ if op == "add":
483
+ return Rat(a.n * b.d + b.n * a.d, a.d * b.d)
484
+ if op == "sub":
485
+ return Rat(a.n * b.d - b.n * a.d, a.d * b.d)
486
+ if op == "min":
487
+ return a if a.n * b.d <= b.n * a.d else b
488
+ return a if a.n * b.d >= b.n * a.d else b
489
+
490
+
491
+ def kind_of(v) -> str:
492
+ """Вид значения динамического терма (DECISION-0252 п. 5): `r` Rational, `d` Decimal."""
493
+ return "r" if isinstance(v, Rat) else "d"
494
+
495
+
496
+ _KINDS = ("i", "d", "m", "r", "q")
497
+
498
+
499
+ def _resolve(v, k: str) -> str:
500
+ """Статический вид плана → вид значения; `n` — Decimal либо Rational по классу."""
501
+ if k == "n":
502
+ k = "r" if isinstance(v, Rat) else "d"
503
+ if k not in _KINDS or (k == "r") != isinstance(v, Rat) or (k == "m") != isinstance(v, Money) \
504
+ or (k == "q") != isinstance(v, Qty):
505
+ raise Refused("вид операнда расходится с планом: " + str(k))
506
+ return k
507
+
508
+
509
+ def _exact_of(num: int, den: int, like):
510
+ """Точное десятичное num/den (§18) видом `like` (Money либо Dec) или None."""
511
+ q = _exact(num, den)
512
+ if q is None:
513
+ return None
514
+ if isinstance(like, Qty):
515
+ return Qty(q[0], q[1], like.unit)
516
+ return Money(q[0], q[1], like.cur) if isinstance(like, Money) else Dec(q[0], q[1])
517
+
518
+
519
+ def _arith(op: str, a, b, lk: str, rk: str):
520
+ """Таблица §58 `values.binary` по видам операндов (DECISION-0252 п. 3–4).
521
+
522
+ Виды — из плана: `i` Integer, `d` Decimal, `m` Money, `r` Rational, `n` —
523
+ Decimal либо Rational по значению. Integer и Decimal рантайм по значению
524
+ не различает, поэтому пара решается видами, а не классами. Ошибка
525
+ значения — TermError с кодом оракула, в его порядке проверок."""
526
+ l, r = _resolve(a, lk), _resolve(b, rk)
527
+ pair = {l, r}
528
+ if op in ("add", "sub"):
529
+ if l == r == "m" and a.cur != b.cur:
530
+ raise TermError("CURRENCY_MISMATCH")
531
+ if l == r == "q" and a.unit != b.unit:
532
+ raise TermError("DIMENSION_MISMATCH") # §49, T023
533
+ if l != r:
534
+ raise TermError("TYPE_ERROR")
535
+ if l == "r":
536
+ return _rat_pair(a, b, op)
537
+ return add(a, b) if op == "add" else sub(a, b)
538
+ if op == "mul":
539
+ if l == r == "i" or ("i" in pair and pair & {"m", "d", "q"}):
540
+ return mul(a, b)
541
+ if pair == {"i", "r"}:
542
+ q, k = (a, b) if l == "r" else (b, a)
543
+ return Rat(q.n * _int(k), q.d)
544
+ if "i" in pair:
545
+ raise TermError("TYPE_ERROR")
546
+ if l == r == "r":
547
+ return Rat(a.n * b.n, a.d * b.d)
548
+ if pair == {"m", "r"}:
549
+ # E-0085: доля масштабирует деньги точно; 10-гладкость — после сокращения.
550
+ m, q = (a, b) if l == "m" else (b, a)
551
+ out = _exact_of(m.u * q.n, 10 ** m.s * q.d, m)
552
+ if out is None:
553
+ raise TermError("INEXACT_DIVISION")
554
+ return out
555
+ if "r" in pair or pair in ({"m"}, {"q"}, {"m", "q"}):
556
+ raise TermError("TYPE_ERROR")
557
+ return mul(a, b)
558
+ if op == "div":
559
+ if l == r == "r":
560
+ if b.n == 0:
561
+ raise TermError("DIVISION_BY_ZERO")
562
+ return Rat(a.n * b.d, a.d * b.n)
563
+ if l == r == "i":
564
+ if _int(b) == 0:
565
+ raise TermError("DIVISION_BY_ZERO")
566
+ return Rat(_int(a), _int(b))
567
+ if "r" in pair:
568
+ raise TermError("TYPE_ERROR")
569
+ num, den = a.u * 10 ** b.s, b.u * 10 ** a.s
570
+ if l == r == "m":
571
+ # E-0083: частное сумм одной валюты безразмерно — Decimal либо Rational.
572
+ if a.cur != b.cur:
573
+ raise TermError("CURRENCY_MISMATCH")
574
+ if b.u == 0:
575
+ raise TermError("DIVISION_BY_ZERO")
576
+ out = _exact_of(num, den, None)
577
+ return out if out is not None else Rat(num, den)
578
+ if l == r == "q":
579
+ # E-0068: частное одной единицы безразмерно; разные единицы — TYPE_ERROR раньше нуля.
580
+ if a.unit != b.unit:
581
+ raise TermError("TYPE_ERROR")
582
+ if b.u == 0:
583
+ raise TermError("DIVISION_BY_ZERO")
584
+ out = _exact_of(num, den, None)
585
+ return out if out is not None else Rat(num, den)
586
+ if (l, r) not in (("d", "d"), ("m", "d"), ("q", "d")):
587
+ raise TermError("TYPE_ERROR")
588
+ if b.u == 0:
589
+ raise TermError("DIVISION_BY_ZERO")
590
+ out = _exact_of(num, den, a)
591
+ if out is not None:
592
+ return out
593
+ if l == "d":
594
+ return Rat(num, den)
595
+ raise TermError("INEXACT_DIVISION")
596
+ if op == "mod":
597
+ if l == r == "i":
598
+ y = _int(b)
599
+ if y == 0:
600
+ raise TermError("DIVISION_BY_ZERO")
601
+ return Dec(_int(a) % _b.abs(y), 0)
602
+ raise TermError("TYPE_ERROR")
603
+ raise Refused("операция вне среза рантайма: " + str(op))
604
+
605
+
606
+ def arith(op: str, a, b, lk: str, rk: str):
607
+ for v in (a, b):
608
+ if isinstance(v, Dec):
609
+ _bounded_coefficient(v.u, v.s)
610
+ out = _arith(op, a, b, lk, rk)
611
+ if isinstance(out, Dec):
612
+ _bounded_coefficient(out.u, out.s)
613
+ if lk == rk == "i" and op in ("add", "sub", "mul", "mod") and not (-(1 << 63) <= out.u < (1 << 63)):
614
+ raise Refused("NUMERIC_LIMIT_UNAVAILABLE: current i64/i128 arithmetic boundary")
615
+ return out
616
+
617
+
618
+ def min(a, b) -> Dec: # noqa: A001
619
+ if isinstance(a, Rat) or isinstance(b, Rat):
620
+ return _rat_pair(a, b, "min")
621
+ _same(a, b, "min")
622
+ x, y, _ = _pair(a, b)
623
+ return a if x <= y else b
624
+
625
+
626
+ def max(a, b) -> Dec: # noqa: A001
627
+ if isinstance(a, Rat) or isinstance(b, Rat):
628
+ return _rat_pair(a, b, "max")
629
+ _same(a, b, "max")
630
+ x, y, _ = _pair(a, b)
631
+ return a if x >= y else b
632
+
633
+
634
+ def abs(v) -> Dec: # noqa: A001
635
+ if isinstance(v, Dec) and v.s == 0 and v.u == -(1 << 63):
636
+ raise Refused("NUMERIC_LIMIT_UNAVAILABLE: current i64/i128 arithmetic boundary")
637
+ if isinstance(v, Rat):
638
+ return Rat(_b.abs(v.n), v.d)
639
+ if not _is_num(v):
640
+ raise Refused("§58: abs — не числовой вид")
641
+ return _make(v.u if v.u >= 0 else -v.u, v.s, v)
642
+
643
+
644
+ POW_EXP_LIMIT = 4096
645
+ POW_MIN, POW_MAX = -(2 ** 63), 2 ** 63 - 1
646
+
647
+
648
+ def pow(base, exp) -> Dec: # noqa: A001
649
+ """§58 (E-0075): только Integer pow Integer; порядок проверок — знак,
650
+ предел, вычисление, диапазон — часть контракта."""
651
+ if not (_integral(base) and _integral(exp)):
652
+ raise Refused("§58: pow определён только для Integer")
653
+ e = _int(exp)
654
+ if e < 0:
655
+ raise Refused("§58: pow — показатель отрицателен")
656
+ if e > POW_EXP_LIMIT:
657
+ raise Refused("RESOURCE_LIMIT: pow — показатель превышает 4096")
658
+ r = _int(base) ** e
659
+ if r < POW_MIN or r > POW_MAX:
660
+ raise Refused("RESOURCE_LIMIT: pow — результат вне 64-битного диапазона")
661
+ return Dec(r, 0)
662
+
663
+
664
+ def round(m, digits, mode: str) -> Dec: # noqa: A001
665
+ if mode != "HALF_UP":
666
+ raise Refused("§50: режим округления не реализован: " + mode)
667
+ if not _is_num(m):
668
+ raise Refused("§50: round — не числовой вид")
669
+ n = digits if isinstance(digits, int) else digits.u // 10 ** digits.s
670
+ if m.s <= n:
671
+ return _make(m.u * 10 ** (n - m.s), n, m)
672
+ drop = 10 ** (m.s - n)
673
+ neg = m.u < 0
674
+ a = -m.u if neg else m.u
675
+ q, r = divmod(a, drop)
676
+ up = q + 1 if r * 2 >= drop else q # HALF_UP — от нуля
677
+ return _make(-up if neg else up, n, m)
678
+
679
+
680
+ # --- политика округления §50 (DECISION-0259) ---------------------------------
681
+
682
+ ROUNDING_MODES = ("CEILING", "DOWN", "FLOOR", "HALF_DOWN", "HALF_EVEN", "HALF_UP", "UP")
683
+ DIV_ROUND_MAX_PRECISION = 34
684
+
685
+
686
+ def _round_int(num: int, den: int, mode: str) -> int:
687
+ """Порт `values._round_fraction` на целых: num/den (den > 0) к целому по
688
+ режиму §50; `2*tail` против знаменателя, ветви в порядке оракула."""
689
+ negative = num < 0
690
+ head, tail = divmod(-num if negative else num, den)
691
+ up = {
692
+ "HALF_UP": 2 * tail >= den,
693
+ "HALF_EVEN": 2 * tail > den or (2 * tail == den and head % 2 == 1),
694
+ "HALF_DOWN": 2 * tail > den,
695
+ "UP": tail > 0,
696
+ "DOWN": False,
697
+ "CEILING": tail > 0 and not negative,
698
+ "FLOOR": tail > 0 and negative,
699
+ }[mode]
700
+ if up:
701
+ head += 1
702
+ return -head if negative else head
703
+
704
+
705
+ def _policy_scale(p, pk: str) -> int:
706
+ """Точность политики: Integer либо целый Decimal, вид — из плана. Дробная
707
+ точность — TYPE_ERROR; проверка на целом до любого приведения."""
708
+ if pk not in ("i", "d"):
709
+ raise Refused("вид точности расходится с планом: " + str(pk))
710
+ _resolve(p, pk)
711
+ if p.u % 10 ** p.s:
712
+ raise TermError("TYPE_ERROR")
713
+ return p.u // 10 ** p.s
714
+
715
+
716
+ def _policy_mode(mode) -> str:
717
+ if not isinstance(mode, str) or mode not in ROUNDING_MODES:
718
+ raise TermError("TYPE_ERROR")
719
+ return mode
720
+
721
+
722
+ def round_mode(v, p, mode, vk: str, pk: str):
723
+ """§50 `round` с проверкой политики (DECISION-0259 п. 4, 7): порядок
724
+ `values.round_value` — вид, точность (целое >= 0), режим. Integer — тождество
725
+ (E-0110); точность не короче дробной части — значение то же (E-0184)."""
726
+ k = _resolve(v, vk)
727
+ if k == "r":
728
+ raise TermError("TYPE_ERROR")
729
+ scale = _policy_scale(p, pk)
730
+ if scale < 0:
731
+ raise TermError("TYPE_ERROR")
732
+ mode = _policy_mode(mode)
733
+ if k == "i" or scale >= v.s:
734
+ return v
735
+ return _make(_round_int(v.u, 10 ** (v.s - scale), mode), scale, v)
736
+
737
+
738
+ def div_round(a, b, p, mode, ak: str, bk: str, pk: str):
739
+ """§50/§58 `div_round` (E-0036, E-0170; DECISION-0259 п. 4, 7): пара →
740
+ валюта → ноль → точность 0..34 → режим. Частное точное, округление всегда;
741
+ Money/Money — доля Decimal без тега."""
742
+ l, r = _resolve(a, ak), _resolve(b, bk)
743
+ if (l, r) not in (("m", "d"), ("q", "d"), ("d", "d"), ("m", "m")):
744
+ raise TermError("TYPE_ERROR")
745
+ if l == r == "m" and a.cur != b.cur:
746
+ raise TermError("CURRENCY_MISMATCH")
747
+ if b.u == 0:
748
+ raise TermError("DIVISION_BY_ZERO")
749
+ scale = _policy_scale(p, pk)
750
+ if not 0 <= scale <= DIV_ROUND_MAX_PRECISION:
751
+ raise TermError("TYPE_ERROR")
752
+ mode = _policy_mode(mode)
753
+ num, den = a.u * 10 ** (b.s + scale), b.u * 10 ** a.s
754
+ if den < 0:
755
+ num, den = -num, -den
756
+ q = _round_int(num, den, mode)
757
+ return Dec(q, scale) if r == "m" else _make(q, scale, a)
758
+
759
+
760
+ # --- сравнение и равенство ---------------------------------------------------
761
+
762
+ def cmp(a, op: str, b) -> bool: # noqa: A001
763
+ if isinstance(a, bool) or isinstance(b, bool):
764
+ # §57 (DECISION-0266): Boolean сравнимо только на равенство и только с Boolean.
765
+ same_bool = isinstance(a, bool) and isinstance(b, bool) and a == b
766
+ if op == "eq":
767
+ return same_bool
768
+ if op == "ne":
769
+ return not same_bool
770
+ raise Refused("§57: вид Boolean не упорядочен")
771
+ if isinstance(a, Rat) or isinstance(b, Rat):
772
+ # §57/§48 (DECISION-0252 п. 6): Rational сравнивается только с Rational;
773
+ # иной вид — TYPE_ERROR оракула, а не false.
774
+ if not (isinstance(a, Rat) and isinstance(b, Rat)):
775
+ raise TermError("TYPE_ERROR")
776
+ x, y = a.n * b.d, b.n * a.d
777
+ table = {"lt": x < y, "le": x <= y, "gt": x > y, "ge": x >= y,
778
+ "eq": x == y, "ne": x != y}
779
+ if op not in table:
780
+ raise Refused("сравнение вне среза рантайма: " + op)
781
+ return table[op]
782
+ if isinstance(a, Qty) or isinstance(b, Qty):
783
+ # §49/§57 (DECISION-0256 п. 5): величина — только с величиной той же единицы.
784
+ _same(a, b, "cmp")
785
+ x, y, _ = _pair(a, b)
786
+ table = {"lt": x < y, "le": x <= y, "gt": x > y, "ge": x >= y,
787
+ "eq": x == y, "ne": x != y}
788
+ if op not in table:
789
+ raise Refused("сравнение вне среза рантайма: " + op)
790
+ return table[op]
791
+ if isinstance(a, Sym) or isinstance(b, Sym):
792
+ if op == "eq":
793
+ return isinstance(a, Sym) and isinstance(b, Sym) and a.id == b.id
794
+ if op == "ne":
795
+ return not (isinstance(a, Sym) and isinstance(b, Sym) and a.id == b.id)
796
+ raise Refused("§57: вид enum не упорядочен")
797
+ if isinstance(a, Instant) or isinstance(b, Instant):
798
+ if isinstance(a, Instant) and isinstance(b, Instant):
799
+ x, y = _moment_pair(op, a, b)
800
+ table = {"lt": x < y, "le": x <= y, "gt": x > y, "ge": x >= y, "eq": x == y, "ne": x != y}
801
+ if op not in table:
802
+ raise Refused("сравнение вне среза рантайма: " + op)
803
+ return table[op]
804
+ # §57/§2.15: Date и Instant не сравниваются (DATE_INSTANT_MISMATCH оракула).
805
+ raise Refused("§57: Instant сравнивается только с Instant")
806
+ if isinstance(a, Date) or isinstance(b, Date):
807
+ if isinstance(a, Date) and isinstance(b, Date):
808
+ table = {"lt": a.v < b.v, "le": a.v <= b.v, "gt": a.v > b.v, "ge": a.v >= b.v,
809
+ "eq": a.v == b.v, "ne": a.v != b.v}
810
+ if op not in table:
811
+ raise Refused("сравнение вне среза рантайма: " + op)
812
+ return table[op]
813
+ if op == "eq":
814
+ return False
815
+ if op == "ne":
816
+ return True
817
+ raise Refused("§57: Date сравнивается только с Date")
818
+ if isinstance(a, str) or isinstance(b, str):
819
+ if op == "eq":
820
+ return a == b
821
+ if op == "ne":
822
+ return a != b
823
+ raise Refused("порядок на Text вне среза рантайма: " + op)
824
+ if isinstance(a, Money) != isinstance(b, Money):
825
+ raise Refused("§49: сравнение денег с безразмерным")
826
+ if isinstance(a, Money) and a.cur != b.cur:
827
+ raise Refused("§49: разные валюты")
828
+ x, y, _ = _pair(a, b)
829
+ table = {"lt": x < y, "le": x <= y, "gt": x > y, "ge": x >= y,
830
+ "eq": x == y, "ne": x != y}
831
+ if op not in table:
832
+ raise Refused("сравнение вне среза рантайма: " + op)
833
+ return table[op]
834
+
835
+
836
+ def eq(a, b) -> bool:
837
+ if isinstance(a, bool) or isinstance(b, bool):
838
+ # DECISION-0266: Boolean равен только Boolean того же значения; bool —
839
+ # подкласс int в Python, поэтому ветвь стоит первой.
840
+ return isinstance(a, bool) and isinstance(b, bool) and a == b
841
+ if isinstance(a, Rat) or isinstance(b, Rat):
842
+ return isinstance(a, Rat) and isinstance(b, Rat) and a.n == b.n and a.d == b.d
843
+ if isinstance(a, Qty) or isinstance(b, Qty):
844
+ if not (isinstance(a, Qty) and isinstance(b, Qty)) or a.unit != b.unit:
845
+ return False
846
+ x, y, _ = _pair(a, b)
847
+ return x == y
848
+ if isinstance(a, Sym) or isinstance(b, Sym):
849
+ return isinstance(a, Sym) and isinstance(b, Sym) and a.id == b.id
850
+ if isinstance(a, Date) or isinstance(b, Date):
851
+ return isinstance(a, Date) and isinstance(b, Date) and a.v == b.v
852
+ if isinstance(a, Instant) or isinstance(b, Instant):
853
+ if not (isinstance(a, Instant) and isinstance(b, Instant)):
854
+ return False
855
+ x, y = _moment_pair("eq", a, b)
856
+ return x == y
857
+ if isinstance(a, str) or isinstance(b, str):
858
+ return a == b
859
+ if isinstance(a, Money) != isinstance(b, Money):
860
+ return False
861
+ if isinstance(a, Money) and a.cur != b.cur:
862
+ return False
863
+ x, y, _ = _pair(a, b)
864
+ return x == y
865
+
866
+
867
+ def _norm(d: Dec) -> str:
868
+ u, s = d.u, d.s
869
+ if u == 0:
870
+ return "0e-0"
871
+ while s > 0 and u % 10 == 0:
872
+ u //= 10
873
+ s -= 1
874
+ return f"{u}e-{s}"
875
+
876
+
877
+ def _esc(text: str) -> str:
878
+ """Разделитель и экран внутри текста экранируются: иначе ["a|e:b","c"] и
879
+ ["a","b|e:c"] склеивались в один ключ, и второй факт терялся."""
880
+ return text.replace("\\", "\\\\").replace("|", "\\|")
881
+
882
+
883
+ def key(row) -> str:
884
+ out = []
885
+ for v in row:
886
+ if isinstance(v, str):
887
+ out.append("e:" + _esc(v))
888
+ elif isinstance(v, bool):
889
+ out.append("b:" + ("true" if v else "false"))
890
+ elif isinstance(v, Date):
891
+ out.append("t:" + v.v)
892
+ elif isinstance(v, Instant):
893
+ out.append("i:" + _esc(v.v))
894
+ elif isinstance(v, Sym):
895
+ out.append("c:" + _esc(v.id))
896
+ elif isinstance(v, Rat):
897
+ out.append(f"r:{v.n}/{v.d}")
898
+ elif isinstance(v, Qty):
899
+ out.append("q:" + _esc(v.unit) + ":" + _norm(v))
900
+ elif isinstance(v, Money):
901
+ out.append("m:" + v.cur + ":" + _norm(v))
902
+ else:
903
+ out.append("d:" + _norm(v))
904
+ return "|".join(out)
905
+
906
+
907
+ class _Context:
908
+ """Контекст одного вычисления (DECISION-0423 §2.3): правовое время дела,
909
+ политика срока §86, снимок календаря §85 и загруженный календарь. Живёт в
910
+ `contextvars`: `evaluate` открывает новый контекст на каждый вызов, и
911
+ параллельные вызовы из потоков его не делят."""
912
+ __slots__ = ("legal_time", "policy", "snapshot", "cal")
913
+
914
+ def __init__(self):
915
+ self.legal_time = ""
916
+ self.policy = None # DeadlinePolicy §86 дела либо None
917
+ self.snapshot = None # запись снимка плана (`calendars`) либо None
918
+ self.cal = None # загруженный календарь
919
+
920
+
921
+ _CONTEXT = _cv.ContextVar("law_codegen_context")
922
+
923
+
924
+ def begin_context() -> None:
925
+ """Новый пустой контекст вычисления в текущем `contextvars.Context`."""
926
+ _CONTEXT.set(_Context())
927
+
928
+
929
+ def _ctx() -> _Context:
930
+ try:
931
+ return _CONTEXT.get()
932
+ except LookupError:
933
+ c = _Context()
934
+ _CONTEXT.set(c)
935
+ return c
936
+
937
+
938
+ def set_legal_time(t: str) -> None:
939
+ _ctx().legal_time = t
940
+
941
+
942
+ def legal_time() -> str:
943
+ t = _ctx().legal_time
944
+ if not t:
945
+ raise Refused("legalTime не подан делом")
946
+ return t
947
+
948
+
949
+ # --- календарь §84–§86 (DECISION-0245) ---------------------------------------
950
+ # Порт `lawref.values` и `lawref.calendar` дословно по порядку проверок: код
951
+ # ошибки — часть ответа (issue модели), а не текст.
952
+
953
+ import datetime as _dt
954
+
955
+
956
+ class TermError(Exception):
957
+ """Ошибка вычисления терма §58 с кодом: модель превращает её в issue
958
+ правила (`support.term_error` оракула)."""
959
+
960
+ def __init__(self, code: str):
961
+ super().__init__(code)
962
+ self.code = code
963
+
964
+
965
+ def set_calendar(policy, snapshot) -> None:
966
+ c = _ctx()
967
+ c.policy, c.snapshot, c.cal = policy, snapshot, None
968
+
969
+
970
+ _WEEKDAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
971
+
972
+
973
+ def _calendar():
974
+ """`TermEnv.calendar`: ленивая загрузка; ошибка загрузки — код плана."""
975
+ c = _ctx()
976
+ if c.cal is None:
977
+ snap = c.snapshot
978
+ if snap is None or snap.get("error") == "MISSING_CALENDAR":
979
+ raise TermError("MISSING_CALENDAR")
980
+ if "error" in snap:
981
+ raise TermError(snap["error"])
982
+ c.cal = (_dt.date.fromisoformat(snap["start"]), _dt.date.fromisoformat(snap["endExclusive"]),
983
+ snap["weekly"], {_dt.date.fromisoformat(k): v for k, v in snap["overrides"].items()})
984
+ return c.cal
985
+
986
+
987
+ def _coverage(cal, day) -> None:
988
+ if not (cal[0] <= day < cal[1]):
989
+ raise TermError("CALENDAR_OUT_OF_RANGE")
990
+
991
+
992
+ def _working(cal, day) -> bool:
993
+ _coverage(cal, day)
994
+ if day in cal[3]:
995
+ return cal[3][day]
996
+ return cal[2][day.weekday()]
997
+
998
+
999
+ def _previous_working(cal, day):
1000
+ _coverage(cal, day)
1001
+ current = day - _dt.timedelta(days=1)
1002
+ while True:
1003
+ _coverage(cal, current)
1004
+ if _working(cal, current):
1005
+ return current
1006
+ current -= _dt.timedelta(days=1)
1007
+
1008
+
1009
+ def _next_working(cal, day):
1010
+ while not _working(cal, day):
1011
+ day += _dt.timedelta(days=1)
1012
+ _coverage(cal, day)
1013
+ return day
1014
+
1015
+
1016
+ def _last_day(year: int, month: int) -> int:
1017
+ if month == 12:
1018
+ return 31
1019
+ return (_dt.date(year, month + 1, 1) - _dt.timedelta(days=1)).day
1020
+
1021
+
1022
+ def _add_months(day, months: int, month_end: str):
1023
+ total = day.month - 1 + months
1024
+ year, month = day.year + total // 12, total % 12 + 1
1025
+ last = _last_day(year, month)
1026
+ if day.day <= last:
1027
+ return _dt.date(year, month, day.day)
1028
+ if month_end == "last_day_of_month":
1029
+ return _dt.date(year, month, last)
1030
+ raise TermError("DEADLINE_POLICY_INVALID")
1031
+
1032
+
1033
+ def _needs_calendar(unit: str, policy) -> bool:
1034
+ if unit not in ("calendar_month", "calendar_year", "calendar_week"):
1035
+ return True
1036
+ policy = policy or {}
1037
+ return policy.get("roll") != "no_roll" or policy.get("include_end") is not True
1038
+
1039
+
1040
+ def _deadline(cal, after, count: int, policy, after_time, unit: str, calendar_day_step: bool = False):
1041
+ """Порт `calendar.compute_deadline` без приостановлений (терм их не несёт)."""
1042
+ if policy is None:
1043
+ raise TermError("MISSING_POLICY")
1044
+ if set(policy) - {"start_count", "include_end", "roll", "cutoff", "month_end"}:
1045
+ raise TermError("DEADLINE_POLICY_INVALID")
1046
+ start_count, roll = policy.get("start_count"), policy.get("roll")
1047
+ include_end, cutoff = policy.get("include_end"), policy.get("cutoff")
1048
+ if start_count not in ("same_day", "next_day") \
1049
+ or roll not in ("no_roll", "next_working_day", "previous_working_day") \
1050
+ or not isinstance(include_end, bool) or not (cutoff is None or isinstance(cutoff, str)):
1051
+ raise TermError("DEADLINE_POLICY_INVALID")
1052
+ if count < 0:
1053
+ raise TermError("DEADLINE_POLICY_INVALID")
1054
+ month_end = policy.get("month_end")
1055
+ if unit in ("calendar_month", "calendar_year"):
1056
+ if month_end not in ("last_day_of_month", "reject_nonexistent"):
1057
+ raise TermError("DEADLINE_POLICY_INVALID")
1058
+ elif month_end is not None and month_end not in ("last_day_of_month", "reject_nonexistent"):
1059
+ raise TermError("DEADLINE_POLICY_INVALID")
1060
+ start = after
1061
+ wants = False if calendar_day_step else _needs_calendar(unit, policy)
1062
+ if wants:
1063
+ _coverage(cal, start)
1064
+ if cutoff is not None and after_time is not None and after_time > cutoff:
1065
+ start += _dt.timedelta(days=1)
1066
+ if wants:
1067
+ _coverage(cal, start)
1068
+ if start_count == "next_day":
1069
+ start += _dt.timedelta(days=1)
1070
+ if wants:
1071
+ _coverage(cal, start)
1072
+ if unit in ("calendar_month", "calendar_year"):
1073
+ months = count * (12 if unit == "calendar_year" else 1)
1074
+ due = _add_months(start, months, month_end) if count > 0 else start
1075
+ if count > 0 and not include_end:
1076
+ _coverage(cal, due)
1077
+ due = _previous_working(cal, due)
1078
+ elif calendar_day_step or unit == "calendar_week":
1079
+ multiplier = 1 if calendar_day_step else 7
1080
+ due = start + _dt.timedelta(days=multiplier * count) if count > 0 else start
1081
+ if count > 0 and not include_end:
1082
+ _coverage(cal, due)
1083
+ due = _previous_working(cal, due)
1084
+ elif count > 0:
1085
+ due, counted = start, 0
1086
+ while True:
1087
+ _coverage(cal, due)
1088
+ if (_working(cal, due) if unit == "business_day" else True):
1089
+ counted += 1
1090
+ if counted == count:
1091
+ break
1092
+ due += _dt.timedelta(days=1)
1093
+ if not include_end:
1094
+ due = _previous_working(cal, due)
1095
+ else:
1096
+ due = start
1097
+ skip_roll = (calendar_day_step or unit in ("calendar_month", "calendar_year", "calendar_week")) \
1098
+ and roll == "no_roll"
1099
+ if not skip_roll and not _working(cal, due) and roll != "no_roll":
1100
+ due = _next_working(cal, due) if roll == "next_working_day" else _previous_working(cal, due)
1101
+ return due
1102
+
1103
+
1104
+ def _month_parts(d) -> tuple:
1105
+ """§84.2 (E-0084, DECISION-0267): год, месяц и последнее число месяца
1106
+ даты; не Date и не ISO-дата — TYPE_ERROR (`values.month_bound_value`)."""
1107
+ if not isinstance(d, Date):
1108
+ raise TermError("TYPE_ERROR")
1109
+ try:
1110
+ day = _dt.date.fromisoformat(d.v)
1111
+ except ValueError:
1112
+ raise TermError("TYPE_ERROR") from None
1113
+ if len(d.v) != 10:
1114
+ raise TermError("TYPE_ERROR")
1115
+ return day.year, day.month, _last_day(day.year, day.month)
1116
+
1117
+
1118
+ def month_start(d) -> Date:
1119
+ """§84.2: первое число месяца; политика §86 и снимок §85 не читаются."""
1120
+ y, m, _ = _month_parts(d)
1121
+ return Date(f"{y:04d}-{m:02d}-01")
1122
+
1123
+
1124
+ def month_end(d) -> Date:
1125
+ """§84.2: последнее число месяца."""
1126
+ y, m, last = _month_parts(d)
1127
+ return Date(f"{y:04d}-{m:02d}-{last:02d}")
1128
+
1129
+
1130
+ def days_between(a: Date, b: Date) -> Dec:
1131
+ """§84.4 (E-0015): календарные дни от `a` до `b`, со знаком."""
1132
+ return Dec((_dt.date.fromisoformat(b.v) - _dt.date.fromisoformat(a.v)).days, 0)
1133
+
1134
+
1135
+ def _duration_between(a, b, unit: int, where: str) -> Dec:
1136
+ """§84.1 (E-0028/E-0058): полных единиц между мгновениями, знак сохранён, дробь к нулю."""
1137
+ if not (isinstance(a, Instant) and isinstance(b, Instant)):
1138
+ raise TermError("TYPE_ERROR")
1139
+ x, y = _moment(a), _moment(b)
1140
+ seconds = y[0] - x[0]
1141
+ if seconds > 0 and y[1] < x[1]:
1142
+ seconds -= 1
1143
+ elif seconds < 0 and y[1] > x[1]:
1144
+ seconds += 1
1145
+ return Dec(seconds // unit if seconds >= 0 else -((-seconds) // unit), 0)
1146
+
1147
+
1148
+ def hours_between(a, b) -> Dec:
1149
+ return _duration_between(a, b, 3600, "hours_between")
1150
+
1151
+
1152
+ def minutes_between(a, b) -> Dec:
1153
+ return _duration_between(a, b, 60, "minutes_between")
1154
+
1155
+
1156
+ _DURATION_SECONDS = {"second": 1, "minute": 60, "hour": 3600, "day": 86400}
1157
+
1158
+
1159
+ def add_duration(after, count: int, unit: str) -> Instant:
1160
+ """§84.1 (E-0185): мгновение через целую Quantity секунд/минут/часов/суток; канон UTC без дробных секунд."""
1161
+ if not isinstance(after, Instant) or unit not in _DURATION_SECONDS:
1162
+ raise TermError("TYPE_ERROR")
1163
+ seconds, _ = _moment(after)
1164
+ try:
1165
+ result = _dt.datetime(1970, 1, 1) + _dt.timedelta(seconds=seconds + int(count) * _DURATION_SECONDS[unit])
1166
+ except (OverflowError, ValueError) as exc:
1167
+ raise TermError("RESOURCE_LIMIT") from exc
1168
+ return Instant(f"{result.year:04d}-{result.month:02d}-{result.day:02d}T{result.hour:02d}:{result.minute:02d}:{result.second:02d}Z")
1169
+
1170
+
1171
+ def add_calendar_period(after: Date, count: int, unit: str) -> Date:
1172
+ """§84.2 (E-0029): `TermEnv.calendar_period` — снимок не читается."""
1173
+ policy = _ctx().policy or {}
1174
+ if policy.get("roll") not in (None, "no_roll") or policy.get("cutoff") is not None \
1175
+ or policy.get("include_end") is not True:
1176
+ raise TermError("MISSING_POLICY")
1177
+ due = _deadline(None, _dt.date.fromisoformat(after.v), count, policy, None, unit,
1178
+ calendar_day_step=(unit == "calendar_day"))
1179
+ return Date(due.isoformat())
1180
+
1181
+
1182
+ _COUNTER_UNITS = {
1183
+ "add_business_days": ("business_day", "calendar_day"),
1184
+ "add_calendar_period": ("calendar_day", "calendar_month", "calendar_year", "calendar_week"),
1185
+ "add_legal_term": ("business_day", "calendar_day", "calendar_month", "calendar_year", "calendar_week"),
1186
+ }
1187
+
1188
+
1189
+ def counter(v, fn: str) -> tuple:
1190
+ """§86.1 (DECISION-0256 п. 6): счётчик-связыватель — Quantity с единицей,
1191
+ допустимой функции, целый; иначе TYPE_ERROR (`values.add_*_value`). Знак не
1192
+ проверяется: отрицательный счётчик отвергает сама операция политикой §86."""
1193
+ if not isinstance(v, Qty) or v.unit not in _COUNTER_UNITS[fn] or v.u % (10 ** v.s) != 0:
1194
+ raise TermError("TYPE_ERROR")
1195
+ return v.u // (10 ** v.s), v.unit
1196
+
1197
+
1198
+ def add_legal_term(after: Date, count: int, unit: str) -> Date:
1199
+ """§86.1: ветвь по единице на деле — рабочий счёт либо календарный шаг."""
1200
+ if unit in ("business_day", "calendar_day"):
1201
+ return add_business_days(after, count, unit)
1202
+ return add_calendar_period(after, count, unit)
1203
+
1204
+
1205
+ def add_business_days(after: Date, count: int, unit: str, after_time=None) -> Date:
1206
+ """§86: `TermEnv.deadline` — политика, затем снимок, если срок его спрашивает."""
1207
+ policy = _ctx().policy
1208
+ if policy is not None and policy.get("cutoff") is not None and after_time is None:
1209
+ raise TermError("MISSING_POLICY")
1210
+ cal = _calendar() if _needs_calendar(unit, policy) else None
1211
+ due = _deadline(cal, _dt.date.fromisoformat(after.v), count, policy, after_time, unit)
1212
+ return Date(due.isoformat())
1213
+
1214
+
1215
+ # ---------------------------------------------------------------------------
1216
+ # §47.3 Канал суждения (DECISION-0274): порт `whynot.judgment_blockers`,
1217
+ # `defeater_judgment_blockers`, `power_judgment_blockers` и
1218
+ # `position_judgment_blockers` над ГОТОВЫМ состоянием модели. Один код для
1219
+ # раннера и API: модель и переводы термов приходят параметрами (`hooks`:
1220
+ # `val` — литерал-терм §53 → значение, `cell` — ячейка таблицы → литерал-терм).
1221
+ # Запрос называет судью только там, где его ответ может изменить исход:
1222
+ # живость правила по конъюнктам (E-0101), по поражению вывода (E-0103), по
1223
+ # способности дефитера снять вывод (E-0100), по иммунитету (E-0120); порядок
1224
+ # запросов — канонические байты объекта `request` (E-0111).
1225
+ JUDGMENT_ENUM_CAP = 8
1226
+ _J_TESTABLE = ("established", "supported", "monotone", "refuted")
1227
+ _J_MIRROR = {"TRUE_ONLY": "FALSE_ONLY", "FALSE_ONLY": "TRUE_ONLY", "BOTH": "BOTH", "NEITHER": "NEITHER"}
1228
+ _J_CONJUNCT = {"TRUE_ONLY": "SATISFIED", "FALSE_ONLY": "NOT_SATISFIED", "BOTH": "CONFLICTED", "NEITHER": "UNDETERMINED"}
1229
+ _J_COMPUTED = ("binary", "call", "if", "let", "match", "aggregate")
1230
+
1231
+
1232
+ def jcanon(v) -> str:
1233
+ """Канонические байты §208 объекта запроса: ключи по порядку, целое из JSON — цифрами."""
1234
+ import json as _json
1235
+ if isinstance(v, dict) and set(v) == {"int"}:
1236
+ return str(v["int"])
1237
+ if isinstance(v, dict):
1238
+ return "{" + ",".join(_json.dumps(k, ensure_ascii=False) + ":" + jcanon(v[k]) for k in sorted(v)) + "}"
1239
+ if isinstance(v, list):
1240
+ return "[" + ",".join(jcanon(x) for x in v) + "]"
1241
+ return _json.dumps(v, ensure_ascii=False)
1242
+
1243
+
1244
+ def request_key(lit: dict) -> str:
1245
+ return jcanon({"relation": lit["predicate"], "polarity": lit.get("polarity", "positive"), "args": lit.get("args") or []})
1246
+
1247
+
1248
+ def order_requests(lits: list) -> list:
1249
+ found: dict = {}
1250
+ for lit in lits:
1251
+ found.setdefault(request_key(lit), lit)
1252
+ return [found[k] for k in sorted(found)]
1253
+
1254
+
1255
+ def _jtruth(M, hooks, lit: dict) -> str:
1256
+ st = M.status(lit["predicate"], [hooks["val"](a) for a in lit.get("args") or []])
1257
+ return _J_MIRROR[st] if lit.get("polarity", "positive") == "negative" else st
1258
+
1259
+
1260
+ def _jvars(t, out: set) -> None:
1261
+ if isinstance(t, list):
1262
+ for x in t:
1263
+ _jvars(x, out)
1264
+ elif isinstance(t, dict):
1265
+ if t.get("kind") == "var":
1266
+ out.add(str(t.get("var")))
1267
+ else:
1268
+ for v in t.values():
1269
+ _jvars(v, out)
1270
+
1271
+
1272
+ def _jkind(name: str) -> str:
1273
+ return {"urn:law:std#Integer": "i", "urn:law:std#Natural": "i", "urn:law:std#Decimal": "d", "urn:law:std#Money": "m",
1274
+ "urn:law:std#Rational": "r", "urn:law:std#Quantity": "q"}.get(name, "x")
1275
+
1276
+
1277
+ def _jterm(M, hooks, t: dict, subst: dict):
1278
+ """Гард §57–§58 под подстановкой: переменные, литералы, константы §54 и
1279
+ арифметика §58; иное — исключение, у оракула это UNDETERMINED (E-0101)."""
1280
+ k = t.get("kind")
1281
+ if k == "var":
1282
+ if t["var"] not in subst:
1283
+ raise Refused("несвязанная переменная гарда")
1284
+ return _jterm(M, hooks, subst[t["var"]], subst)
1285
+ consts = getattr(M, "BACKCHAIN_CONSTS", None) or {}
1286
+ if k == "const_ref" and t.get("id") in consts:
1287
+ return _jterm(M, hooks, consts[t["id"]], subst)
1288
+ if k == "value":
1289
+ return hooks["val"](t), _jkind(str((t.get("type") or {}).get("name")))
1290
+ if k in ("entity_ref", "const_ref"):
1291
+ return hooks["val"](t), "x"
1292
+ if k == "binary" and t.get("op") in ("add", "sub", "mul", "div", "mod"):
1293
+ lv, lk = _jterm(M, hooks, t["left"], subst)
1294
+ rv, rk = _jterm(M, hooks, t["right"], subst)
1295
+ if lk == "x" or rk == "x":
1296
+ raise Refused("арифметика над нечисловым операндом")
1297
+ v = arith(t["op"], lv, rv, lk, rk)
1298
+ kind = "r" if isinstance(v, Rat) else "m" if isinstance(v, Money) else "q" if isinstance(v, Qty) \
1299
+ else "i" if lk == "i" and rk == "i" else "d"
1300
+ return v, kind
1301
+ raise Refused(f"терм гарда вне обхода суждения: {k}")
1302
+
1303
+
1304
+ def _jconjunct(M, hooks, status: str, lit: dict, subst: dict):
1305
+ """Порт `whynot._conjunct_report`: None — конъюнкт с несвязанной переменной;
1306
+ иначе (trigger, ground) — ground None у гарда, квантора и вычисляемого аргумента."""
1307
+ if status == "guard":
1308
+ names: set = set()
1309
+ _jvars(lit, names)
1310
+ if any(n not in subst for n in names):
1311
+ return None
1312
+ try:
1313
+ lv, _ = _jterm(M, hooks, lit["left"], subst)
1314
+ rv, _ = _jterm(M, hooks, lit["right"], subst)
1315
+ ok = cmp(lv, lit["op"], rv)
1316
+ except Exception: # noqa: BLE001 — терм-ошибка §58 не убивает правило
1317
+ return ("UNDETERMINED", None)
1318
+ return ("SATISFIED" if ok else "NOT_SATISFIED", None)
1319
+ if (lit or {}).get("kind") != "literal":
1320
+ return ("UNDETERMINED", None)
1321
+ args = lit.get("args") or []
1322
+ names = set()
1323
+ _jvars(args, names)
1324
+ if any(n not in subst for n in names):
1325
+ return None
1326
+ if any((a or {}).get("kind") in _J_COMPUTED for a in args):
1327
+ return ("UNDETERMINED", None)
1328
+ shown = dict(lit, args=[subst[a["var"]] if a.get("kind") == "var" else a for a in args])
1329
+ if any((a or {}).get("kind") == "wildcard" for a in args):
1330
+ # §98 (E-0165): свидетель существования, значение не связывается.
1331
+ if lit.get("polarity", "positive") != "positive" or status not in ("established", "supported", "monotone"):
1332
+ return ("UNDETERMINED", shown)
1333
+ want = [None if a.get("kind") == "wildcard" else hooks["val"](a) for a in shown["args"]]
1334
+ rows = M.TABLES.get(lit["predicate"], [])
1335
+ witness = any(len(r) == len(want) and all(w is None or key([v]) == key([w]) for v, w in zip(r, want))
1336
+ and (M.status(lit["predicate"], r) == "TRUE_ONLY" if status == "established"
1337
+ else M.status(lit["predicate"], r) in ("TRUE_ONLY", "BOTH"))
1338
+ for r in rows)
1339
+ return ("SATISFIED" if witness else "UNDETERMINED", shown)
1340
+ pair = _jtruth(M, hooks, shown)
1341
+ if status == "not_known":
1342
+ return ("SATISFIED" if pair == "NEITHER" else "NOT_SATISFIED", shown)
1343
+ if status in ("supported", "monotone"):
1344
+ return ("SATISFIED" if pair in ("TRUE_ONLY", "BOTH") else "UNDETERMINED", shown)
1345
+ if status in ("refuted", "unknown"):
1346
+ want_pair = "FALSE_ONLY" if status == "refuted" else "NEITHER"
1347
+ return ("SATISFIED" if pair == want_pair else "NOT_SATISFIED", shown)
1348
+ return (_J_CONJUNCT.get(pair, "UNDETERMINED"), shown)
1349
+
1350
+
1351
+ def _junify(head: dict, target: dict):
1352
+ """Порт `unify_head`: подстановка головы под ground-цель; переменная дважды — одинаково."""
1353
+ if (head or {}).get("kind") != "literal" or (target or {}).get("kind") != "literal":
1354
+ return None
1355
+ if head.get("predicate") != target.get("predicate"):
1356
+ return None
1357
+ if head.get("polarity", "positive") != target.get("polarity", "positive"):
1358
+ return None
1359
+ h, g = head.get("args") or [], target.get("args") or []
1360
+ if len(h) != len(g):
1361
+ return None
1362
+ subst: dict = {}
1363
+ for x, y in zip(h, g):
1364
+ if x.get("kind") == "var":
1365
+ if x["var"] in subst and jcanon(subst[x["var"]]) != jcanon(y):
1366
+ return None
1367
+ subst[x["var"]] = y
1368
+ elif jcanon(x) != jcanon(y):
1369
+ return None
1370
+ return subst
1371
+
1372
+
1373
+ def _jenumerate(M, hooks, lit: dict, subst: dict, unbound: list) -> list:
1374
+ """DECISION-0036 (б): перечисление судейского конъюнкта по активному домену —
1375
+ аргументы всех атомов таблиц, порядок §208, кап ДО фильтра по NEITHER."""
1376
+ args = lit.get("args") or []
1377
+ if any((a or {}).get("kind") == "wildcard" for a in args):
1378
+ return []
1379
+ domain: dict = {}
1380
+ for pred, rows in M.TABLES.items():
1381
+ for row in rows:
1382
+ for i, v in enumerate(row):
1383
+ t = hooks["cell"](pred, i, v)
1384
+ domain[jcanon(t)] = t
1385
+ terms = [domain[k] for k in sorted(domain)]
1386
+ if not terms:
1387
+ return []
1388
+ names = sorted(unbound)
1389
+ out: list = []
1390
+ total = len(terms) ** len(names)
1391
+ for idx in range(total if total < JUDGMENT_ENUM_CAP else JUDGMENT_ENUM_CAP): # `min` здесь — функция §58 рантайма
1392
+ # itertools.product: первая переменная меняется медленнее всех.
1393
+ full = dict(subst)
1394
+ rest = idx
1395
+ for name in reversed(names):
1396
+ full[name] = terms[rest % len(terms)]
1397
+ rest //= len(terms)
1398
+ ground = dict(lit, args=[full[a["var"]] if a.get("kind") == "var" else a for a in args])
1399
+ if _jtruth(M, hooks, ground) == "NEITHER":
1400
+ out.append(ground)
1401
+ return out
1402
+
1403
+
1404
+ def _jprio(M) -> set:
1405
+ return getattr(M, "PRIO_ACTIVE", None) if getattr(M, "PRIO_ACTIVE", None) is not None else getattr(M, "PRIO", set())
1406
+
1407
+
1408
+ def _jrow_key(hooks, lit: dict) -> str:
1409
+ return key([hooks["val"](a) for a in lit.get("args") or []])
1410
+
1411
+
1412
+ def _jconclusion_defeated(M, hooks, rule: dict, target: dict) -> bool:
1413
+ """E-0103: поражён ли был бы вывод поражаемого правила при любом ответе судьи."""
1414
+ if rule.get("strength") != "defeasible":
1415
+ return False
1416
+ prio, tkey = _jprio(M), _jrow_key(hooks, target)
1417
+ pos = target.get("polarity", "positive") != "negative"
1418
+ for a in getattr(M, "APPLIED", []):
1419
+ if not a["defeater"] or a["urn"] != target["predicate"] or a["key"] != tkey or a["positive"] != pos:
1420
+ continue
1421
+ if (rule["name"], a["rule"]) not in prio:
1422
+ return True
1423
+ return False
1424
+
1425
+
1426
+ def _jdefeater_can_remove(M, hooks, rule: dict, target: dict) -> bool:
1427
+ """E-0100: способен ли живой дефитер снять СТОЯЩИЙ вывод — строгая опора не
1428
+ поражается; каждое уцелевшее поражаемое применение обязано уступать ему."""
1429
+ row = [hooks["val"](a) for a in target.get("args") or []]
1430
+ bits = M.support_bits(target["predicate"], row) if hasattr(M, "support_bits") else 0
1431
+ pos = target.get("polarity", "positive") != "negative"
1432
+ if bits & (1 if pos else 2):
1433
+ return False
1434
+ tkey, prio = key(row), _jprio(M)
1435
+ apps = [a for a in getattr(M, "APPLIED", [])
1436
+ if not a["defeater"] and not a["defeated"] and a["urn"] == target["predicate"] and a["key"] == tkey and a["positive"] == pos]
1437
+ if not apps:
1438
+ return False
1439
+ return all((a["rule"], rule["name"]) not in prio for a in apps)
1440
+
1441
+
1442
+ def collect_judgments(M, hooks, goal: dict, root_defeaters: bool) -> list:
1443
+ """Порт `whynot._collect_judgments`: общий обход §185 от цели без опоры
1444
+ (root_defeaters=False) и от стоящей цели через дефитеры её головы (True)."""
1445
+ J = getattr(M, "JUDGMENTS", {}) or {}
1446
+ index: dict = {}
1447
+ for r in getattr(M, "BACKCHAIN", []) or []:
1448
+ index.setdefault(r["head"]["predicate"], []).append(r)
1449
+ found: dict = {}
1450
+ visited: set = set()
1451
+ now = legal_time()
1452
+
1453
+ def dead(reports) -> bool:
1454
+ for st, _lit, rep in reports:
1455
+ if rep is None or rep[0] not in ("NOT_SATISFIED", "CONFLICTED"):
1456
+ continue
1457
+ # DECISION-0036 (а): refuted-тест над самим отношением суждения при паре NEITHER решает судья.
1458
+ if st == "refuted" and rep[1] is not None and rep[1]["predicate"] in J and _jtruth(M, hooks, rep[1]) == "NEITHER":
1459
+ continue
1460
+ return True
1461
+ return False
1462
+
1463
+ def walk(target: dict, defeaters_only: bool) -> None:
1464
+ vkey = jcanon({"p": target["predicate"], "a": target.get("args") or [], "n": target.get("polarity", "positive")})
1465
+ if vkey in visited:
1466
+ return
1467
+ visited.add(vkey)
1468
+ for r in index.get(target["predicate"], ()):
1469
+ if defeaters_only and r.get("strength") != "defeater":
1470
+ continue
1471
+ subst = _junify(r["head"], target)
1472
+ if subst is None:
1473
+ continue
1474
+ w = r.get("window")
1475
+ if w and ((w.get("start") and now < w["start"]) or (w.get("end") and now >= w["end"])):
1476
+ continue
1477
+ reports = [(st, lit, _jconjunct(M, hooks, st, lit, subst)) for st, lit in r["body"]]
1478
+ if dead(reports):
1479
+ continue
1480
+ if _jconclusion_defeated(M, hooks, r, target):
1481
+ continue
1482
+ if defeaters_only and not _jdefeater_can_remove(M, hooks, r, target):
1483
+ continue
1484
+ for st, lit, rep in reports:
1485
+ if rep is None:
1486
+ if st in _J_TESTABLE and (lit or {}).get("kind") == "literal" and lit.get("predicate") in J:
1487
+ names: set = set()
1488
+ _jvars(lit.get("args") or [], names)
1489
+ for g in _jenumerate(M, hooks, lit, subst, [n for n in names if n not in subst]):
1490
+ found.setdefault(request_key(g), g)
1491
+ continue
1492
+ trigger, ground = rep
1493
+ if ground is None or ground.get("kind") != "literal" \
1494
+ or any((a or {}).get("kind") == "wildcard" for a in ground.get("args") or []):
1495
+ continue
1496
+ if trigger == "UNDETERMINED":
1497
+ if ground["predicate"] in J:
1498
+ found.setdefault(request_key(ground), ground)
1499
+ else:
1500
+ walk(ground, False)
1501
+ elif st == "refuted" and trigger == "NOT_SATISFIED" and ground["predicate"] in J \
1502
+ and _jtruth(M, hooks, ground) == "NEITHER":
1503
+ found.setdefault(request_key(ground), ground)
1504
+
1505
+ walk(goal, root_defeaters)
1506
+ return list(found.values())
1507
+
1508
+
1509
+ def _nlit_literal(hooks, n) -> dict:
1510
+ pred, positive, row = n[0], n[1], n[2]
1511
+ return {"kind": "literal", "polarity": "positive" if positive else "negative", "predicate": pred,
1512
+ "args": [hooks["cell"](pred, i, v) for i, v in enumerate(row)]}
1513
+
1514
+
1515
+ def _nlit_status(M, n) -> str:
1516
+ st = M.status(n[0], n[2])
1517
+ return st if n[1] else _J_MIRROR[st]
1518
+
1519
+
1520
+ def power_judgments(M, hooks, goal: dict) -> list:
1521
+ """E-0100, носитель `valid_when` §127: судьи действительности осуществлённого
1522
+ полномочия, чей эффект есть цель; E-0120 — не при эффекте, запертом иммунитетом."""
1523
+ J = getattr(M, "JUDGMENTS", {}) or {}
1524
+ if goal.get("polarity", "positive") != "positive" or not hasattr(M, "POSITIONS"):
1525
+ return []
1526
+ gkey = (goal["predicate"], _jrow_key(hooks, goal))
1527
+ if gkey in (getattr(M, "BLOCKED_IMMUNITY", None) or set()):
1528
+ return []
1529
+ out: list = []
1530
+ for p in M.POSITIONS:
1531
+ if p["modality"] != "power" or p["status"] != "ACTIVE":
1532
+ continue
1533
+ eff = p["spec"]["effect"]
1534
+ if eff is None or not eff[1] or eff[0] != goal["predicate"] or key(eff[2]) != gkey[1]:
1535
+ continue
1536
+ ex = p["spec"]["exercise"]
1537
+ if ex is None or _nlit_status(M, ex) not in ("TRUE_ONLY", "BOTH"):
1538
+ continue
1539
+ vw = p["spec"]["validWhen"]
1540
+ if vw is None or vw[0] not in J:
1541
+ continue
1542
+ if _nlit_status(M, vw) == "NEITHER":
1543
+ out.append(_nlit_literal(hooks, vw))
1544
+ return order_requests(out)
1545
+
1546
+
1547
+ def position_judgments(M, hooks) -> list:
1548
+ """E-0100, носитель `condition` цели §123/§124: судьи статуса позиции обязанности."""
1549
+ J = getattr(M, "JUDGMENTS", {}) or {}
1550
+ if not hasattr(M, "POSITIONS"):
1551
+ return []
1552
+ out: list = []
1553
+ for p in M.POSITIONS:
1554
+ if p["status"] not in ("ACTIVE", "UNDETERMINED"):
1555
+ continue
1556
+ cond = p["spec"]["cond"]
1557
+ if cond is None or cond[0] not in J:
1558
+ continue
1559
+ if _nlit_status(M, cond) == "NEITHER":
1560
+ out.append(_nlit_literal(hooks, cond))
1561
+ return order_requests(out)
1562
+
1563
+
1564
+ def build_requests(M, blockers: list, tail: str) -> list:
1565
+ """JudgmentRequest схемы evaluation по упорядоченным блокерам (без proof-узла)."""
1566
+ out: list = []
1567
+ for i, b in enumerate(blockers):
1568
+ d = (getattr(M, "JUDGMENTS", {}) or {}).get(b["predicate"], {})
1569
+ r = {"id": f"urn:judgment:{tail}:{i}", "predicate": b["predicate"], "authority": d.get("authority", b["predicate"]),
1570
+ "request": {"relation": b["predicate"], "polarity": b.get("polarity", "positive"), "args": b.get("args") or []}}
1571
+ if d.get("requestSchema") is not None:
1572
+ r["requestSchema"] = d["requestSchema"]
1573
+ out.append(r)
1574
+ return out
1575
+
1576
+
1577
+ def judgment_blockers(M, hooks, goal: dict, status: str, conflict: bool) -> list:
1578
+ """Порт ветвей `queries.query_truth`: цель — отношение суждения без опоры;
1579
+ цель без опоры — тела правил и `valid_when`; стоящая цель — дефитеры головы."""
1580
+ J = getattr(M, "JUDGMENTS", {}) or {}
1581
+ if not J:
1582
+ return []
1583
+ unresolved = status == "NEITHER" and not conflict
1584
+ if goal["predicate"] in J and unresolved:
1585
+ return [goal]
1586
+ if unresolved:
1587
+ return order_requests(collect_judgments(M, hooks, goal, False) + power_judgments(M, hooks, goal))
1588
+ if status == "TRUE_ONLY" and not conflict:
1589
+ return order_requests(collect_judgments(M, hooks, goal, True))
1590
+ return []
1591
+
1592
+
1593
+ _J_KINDS = {"urn:law:std#Boolean": "boolean", "urn:law:std#Decimal": "decimal", "urn:law:std#Text": "text",
1594
+ "urn:law:std#Date": "date", "urn:law:std#Instant": "instant", "urn:law:std#Integer": "integer",
1595
+ "urn:law:std#Natural": "integer", "urn:law:std#Money": "money", "urn:law:std#Quantity": "quantity",
1596
+ "urn:law:std#Rational": "rational"}
1597
+
1598
+
1599
+ def judgment_answer(M, assertion: dict):
1600
+ """§47.3.1 (E-0089): ответ судьи с объектом `judgment` — орган и форма payload.
1601
+ Возвращает код issue либо None (ответ принят)."""
1602
+ judgment = assertion.get("judgment")
1603
+ if not isinstance(judgment, dict):
1604
+ return None
1605
+ pred = (assertion.get("literal") or {}).get("predicate")
1606
+ d = (getattr(M, "JUDGMENTS", {}) or {}).get(pred, {})
1607
+ if judgment.get("authority") != d.get("authority", pred):
1608
+ return "JUDGMENT_AUTHORITY_MISMATCH"
1609
+ payload = judgment.get("payload")
1610
+ if payload is not None:
1611
+ schema = d.get("requestSchema")
1612
+ if schema is None:
1613
+ return "JUDGMENT_PAYLOAD_MISMATCH"
1614
+ want = _J_KINDS.get(str(schema.get("name")))
1615
+ if want is not None:
1616
+ got = _J_KINDS.get(str((payload.get("type") or {}).get("name"))) if payload.get("kind") == "value" \
1617
+ else "entity_ref" if payload.get("kind") == "entity_ref" else "enum"
1618
+ if got != want:
1619
+ return "JUDGMENT_PAYLOAD_MISMATCH"
1620
+ return None
1621
+
1622
+
1623
+ def admit_numeric_inputs(case, query, judgments=False):
1624
+ # Typed request nodes only; assertion payload/metadata are not term AST.
1625
+ stack = [query, *((a.get("literal") or {}) for a in case.get("assertions") or [])]
1626
+ while stack:
1627
+ node = stack.pop()
1628
+ if isinstance(node, list): stack.extend(node)
1629
+ elif isinstance(node, dict):
1630
+ if node.get("kind") == "value" and (node.get("type") or {}).get("name") == "urn:law:std#Number":
1631
+ raise Refused("NUMBER_UNAVAILABLE: exact Number and slot narrowing (E-0316/E-0319/E-0334) are outside this slice")
1632
+ if judgments and node.get("kind") in ("aggregate", "comprehension"):
1633
+ raise Refused("AGGREGATE_JUDGMENT_UNAVAILABLE: open aggregate members require E-0357")
1634
+ stack.extend(v for v in node.values() if isinstance(v, (dict, list)))