equiframe 0.12.4__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 (170) hide show
  1. equiframe/__init__.py +832 -0
  2. equiframe/__main__.py +3 -0
  3. equiframe/_base.py +84 -0
  4. equiframe/_cli_support.py +125 -0
  5. equiframe/_comparison_io.py +161 -0
  6. equiframe/_dataset_provenance.py +146 -0
  7. equiframe/_decimal.py +22 -0
  8. equiframe/_diff_capture.py +433 -0
  9. equiframe/_doctor.py +123 -0
  10. equiframe/_document_io.py +61 -0
  11. equiframe/_documents.py +79 -0
  12. equiframe/_duckdb_transport.py +149 -0
  13. equiframe/_error_reporting.py +52 -0
  14. equiframe/_first_use.py +102 -0
  15. equiframe/_path_compat.py +26 -0
  16. equiframe/_physical.py +133 -0
  17. equiframe/_r_review.R +308 -0
  18. equiframe/_regular_io.py +167 -0
  19. equiframe/_report_format_v1.py +298 -0
  20. equiframe/_report_io.py +591 -0
  21. equiframe/_stability_inventory.py +1421 -0
  22. equiframe/_timezones.py +257 -0
  23. equiframe/_vendor/cyclonedx/LICENSE +201 -0
  24. equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
  25. equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
  26. equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
  27. equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
  28. equiframe/adapters.py +454 -0
  29. equiframe/assertion_types.py +122 -0
  30. equiframe/assertions.py +271 -0
  31. equiframe/benchmark.py +101 -0
  32. equiframe/bundle.py +240 -0
  33. equiframe/cli.py +693 -0
  34. equiframe/comparison.py +937 -0
  35. equiframe/comparison_report.py +660 -0
  36. equiframe/comparison_types.py +257 -0
  37. equiframe/compatibility.py +546 -0
  38. equiframe/contracts.py +879 -0
  39. equiframe/datasets.py +1301 -0
  40. equiframe/decision.py +34 -0
  41. equiframe/destinations.py +295 -0
  42. equiframe/detector_types.py +431 -0
  43. equiframe/detectors.py +389 -0
  44. equiframe/diff.py +192 -0
  45. equiframe/diff_contracts.py +523 -0
  46. equiframe/docs/adoption.md +329 -0
  47. equiframe/docs/api.md +534 -0
  48. equiframe/docs/arquitecto.md +143 -0
  49. equiframe/docs/choosing-api.md +84 -0
  50. equiframe/docs/cientifico.md +180 -0
  51. equiframe/docs/compatibility.md +254 -0
  52. equiframe/docs/contributing.md +171 -0
  53. equiframe/docs/dependencies.md +237 -0
  54. equiframe/docs/eda.md +166 -0
  55. equiframe/docs/extending.md +158 -0
  56. equiframe/docs/fast-ci.md +52 -0
  57. equiframe/docs/first-use.md +78 -0
  58. equiframe/docs/index.md +142 -0
  59. equiframe/docs/ingestion.md +145 -0
  60. equiframe/docs/inicio.md +129 -0
  61. equiframe/docs/installed-private.md +97 -0
  62. equiframe/docs/journeys.md +362 -0
  63. equiframe/docs/lectura-conversion.md +211 -0
  64. equiframe/docs/licensing.md +17 -0
  65. equiframe/docs/maintenance.md +233 -0
  66. equiframe/docs/migration-ir.md +333 -0
  67. equiframe/docs/option-audit.md +45 -0
  68. equiframe/docs/outcome-gates.md +134 -0
  69. equiframe/docs/performance.md +263 -0
  70. equiframe/docs/piloto-simulado.md +56 -0
  71. equiframe/docs/quickstart.md +156 -0
  72. equiframe/docs/r_review.md +212 -0
  73. equiframe/docs/recipes.md +248 -0
  74. equiframe/docs/releasing.md +183 -0
  75. equiframe/docs/security.md +218 -0
  76. equiframe/docs/semantics.md +274 -0
  77. equiframe/docs/sqlite-snapshots.md +262 -0
  78. equiframe/docs/universal-sources.md +49 -0
  79. equiframe/docs/universalidad-cinco-pasos.md +107 -0
  80. equiframe/docs/upgrading.md +538 -0
  81. equiframe/duckdb_capture.py +410 -0
  82. equiframe/eda/__init__.py +36 -0
  83. equiframe/eda/__main__.py +3 -0
  84. equiframe/eda/_backend.py +612 -0
  85. equiframe/eda/_inventory.py +49 -0
  86. equiframe/eda/_paths.py +49 -0
  87. equiframe/eda/_report.py +291 -0
  88. equiframe/eda/api.py +535 -0
  89. equiframe/eda/cli.py +102 -0
  90. equiframe/eda/models.py +239 -0
  91. equiframe/errors.py +83 -0
  92. equiframe/evidence.py +375 -0
  93. equiframe/examples/integrations/eda_generic.py +60 -0
  94. equiframe/examples/integrations/outcome_gates.py +94 -0
  95. equiframe/examples/integrations/sqlite_snapshot.py +246 -0
  96. equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
  97. equiframe/examples/integrations/universal_first_use.py +296 -0
  98. equiframe/examples/journeys/README.md +31 -0
  99. equiframe/examples/journeys/__init__.py +1 -0
  100. equiframe/examples/journeys/a_pandas_refactor.py +88 -0
  101. equiframe/examples/journeys/b_native_migration.py +181 -0
  102. equiframe/examples/journeys/c_retail_etl.py +460 -0
  103. equiframe/examples/journeys/common.py +136 -0
  104. equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
  105. equiframe/examples/journeys/e_telemetry.py +135 -0
  106. equiframe/examples/journeys/f_data_science_preparation.py +84 -0
  107. equiframe/examples/journeys/g_external_snapshots.py +197 -0
  108. equiframe/examples/journeys/h_portable_ci.py +129 -0
  109. equiframe/examples/journeys/requirements.lock +249 -0
  110. equiframe/examples/journeys/verify_journeys.py +117 -0
  111. equiframe/examples/load_convert_compare.py +85 -0
  112. equiframe/execution.py +355 -0
  113. equiframe/execution_types.py +177 -0
  114. equiframe/ingestion.py +744 -0
  115. equiframe/local.py +126 -0
  116. equiframe/migration/__init__.py +94 -0
  117. equiframe/migration/_physical.py +8 -0
  118. equiframe/migration/backends/_timestamp_transport.py +109 -0
  119. equiframe/migration/backends/duckdb.py +651 -0
  120. equiframe/migration/backends/duckdb_decimal.py +125 -0
  121. equiframe/migration/backends/duckdb_time.py +87 -0
  122. equiframe/migration/backends/pandas.py +544 -0
  123. equiframe/migration/backends/polars.py +915 -0
  124. equiframe/migration/backends/polars_decimal.py +231 -0
  125. equiframe/migration/io.py +198 -0
  126. equiframe/migration/ports.py +96 -0
  127. equiframe/migration/runner.py +545 -0
  128. equiframe/migration/runner_types.py +405 -0
  129. equiframe/migration/types.py +354 -0
  130. equiframe/migration/validation.py +619 -0
  131. equiframe/normalization.py +358 -0
  132. equiframe/pipeline.py +274 -0
  133. equiframe/pipeline_types.py +247 -0
  134. equiframe/ports.py +39 -0
  135. equiframe/preprocessing.py +110 -0
  136. equiframe/profile_types.py +343 -0
  137. equiframe/profiles.py +447 -0
  138. equiframe/py.typed +0 -0
  139. equiframe/r_review.py +706 -0
  140. equiframe/reduction.py +518 -0
  141. equiframe/registry.py +121 -0
  142. equiframe/report_rendering.py +366 -0
  143. equiframe/report_types.py +294 -0
  144. equiframe/reporting.py +26 -0
  145. equiframe/rule_types.py +178 -0
  146. equiframe/rules.py +819 -0
  147. equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
  148. equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
  149. equiframe/schemas/r-review-options-1.0.schema.json +38 -0
  150. equiframe/schemas/r-review-result-1.0.schema.json +207 -0
  151. equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
  152. equiframe/serialization.py +152 -0
  153. equiframe/snapshot.py +296 -0
  154. equiframe/sources.py +283 -0
  155. equiframe/tables.py +459 -0
  156. equiframe/types.py +71 -0
  157. equiframe/values.py +252 -0
  158. equiframe/verification_request.py +35 -0
  159. equiframe/verify.py +799 -0
  160. equiframe/verify_types.py +255 -0
  161. equiframe/workflow.py +510 -0
  162. equiframe/workflow_request.py +38 -0
  163. equiframe-0.12.4.dist-info/METADATA +268 -0
  164. equiframe-0.12.4.dist-info/RECORD +170 -0
  165. equiframe-0.12.4.dist-info/WHEEL +4 -0
  166. equiframe-0.12.4.dist-info/entry_points.txt +3 -0
  167. equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
  168. equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
  169. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
  170. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
