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,1248 @@
1
+ #!/usr/bin/env python3
2
+ """Ворота кодогенерации (DECISION-0188).
3
+
4
+ План строится из CLIR один раз и печатается в целевые языки. Каждый бэкенд
5
+ исполняет L0-векторы и обязан совпасть С ГОЛДЕНОМ и С ОСТАЛЬНЫМИ бэкендами.
6
+
7
+ Три исхода исполнения различаются протоколом выхода раннера, а не одним
8
+ ненулевым кодом: ответ (код 0, JSON), названный отказ среза (код 3, JSON
9
+ `{"refused": текст}`) и крах (любой другой код). Крах валит ворота. Отказ
10
+ обязан быть единогласным и дословно одинаковым у всех целей: отказ одной
11
+ цели при ответе другой — расхождение, а не «отказ рантайма». До этого
12
+ протокола ворота прерывали вектор на первом ненулевом коде и не видели, что
13
+ на `ERR-E0046-CASE-ARG-TYPE` две цели отвечают `TRUE_ONLY` против голдена.
14
+
15
+ Поражаемый слой (DECISION-0237): векторы `layer: L1` идут тем же путём, но
16
+ со своим полом храповика; голден без результата у L1-вектора обязан быть
17
+ структурным отказом плана с той же причиной. Фикстуры `l1_fixtures.py`
18
+ держат решающую норму канарейками и порядок — перестановкой.
19
+
20
+ Исход каждого L0- и L1-вектора закреплён в манифесте `verify/ci/data/backends-vectors.json`:
21
+ совпадение, отказ плана, вид запроса, голден без результата, отказ рантайма.
22
+ Закреплённый исход обязан повториться; закреплённый вектор обязан
23
+ существовать; незакреплённое совпадение — ошибка регистрации
24
+ (`--write-manifest`). Одно число исполненных векторов позволило бы потерять
25
+ старый случай и компенсировать его новым, поэтому пол `MIN_CHECKED` остался
26
+ лишь второй защитой.
27
+
28
+ Цели — каталоги `engines/backends/targets/<цель>/` с дескриптором в
29
+ `emit.py` (`available`, `MODEL`, `layout`, `build`, `run`). По умолчанию
30
+ исполняются `ts,py,go`; `--targets` выбирает другие или `all`. Цель Python не
31
+ требует тулчейна и проверяется ВСЕГДА: без неё ворота сообщали бы «зелено»,
32
+ не исполнив ни одного бэкенда. Отсутствие тулчейна НАЗЫВАЕТСЯ в отчёте;
33
+ `--require` делает его отказом ворот.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import collections
39
+ import copy
40
+ import json
41
+ import shutil
42
+ import subprocess
43
+ import sys
44
+ import tempfile
45
+ from pathlib import Path
46
+
47
+ ROOT = Path(__file__).resolve().parents[4]
48
+ BACKENDS = ROOT / "engines" / "backends"
49
+ VECTORS = ROOT / "verify" / "vectors"
50
+ MANIFEST = ROOT / "verify" / "ci" / "data" / "backends-vectors.json"
51
+ MANIFEST_FORMAT = "law.backends-vectors/1"
52
+ CERT = BACKENDS / "runtime-cert.json"
53
+ REFUSED_EXIT = 3
54
+ DEFAULT_TARGETS = "ts,py,go"
55
+
56
+ sys.path.insert(0, str(BACKENDS))
57
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
58
+ import plan as P # noqa: E402
59
+ import deliver as D # noqa: E402
60
+ import l1_fixtures as FX # noqa: E402
61
+
62
+ # Пол храповика: столько векторов обязаны исполниться и совпасть. Вторая защита
63
+ # после манифеста: манифест ловит подмену случая, пол — массовую потерю.
64
+ # 16.09.2026 (DECISION-0266–0268, перепин манифеста после 0252/0256/0259): 82 → 225.
65
+ # 16.09.2026 (DECISION-0270, условный терм и вычисляемый аргумент тела): 225 → 234.
66
+ # 16.09.2026 (DECISION-0273, функции §46, параметры A3, let/match): 234 → 251.
67
+ # 16.09.2026 (DECISION-0274, канал суждения §47.3): 251 → 255.
68
+ MIN_CHECKED = 255
69
+ # Пол поражаемого слоя (DECISION-0237): совпадения L1-векторов плюс голдены
70
+ # без результата, сверенные как структурный отказ. Отдельный от L0: рост
71
+ # одного слоя не должен прятать потерю в другом.
72
+ # 16.09.2026 (DECISION-0269, датированные приоритеты): 37 → 46.
73
+ # 16.09.2026 (DECISION-0270): 46 → 59.
74
+ # 16.09.2026 (DECISION-0274, канал суждения §47.3): 59 → 60.
75
+ MIN_CHECKED_L1 = 60
76
+ # Пол нормативных позиций (DECISION-0242): совпадения векторов слоёв L2 и L3,
77
+ # исполняющих позиции; отдельный от L0 и L1.
78
+ # 16.09.2026 (DECISION-0274, канал суждения §47.3: LAY-L2-JUDGMENT-*, E-0100/E-0120/E-0179, позиции): 35 → 71.
79
+ MIN_CHECKED_L2 = 71
80
+
81
+ # Голден без результата у L1-вектора — фатальная диагностика оракула до
82
+ # вывода. Срез обязан отказать планом по той же причине; иной отказ или
83
+ # построенный план — ошибка ворот, а не «голден без результата».
84
+ STRUCTURAL = {
85
+ "NON_EXECUTABLE_UNSTRATIFIED": P.UNSTRATIFIED,
86
+ "NON_EXECUTABLE_STRATUM_BARRIER": "дефитер без обычного кандидата той же полярности "
87
+ "(§110 п. 5, NON_EXECUTABLE_STRATUM_BARRIER)",
88
+ "LDC-E4101": "несвязанная переменная в голове",
89
+ }
90
+
91
+ # Коды issues, которые срез порождает сам: по ним ответ сверяется с голденом,
92
+ # а не только между целями. Иначе KEY_CONFLICT, не влияющий на статус, был бы
93
+ # невидим воротам: модуль, молча не считающий ключи §45.1, совпадал бы с
94
+ # голденом по статусу и с соседями по пустому списку. Остальные диагностики
95
+ # голдена срез не порождает и не сверяет.
96
+ BACKEND_ISSUES = {"TYPE_ERROR", "KEY_CONFLICT", "DATE_INSTANT_MISMATCH", "UNSUPPORTED_IN_SLICE",
97
+ # DECISION-0274: ответ судьи §47.3.1 не от того органа / не по форме.
98
+ "JUDGMENT_AUTHORITY_MISMATCH", "JUDGMENT_PAYLOAD_MISMATCH",
99
+ # DECISION-0252: ошибки значения §58 в правилах плана 0.12 и в вопросе `term`.
100
+ "DIVISION_BY_ZERO", "INEXACT_DIVISION", "CURRENCY_MISMATCH",
101
+ # DECISION-0242 K1: эффекты полномочий §127–§128.
102
+ "POWER_INVALID_EXERCISE", "EFFECT_BLOCKED_BY_IMMUNITY", "UNRESOLVED_NORMATIVE_CONFLICT",
103
+ # DECISION-0239 G3: ошибка значения агрегата в правиле — issue и пропуск.
104
+ "EMPTY_AGGREGATE", "MONOTONE_GUARD_NEGATIVE_ADDEND",
105
+ # DECISION-0245: ошибки календаря §85/§86 в термах правил.
106
+ "MISSING_POLICY", "DEADLINE_POLICY_INVALID", "MISSING_CALENDAR", "CALENDAR_OUT_OF_RANGE",
107
+ "CALENDAR_DATASET_INVALID", "CALENDAR_DATASET_HASH_MISMATCH"}
108
+
109
+ # Пакеты корпуса с поверхностью API (DECISION-0234): снимки CLIR, у которых
110
+ # план строит вопросы. Проверка API — раннер против API одной цели и цели
111
+ # между собой на синтетических делах; векторы `pub` не несут, поэтому
112
+ # здесь читается корпусный снимок, а не вектор.
113
+ API_PACKAGES = ("codegen-api-strict", "codegen-api-aggregate")
114
+ # Пакеты с поверхностью API над L1-планом (DECISION-0237 этап 5).
115
+ API_PACKAGES_L1 = ("codegen-api-defeasible",)
116
+ CLIR = ROOT / "corpus" / "clir"
117
+
118
+
119
+ def version_probe(emit: dict) -> list[str]:
120
+ """Отрицательная проба: план чужой версии обязан быть отвергнут каждым
121
+ эмиттером. Без пробы проверка версии — договорённость, а не ворота."""
122
+ empty = {"package": {}, "tables": [], "rules": [], "facts": [], "consts": {}, "bases": {}}
123
+ failures = []
124
+ for lang, em in emit.items():
125
+ try:
126
+ em.emit(dict(empty, planVersion="law-backend-plan/999"))
127
+ except ValueError:
128
+ continue
129
+ failures.append(f"эмиттер {lang} принял план версии law-backend-plan/999")
130
+ for em in emit.values():
131
+ em.emit(dict(empty, planVersion=P.PLAN_VERSION))
132
+ # DECISION-0237: L1-план уходит только цели, объявившей его возможности;
133
+ # цель без объявления обязана отвергнуть и сам план, и поставку.
134
+ for version, cap in ((P.PLAN_VERSION_L1, "defeasible"), (P.PLAN_VERSION_09, "closure"),
135
+ (P.PLAN_VERSION_10, "norm"), (P.PLAN_VERSION_11, "calendar:business")):
136
+ probe = dict(empty, planVersion=version, capabilities=[cap])
137
+ for lang, em in emit.items():
138
+ if D.missing_capabilities(probe, em):
139
+ try:
140
+ em.emit(probe)
141
+ failures.append(f"эмиттер {lang} без возможности {cap} принял план {version}")
142
+ except ValueError:
143
+ pass
144
+ return failures
145
+
146
+
147
+ def candidates():
148
+ """L0- и L1-векторы с промежуточным представлением, делом, запросом и голденом."""
149
+ for v in sorted(VECTORS.rglob("vector.json")):
150
+ meta = json.loads(v.read_text(encoding="utf-8"))
151
+ if meta.get("layer") not in LAYERS:
152
+ continue
153
+ d = v.parent
154
+ if not all((d / f).exists() for f in ("ir.json", "case.json", "query.json", "expected.json")):
155
+ continue
156
+ yield d, meta["layer"]
157
+
158
+
159
+ def load(d: Path, name: str):
160
+ return json.loads((d / name).read_text(encoding="utf-8"))
161
+
162
+
163
+ def golden_code(doc: dict) -> str | None:
164
+ """Код фатальной диагностики голдена без результата: `error.code` либо
165
+ первый fatal issue."""
166
+ if isinstance(doc.get("error"), dict):
167
+ return doc["error"].get("code")
168
+ return next((i.get("code") for i in doc.get("issues") or [] if i.get("severity") == "fatal"), None)
169
+
170
+
171
+ def classify(d: Path, kinds: set, emit: dict, layer: str = "L0"):
172
+ """Исход до исполнения: отказ плана, вид запроса, голден. `None` — исполнять.
173
+
174
+ `kinds` — виды вопросов, которые исполняют ВСЕ активные цели (пересечение
175
+ `QUERY_KINDS` дескрипторов): вектор другого вида не исполняется, иначе
176
+ цель без поддержки отказала бы при ответе соседей, и это читалось бы
177
+ расхождением, а не границей среза. Так же с возможностями плана
178
+ (DECISION-0237): их обязаны объявить ВСЕ активные цели.
179
+
180
+ Возвращает (исход, план, ошибки ворот)."""
181
+ doc = load(d, "expected.json")
182
+ n = len(doc.get("results") or [])
183
+ code = golden_code(doc) if n == 0 else None
184
+ try:
185
+ ir = load(d, "ir.json")
186
+ # Мир вектора с импортом — его `producer.ir.json` (DECISION-0236);
187
+ # снимки корпуса векторам не нужны.
188
+ extra = [load(d, "producer.ir.json")] if (d / "producer.ir.json").exists() else []
189
+ world = P.resolve_world(ir, {}, extra) if ir.get("imports") else []
190
+ # Отрицательный факт дела требует модели с опорами обеих полярностей.
191
+ negative = any((a.get("literal") or {}).get("polarity") == "negative"
192
+ for a in load(d, "case.json").get("assertions") or [])
193
+ # Байты снимка §85 у вектора — `calendar.resource.json` рядом (DECISION-0245).
194
+ resource = d / "calendar.resource.json"
195
+ resources = {n.get("resourceUri"): resource.read_bytes() for n in ir["nodes"]
196
+ if n.get("kind") == "calendar_snapshot"} if resource.exists() else {}
197
+ pl = P.build_plan(ir, world, {"negation"} if negative else None, resources)
198
+ except P.Refused as exc:
199
+ head = str(exc).split(":")[0]
200
+ if layer == "L1" and n == 0:
201
+ if STRUCTURAL.get(code) == head:
202
+ return f"structural: {code}", None, []
203
+ # Иная СТРУКТУРНАЯ причина — подмена диагностики, ошибка ворот.
204
+ # Отказ по форме вне среза (терм, функция, тип) закреплён
205
+ # манифестом как отказ плана: срез не отвечает, а до проверки
206
+ # страт не доходит — форма, замыкающая цикл, сама вне среза
207
+ # (ERR-E0176-DEFEASIBLE-SELF-CYCLE: условный терм головы §56).
208
+ if head in STRUCTURAL.values():
209
+ return f"plan: {head}", None, [f"голден — структурный отказ {code}, план отказал иной причиной: {exc}"]
210
+ return f"plan: {head}", None, []
211
+ return f"plan: {head}", None, []
212
+ missing = sorted({c for em in emit.values() for c in D.missing_capabilities(pl, em)})
213
+ if missing:
214
+ return f"capability: {', '.join(missing)}", None, []
215
+ kind = load(d, "query.json").get("kind")
216
+ if kind not in kinds:
217
+ return f"query: {kind}", None, []
218
+ if n != 1:
219
+ errors = [f"голден — структурный отказ {code}, а план построен"] if layer == "L1" and n == 0 else []
220
+ return f"golden: результатов {n}", None, errors
221
+ return None, pl, []
222
+
223
+
224
+ def crash_line(stderr: str) -> str:
225
+ """Строка stderr, называющая причину: у Go это первая `panic:`, у Node —
226
+ строка с `Error:`, у Python — последняя строка трассировки."""
227
+ lines = [line.strip() for line in stderr.splitlines() if line.strip()]
228
+ if not lines:
229
+ return "(stderr пуст)"
230
+ for line in lines:
231
+ if line.startswith("panic:") or "Error:" in line or "Error [" in line or "error:" in line:
232
+ return line[:200]
233
+ return lines[-1][:200]
234
+
235
+
236
+ def sh(cmd, cwd=None) -> subprocess.CompletedProcess:
237
+ return subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
238
+
239
+
240
+ def build_projects(emit: dict, projs: dict[str, list[Path]], work: Path) -> list[str]:
241
+ """Сборка всех проектов каждой цели; список отказов сборки."""
242
+ problems = []
243
+ for lang, dirs in projs.items():
244
+ em = emit[lang]
245
+ if not dirs:
246
+ continue
247
+ if hasattr(em, "build_many"):
248
+ for offset in range(0, len(dirs), 32):
249
+ batch = dirs[offset:offset + 32]
250
+ cmd, cwd = em.build_many([str(d) for d in batch], work / "out")
251
+ res = sh(cmd, cwd)
252
+ if res.returncode != 0:
253
+ problems.append(f"сборка {lang} отказала\n{(res.stdout + res.stderr)[:2000]}")
254
+ break
255
+ continue
256
+ if lang == "go" and len(dirs) > 1:
257
+ from concurrent.futures import ThreadPoolExecutor
258
+ def build_one(directory):
259
+ command, cwd = em.build(str(directory))
260
+ result = sh(command, cwd)
261
+ return (f"сборка {lang} отказала на {directory.relative_to(work)}\n"
262
+ + (result.stdout + result.stderr)[:1200]) if result.returncode else None
263
+ # Go's cache supports concurrent writers; these are distinct projects.
264
+ with ThreadPoolExecutor(max_workers=4) as pool:
265
+ problems.extend(error for error in pool.map(build_one, dirs) if error)
266
+ continue
267
+ for d in dirs:
268
+ step = em.build(str(d))
269
+ if step is None:
270
+ continue
271
+ cmd, cwd = step
272
+ res = sh(cmd, cwd)
273
+ if res.returncode != 0:
274
+ problems.append(f"сборка {lang} отказала на {d.relative_to(work)}\n{(res.stdout + res.stderr)[:1200]}")
275
+ return problems
276
+
277
+
278
+ def run_target(em, proj: Path, main: str, args: list[str]):
279
+ """Один запуск: ('answer', кортеж) | ('refused', текст) | ('crash', причина)."""
280
+ cmd, cwd = em.run(str(proj), main)
281
+ res = sh(cmd + args, cwd)
282
+ if res.returncode == 0:
283
+ try:
284
+ a = json.loads(res.stdout)
285
+ if main == "cert":
286
+ return "answer", a
287
+ return "answer", answer_key(a)
288
+ except (ValueError, KeyError, TypeError):
289
+ return "crash", f"код 0, но ответ не JSON ответа: {res.stdout[:200]!r}"
290
+ if res.returncode == REFUSED_EXIT:
291
+ try:
292
+ return "refused", json.loads(res.stdout)["refused"]
293
+ except (ValueError, KeyError, TypeError):
294
+ return "crash", f"код {REFUSED_EXIT} без JSON отказа: {res.stdout[:200]!r}"
295
+ return "crash", f"код {res.returncode}: {crash_line(res.stderr or res.stdout)}"
296
+
297
+
298
+ def canon_value(value) -> str:
299
+ """Канонические байты §208 значения результата — оракульским каноном, а
300
+ не сериализатором цели: порядок элементов коллекции и запись чисел у
301
+ раннера проверяются против той же функции, что печатает голден."""
302
+ sys.path.insert(0, str(ROOT / "engines" / "lawref"))
303
+ from lawref.canon import canonical_bytes
304
+ return canonical_bytes(value, document=False).decode("utf-8")
305
+
306
+
307
+ LAYERS = ("L0", "L1", "L2", "L3")
308
+
309
+
310
+ def positions_key(status: str, positions: list, supports: list) -> str:
311
+ """Ответ `positions` без id и доказательства (DECISION-0242 K4): сводный
312
+ статус, мультимножество позиций (шаблон, модальность, держатель,
313
+ beneficiary, заземлённый payload, статус) и мультимножество опор статуса.
314
+ Держатель-заглушка `<позиция>/holder` зависит от счётчика позиций и
315
+ сравнивается как заглушка."""
316
+ def actor(v):
317
+ return "<fallback>" if str(v).startswith("urn:position:") else v
318
+
319
+ items = []
320
+ for p in positions:
321
+ item = {"template": p["template"], "modality": p["modality"], "holder": actor(p.get("holder")),
322
+ "status": p["status"], "payload": p["payload"]}
323
+ if "beneficiary" in p:
324
+ item["beneficiary"] = actor(p["beneficiary"])
325
+ items.append(canon_value(item))
326
+ sups = sorted(canon_value([s["status"], s["truthStatus"]]) for s in supports)
327
+ return "NORM_POSITION:" + status + ":" + canon_value(sorted(items)) + ":" + canon_value(sups)
328
+
329
+
330
+ def answer_key(a: dict) -> tuple:
331
+ """Сравнимая тройка ответа: у truth — статус истинности, у term/collect —
332
+ канонические байты значения, у positions — позиции без id; далее статус
333
+ вычисления и issues."""
334
+ if "normativeStatus" in a:
335
+ head = positions_key(a["normativeStatus"], a["positions"], a["supports"])
336
+ elif "truthStatus" in a:
337
+ head = a["truthStatus"]
338
+ else:
339
+ # RUNTIME_ERROR вопроса `term` значения не несёт (DECISION-0252 п. 9) —
340
+ # как голден, а не `null`.
341
+ head = a["resultKind"] + ":" + (canon_value(a["value"]) if "value" in a else "")
342
+ return (head, a["evaluationStatus"], tuple(a.get("issues") or []), requests_key(a.get("judgmentRequests") or []))
343
+
344
+
345
+ def requests_key(requests: list) -> str:
346
+ """DECISION-0274: запросы судье §47.3 сверяются с голденом целиком, кроме
347
+ proof-полей (`proof` — узел proof-графа, которого у среза нет): id,
348
+ отношение, орган, объект запроса, форма."""
349
+ return canon_value([{k: v for k, v in r.items() if k != "proof"} for r in requests])
350
+
351
+
352
+ def golden_requests(doc: dict) -> str:
353
+ return requests_key((doc["results"][0] or {}).get("judgmentRequests") or [])
354
+
355
+
356
+ def golden_key(doc: dict) -> tuple:
357
+ exp = doc["results"][0]
358
+ if exp.get("resultKind") == "NORM_POSITION":
359
+ return (positions_key(exp.get("normativeStatus"), doc.get("positions") or [],
360
+ exp.get("normativeStatusSupports") or []), exp.get("evaluationStatus"))
361
+ if "truthStatus" in exp:
362
+ return (exp.get("truthStatus"), exp.get("evaluationStatus"))
363
+ head = exp.get("resultKind") + ":" + (canon_value(exp["value"]) if "value" in exp else "")
364
+ return (head, exp.get("evaluationStatus"))
365
+
366
+
367
+ def certify(emit: dict, work: Path, active: list[str]) -> list[str]:
368
+ """Сертификация рантайма: фикстура `runtime-cert.json` исполняется каждой
369
+ целью, ответ каждого случая сверяется с ожиданием, записанным из контракта
370
+ README, а не снятым с реализации — сравнение целей между собой общую
371
+ ошибку не находит (коллизия ключа `["a|e:b","c"]` / `["a","b|e:c"]` жила
372
+ во всех трёх рантаймах одинаково)."""
373
+ fixture = json.loads(CERT.read_text(encoding="utf-8"))
374
+ expect = {c["id"]: c["expect"] for c in fixture["cases"]}
375
+ problems = []
376
+ projs = {}
377
+ for lang in active:
378
+ d = work / "cert" / lang
379
+ D.emit_project({}, lang, d, main="cert")
380
+ projs[lang] = [d]
381
+ problems += build_projects(emit, projs, work / "cert")
382
+ if problems:
383
+ return ["сертификация: " + p for p in problems]
384
+ for lang in active:
385
+ kind, payload = run_target(emit[lang], work / "cert" / lang, "cert", [str(CERT)])
386
+ if kind != "answer":
387
+ problems.append(f"сертификация {lang}: {kind.upper()}, {payload}")
388
+ continue
389
+ got = {row["id"]: row["got"] for row in payload}
390
+ for cid, want in expect.items():
391
+ if cid not in got:
392
+ problems.append(f"сертификация {lang}: случай {cid} не исполнен")
393
+ elif got[cid] != want:
394
+ problems.append(f"сертификация {lang}: {cid}: ожидалось {json.dumps(want, ensure_ascii=False)}, "
395
+ f"получено {json.dumps(got[cid], ensure_ascii=False)}")
396
+ return problems
397
+
398
+
399
+ def synth_arg(kind: str, i: int, param: str = ""):
400
+ """Синтетический литерал-терм §53 по виду столбца; enum — нет (члены плану неизвестны).
401
+ Тип значения — объявленный тип параметра: синтетика положительных заданий
402
+ канонична и по §189 принимается и раннером, и API."""
403
+ if kind == "text":
404
+ if param == "urn:law:std#Text":
405
+ return {"kind": "value", "type": {"name": param}, "value": f"apicheck-{i}"}
406
+ return {"kind": "entity_ref", "id": f"urn:apicheck:e{i}"}
407
+ if kind == "money":
408
+ return {"kind": "value", "type": {"name": "urn:law:std#Money"}, "value": str(100 + i), "currency": "KZT"}
409
+ if kind == "dec":
410
+ name = param if param in ("urn:law:std#Integer", "urn:law:std#Natural") else "urn:law:std#Decimal"
411
+ # §208 (errata E-0197): канон литерала `Integer` — целое JSON-ЧИСЛО, а
412
+ # `Decimal` — строка. До errata обе реализации и все три цели читали
413
+ # `"10"` в слоте Integer средствами своего языка; теперь это TYPE_ERROR
414
+ # у оракула, и синтетический аргумент ворот обязан быть каноничен.
415
+ if name != "urn:law:std#Decimal":
416
+ return {"kind": "value", "type": {"name": name}, "value": 10 + i}
417
+ return {"kind": "value", "type": {"name": name}, "value": str(10 + i)}
418
+ if kind == "date":
419
+ return {"kind": "value", "type": {"name": "urn:law:std#Date"}, "value": "2026-01-01"}
420
+ return None
421
+
422
+
423
+ def wrong_arg(kind: str, param: str):
424
+ """Литерал заведомо чужого типа для слота (§189, DECISION-0423 §2.6):
425
+ Decimal в слоте сущности и Text, Decimal в Integer, Integer в Decimal,
426
+ Decimal в Money, Text в Date. Раннер и оракул не принимают такое
427
+ утверждение (`TYPE_ERROR`); API обязан ответить так же."""
428
+ dec = {"kind": "value", "type": {"name": "urn:law:std#Decimal"}, "value": "10.5"}
429
+ if kind == "text":
430
+ return dec
431
+ if kind == "dec":
432
+ if param in ("urn:law:std#Integer", "urn:law:std#Natural"):
433
+ return dec
434
+ return {"kind": "value", "type": {"name": "urn:law:std#Integer"}, "value": 10}
435
+ if kind == "money":
436
+ return dec
437
+ if kind == "date":
438
+ return {"kind": "value", "type": {"name": "urn:law:std#Text"}, "value": "2026-01-01"}
439
+ return None
440
+
441
+
442
+ def _job(q: dict, facts: list, args: list, tag: str) -> dict:
443
+ return {"question": q["name"], "pred": q["pred"], "on": "2026-01-01", "args": args, "facts": facts, "tag": tag}
444
+
445
+
446
+ def api_jobs(pl: dict) -> list:
447
+ """Задания проверки API (DECISION-0423 §2.6). Для каждого вопроса:
448
+ дело из одного факта на каждый его вход (одна и та же сущность во всех
449
+ аргументах, чтобы правила с соединением по переменной срабатывали);
450
+ у модели L1 — тот же вопрос с первым входом, поданным отрицательным
451
+ фактом; и отрицательный случай — первый аргумент первого факта подан
452
+ литералом чужого типа (§189)."""
453
+ jobs = []
454
+ inputs = {i["name"]: i for i in pl["api"]["inputs"]}
455
+ by_pred = {i["pred"]: i["name"] for i in pl["api"]["inputs"]}
456
+ for q in pl["api"]["questions"]:
457
+ args = [synth_arg(k, 0, t) for k, t in zip(q["kinds"], q["params"])]
458
+ facts = []
459
+ for pred in q["inputs"]:
460
+ i = inputs[by_pred[pred]]
461
+ fa = [synth_arg(k, 0, t) for k, t in zip(i["kinds"], i["params"])]
462
+ if any(a is None for a in fa):
463
+ facts = None
464
+ break
465
+ facts.append({"input": i["name"], "pred": pred, "args": fa})
466
+ if facts is None or any(a is None for a in args):
467
+ continue
468
+ jobs.append(_job(q, facts, args, "positive"))
469
+ # L1 (DECISION-0237): тот же вопрос с первым входом, поданным
470
+ # отрицательным фактом, — путь `negated_*` API и §113 раннера.
471
+ if facts and "negation" in (pl.get("capabilities") or ()):
472
+ neg = [dict(facts[0], polarity="negative")] + facts[1:]
473
+ jobs.append(_job(q, neg, args, "negated"))
474
+ if facts:
475
+ i = inputs[facts[0]["input"]]
476
+ bad = wrong_arg(i["kinds"][0], i["params"][0]) if i["params"] else None
477
+ if bad is not None:
478
+ wrong = [dict(facts[0], args=[bad] + facts[0]["args"][1:])] + facts[1:]
479
+ jobs.append(_job(q, wrong, args, "wrong-type"))
480
+ return jobs
481
+
482
+
483
+ def key_conflict_plan(pl: dict):
484
+ """Синтетический ключ §45.1 на первом входе арности не меньше двух: план
485
+ с `keys = [[0]]` у его таблицы и имя входа. Дело с двумя фактами одного
486
+ первого аргумента даёт `KEY_CONFLICT`; вопрос, читающий вход, — у
487
+ раннера `NON_EXECUTABLE` (E-0242), независимый — `COMPUTED`. Норма общая
488
+ для любого пакета, поэтому ключ синтетический (DECISION-0423 §2.6)."""
489
+ for i in pl["api"]["inputs"]:
490
+ if len(i["params"]) < 2 or any(synth_arg(k, 1, t) is None for k, t in zip(i["kinds"], i["params"])):
491
+ continue
492
+ mutated = copy.deepcopy(pl)
493
+ for t in mutated["tables"]:
494
+ if t["pred"] == i["pred"]:
495
+ t["keys"] = [[0]]
496
+ return mutated, i
497
+ return None, None
498
+
499
+
500
+ def key_conflict_jobs(pl: dict, keyed: dict) -> list:
501
+ jobs = []
502
+ for job in api_jobs(pl):
503
+ if job["tag"] != "positive":
504
+ continue
505
+ extra = {"input": keyed["name"], "pred": keyed["pred"],
506
+ "args": [synth_arg(keyed["kinds"][0], 0, keyed["params"][0])]
507
+ + [synth_arg(k, 1, t) for k, t in zip(keyed["kinds"][1:], keyed["params"][1:])]}
508
+ first = {"input": keyed["name"], "pred": keyed["pred"],
509
+ "args": [synth_arg(k, 0, t) for k, t in zip(keyed["kinds"], keyed["params"])]}
510
+ facts = [f for f in job["facts"] if f["input"] != keyed["name"]] + [first, extra]
511
+ jobs.append(dict(job, facts=facts, tag="key-conflict"))
512
+ return jobs
513
+
514
+
515
+ def _api_case_query(job: dict, j: int) -> tuple[dict, dict]:
516
+ case = {"context": {"legalTime": job["on"]},
517
+ "assertions": [{"literal": {"kind": "literal", "predicate": f["pred"],
518
+ "polarity": f.get("polarity", "positive"), "args": f["args"]}}
519
+ for f in job["facts"]]}
520
+ query = {"kind": "truth", "queryId": job["queryId"],
521
+ "literal": {"kind": "literal", "predicate": job["pred"], "polarity": "positive", "args": job["args"]}}
522
+ return case, query
523
+
524
+
525
+ def _api_emit(emit: dict, pl: dict, active: list[str], root: Path) -> tuple[dict, list[str]]:
526
+ projs, problems = {}, []
527
+ for lang in active:
528
+ proj = root / lang
529
+ D.emit_project(pl, lang, proj)
530
+ harness = BACKENDS / "targets" / lang / emit[lang].MAINS["apicheck"]
531
+ shutil.copyfile(harness, proj / harness.name)
532
+ projs[lang] = [proj]
533
+ problems += build_projects(emit, projs, root)
534
+ for lang in active:
535
+ if hasattr(emit[lang], "build_api"):
536
+ cmd, cwd = emit[lang].build_api(str(projs[lang][0]))
537
+ res = sh(cmd, cwd)
538
+ if res.returncode != 0:
539
+ problems.append(f"сборка харнесса {lang} отказала\n{(res.stdout + res.stderr)[:1200]}")
540
+ return projs, problems
541
+
542
+
543
+ def _api_batch(em, proj: Path, path: Path) -> tuple[str, object]:
544
+ """Пакет заданий в одном процессе харнесса: ('answers', [исход…]) либо
545
+ ('crash', причина). Исход задания — ('answer', ключ) или ('refused', текст)."""
546
+ cmd, cwd = em.run(str(proj), "apicheck")
547
+ res = sh(cmd + [str(path)], cwd)
548
+ if res.returncode != 0:
549
+ return "crash", f"код {res.returncode}: {crash_line(res.stderr or res.stdout)}"
550
+ try:
551
+ out = json.loads(res.stdout)
552
+ return "answers", [("refused", a["refused"]) if "refused" in a else ("answer", answer_key(a))
553
+ for a in out["answers"]]
554
+ except (ValueError, KeyError, TypeError):
555
+ return "crash", f"ответ пакета не JSON: {res.stdout[:200]!r}"
556
+
557
+
558
+ def _api_run(emit: dict, projs: dict, active: list[str], jobs: list, root: Path, label: str,
559
+ problems: list) -> tuple[int, dict]:
560
+ """Каждое задание: раннер и API каждой цели дают один исход. Возвращает
561
+ число сверенных заданий и исходы раннера по номеру задания."""
562
+ checked, runner = 0, {}
563
+ for j, job in enumerate(jobs):
564
+ d = root / "jobs" / f"{j:03d}"
565
+ d.mkdir(parents=True, exist_ok=True)
566
+ job["queryId"] = f"urn:query:apicheck:{j}"
567
+ case, query = _api_case_query(job, j)
568
+ (d / "job.json").write_text(json.dumps(job, ensure_ascii=False), encoding="utf-8")
569
+ (d / "case.json").write_text(json.dumps(case, ensure_ascii=False), encoding="utf-8")
570
+ (d / "query.json").write_text(json.dumps(query, ensure_ascii=False), encoding="utf-8")
571
+ got = {}
572
+ for lang in active:
573
+ proj = projs[lang][0]
574
+ got[f"{lang}/runner"] = run_target(emit[lang], proj, "runner", [str(d / "case.json"), str(d / "query.json")])
575
+ got[f"{lang}/api"] = run_target(emit[lang], proj, "apicheck", [str(d / "job.json")])
576
+ crashes = [f"{k}: {msg}" for k, (kind, msg) in got.items() if kind == "crash"]
577
+ if crashes:
578
+ problems.append(f"api {label} {job['question']} ({job['tag']}): КРАХ {crashes}")
579
+ continue
580
+ if len(set(got.values())) != 1:
581
+ problems.append(f"api {label} {job['question']} ({job['tag']}): ответы разошлись {got}")
582
+ continue
583
+ runner[j] = got[f"{active[0]}/runner"]
584
+ checked += 1
585
+ return checked, runner
586
+
587
+
588
+ def api_check(emit: dict, work: Path, active: list[str]) -> tuple[list[str], int]:
589
+ """API против раннера (DECISION-0234, DECISION-0423 §2.6): те же факты и
590
+ тот же вопрос дают один исход у API и раннера каждой цели и у всех целей
591
+ между собой — на положительных заданиях, отрицательных фактах L1,
592
+ литералах чужого типа (§189) и синтетическом конфликте ключа §45.1 при
593
+ читающем и независимом вопросе. Затем пакет всех заданий с повтором
594
+ первого исполняется одним процессом харнесса — последовательно и
595
+ параллельно (потоки Python, горутины Go): состояние между вызовами не
596
+ протекает. Канарейка: порча статуса и issues в общей части API Python
597
+ обязана дать расхождение."""
598
+ problems, checked = [], 0
599
+ for name in API_PACKAGES + API_PACKAGES_L1:
600
+ path = CLIR / f"{name}.lawir.json"
601
+ if not path.exists():
602
+ problems.append(f"api {name}: снимка {path.relative_to(ROOT)} нет")
603
+ continue
604
+ try:
605
+ ir = json.loads(path.read_text(encoding="utf-8"))
606
+ world = P.resolve_world(ir, P.world_index(ROOT)) if ir.get("imports") else []
607
+ pl = P.build_plan(ir, world)
608
+ except P.Refused as exc:
609
+ problems.append(f"api {name}: план отказал — {exc}")
610
+ continue
611
+ jobs = api_jobs(pl)
612
+ if not jobs:
613
+ problems.append(f"api {name}: ни одного вопроса с синтезируемыми входами")
614
+ continue
615
+ projs, build = _api_emit(emit, pl, active, work / "api" / name)
616
+ problems += [f"api {name}: {p}" for p in build]
617
+ if build:
618
+ return problems, checked
619
+ n, runner = _api_run(emit, projs, active, jobs, work / "api" / name, name, problems)
620
+ checked += n
621
+ # Пакет в одном процессе: A, B, …, A — повторный вызов не видит прежних фактов.
622
+ batch = jobs + jobs[:1]
623
+ expect = [runner.get(j) for j in range(len(jobs))] + [runner.get(0)]
624
+ for concurrent in (False, True):
625
+ bpath = work / "api" / name / f"batch-{'par' if concurrent else 'seq'}.json"
626
+ bpath.write_text(json.dumps({"batch": batch, "concurrent": concurrent}, ensure_ascii=False), encoding="utf-8")
627
+ for lang in active:
628
+ kind, outs = _api_batch(emit[lang], projs[lang][0], bpath)
629
+ if kind == "crash":
630
+ problems.append(f"api {name}: пакет {lang} {'параллельно' if concurrent else 'последовательно'} — КРАХ {outs}")
631
+ continue
632
+ for j, (want, have) in enumerate(zip(expect, outs)):
633
+ if want is not None and want != have:
634
+ problems.append(f"api {name}: пакет {lang} {'параллельно' if concurrent else 'последовательно'}, "
635
+ f"задание {j}: {have} против раннера {want}")
636
+ if len(outs) != len(expect):
637
+ problems.append(f"api {name}: пакет {lang} ответил {len(outs)} исходами из {len(expect)}")
638
+ keyed_plan, keyed = key_conflict_plan(pl)
639
+ if keyed_plan is not None:
640
+ kjobs = key_conflict_jobs(keyed_plan, keyed)
641
+ kprojs, build = _api_emit(emit, keyed_plan, active, work / "api" / name / "key-conflict")
642
+ problems += [f"api {name} key-conflict: {p}" for p in build]
643
+ if not build and kjobs:
644
+ n, _ = _api_run(emit, kprojs, active, kjobs, work / "api" / name / "key-conflict",
645
+ name + " key-conflict", problems)
646
+ checked += n
647
+ if "py" in active:
648
+ problems += _api_canary(emit, projs["py"][0], jobs, work / "api" / name / "canary", name)
649
+ return problems, checked
650
+
651
+
652
+ def _api_canary(emit: dict, proj: Path, jobs: list, root: Path, name: str) -> list[str]:
653
+ """Канарейка API: копия проекта `py`, у которой общая часть API отвечает
654
+ `COMPUTED` без issues вместо ответа `evaluate`. На заданиях с литералом
655
+ чужого типа раннер даёт `TYPE_ERROR`, поэтому хоть одно расхождение
656
+ обязано найтись — иначе ворота не видят статуса и issues API."""
657
+ wrong = [j for j in jobs if j["tag"] == "wrong-type"]
658
+ if not wrong:
659
+ return []
660
+ if root.exists():
661
+ shutil.rmtree(root)
662
+ shutil.copytree(proj, root)
663
+ base = root / "api_base.py"
664
+ if not base.exists():
665
+ return [f"api {name}: канарейке нечего портить — в проекте py нет api_base.py"]
666
+ text = base.read_text(encoding="utf-8")
667
+ spoiled = text.replace('evaluation_status=doc["evaluationStatus"], issues=tuple(doc["issues"])',
668
+ 'evaluation_status="COMPUTED", issues=()')
669
+ if spoiled == text:
670
+ return [f"api {name}: канарейка не нашла, что портить в api_base.py"]
671
+ base.write_text(spoiled, encoding="utf-8")
672
+ for j, job in enumerate(wrong):
673
+ d = root / "jobs" / f"{j:03d}"
674
+ d.mkdir(parents=True, exist_ok=True)
675
+ job = dict(job, queryId=f"urn:query:apicheck:c{j}")
676
+ case, query = _api_case_query(job, j)
677
+ (d / "job.json").write_text(json.dumps(job, ensure_ascii=False), encoding="utf-8")
678
+ (d / "case.json").write_text(json.dumps(case, ensure_ascii=False), encoding="utf-8")
679
+ (d / "query.json").write_text(json.dumps(query, ensure_ascii=False), encoding="utf-8")
680
+ a = run_target(emit["py"], root, "apicheck", [str(d / "job.json")])
681
+ r = run_target(emit["py"], root, "runner", [str(d / "case.json"), str(d / "query.json")])
682
+ if a != r:
683
+ return []
684
+ return [f"api {name}: канарейка — порча статуса и issues в api_base.py не видна воротам"]
685
+
686
+
687
+ def fixtures_check(emit: dict, work: Path, active: list[str]) -> tuple[list[str], list[str]]:
688
+ """Фикстуры поражаемого слоя (DECISION-0237): ответ из прозы на всех
689
+ целях, тот же ответ в перестановке правил и фактов, и канарейка —
690
+ программа без решающей нормы обязана дать ДРУГОЙ, тоже записанный из
691
+ прозы ответ, то есть ровно на своём случае провалить сверку с ожиданием.
692
+ Структурные фикстуры сверяют причину отказа плана."""
693
+ problems, report = [], []
694
+ jobs = []
695
+ for fx in FX.FIXTURES:
696
+ if "plan" not in fx and (fx["canary"]["expect"], sorted(fx["canary"].get("issues", fx.get("issues", [])))) \
697
+ == (fx["expect"], sorted(fx.get("issues", []))):
698
+ problems.append(f"фикстура {fx['name']}: ожидание канарейки совпадает с ожиданием — она не упадёт")
699
+ if "plan" in fx:
700
+ try:
701
+ P.build_plan(FX.program(fx), None, None, FX.resources(fx))
702
+ problems.append(f"фикстура {fx['name']}: план построен, ожидался отказ «{fx['plan']}»")
703
+ except P.Refused as exc:
704
+ if str(exc).split(":")[0] != fx["plan"]:
705
+ problems.append(f"фикстура {fx['name']}: отказ «{exc}», ожидался «{fx['plan']}»")
706
+ continue
707
+ base = (fx["expect"], tuple(sorted(fx.get("issues", []))))
708
+ canary = (fx["canary"]["expect"], tuple(sorted(fx["canary"].get("issues", fx.get("issues", [])))))
709
+ drop = tuple(fx["canary"]["drop"])
710
+ variants = [("base", FX.program(fx), FX.case(fx), base, ()),
711
+ ("perm", FX.program(fx, reverse=True), FX.case(fx, reverse=True), base, ()),
712
+ ("canary", FX.program(fx, drop=drop), FX.case(fx, drop=drop), canary, drop)]
713
+ for tag, ir, kase, want, dropped in variants:
714
+ d = work / "fixtures" / f"{fx['name']}-{tag}"
715
+ try:
716
+ pl = P.build_plan(ir, None, None, FX.resources(fx, dropped))
717
+ except P.Refused as exc:
718
+ problems.append(f"фикстура {fx['name']} ({tag}): план отказал — {exc}")
719
+ continue
720
+ d.mkdir(parents=True, exist_ok=True)
721
+ (d / "case.json").write_text(json.dumps(kase, ensure_ascii=False), encoding="utf-8")
722
+ (d / "query.json").write_text(json.dumps(FX.query(fx), ensure_ascii=False), encoding="utf-8")
723
+ jobs.append((fx, tag, d, want))
724
+ for lang in active:
725
+ D.emit_project(pl, lang, d / lang)
726
+ if problems:
727
+ return problems, report
728
+ build = build_projects(emit, {lang: [d / lang for _fx, _t, d, _w in jobs] for lang in active}, work / "fixtures")
729
+ if build:
730
+ return [f"фикстуры: {b}" for b in build], report
731
+ for fx, tag, d, want in jobs:
732
+ args = [str(d / "case.json"), str(d / "query.json")]
733
+ got = {lang: run_target(emit[lang], d / lang, "runner", args) for lang in active}
734
+ bad = {lang: v for lang, v in got.items() if v[0] != "answer"}
735
+ if bad:
736
+ problems.append(f"фикстура {fx['name']} ({tag}): не ответ {bad}")
737
+ continue
738
+ # У ответа `positions` ключ начинается сводным статусом (DECISION-0242).
739
+ head = lambda k: k.split(":")[1] if k.startswith("NORM_POSITION:") else k
740
+ statuses = {lang: (head(v[1][0]), tuple(sorted(c for c in v[1][2] if c in BACKEND_ISSUES)))
741
+ for lang, v in got.items()}
742
+ if len(set(statuses.values())) != 1:
743
+ problems.append(f"фикстура {fx['name']} ({tag}): цели разошлись {statuses}")
744
+ continue
745
+ status = next(iter(statuses.values()))
746
+ if status != want:
747
+ problems.append(f"фикстура {fx['name']} ({tag}): {status} против ожидания {want}")
748
+ elif tag == "canary":
749
+ # Канарейка упала ровно на своём случае: снятая норма меняет ответ.
750
+ shown = lambda st, codes: st + (f" {list(codes)}" if codes else " без issues")
751
+ report.append(f"канарейка {fx['name']}: без {', '.join(fx['canary']['drop'])} — "
752
+ f"{shown(*status)}, сверка с ожиданием "
753
+ f"{shown(fx['expect'], sorted(fx.get('issues', [])))} падает")
754
+ if not problems:
755
+ n = sum(1 for fx in FX.FIXTURES if "plan" not in fx)
756
+ report.insert(0, f"фикстуры L1 — {n} с ответом (и перестановкой), "
757
+ f"{len(FX.FIXTURES) - n} структурных отказов, совпали на целях {', '.join(active)}")
758
+ return problems, report
759
+
760
+
761
+ # DECISION-0252/0256: мутационные канарейки плана значений. Решающую норму
762
+ # Rational и Quantity держат векторы `LAY-L0-RAT-*`/`LAY-L0-QTY-*`, но вектор
763
+ # подтверждает только то, что модуль С нормой совпал; модуль, из плана которого
764
+ # норма снята, обязан с голденом РАЗОЙТИСЬ — иначе норма воротам не видна.
765
+ # Язык фикстур L1 деления и Rational не знает, поэтому канарейка — мутация
766
+ # плана вектора, а не программы. Исполняется целью py (она всегда активна).
767
+ VALUE_CANARIES = (
768
+ ("LAY-L0-RAT-DYN-DECIMAL-KEPT", "kindChecks", "сверка вида головы E-0077"),
769
+ ("LAY-L0-RAT-GUARD-DYN-MIX", "err", "ошибка значения гарда — issue"),
770
+ ("LAY-L0-RAT-HEAD-DIV-ZERO", "headFallible", "ошибка значения головы — issue"),
771
+ ("LAY-L0-RAT-DYN-RATIONAL-KEPT", "lk", "статический вид операнда"),
772
+ ("LAY-L0-QTY-GUARD-DIMENSION", "err", "разные единицы в гарде — issue"),
773
+ ("LAY-L0-QTY-ADD-DIMENSION", "headFallible", "разные единицы в голове — issue"),
774
+ # DECISION-0259 п. 10: политика округления §50 в плане 0.14.
775
+ ("LAY-L0-RND-DIVROUND-GUARD-ZERO", "err", "ошибка значения div_round в гарде — issue"),
776
+ ("LAY-L0-RND-DIVROUND-HEAD-ZERO", "headFallible", "ошибка значения div_round в голове — issue"),
777
+ ("LAY-L0-RND-GUARD-CEILING", "mode", "режим округления из литерала"),
778
+ ("LAY-L0-RND-DIVROUND-MONEY-RATIO", "precision", "точность политики: округление не пропущено"),
779
+ ("LAY-L0-RND-DIVROUND-MONEY-RATIO", "ak", "статический вид аргумента политики"),
780
+ )
781
+
782
+
783
+ def _mutate_value_plan(pl: dict, what: str) -> dict:
784
+ out = copy.deepcopy(pl)
785
+ for r in out.get("rules", []):
786
+ if what in ("kindChecks", "headFallible"):
787
+ r.pop(what, None)
788
+ elif what == "err":
789
+ for g in r.get("guards", []):
790
+ g.pop("err", None)
791
+ if what in ("mode", "precision", "ak"):
792
+ # DECISION-0259: режим — на HALF_UP, литеральная точность — на единицу
793
+ # меньше, вид делимого — Decimal вместо плана.
794
+ stack = [out]
795
+ while stack:
796
+ x = stack.pop()
797
+ if isinstance(x, dict):
798
+ if x.get("k") == "call" and "ak" in x:
799
+ mode, prec = x["args"][-1], x["args"][-2]
800
+ if what == "mode" and mode.get("k") == "lit":
801
+ mode["v"] = "HALF_UP"
802
+ elif what == "precision" and prec.get("k") == "lit" and int(prec["v"]) > 0:
803
+ prec["v"] = str(int(prec["v"]) - 1)
804
+ elif what == "ak":
805
+ x["ak"][0] = "d" if x["ak"][0] != "d" else "m"
806
+ stack.extend(v for v in x.values() if isinstance(v, (dict, list)))
807
+ elif isinstance(x, list):
808
+ stack.extend(x)
809
+ if what == "lk":
810
+ stack = [out]
811
+ while stack:
812
+ x = stack.pop()
813
+ if isinstance(x, dict):
814
+ if x.get("k") == "bin" and x.get("op") == "div" and "lk" in x:
815
+ x["lk"] = x["rk"] = "d"
816
+ stack.extend(v for v in x.values() if isinstance(v, (dict, list)))
817
+ elif isinstance(x, list):
818
+ stack.extend(x)
819
+ return out
820
+
821
+
822
+ def value_canaries(emit: dict, work: Path) -> tuple[list[str], list[str]]:
823
+ """Канарейки VALUE_CANARIES: мутированный план обязан разойтись с голденом."""
824
+ problems, report, jobs = [], [], []
825
+ for vid, what, why in VALUE_CANARIES:
826
+ d = VECTORS / "lay" / vid
827
+ try:
828
+ pl = P.build_plan(load(d, "ir.json"))
829
+ except P.Refused as exc:
830
+ # DECISION-0372: архивный вектор 0.1 план не строит — канарейке
831
+ # нужен вектор поддерживаемой версии, а не обход отказа.
832
+ problems.append(f"канарейка {vid}: план отказал — {exc}")
833
+ continue
834
+ mutated = _mutate_value_plan(pl, what)
835
+ if mutated == pl:
836
+ problems.append(f"канарейка {vid}: мутация «{what}» план не меняет — нормы в плане нет")
837
+ continue
838
+ # Одна программа может нести несколько канареек: проект — на пару (вектор, мутация).
839
+ proj = work / "value-canaries" / f"{vid}-{what}"
840
+ D.emit_project(mutated, "py", proj)
841
+ jobs.append((vid, what, why, d, proj))
842
+ if problems:
843
+ return problems, report
844
+ build = build_projects(emit, {"py": [j[4] for j in jobs]}, work / "value-canaries")
845
+ if build:
846
+ return [f"канарейки значений: {b}" for b in build], report
847
+ for vid, what, why, d, proj in jobs:
848
+ doc = load(d, "expected.json")
849
+ want = golden_key(doc)
850
+ want_issues = sorted(i["code"] for i in doc.get("issues") or [] if i.get("code") in BACKEND_ISSUES)
851
+ kind, payload = run_target(emit["py"], proj, "runner",
852
+ [str(d.resolve() / "case.json"), str(d.resolve() / "query.json")])
853
+ if kind == "crash":
854
+ problems.append(f"канарейка {vid}: без «{what}» — крах {payload}")
855
+ elif kind == "answer" and payload[:2] == want \
856
+ and sorted(c for c in payload[2] if c in BACKEND_ISSUES) == want_issues:
857
+ problems.append(f"канарейка {vid}: без «{what}» ответ совпал с голденом — норма «{why}» воротам не видна")
858
+ else:
859
+ shown = payload[:2] if kind == "answer" else f"отказ «{payload}»"
860
+ report.append(f"канарейка {vid}: без «{what}» ({why}) — {shown}, сверка с голденом падает")
861
+ return problems, report
862
+
863
+
864
+ # Политика пустой коллекции §59 (errata E-0195). Векторы `ERR-E0195-EMPTY-POLICY-*`
865
+ # несут в comprehension СВЯЗКУ, и раннер отказывает до агрегата — норма о сверке
866
+ # вида политики с видом элемента им не проверяется ни у одной цели. Пробы ниже
867
+ # берут тот же пакет и то же дело вектора, но comprehension без связок: путь
868
+ # агрегата раннера достижим. Ожидания записаны ИЗ errata («вид политики обязан
869
+ # совпасть с видом элемента ПУСТОЙ коллекции; у оракула несовпадение —
870
+ # TYPE_ERROR»), а не сняты с реализации; первая проба — свидетель нормы (без
871
+ # сверки цели отвечают `Integer 0` и `COMPUTED`, то есть ДРУГИМ ответом, а не
872
+ # меньшим), две остальные держат её от переусердствования: совпавший вид и
873
+ # непустая коллекция обязаны отвечать как прежде.
874
+ EMPTY_POLICY_VECTOR = "errata/ERR-E0195-EMPTY-POLICY-KIND-RUNTIME"
875
+ _EP_MONEY = {"kind": "value", "type": {"name": "urn:law:std#Money"}, "value": "0", "currency": "KZT"}
876
+ _EP_INTEGER = {"kind": "value", "type": {"name": "urn:law:std#Integer"}, "value": 0}
877
+ _EP_SUM = {"kind": "value", "type": {"name": "urn:law:std#Money"}, "value": "80", "currency": "KZT"}
878
+ EMPTY_POLICY_PROBES = (
879
+ ("несовпадение вида над пустой коллекцией", "absent", _EP_INTEGER, None, "TYPE_ERROR", ("TYPE_ERROR",)),
880
+ ("совпадение вида над пустой коллекцией", "absent", _EP_MONEY, _EP_MONEY, "COMPUTED", ()),
881
+ ("несовпадение вида над НЕпустой коллекцией", "order_amount", _EP_INTEGER, _EP_SUM, "COMPUTED", ()),
882
+ )
883
+
884
+
885
+ def empty_policy_probes(emit: dict, work: Path, active: list[str]) -> tuple[list[str], list[str]]:
886
+ """Сверка вида empty-политики §59 в раннере каждой цели (errata E-0195)."""
887
+ d = VECTORS / EMPTY_POLICY_VECTOR
888
+ if not d.exists():
889
+ return [f"проба политики §59: вектора {EMPTY_POLICY_VECTOR} нет"], []
890
+ problems, report = [], []
891
+ try:
892
+ pl = P.build_plan(load(d, "ir.json"))
893
+ except P.Refused as exc:
894
+ return [f"проба политики §59: план вектора {EMPTY_POLICY_VECTOR} отказал — {exc}"], report
895
+ base = work / "empty-policy"
896
+ for lang in active:
897
+ D.emit_project(pl, lang, base / lang)
898
+ build = build_projects(emit, {lang: [base / lang] for lang in active}, base)
899
+ if build:
900
+ return [f"проба политики §59: {b}" for b in build], report
901
+ template = load(d, "query.json")
902
+ for n, (why, pred, policy, value, status, issues) in enumerate(EMPTY_POLICY_PROBES):
903
+ q = copy.deepcopy(template)
904
+ q["term"]["input"].pop("binders", None)
905
+ q["term"]["input"]["generator"]["formula"]["predicate"] = f"urn:law:vectors:err-e0195#{pred}"
906
+ q["term"]["options"] = {"empty": policy}
907
+ job = base / f"q{n}"
908
+ job.mkdir(parents=True, exist_ok=True)
909
+ (job / "query.json").write_text(json.dumps(q, ensure_ascii=False), encoding="utf-8")
910
+ want = ("DATA:" + (canon_value(value) if value is not None else ""), status, tuple(issues))
911
+ got = {}
912
+ for lang in active:
913
+ kind, payload = run_target(emit[lang], base / lang, "runner",
914
+ [str(d.resolve() / "case.json"), str(job / "query.json")])
915
+ got[lang] = (kind, payload[:3] if kind == "answer" else payload)
916
+ bad = {lang: v for lang, v in got.items() if v[0] != "answer"}
917
+ if bad:
918
+ problems.append(f"проба политики §59 ({why}): не ответ {bad}")
919
+ continue
920
+ answers = {lang: v[1] for lang, v in got.items()}
921
+ if len(set(answers.values())) != 1:
922
+ problems.append(f"проба политики §59 ({why}): цели разошлись {answers}")
923
+ continue
924
+ answer = next(iter(answers.values()))
925
+ if answer != want:
926
+ problems.append(f"проба политики §59 ({why}): {answer} против ожидания {want}")
927
+ if not problems:
928
+ report.append(f"политика пустой коллекции §59 (E-0195) — {len(EMPTY_POLICY_PROBES)} пробы "
929
+ f"совпали с нормой на целях {', '.join(active)}")
930
+ return problems, report
931
+
932
+
933
+ # Граница JSON-числа канона §208 (errata E-0202). Вектор
934
+ # `ERR-E0202-CANON-NUMBER-I64-MAX` держит ОДНУ сторону границы — целое i64
935
+ # читается точно; вторая сторона воротам недостижима: у float и у целого вне
936
+ # i64 голден есть error-объект §172 без результата, и `classify` такой вектор
937
+ # не исполняет. Между тем ровно там ts терял знаки (`JSON.parse` сводит число
938
+ # к double), и без пробы норма держалась бы на слове. Ожидания записаны ИЗ
939
+ # errata: целое знакового 64-битного диапазона законно, всё прочее — отказ
940
+ # одним текстом у всех целей, потому что канала error-объекта у профиля
941
+ # `core-executable` нет.
942
+ CANON_NUMBER_VECTOR = "errata/ERR-E0202-CANON-NUMBER-I64-MAX"
943
+ CANON_NUMBER_REFUSAL = ("JSON-число вне канона §208 (только целое знакового 64-битного "
944
+ "диапазона) вне среза раннера")
945
+ CANON_NUMBER_PROBES = (
946
+ ("i64 max читается точно", 2 ** 63 - 1, str(2 ** 63 - 1)),
947
+ ("i64 min читается точно", -(2 ** 63), str(-(2 ** 63))),
948
+ ("целое над i64 — отказ", 2 ** 63, None),
949
+ ("целое под i64 — отказ", -(2 ** 63) - 1, None),
950
+ ("JSON-float — отказ", 1.5, None),
951
+ )
952
+
953
+
954
+ def canon_number_probes(emit: dict, work: Path, active: list[str]) -> tuple[list[str], list[str]]:
955
+ """Граница i64 §208 в раннере каждой цели (errata E-0202)."""
956
+ d = VECTORS / CANON_NUMBER_VECTOR
957
+ if not d.exists():
958
+ return [f"проба числа канона §208: вектора {CANON_NUMBER_VECTOR} нет"], []
959
+ problems, report = [], []
960
+ try:
961
+ pl = P.build_plan(load(d, "ir.json"))
962
+ except P.Refused as exc:
963
+ return [f"проба числа канона §208: план вектора {CANON_NUMBER_VECTOR} отказал — {exc}"], report
964
+ base = work / "canon-number"
965
+ for lang in active:
966
+ D.emit_project(pl, lang, base / lang)
967
+ build = build_projects(emit, {lang: [base / lang] for lang in active}, base)
968
+ if build:
969
+ return [f"проба числа канона §208: {b}" for b in build], report
970
+ template = load(d, "query.json")
971
+ for n, (why, value, want_digits) in enumerate(CANON_NUMBER_PROBES):
972
+ q = copy.deepcopy(template)
973
+ q["term"]["value"] = value
974
+ job = base / f"q{n}"
975
+ job.mkdir(parents=True, exist_ok=True)
976
+ job.joinpath("query.json").write_text(json.dumps(q, ensure_ascii=False), encoding="utf-8")
977
+ got = {}
978
+ for lang in active:
979
+ kind, payload = run_target(emit[lang], base / lang, "runner",
980
+ [str(d.resolve() / "case.json"), str(job / "query.json")])
981
+ got[lang] = (kind, payload[:2] if kind == "answer" else payload)
982
+ crashes = {lang: v for lang, v in got.items() if v[0] == "crash"}
983
+ if crashes:
984
+ problems.append(f"проба числа канона §208 ({why}): КРАХ {crashes}")
985
+ continue
986
+ if len({v for v in got.values()}) != 1:
987
+ problems.append(f"проба числа канона §208 ({why}): цели разошлись {got}")
988
+ continue
989
+ kind, payload = next(iter(got.values()))
990
+ if want_digits is None:
991
+ if kind != "refused" or payload != CANON_NUMBER_REFUSAL:
992
+ problems.append(f"проба числа канона §208 ({why}): ожидался отказ "
993
+ f"«{CANON_NUMBER_REFUSAL}», получено {kind} {payload}")
994
+ else:
995
+ want = ('DATA:{"kind":"value","type":{"name":"urn:law:std#Integer"},'
996
+ f'"value":{want_digits}}}', "COMPUTED")
997
+ if kind != "answer" or tuple(payload) != want:
998
+ problems.append(f"проба числа канона §208 ({why}): {kind} {payload} против {want}")
999
+ if not problems:
1000
+ report.append(f"граница i64 §208 (E-0202) — {len(CANON_NUMBER_PROBES)} пробы совпали "
1001
+ f"с нормой на целях {', '.join(active)}")
1002
+ return problems, report
1003
+
1004
+
1005
+ def execute(emit: dict, d: Path, work: Path, active: list[str]):
1006
+ """Исход исполнения вектора всеми целями и список ошибок ворот по нему."""
1007
+ doc = load(d, "expected.json")
1008
+ want = golden_key(doc)
1009
+ want_issues = sorted(i["code"] for i in doc.get("issues") or [] if i.get("code") in BACKEND_ISSUES)
1010
+ args = [str(d.resolve() / "case.json"), str(d.resolve() / "query.json")]
1011
+ got = {lang: run_target(emit[lang], work / d.name / lang, "runner", args) for lang in active}
1012
+ crashes = [f"КРАХ {lang}: {msg}" for lang, (k, msg) in got.items() if k == "crash"]
1013
+ if crashes:
1014
+ return "crash", crashes
1015
+ refused = {lang: msg for lang, (k, msg) in got.items() if k == "refused"}
1016
+ if refused and len(refused) != len(got):
1017
+ answered = {lang: v for lang, (k, v) in got.items() if k == "answer"}
1018
+ return "divergence", [f"отказ не единогласен: отказали {sorted(refused)}, ответили {answered}"]
1019
+ if refused:
1020
+ if len(set(refused.values())) != 1:
1021
+ return "divergence", [f"тексты отказа различаются: {refused}"]
1022
+ return f"runtime: {next(iter(refused.values()))}", []
1023
+ answers = {lang: v for lang, (_k, v) in got.items()}
1024
+ if len(set(answers.values())) != 1:
1025
+ return "divergence", [f"РАСХОЖДЕНИЕ между бэкендами: {answers}"]
1026
+ answer = next(iter(answers.values()))
1027
+ if answer[:2] != want:
1028
+ return "divergence", [f"расхождение с голденом: {answer[:2]} против {want}"]
1029
+ got_issues = sorted(c for c in answer[2] if c in BACKEND_ISSUES)
1030
+ if got_issues != want_issues:
1031
+ return "divergence", [f"расхождение issues с голденом: {got_issues} против {want_issues}"]
1032
+ if answer[3] != golden_requests(doc):
1033
+ # DECISION-0274: судьи §47.3 — часть ответа, сверяются кроме proof-полей.
1034
+ return "divergence", [f"расхождение judgmentRequests с голденом: {answer[3]} против {golden_requests(doc)}"]
1035
+ return "match", []
1036
+
1037
+
1038
+ def main() -> int:
1039
+ ap = argparse.ArgumentParser()
1040
+ ap.add_argument("--keep", metavar="DIR", help="сохранить порождённые проекты")
1041
+ ap.add_argument("--targets", default=DEFAULT_TARGETS, metavar="ЦЕЛИ",
1042
+ help=f"цели через запятую либо all (по умолчанию {DEFAULT_TARGETS})")
1043
+ ap.add_argument("--require", default="", metavar="ЦЕЛИ",
1044
+ help="цели через запятую, отсутствие тулчейна которых — отказ ворот")
1045
+ ap.add_argument("--manifest", type=Path, default=MANIFEST)
1046
+ ap.add_argument("--write-manifest", action="store_true",
1047
+ help="закрепить исходы всех L0- и L1-векторов (только при отсутствии крахов и расхождений)")
1048
+ args = ap.parse_args()
1049
+
1050
+ known = D.targets(protocol="runner")
1051
+ wanted = known if args.targets == "all" else [x for x in args.targets.split(",") if x]
1052
+ unknown = [x for x in wanted if x not in known]
1053
+ if unknown:
1054
+ print(f"check_backends: неизвестные цели: {', '.join(unknown)} (есть: {', '.join(known)})")
1055
+ return 1
1056
+ emit = {lang: D.emitter(lang) for lang in known}
1057
+ failures = version_probe(emit)
1058
+ if failures:
1059
+ for line in failures:
1060
+ print("check_backends: " + line)
1061
+ return 1
1062
+ l1_targets = sorted(lang for lang, em in emit.items() if not D.missing_capabilities(
1063
+ {"capabilities": ["defeasible", "negation", "priority"]}, em))
1064
+ v09_targets = sorted(lang for lang, em in emit.items() if not D.missing_capabilities(
1065
+ {"capabilities": ["aggregate", "closure"]}, em))
1066
+ v10_targets = sorted(lang for lang, em in emit.items() if not D.missing_capabilities(
1067
+ {"capabilities": ["norm"]}, em))
1068
+ v11_targets = sorted(lang for lang, em in emit.items() if not D.missing_capabilities(
1069
+ {"capabilities": sorted(P.CAL_CAPS)}, em))
1070
+ print(f"check_backends: план {P.PLAN_VERSION}; проба чужой версии отвергнута всеми эмиттерами ({len(emit)}); "
1071
+ f"план {P.PLAN_VERSION_L1} печатают {', '.join(l1_targets)}, {P.PLAN_VERSION_09} — "
1072
+ f"{', '.join(v09_targets)}, {P.PLAN_VERSION_10} — {', '.join(v10_targets)}, "
1073
+ f"{P.PLAN_VERSION_11} — {', '.join(v11_targets)}, остальные отвергают")
1074
+
1075
+ tools = {lang: emit[lang].available() for lang in wanted}
1076
+ active = [lang for lang in wanted if tools[lang][0]]
1077
+ if "py" not in active:
1078
+ print("check_backends: цель py обязательна всегда")
1079
+ return 1
1080
+ print("check_backends: цели —", ", ".join(f"{k} ({tools[k][1]})" for k in active))
1081
+ for lang in wanted:
1082
+ if not tools[lang][0]:
1083
+ print(f"check_backends: цель {lang} ПРОПУЩЕНА — {tools[lang][1]}")
1084
+ required = [x for x in args.require.split(",") if x]
1085
+ missing = [x for x in required if x not in active]
1086
+ if missing:
1087
+ print(f"check_backends: обязательные цели без тулчейна: {', '.join(missing)}")
1088
+ return 1
1089
+ emit = {lang: emit[lang] for lang in active}
1090
+
1091
+ kinds = set.intersection(*[set(getattr(emit[lang], "QUERY_KINDS", {"truth"})) for lang in active])
1092
+ print(f"check_backends: виды вопросов у всех активных целей — {', '.join(sorted(kinds))}")
1093
+ outcomes: dict[str, str] = {}
1094
+ layers: dict[str, str] = {}
1095
+ accepted = []
1096
+ problems: list[str] = []
1097
+ for d, layer in candidates():
1098
+ layers[d.name] = layer
1099
+ outcome, pl, errors = classify(d, kinds, emit, layer)
1100
+ problems += [f"{d.name}: {e}" for e in errors]
1101
+ if outcome is None:
1102
+ accepted.append((d, pl))
1103
+ else:
1104
+ outcomes[d.name] = outcome
1105
+ by_layer = collections.Counter(layers.values())
1106
+ print(f"check_backends: векторов L0 рассмотрено {by_layer['L0']}, L1 — {by_layer['L1']}; "
1107
+ f"план построен и запрос поддержан у {len(accepted)}")
1108
+
1109
+ work = Path(args.keep) if args.keep else Path(tempfile.mkdtemp(prefix="law-backends-"))
1110
+ work.mkdir(parents=True, exist_ok=True)
1111
+
1112
+ cert_problems = certify(emit, work, active)
1113
+ if cert_problems:
1114
+ for line in cert_problems:
1115
+ print(" " + line)
1116
+ print("check_backends: сертификация рантайма НЕ пройдена")
1117
+ return 1
1118
+ n_cases = len(json.loads(CERT.read_text(encoding="utf-8"))["cases"])
1119
+ print(f"check_backends: сертификация рантайма — {n_cases} случаев на целях {', '.join(active)}")
1120
+
1121
+ api_problems, api_checked = api_check(emit, work, active)
1122
+ if api_problems:
1123
+ for line in api_problems:
1124
+ print(" " + line)
1125
+ print("check_backends: проверка API НЕ пройдена")
1126
+ return 1
1127
+ print(f"check_backends: API — {api_checked} вопросов на {len(API_PACKAGES) + len(API_PACKAGES_L1)} пакетах совпали с раннером на целях {', '.join(active)}")
1128
+
1129
+ fx_problems, fx_report = fixtures_check(emit, work, active)
1130
+ for line in fx_report:
1131
+ print("check_backends: " + line)
1132
+ if fx_problems:
1133
+ for line in fx_problems:
1134
+ print(" " + line)
1135
+ print("check_backends: фикстуры поражаемого слоя НЕ пройдены")
1136
+ return 1
1137
+
1138
+ vc_problems, vc_report = value_canaries(emit, work)
1139
+ for line in vc_report:
1140
+ print("check_backends: " + line)
1141
+ if vc_problems:
1142
+ for line in vc_problems:
1143
+ print(" " + line)
1144
+ print("check_backends: канарейки значений (DECISION-0252/0256) НЕ пройдены")
1145
+ return 1
1146
+
1147
+ ep_problems, ep_report = empty_policy_probes(emit, work, active)
1148
+ for line in ep_report:
1149
+ print("check_backends: " + line)
1150
+ if ep_problems:
1151
+ for line in ep_problems:
1152
+ print(" " + line)
1153
+ print("check_backends: пробы политики пустой коллекции §59 (errata E-0195) НЕ пройдены")
1154
+ return 1
1155
+
1156
+ cn_problems, cn_report = canon_number_probes(emit, work, active)
1157
+ for line in cn_report:
1158
+ print("check_backends: " + line)
1159
+ if cn_problems:
1160
+ for line in cn_problems:
1161
+ print(" " + line)
1162
+ print("check_backends: пробы границы JSON-числа канона §208 (errata E-0202) НЕ пройдены")
1163
+ return 1
1164
+
1165
+ # Проекты печатает та же поставка, что отдаёт модуль потребителю
1166
+ # (`deliver.emit_project`): ворота проверяют ровно то, что уезжает.
1167
+ projs = {lang: [] for lang in active}
1168
+ for d, pl in accepted:
1169
+ for lang in active:
1170
+ proj = work / d.name / lang
1171
+ D.emit_project(pl, lang, proj)
1172
+ projs[lang].append(proj)
1173
+ build_problems = build_projects(emit, projs, work)
1174
+ if build_problems:
1175
+ for line in build_problems:
1176
+ print("check_backends: " + line)
1177
+ return 1
1178
+
1179
+ for d, _ in accepted:
1180
+ outcome, errors = execute(emit, d, work, active)
1181
+ outcomes[d.name] = outcome
1182
+ problems += [f"{d.name}: {e}" for e in errors]
1183
+ if not args.keep:
1184
+ shutil.rmtree(work, ignore_errors=True)
1185
+
1186
+ for layer in LAYERS:
1187
+ stat = collections.Counter(o.split(":")[0] for name, o in outcomes.items() if layers[name] == layer)
1188
+ if stat:
1189
+ print(f"check_backends: {layer} — " + ", ".join(f"{key}: {n}" for key, n in sorted(stat.items())))
1190
+ matched_l2 = sum(1 for name, o in outcomes.items() if layers[name] in ("L2", "L3") and o == "match")
1191
+ matched = sum(1 for name, o in outcomes.items() if layers[name] == "L0" and o == "match")
1192
+ matched_l1 = sum(1 for name, o in outcomes.items() if layers[name] == "L1"
1193
+ and (o == "match" or o.startswith("structural:")))
1194
+
1195
+ if problems:
1196
+ for line in problems:
1197
+ print(" " + line)
1198
+ return 1
1199
+
1200
+ if args.write_manifest:
1201
+ args.manifest.write_text(json.dumps(
1202
+ {"format": MANIFEST_FORMAT, "targets": active, "vectors": dict(sorted(outcomes.items()))},
1203
+ ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
1204
+ print(f"check_backends: манифест записан — {len(outcomes)} векторов, {matched} совпадений L0, "
1205
+ f"{matched_l1} L1, {matched_l2} L2/L3")
1206
+ return 0
1207
+
1208
+ if not args.manifest.exists():
1209
+ print(f"check_backends: манифеста нет — {args.manifest} (--write-manifest)")
1210
+ return 1
1211
+ pinned = json.loads(args.manifest.read_text(encoding="utf-8"))
1212
+ if pinned.get("format") != MANIFEST_FORMAT:
1213
+ print(f"check_backends: формат манифеста не {MANIFEST_FORMAT}")
1214
+ return 1
1215
+ registry: dict[str, str] = pinned["vectors"]
1216
+ for name, expected in sorted(registry.items()):
1217
+ actual = outcomes.get(name)
1218
+ if actual is None:
1219
+ problems.append(f"{name}: закреплён как «{expected}», вектора нет")
1220
+ elif actual != expected:
1221
+ problems.append(f"{name}: закреплено «{expected}», получено «{actual}»")
1222
+ unpinned = sorted(n for n in outcomes if n not in registry)
1223
+ for name in unpinned:
1224
+ if outcomes[name] == "match" or outcomes[name].startswith("structural:"):
1225
+ problems.append(f"{name}: совпал (или структурный отказ), но не закреплён в манифесте (--write-manifest)")
1226
+ if unpinned:
1227
+ print(f"check_backends: незакреплённых векторов {len(unpinned)} "
1228
+ f"(без совпадений: {sum(1 for n in unpinned if outcomes[n] != 'match')})")
1229
+ if problems:
1230
+ for line in problems:
1231
+ print(" " + line)
1232
+ return 1
1233
+ if matched < MIN_CHECKED:
1234
+ print(f"check_backends: исполнено {matched} векторов при поле храповика {MIN_CHECKED}")
1235
+ return 1
1236
+ if matched_l1 < MIN_CHECKED_L1:
1237
+ print(f"check_backends: L1 — {matched_l1} совпадений при поле храповика {MIN_CHECKED_L1}")
1238
+ return 1
1239
+ if matched_l2 < MIN_CHECKED_L2:
1240
+ print(f"check_backends: L2/L3 — {matched_l2} совпадений при поле храповика {MIN_CHECKED_L2}")
1241
+ return 1
1242
+ print(f"check_backends: OK — L0 {matched} векторов совпали, L1 {matched_l1} (совпадения и "
1243
+ f"структурные отказы), L2/L3 {matched_l2} на целях {', '.join(active)}; манифест {len(registry)} исходов повторён")
1244
+ return 0
1245
+
1246
+
1247
+ if __name__ == "__main__":
1248
+ raise SystemExit(main())