@@ -0,0 +1,915 @@
1
+ """Polars compiler using native expressions and independent native execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping, Sequence
7
+ from decimal import Decimal, localcontext
8
+ from hashlib import sha256
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from equiframe._decimal import exact_decimal_context
12
+ from equiframe._timezones import (
13
+ _timezone_data,
14
+ _timezone_resource_hash,
15
+ _TimezoneData,
16
+ _TimezoneRule,
17
+ )
18
+ from equiframe.contracts import (
19
+ BooleanType,
20
+ Contract,
21
+ DateType,
22
+ DecimalType,
23
+ FloatType,
24
+ IntegerType,
25
+ LogicalType,
26
+ StringType,
27
+ TimestampType,
28
+ )
29
+ from equiframe.errors import CapabilityError
30
+ from equiframe.registry import PolarsTransformation
31
+
32
+ from ..ports import CompiledPlan, compiler_check, require_supported
33
+ from ..types import (
34
+ Aggregate,
35
+ Arithmetic,
36
+ Boolean,
37
+ Cast,
38
+ Coalesce,
39
+ Column,
40
+ Compare,
41
+ CompilationCheck,
42
+ Concatenate,
43
+ Conditional,
44
+ DecimalRound,
45
+ Deduplicate,
46
+ EquiJoin,
47
+ Expression,
48
+ Filter,
49
+ IRColumn,
50
+ IsNaN,
51
+ IsNull,
52
+ LiteralExpression,
53
+ LocalizeTimestamp,
54
+ Not,
55
+ Project,
56
+ RelationalPlan,
57
+ Scan,
58
+ Window,
59
+ )
60
+ from ..validation import (
61
+ ValidatedIR,
62
+ compilation_identity,
63
+ decimal_limit,
64
+ expression_type,
65
+ integer_bounds,
66
+ iter_expressions,
67
+ literal_scalar,
68
+ )
69
+ from ._timestamp_transport import _TimestampCodec, _TimestampPolarsTransformation
70
+
71
+ if TYPE_CHECKING:
72
+ import polars as pl
73
+
74
+ from equiframe.execution import RunContext
75
+
76
+ _COMPILER_VERSION = "migration-native-ir1.0/polars/product-semantics-v14"
77
+
78
+
79
+ def _dtype(dtype: LogicalType) -> Any:
80
+ import polars as pl
81
+
82
+ if isinstance(dtype, IntegerType):
83
+ return getattr(pl, ("Int" if dtype.signed else "UInt") + str(dtype.bits))
84
+ if isinstance(dtype, DecimalType):
85
+ return pl.Decimal(dtype.precision, dtype.scale)
86
+ if isinstance(dtype, FloatType):
87
+ return pl.Float32 if dtype.bits == 32 else pl.Float64
88
+ if isinstance(dtype, BooleanType):
89
+ return pl.Boolean
90
+ if isinstance(dtype, StringType):
91
+ return pl.String
92
+ if isinstance(dtype, DateType):
93
+ return pl.Date
94
+ if isinstance(dtype, TimestampType):
95
+ return pl.Int64
96
+ raise CapabilityError("unsupported Polars IR type")
97
+
98
+
99
+ def _native_name(position: int) -> str:
100
+ return f"_equiframe_column_{position}"
101
+
102
+
103
+ def _native_frame(frame: pl.DataFrame) -> pl.DataFrame:
104
+ """Enter a collision-free namespace before any native name resolution."""
105
+ return frame.rename({name: _native_name(i) for i, name in enumerate(frame.columns)})
106
+
107
+
108
+ def _logical_frame(frame: pl.DataFrame, names: Sequence[str]) -> pl.DataFrame:
109
+ """Restore the declared names only after native expression evaluation."""
110
+ return frame.rename(dict(zip(frame.columns, names, strict=True)))
111
+
112
+
113
+ def _column(name: str, names: Sequence[str]) -> pl.Expr:
114
+ """Resolve a literal logical name in an already renamed native frame."""
115
+ import polars as pl
116
+
117
+ # Polars 1.30 cannot compose different escaped-name regex selectors. Even
118
+ # nth(index) resolves back to a name which may expand '*' or '^...$'. All
119
+ # evaluated frames therefore use safe generated names, never user patterns.
120
+ return pl.col(_native_name(names.index(name)))
121
+
122
+
123
+ def _native_civil_year(seconds: pl.Expr) -> pl.Expr:
124
+ import polars as pl
125
+
126
+ days = seconds // 86400 + 719468
127
+ era = days // 146097
128
+ day_of_era = days - era * 146097
129
+ year_of_era = (
130
+ day_of_era - day_of_era // 1460 + day_of_era // 36524 - day_of_era // 146096
131
+ ) // 365
132
+ year = year_of_era + era * 400
133
+ day_of_year = day_of_era - (365 * year_of_era + year_of_era // 4 - year_of_era // 100)
134
+ month_prime = (5 * day_of_year + 2) // 153
135
+ month = month_prime + pl.when(month_prime < 10).then(3).otherwise(-9)
136
+ return year + (month <= 2).cast(pl.Int64)
137
+
138
+
139
+ def _native_civil_days(year: pl.Expr, month: int, day: int | pl.Expr) -> pl.Expr:
140
+ adjusted = year - int(month <= 2)
141
+ era = adjusted // 400
142
+ yoe = adjusted - era * 400
143
+ mp = month + (-3 if month > 2 else 9)
144
+ doy = (153 * mp + 2) // 5 + day - 1
145
+ return era * 146097 + yoe * 365 + yoe // 4 - yoe // 100 + doy - 719468
146
+
147
+
148
+ def _native_leap_year(year: pl.Expr) -> pl.Expr:
149
+ return (year % 4 == 0) & ((year % 100 != 0) | (year % 400 == 0))
150
+
151
+
152
+ def _native_rule_seconds(year: pl.Expr, rule: _TimezoneRule) -> pl.Expr:
153
+ import polars as pl
154
+
155
+ if rule.kind == "M":
156
+ month, week, weekday = rule.values
157
+ first = _native_civil_days(year, month, 1)
158
+ day = 1 + (weekday - (first + 4) % 7) % 7 + (week - 1) * 7
159
+ if week == 5:
160
+ length: int | pl.Expr = 29 if month == 2 else 30 if month in (4, 6, 9, 11) else 31
161
+ if month == 2:
162
+ length = 28 + _native_leap_year(year).cast(pl.Int64)
163
+ day = day - pl.when(day > length).then(7).otherwise(0)
164
+ days = _native_civil_days(year, month, day)
165
+ else:
166
+ number = rule.values[0]
167
+ extra = _native_leap_year(year).cast(pl.Int64) if rule.kind == "J" and number >= 60 else 0
168
+ days = _native_civil_days(year, 1, 1) + number - int(rule.kind == "J") + extra
169
+ wide = pl.Decimal(38, 0)
170
+ return days.cast(wide) * pl.lit(Decimal(86400), dtype=wide) + pl.lit(
171
+ Decimal(rule.seconds), dtype=wide
172
+ )
173
+
174
+
175
+ def _native_timezone_offset(
176
+ frame: pl.DataFrame, data: _TimezoneData, context: RunContext
177
+ ) -> pl.DataFrame:
178
+ import polars as pl
179
+
180
+ context.check()
181
+ seconds = pl.col("_seconds")
182
+ if data.transitions:
183
+ boundaries = pl.lit(pl.Series("_boundaries", data.transitions, dtype=pl.Int64))
184
+ history = pl.lit(pl.Series("_offset_values", data.interval_offsets, dtype=pl.Int64)).gather(
185
+ boundaries.search_sorted(seconds, side="right")
186
+ )
187
+ else:
188
+ history = pl.lit(data.interval_offsets[0], dtype=pl.Int64)
189
+ continuation = data.continuation
190
+ if continuation is None:
191
+ return frame.with_columns(history.alias("_actual_offset"))
192
+ if continuation.daylight is None:
193
+ tail = pl.lit(continuation.standard, dtype=pl.Int64)
194
+ else:
195
+ start_rule, end_rule = continuation.start, continuation.end
196
+ if start_rule is None or end_rule is None:
197
+ raise AssertionError("Invalid internal TZif continuation")
198
+ if data.transitions and not frame.select((seconds >= data.transitions[-1]).any()).item():
199
+ return frame.with_columns(history.alias("_actual_offset"))
200
+ frame = frame.with_columns(_native_civil_year(seconds).alias("_year"))
201
+ events = []
202
+ expressions = []
203
+ for index, shift in enumerate((-1, 0, 1)):
204
+ first = "_start" + str(index)
205
+ last = "_end" + str(index)
206
+ expressions.append(
207
+ (
208
+ _native_rule_seconds(pl.col("_year") + shift, start_rule)
209
+ - continuation.standard
210
+ ).alias(first)
211
+ )
212
+ expressions.append(
213
+ (
214
+ _native_rule_seconds(pl.col("_year") + shift, end_rule) - continuation.daylight
215
+ ).alias(last)
216
+ )
217
+ events.extend([(first, continuation.daylight), (last, continuation.standard)])
218
+ frame = frame.with_columns(expressions)
219
+ frame = frame.with_columns(
220
+ pl.max_horizontal(
221
+ [
222
+ pl.when(seconds >= pl.col(name)).then(pl.col(name)).otherwise(None)
223
+ for name, _ in events
224
+ ]
225
+ ).alias("_last_transition")
226
+ )
227
+ tail = pl.lit(continuation.standard, dtype=pl.Int64)
228
+ for name, offset in events:
229
+ tail = pl.when(pl.col("_last_transition") == pl.col(name)).then(offset).otherwise(tail)
230
+ actual_offset = (
231
+ pl.when(seconds >= data.transitions[-1]).then(tail).otherwise(history)
232
+ if data.transitions
233
+ else tail
234
+ )
235
+ return frame.with_columns(actual_offset.alias("_actual_offset"))
236
+
237
+
238
+ def _native_localized_frame(
239
+ frame: pl.DataFrame,
240
+ argument: pl.Expr,
241
+ *,
242
+ data: _TimezoneData,
243
+ unit: str,
244
+ ambiguous: str,
245
+ context: RunContext,
246
+ ) -> tuple[pl.Series, pl.Series, pl.Series, pl.Series]:
247
+ import polars as pl
248
+
249
+ context.check()
250
+ if not (unit in ("s", "ms", "us", "ns") and ambiguous in ("reject", "earlier", "later")):
251
+ raise CapabilityError("Invalid timestamp policy")
252
+ factor = {"s": 1, "ms": 1000, "us": 1000000, "ns": 1000000000}[unit]
253
+ wide = pl.Decimal(38, 0)
254
+ state = frame.with_columns(argument.cast(pl.Int64).alias("_wall")).select("_wall")
255
+ seconds_view = unit == "s"
256
+ if seconds_view:
257
+ state = state.with_columns(
258
+ (pl.col("_wall").cast(wide) * pl.lit(Decimal(1000), dtype=wide))
259
+ .cast(pl.Int64, strict=True)
260
+ .alias("_wall")
261
+ )
262
+ factor = 1000
263
+ state = state.with_columns(
264
+ pl.lit(0, dtype=pl.UInt16).alias("_count"), pl.lit(None, dtype=wide).alias("_chosen")
265
+ )
266
+ for offset in data.offsets:
267
+ context.check()
268
+ candidate = pl.col("_wall").cast(wide) - pl.lit(Decimal(offset * factor), dtype=wide)
269
+ work = state.with_columns(candidate.alias("_candidate"))
270
+ work = work.with_columns(
271
+ (pl.col("_wall") // factor - offset).fill_null(0).alias("_seconds")
272
+ )
273
+ work = _native_timezone_offset(work, data, context)
274
+ valid = (pl.col("_wall").is_not_null() & (pl.col("_actual_offset") == offset)).fill_null(
275
+ False
276
+ )
277
+ work = work.with_columns(valid.alias("_valid_candidate"))
278
+ work = work.with_columns(
279
+ pl.when(pl.col("_valid_candidate"))
280
+ .then(pl.col("_candidate"))
281
+ .otherwise(None)
282
+ .alias("_candidate_option")
283
+ )
284
+ previous = pl.col("_chosen")
285
+ option = pl.col("_candidate_option")
286
+ better = option > previous if ambiguous == "later" else option < previous
287
+ choice = pl.when(previous.is_null() | better).then(option).otherwise(previous)
288
+ state = work.select(
289
+ pl.col("_wall"),
290
+ (pl.col("_count") + pl.col("_valid_candidate").cast(pl.UInt16)).alias("_count"),
291
+ choice.alias("_chosen"),
292
+ )
293
+ fits = pl.col("_chosen").is_between(
294
+ pl.lit(Decimal(-(2**63)), dtype=wide), pl.lit(Decimal(2**63 - 1), dtype=wide)
295
+ )
296
+ state = state.with_columns(
297
+ (pl.col("_chosen").is_not_null() & ~fits).fill_null(False).alias("_output_range_failed")
298
+ )
299
+ state = state.with_columns(
300
+ pl.when(fits)
301
+ .then(pl.col("_chosen"))
302
+ .otherwise(None)
303
+ .cast(pl.Int64, strict=True)
304
+ .alias("_value")
305
+ )
306
+ if seconds_view:
307
+ state = state.with_columns((pl.col("_value") // 1000).alias("_value"))
308
+ return (
309
+ state["_value"],
310
+ state["_count"],
311
+ state["_output_range_failed"],
312
+ state["_wall"].is_not_null(),
313
+ )
314
+
315
+
316
+ def _expression(
317
+ expression: Expression,
318
+ columns: tuple[IRColumn, ...],
319
+ prepared: Mapping[int, pl.Expr] | None = None,
320
+ ) -> pl.Expr:
321
+ import polars as pl
322
+
323
+ if prepared is not None and id(expression) in prepared:
324
+ return prepared[id(expression)]
325
+ if isinstance(expression, Column):
326
+ return _column(expression.name, tuple(column.name for column in columns))
327
+ if isinstance(expression, LiteralExpression):
328
+ scalar = literal_scalar(expression)
329
+ return pl.lit(scalar, dtype=_dtype(expression.logical_type))
330
+ if isinstance(expression, Arithmetic):
331
+ operand = expression_type(expression.left, columns).logical_type
332
+ if (
333
+ isinstance(operand, IntegerType)
334
+ or expression.operation == "divide"
335
+ or (expression.operation == "multiply" and isinstance(operand, DecimalType))
336
+ ):
337
+ raise CapabilityError("arithmetic requires its native eager checkpoint")
338
+ scale = operand.scale if isinstance(operand, DecimalType) else 0
339
+ left = _expression(expression.left, columns, prepared).cast(
340
+ pl.Decimal(38, scale), strict=True
341
+ )
342
+ right = _expression(expression.right, columns, prepared).cast(
343
+ pl.Decimal(38, scale), strict=True
344
+ )
345
+ result = {
346
+ "add": lambda: left + right,
347
+ "subtract": lambda: left - right,
348
+ "multiply": lambda: left * right,
349
+ }[expression.operation]()
350
+ return result.cast(_dtype(expression.logical_type), strict=True)
351
+ if isinstance(expression, DecimalRound):
352
+ raise CapabilityError("DecimalRound requires its native eager checkpoint")
353
+ if isinstance(expression, LocalizeTimestamp):
354
+ raise CapabilityError("timestamp localization requires its native eager checkpoint")
355
+ if isinstance(expression, Coalesce):
356
+ return pl.coalesce([_expression(item, columns, prepared) for item in expression.arguments])
357
+ if isinstance(expression, Conditional):
358
+ return (
359
+ pl.when(_expression(expression.predicate, columns, prepared))
360
+ .then(_expression(expression.then, columns, prepared))
361
+ .otherwise(_expression(expression.otherwise, columns, prepared))
362
+ )
363
+ if isinstance(expression, Compare):
364
+ left = _expression(expression.left, columns, prepared)
365
+ right = _expression(expression.right, columns, prepared)
366
+ operation = {"eq": "eq", "ne": "ne", "lt": "lt", "le": "le", "gt": "gt", "ge": "ge"}[
367
+ expression.operation
368
+ ]
369
+ result = getattr(left, operation)(right)
370
+ if isinstance(expression_type(expression.left, columns).logical_type, FloatType):
371
+ nan = left.is_nan().fill_null(False) | right.is_nan().fill_null(False)
372
+ result = pl.when(nan).then(pl.lit(expression.operation == "ne")).otherwise(result)
373
+ return (
374
+ pl.when(left.is_null() | right.is_null())
375
+ .then(pl.lit(None, dtype=pl.Boolean))
376
+ .otherwise(result)
377
+ )
378
+ if isinstance(expression, Boolean):
379
+ values = [_expression(item, columns, prepared) for item in expression.arguments]
380
+ result = values[0]
381
+ for value in values[1:]:
382
+ result = result & value if expression.operation == "and" else result | value
383
+ return result
384
+ argument = _expression(expression.argument, columns, prepared)
385
+ if isinstance(expression, Not):
386
+ return ~argument
387
+ if isinstance(expression, IsNull):
388
+ return argument.is_null()
389
+ if isinstance(expression, IsNaN):
390
+ return argument.is_nan().fill_null(False)
391
+ raise CapabilityError("unknown Polars expression")
392
+
393
+
394
+ def _check_localizations(
395
+ frame: pl.DataFrame,
396
+ expression: Expression,
397
+ columns: tuple[IRColumn, ...],
398
+ prepared: Mapping[int, pl.Expr],
399
+ ) -> None:
400
+ """Validate each fallible native localization before relational pruning."""
401
+ import polars as pl
402
+
403
+ for item in iter_expressions(expression):
404
+ if not isinstance(item, LocalizeTimestamp):
405
+ continue
406
+ dtype = expression_type(item.argument, columns).logical_type
407
+ if not (isinstance(dtype, TimestampType)):
408
+ raise AssertionError("Internal invariant check failed.")
409
+ before = _expression(item.argument, columns, prepared).cast(pl.Int64)
410
+ after = _expression(item, columns, prepared).cast(pl.Int64)
411
+ # Decimal(38,0) subtraction is native and wide enough for the full
412
+ # difference of two signed int64 values. An int64 subtraction here
413
+ # would wrap too and silently certify the broken timestamp kernel.
414
+ maximum_offset = 86400 * {"s": 1, "ms": 1000, "us": 1000000, "ns": 1000000000}[dtype.unit]
415
+ delta = (after.cast(pl.Decimal(38, 0)) - before.cast(pl.Decimal(38, 0))).abs()
416
+ bad = (before.is_not_null() & after.is_null()) | (
417
+ delta > pl.lit(Decimal(maximum_offset), dtype=pl.Decimal(38, 0))
418
+ )
419
+ if frame.select(bad.fill_null(False).any()).item():
420
+ raise CapabilityError("Polars timestamp localization exceeds faithful int64 range")
421
+
422
+
423
+ def _prepare_arithmetic(
424
+ frame: pl.DataFrame,
425
+ expressions: tuple[Expression, ...],
426
+ columns: tuple[IRColumn, ...],
427
+ context: RunContext,
428
+ zones: Mapping[str, _TimezoneData],
429
+ ) -> tuple[pl.DataFrame, dict[int, pl.Expr]]:
430
+ import polars as pl
431
+
432
+ from .polars_decimal import divide, multiply
433
+
434
+ prepared: dict[int, pl.Expr] = {}
435
+ for expression in expressions:
436
+ for item in reversed(tuple(iter_expressions(expression))):
437
+ if id(item) in prepared:
438
+ continue
439
+ if isinstance(item, LocalizeTimestamp):
440
+ declared = expression_type(item.argument, columns).logical_type
441
+ if not isinstance(declared, TimestampType):
442
+ raise CapabilityError("timestamp localization requires a timestamp argument")
443
+ values, counts, outside, present = _native_localized_frame(
444
+ frame,
445
+ _expression(item.argument, columns, prepared),
446
+ data=zones[item.timezone],
447
+ unit=declared.unit,
448
+ ambiguous=item.ambiguous,
449
+ context=context,
450
+ )
451
+ if ((counts == 0) & present).any():
452
+ raise pl.exceptions.ComputeError(
453
+ "Timestamp localization: nonexistent wall time"
454
+ )
455
+ if item.ambiguous == "reject" and ((counts > 1) & present).any():
456
+ raise pl.exceptions.ComputeError("Timestamp localization: ambiguous wall time")
457
+ if outside.any():
458
+ raise CapabilityError(
459
+ "Polars timestamp localization exceeds faithful int64 range"
460
+ )
461
+ prefix = f"_equiframe_timestamp_{len(prepared)}_"
462
+ while any(name.startswith(prefix) for name in frame.columns):
463
+ prefix += "_"
464
+ name = prefix + "result"
465
+ frame = frame.with_columns(values.alias(name))
466
+ prepared[id(item)] = pl.col(name)
467
+ context.check()
468
+ continue
469
+ if isinstance(item, DecimalRound):
470
+ declared = expression_type(item.argument, columns).logical_type
471
+ if not isinstance(declared, DecimalType):
472
+ raise CapabilityError("DecimalRound requires a Decimal argument")
473
+ prefix = f"_equiframe_round_{len(prepared)}_"
474
+ while any(name.startswith(prefix) for name in frame.columns):
475
+ prefix += "_"
476
+ # The checked native coefficient quotient rounds before its
477
+ # destination cast. A wide input carry never materializes a
478
+ # 39-digit value in the narrower input Decimal storage.
479
+ frame, result = divide(
480
+ frame,
481
+ _expression(item.argument, columns, prepared),
482
+ pl.lit(Decimal(1), dtype=pl.Decimal(1, 0)),
483
+ left_scale=declared.scale,
484
+ right_scale=0,
485
+ precision=item.logical_type.precision,
486
+ scale=item.logical_type.scale,
487
+ rounding=item.rounding,
488
+ prefix=prefix,
489
+ context=context,
490
+ )
491
+ prepared[id(item)] = result
492
+ continue
493
+ if not isinstance(item, Arithmetic):
494
+ continue
495
+ left = expression_type(item.left, columns).logical_type
496
+ right = expression_type(item.right, columns).logical_type
497
+ output = item.logical_type
498
+ if isinstance(left, IntegerType) and isinstance(output, IntegerType):
499
+ prefix = f"_equiframe_arithmetic_{len(prepared)}_"
500
+ lower, upper = integer_bounds(output)
501
+ first = _expression(item.left, columns, prepared)
502
+ second = _expression(item.right, columns, prepared)
503
+ context.check()
504
+ if item.operation == "multiply":
505
+ # UInt64 products can exceed signed Int128 and Decimal128.
506
+ # Prove a safe coefficient bound before any native product.
507
+ frame, result = multiply(
508
+ frame,
509
+ first,
510
+ second,
511
+ precision=len(str(max(abs(lower), abs(upper)))),
512
+ scale=0,
513
+ prefix=prefix,
514
+ context=context,
515
+ )
516
+ else:
517
+ first = first.cast(pl.String).cast(pl.Decimal(38, 0))
518
+ second = second.cast(pl.String).cast(pl.Decimal(38, 0))
519
+ raw = first + second if item.operation == "add" else first - second
520
+ frame = frame.with_columns(raw.alias(prefix + "result"))
521
+ result = pl.col(prefix + "result")
522
+ # Check the exact signed/unsigned destination range before its
523
+ # native integer cast; a cast must never conceal an overflow.
524
+ low = pl.lit(str(lower)).cast(pl.Decimal(38, 0))
525
+ high = pl.lit(str(upper)).cast(pl.Decimal(38, 0))
526
+ if frame.select(((result < low) | (result > high)).any()).item():
527
+ raise CapabilityError("Polars integer arithmetic exceeds declared range")
528
+ context.check()
529
+ prepared[id(item)] = result.cast(pl.String).cast(_dtype(output), strict=True)
530
+ continue
531
+ if item.operation not in ("divide", "multiply"):
532
+ continue
533
+ if not (
534
+ isinstance(left, DecimalType)
535
+ and isinstance(right, DecimalType)
536
+ and isinstance(output, DecimalType)
537
+ ):
538
+ raise CapabilityError("Decimal arithmetic requires declared Decimal types")
539
+ if item.operation == "divide":
540
+ frame, result = divide(
541
+ frame,
542
+ _expression(item.left, columns, prepared),
543
+ _expression(item.right, columns, prepared),
544
+ left_scale=left.scale,
545
+ right_scale=right.scale,
546
+ precision=output.precision,
547
+ scale=output.scale,
548
+ rounding=item.rounding,
549
+ prefix=f"_equiframe_arithmetic_{len(prepared)}_",
550
+ context=context,
551
+ )
552
+ else:
553
+ frame, result = multiply(
554
+ frame,
555
+ _expression(item.left, columns, prepared),
556
+ _expression(item.right, columns, prepared),
557
+ precision=output.precision,
558
+ scale=output.scale,
559
+ prefix=f"_equiframe_arithmetic_{len(prepared)}_",
560
+ context=context,
561
+ )
562
+ prepared[id(item)] = result
563
+ return frame, prepared
564
+
565
+
566
+ def _coerce(frame: pl.DataFrame, validated: ValidatedIR, node_id: str) -> pl.DataFrame:
567
+ for column in validated.schema(node_id):
568
+ dtype, series = column.logical_type, frame[column.name]
569
+ if not column.nullable and series.null_count():
570
+ raise CapabilityError("IR output contains forbidden NULL: " + column.name)
571
+ if isinstance(dtype, (IntegerType, DecimalType)) and frame.height:
572
+ lower, upper = (
573
+ integer_bounds(dtype)
574
+ if isinstance(dtype, IntegerType)
575
+ else (decimal_limit(dtype).copy_negate(), decimal_limit(dtype))
576
+ )
577
+ minimum, maximum = series.min(), series.max()
578
+ if minimum is not None:
579
+ if not isinstance(minimum, (int, Decimal)) or not isinstance(
580
+ maximum, (int, Decimal)
581
+ ):
582
+ raise CapabilityError("numeric IR column has incompatible native values")
583
+ if minimum < lower or maximum > upper:
584
+ raise CapabilityError(
585
+ "IR output exceeds declared numeric range: " + column.name
586
+ )
587
+ names = frame.columns
588
+ selected = _native_frame(frame).select(
589
+ [
590
+ _column(c.name, names).cast(_dtype(c.logical_type), strict=True)
591
+ for c in validated.schema(node_id)
592
+ ]
593
+ )
594
+ return _logical_frame(selected, [c.name for c in validated.schema(node_id)])
595
+
596
+
597
+ def _aggregate(frame: pl.DataFrame, node: Aggregate, validated: ValidatedIR) -> pl.DataFrame:
598
+ import polars as pl
599
+
600
+ names = frame.columns
601
+ frame = _native_frame(frame)
602
+ if node.null_groups == "drop" and node.group_by:
603
+ frame = frame.filter(
604
+ pl.all_horizontal([_column(name, names).is_not_null() for name in node.group_by])
605
+ )
606
+ expressions = []
607
+ for i, measure in enumerate(node.measures):
608
+ if measure.operation == "count_rows":
609
+ result = pl.len()
610
+ else:
611
+ if not (measure.column is not None):
612
+ raise AssertionError("Internal invariant check failed.")
613
+ column = _column(measure.column, names)
614
+ if measure.operation == "count_valid":
615
+ result = column.count()
616
+ elif measure.operation == "sum":
617
+ scale = (
618
+ measure.logical_type.scale
619
+ if isinstance(measure.logical_type, DecimalType)
620
+ else 0
621
+ )
622
+ promoted = column.cast(pl.Decimal(38, scale), strict=True)
623
+ # Polars grouped Decimal SUM can wrap i128. A conservative native
624
+ # bound proves safety before any sum; it may reject cancellation.
625
+ if frame.height:
626
+ with localcontext(exact_decimal_context()):
627
+ threshold = Decimal((10**38 - 1) // frame.height).scaleb(-scale)
628
+ maximum = frame.select(promoted.abs().max()).item()
629
+ if maximum is not None and maximum > threshold:
630
+ raise CapabilityError(
631
+ "Polars SUM exceeds conservative exact accumulation bound"
632
+ )
633
+ zero = Decimal((0, (0,), -scale))
634
+ result = (
635
+ pl.when(column.count() == 0)
636
+ .then(
637
+ pl.lit(
638
+ None if node.empty_sum == "null" else zero, dtype=pl.Decimal(38, scale)
639
+ )
640
+ )
641
+ .otherwise(promoted.sum())
642
+ )
643
+ else:
644
+ result = getattr(column, measure.operation)()
645
+ expressions.append(result.alias(f"_equiframe_measure_{i}"))
646
+ aggregated = (
647
+ frame.group_by([_column(name, names) for name in node.group_by], maintain_order=True).agg(
648
+ expressions
649
+ )
650
+ if node.group_by
651
+ else frame.select(expressions)
652
+ )
653
+ logical = _logical_frame(aggregated, [*node.group_by, *(m.name for m in node.measures)])
654
+ return _coerce(logical, validated, node.node_id)
655
+
656
+
657
+ def _join(left: pl.DataFrame, right: pl.DataFrame, node: EquiJoin) -> pl.DataFrame:
658
+ """Keep join-key selectors scalar using collision-free native-local names."""
659
+ # Polars forbids selector expressions as join keys, including by_name().
660
+ # Rename every column simultaneously into disjoint safe namespaces; this
661
+ # also handles inputs whose real names equal one of these internal names.
662
+ left_names = {name: f"_equiframe_join_left_{i}" for i, name in enumerate(left.columns)}
663
+ right_names = {name: f"_equiframe_join_right_{i}" for i, name in enumerate(right.columns)}
664
+ joined = left.rename(left_names).join(
665
+ right.rename(right_names),
666
+ left_on=[left_names[key.left] for key in node.keys],
667
+ right_on=[right_names[key.right] for key in node.keys],
668
+ how=node.how,
669
+ nulls_equal=node.null_keys == "equal",
670
+ coalesce=False,
671
+ maintain_order="left_right",
672
+ )
673
+ restored = {temporary: name for name, temporary in left_names.items()}
674
+ if node.how not in ("semi", "anti"):
675
+ restored.update(
676
+ {temporary: node.right_prefix + name for name, temporary in right_names.items()}
677
+ )
678
+ return joined.rename(restored)
679
+
680
+
681
+ def _plan_timezone_data(plan: RelationalPlan) -> dict[str, _TimezoneData]:
682
+ zones: dict[str, _TimezoneData] = {}
683
+ for node in plan.nodes:
684
+ roots = (
685
+ tuple(item.expression for item in node.columns)
686
+ if isinstance(node, Project)
687
+ else (node.predicate,)
688
+ if isinstance(node, Filter)
689
+ else ()
690
+ )
691
+ for root in roots:
692
+ for expression in iter_expressions(root):
693
+ if isinstance(expression, LocalizeTimestamp) and expression.timezone not in zones:
694
+ zones[expression.timezone] = _timezone_data(expression.timezone)
695
+ return zones
696
+
697
+
698
+ class PolarsCompiler:
699
+ engine = "polars"
700
+
701
+ def check(self, plan: RelationalPlan, contract: Contract) -> CompilationCheck:
702
+ return compiler_check(self.engine, plan, contract)
703
+
704
+ def compile(self, plan: RelationalPlan, contract: Contract) -> CompiledPlan:
705
+ check = self.check(plan, contract)
706
+ if not check.supported:
707
+ raise CapabilityError(check.diagnostics[0].message)
708
+ validated = require_supported(self.engine, plan, contract)
709
+ zones = _plan_timezone_data(plan)
710
+ version = _COMPILER_VERSION
711
+ if zones:
712
+ resource_bytes = json.dumps(
713
+ {key: value.resource_sha256 for key, value in sorted(zones.items())},
714
+ sort_keys=True,
715
+ separators=(",", ":"),
716
+ ).encode()
717
+ version += "/tzif-" + sha256(resource_bytes).hexdigest()
718
+ identifier, ir_sha, contract_sha = compilation_identity(
719
+ validated,
720
+ self.engine,
721
+ implementation_version=version,
722
+ )
723
+
724
+ def transform(tables: Mapping[str, pl.DataFrame], context: RunContext) -> pl.DataFrame:
725
+ import polars as pl
726
+
727
+ for key, data in zones.items():
728
+ context.check()
729
+ if _timezone_resource_hash(key) != data.resource_sha256:
730
+ raise CapabilityError("timezone resource changed after compilation")
731
+ frames: dict[str, pl.DataFrame] = {}
732
+ for node in plan.nodes:
733
+ context.check()
734
+ if isinstance(node, Scan):
735
+ frame = tables[node.table].clone()
736
+ elif isinstance(node, Project):
737
+ native, prepared = _prepare_arithmetic(
738
+ _native_frame(frames[node.source]),
739
+ tuple(item.expression for item in node.columns),
740
+ validated.schema(node.source),
741
+ context,
742
+ zones,
743
+ )
744
+ for item in node.columns:
745
+ _check_localizations(
746
+ native, item.expression, validated.schema(node.source), prepared
747
+ )
748
+ # Stored source frames have validated order after _coerce.
749
+ # Evaluate every expression against the same renamed source;
750
+ # distinct temporary output names preserve simultaneous aliases.
751
+ frame = native.with_columns(
752
+ [
753
+ _expression(
754
+ item.expression, validated.schema(node.source), prepared
755
+ ).alias(f"_equiframe_project_{i}")
756
+ for i, item in enumerate(node.columns)
757
+ ]
758
+ )
759
+ frame = frame.select(
760
+ [pl.col(f"_equiframe_project_{i}") for i in range(len(node.columns))]
761
+ )
762
+ frame = _logical_frame(frame, [item.name for item in node.columns])
763
+ elif isinstance(node, Filter):
764
+ source = frames[node.source]
765
+ native, prepared = _prepare_arithmetic(
766
+ _native_frame(source),
767
+ (node.predicate,),
768
+ validated.schema(node.source),
769
+ context,
770
+ zones,
771
+ )
772
+ _check_localizations(
773
+ native, node.predicate, validated.schema(node.source), prepared
774
+ )
775
+ frame = _logical_frame(
776
+ native.filter(
777
+ _expression(node.predicate, validated.schema(node.source), prepared)
778
+ ).select([pl.col(_native_name(i)) for i in range(len(source.columns))]),
779
+ source.columns,
780
+ )
781
+ elif isinstance(node, Cast):
782
+ frame = frames[node.source]
783
+ elif isinstance(node, EquiJoin):
784
+ for side, names, unique in (
785
+ (
786
+ frames[node.left],
787
+ [key.left for key in node.keys],
788
+ node.cardinality in ("one_to_one", "one_to_many"),
789
+ ),
790
+ (
791
+ frames[node.right],
792
+ [key.right for key in node.keys],
793
+ node.cardinality in ("one_to_one", "many_to_one"),
794
+ ),
795
+ ):
796
+ native = _native_frame(side)
797
+ matchable = (
798
+ native
799
+ if node.null_keys == "equal"
800
+ else native.filter(
801
+ pl.all_horizontal(
802
+ [_column(name, side.columns).is_not_null() for name in names]
803
+ )
804
+ )
805
+ )
806
+ if (
807
+ unique
808
+ and matchable.select(
809
+ pl.struct([_column(name, side.columns) for name in names])
810
+ .is_duplicated()
811
+ .any()
812
+ ).item()
813
+ ):
814
+ raise CapabilityError(
815
+ "Join violates declared cardinality: " + node.cardinality
816
+ )
817
+ frame = _join(frames[node.left], frames[node.right], node)
818
+ elif isinstance(node, Window):
819
+ names = frames[node.source].columns
820
+ frame = _native_frame(frames[node.source])
821
+ tie_keys = list(
822
+ dict.fromkeys((*node.partition_by, *(key.column for key in node.by)))
823
+ )
824
+ if frame.select(
825
+ pl.struct([_column(name, names) for name in tie_keys]).is_duplicated().any()
826
+ ).item():
827
+ raise CapabilityError("Window encountered forbidden order ties")
828
+ frame = frame.sort(
829
+ [_column(key.column, names) for key in node.by],
830
+ descending=[key.direction == "descending" for key in node.by],
831
+ nulls_last=[key.nulls == "last" for key in node.by],
832
+ maintain_order=True,
833
+ )
834
+ window_expressions: list[pl.Expr] = []
835
+ for window_measure in node.measures:
836
+ if window_measure.operation == "row_number":
837
+ value = pl.int_range(1, pl.len() + 1, dtype=pl.Int64)
838
+ else:
839
+ if window_measure.column is None:
840
+ raise CapabilityError("lag/lead require an input column")
841
+ offset = (
842
+ window_measure.offset
843
+ if window_measure.operation == "lag"
844
+ else -window_measure.offset
845
+ )
846
+ value = _column(window_measure.column, names).shift(offset)
847
+ if node.partition_by:
848
+ value = value.over([_column(name, names) for name in node.partition_by])
849
+ window_expressions.append(
850
+ value.alias(f"_equiframe_window_{len(window_expressions)}")
851
+ )
852
+ frame = frame.with_columns(window_expressions)
853
+ frame = _logical_frame(
854
+ frame, [*names, *(window_measure.name for window_measure in node.measures)]
855
+ )
856
+ elif isinstance(node, Aggregate):
857
+ frame = _aggregate(frames[node.source], node, validated)
858
+ elif isinstance(node, Concatenate):
859
+ frame = pl.concat([frames[ref] for ref in node.sources], how="vertical")
860
+ elif isinstance(node, Deduplicate):
861
+ names = frames[node.source].columns
862
+ frame = _native_frame(frames[node.source])
863
+ if node.keep == "first":
864
+ tie_keys = list(
865
+ dict.fromkeys((*node.keys, *(key.column for key in node.by)))
866
+ )
867
+ if frame.select(
868
+ pl.struct([_column(name, names) for name in tie_keys])
869
+ .is_duplicated()
870
+ .any()
871
+ ).item():
872
+ raise CapabilityError("Deduplicate encountered forbidden tiebreak ties")
873
+ frame = frame.sort(
874
+ [_column(key.column, names) for key in node.by],
875
+ descending=[key.direction == "descending" for key in node.by],
876
+ nulls_last=[key.nulls == "last" for key in node.by],
877
+ maintain_order=True,
878
+ )
879
+ frame = frame.unique(
880
+ subset=[_native_name(names.index(name)) for name in node.keys],
881
+ keep="first",
882
+ maintain_order=True,
883
+ )
884
+ frame = _logical_frame(frame, names)
885
+ else:
886
+ names = frames[node.source].columns
887
+ frame = _native_frame(frames[node.source])
888
+ if (
889
+ node.ties == "reject"
890
+ and frame.select(
891
+ pl.struct([_column(key.column, names) for key in node.by])
892
+ .is_duplicated()
893
+ .any()
894
+ ).item()
895
+ ):
896
+ raise CapabilityError("Sort encountered forbidden ties")
897
+ frame = frame.sort(
898
+ [_column(key.column, names) for key in node.by],
899
+ descending=[key.direction == "descending" for key in node.by],
900
+ nulls_last=[key.nulls == "last" for key in node.by],
901
+ maintain_order=True,
902
+ )
903
+ frame = _logical_frame(frame, names)
904
+ frames[node.node_id] = _coerce(frame, validated, node.node_id)
905
+ return frames[plan.output]
906
+
907
+ codec = _TimestampCodec.for_contract(contract)
908
+ transformation = (
909
+ _TimestampPolarsTransformation(identifier, transform, version, timestamp_codec=codec)
910
+ if codec.inputs or codec.output
911
+ else PolarsTransformation(identifier, transform, version)
912
+ )
913
+ return CompiledPlan(
914
+ self.engine, transformation, ir_sha, contract_sha, self.check(plan, contract)
915
+ )