test_rule_polars.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865
  1. from __future__ import annotations
  2. import copy
  3. from datetime import date, datetime
  4. import polars as pl
  5. import pytest
  6. from app.core.common.identifiers import new_governance_uid
  7. from app.runner.nodes import NodeExecutionError
  8. from tests.core.data_rules.test_polars_compiler import (
  9. _backend,
  10. _binding,
  11. _published_rule,
  12. _schema,
  13. )
  14. from tests.runner.test_artifacts import FakeMinio
  15. def _plan_store(client):
  16. from app.runner.artifacts import ArtifactStore
  17. return ArtifactStore(
  18. client,
  19. bucket="dataops-rules",
  20. max_artifact_bytes=8 * 1024 * 1024,
  21. max_rows=10_000,
  22. memory_limit_bytes=256 * 1024 * 1024,
  23. max_ttl_seconds=3600,
  24. )
  25. class Resolver:
  26. def __init__(self, values, artifact_store):
  27. self.values = values
  28. self.artifact_store = artifact_store
  29. self.calls = []
  30. self.events = []
  31. self.attest_error = None
  32. self.publish_error = None
  33. self.registrations = []
  34. self.handoffs = {}
  35. def resolve(self, *, binding_id, correlation_id, kind):
  36. self.calls.append((binding_id, correlation_id))
  37. self.events.append(("resolve", binding_id, kind))
  38. return self.values[binding_id]
  39. def attest_binding(self, *, binding_id, binding_hash, access_mode):
  40. self.events.append(("attest", binding_id, binding_hash, access_mode))
  41. if self.attest_error is not None:
  42. raise self.attest_error
  43. return {"binding_hash": binding_hash}
  44. def resolve_handoff(self, *, artifact_ref, correlation_id):
  45. self.events.append(("handoff", artifact_ref, correlation_id))
  46. return self.handoffs[artifact_ref]
  47. def publish_path(
  48. self,
  49. path,
  50. *,
  51. binding_id,
  52. correlation_id,
  53. kind,
  54. binding_hash,
  55. ttl_seconds,
  56. schema_fields,
  57. limits,
  58. ):
  59. self.events.append(("publish", binding_id, kind))
  60. if self.publish_error is not None:
  61. raise self.publish_error
  62. artifact = self.artifact_store.write_path(
  63. path,
  64. correlation_id,
  65. ttl_seconds,
  66. schema_fields=schema_fields,
  67. limits=limits,
  68. )
  69. self.registrations.append(
  70. {
  71. "binding_id": binding_id,
  72. "correlation_id": correlation_id,
  73. "artifact": artifact,
  74. "kind": kind,
  75. "binding_hash": binding_hash,
  76. }
  77. )
  78. return artifact
  79. def _compiled_plan(steps):
  80. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  81. fields = [
  82. ("customer_id", "integer", False),
  83. ("name", "string", True),
  84. ("mobile", "string", True),
  85. ]
  86. input_schema = _schema("bd:customer:raw", fields)
  87. output_schema = _schema("bd:customer:clean", fields)
  88. input_binding = _binding(input_schema, access_mode="read")
  89. output_binding = _binding(output_schema, access_mode="write")
  90. rule = _published_rule(input_schema, output_schema, steps)
  91. compiled = PolarsRuleCompiler().compile(
  92. rule_version=rule,
  93. input_schema=input_schema,
  94. output_schema=output_schema,
  95. input_binding=input_binding,
  96. output_binding=output_binding,
  97. backend=_backend(memory_limit_bytes=128 * 1024 * 1024),
  98. )
  99. return compiled, input_binding
  100. def _node(compiled):
  101. return {
  102. "id": "task5_polars",
  103. "type": "rule.apply",
  104. "purpose": "write",
  105. "idempotency": {
  106. "strategy": "deduplication_key",
  107. "key": "customer_id",
  108. },
  109. "config": {
  110. "component_binding_id": new_governance_uid(),
  111. "rule_version_id": compiled["plan"]["rule_version_id"],
  112. "execution_plan_hash": compiled["plan_hash"],
  113. },
  114. }
  115. def test_polars_adapter_reconstructs_assert_and_deduplicate_and_writes_artifact():
  116. from app.runner.rule_polars import PolarsRulePlanAdapter
  117. compiled, input_binding = _compiled_plan(
  118. [
  119. {
  120. "id": "trim_name",
  121. "op": "normalize_text",
  122. "column": "name",
  123. "trim": True,
  124. },
  125. {
  126. "id": "mobile_format",
  127. "op": "assert",
  128. "expression": "matches(mobile, '^[0-9]{11}$')",
  129. "on_failure": "reject",
  130. "severity": "error",
  131. },
  132. {
  133. "id": "one_customer",
  134. "op": "deduplicate",
  135. "keys": ["customer_id"],
  136. "order_by": ["name"],
  137. "keep": "first",
  138. },
  139. ]
  140. )
  141. store = _plan_store(FakeMinio())
  142. correlation_id = new_governance_uid()
  143. source = store.write(
  144. pl.DataFrame(
  145. {
  146. "customer_id": [1, 1, 2],
  147. "name": [" Alice ", "Alice B", " Bad "],
  148. "mobile": ["13800138000", "13800138000", "invalid"],
  149. }
  150. ).lazy(),
  151. correlation_id,
  152. 600,
  153. schema_fields=compiled["plan"]["input_fields"],
  154. limits=compiled["plan"]["resource_limits"],
  155. )
  156. resolver = Resolver(
  157. {
  158. input_binding["id"]: {
  159. **source,
  160. "binding_hash": compiled["plan"]["input_binding_hash"],
  161. }
  162. },
  163. store,
  164. )
  165. adapter = PolarsRulePlanAdapter(
  166. artifact_store=store,
  167. artifact_resolver=resolver,
  168. masking_policies={
  169. "customer_mobile_last4": "preserve_last_4"
  170. },
  171. artifact_ttl_seconds=300,
  172. )
  173. result = adapter.execute(
  174. plan=compiled["plan"],
  175. node=_node(compiled),
  176. parameters={},
  177. write_authorized=True,
  178. correlation_id=correlation_id,
  179. )
  180. assert result["rows_in"] == 3
  181. assert result["rows_out"] == 1
  182. assert result["rows_rejected"] == 1
  183. assert result["rows_filtered"] == 0
  184. assert result["rows_deduplicated"] == 1
  185. assert result["rows_join_dropped"] == 0
  186. assert result["rows_aggregated"] == 0
  187. assert result["violation_count"] == 1
  188. assert result["violations"] == [
  189. {"step_id": "mobile_format", "count": 1}
  190. ]
  191. assert len(result["_violation_sample"]) == 1
  192. assert result["_violation_sample"][0]["mobile"] == "[REDACTED]"
  193. assert result["commit_outcome"] == "committed"
  194. assert "schema_fields" not in result
  195. assert resolver.events[0] == (
  196. "attest",
  197. compiled["plan"]["output_binding_id"],
  198. compiled["plan"]["output_binding_hash"],
  199. "write",
  200. )
  201. assert resolver.registrations[0]["kind"] == "output"
  202. assert store.read(
  203. result["artifact_ref"],
  204. result["digest"],
  205. expected_schema_fields=compiled["plan"]["output_fields"],
  206. limits=compiled["plan"]["resource_limits"],
  207. ).collect().to_dicts() == [
  208. {
  209. "customer_id": 1,
  210. "name": "Alice",
  211. "mobile": "13800138000",
  212. }
  213. ]
  214. def test_polars_adapter_executes_quality_node_without_business_write_authority():
  215. from app.runner.rule_polars import PolarsRulePlanAdapter
  216. compiled, input_binding = _compiled_plan(
  217. [
  218. {
  219. "id": "mobile_format",
  220. "op": "assert",
  221. "expression": "matches(mobile, '^[0-9]{11}$')",
  222. "on_failure": "quarantine",
  223. "severity": "error",
  224. }
  225. ]
  226. )
  227. store = _plan_store(FakeMinio())
  228. correlation_id = new_governance_uid()
  229. source = store.write(
  230. pl.DataFrame(
  231. {
  232. "customer_id": [1, 2],
  233. "name": ["Alice", "Bad"],
  234. "mobile": ["13800138000", "invalid"],
  235. }
  236. ),
  237. correlation_id,
  238. 600,
  239. schema_fields=compiled["plan"]["input_fields"],
  240. limits=compiled["plan"]["resource_limits"],
  241. )
  242. resolver = Resolver(
  243. {
  244. input_binding["id"]: {
  245. **source,
  246. "binding_hash": compiled["plan"]["input_binding_hash"],
  247. }
  248. },
  249. store,
  250. )
  251. node = _node(compiled)
  252. node["type"] = "quality.check"
  253. node["purpose"] = "read"
  254. del node["idempotency"]
  255. result = PolarsRulePlanAdapter(
  256. artifact_store=store,
  257. artifact_resolver=resolver,
  258. ).execute(
  259. plan=compiled["plan"],
  260. node=node,
  261. parameters={},
  262. write_authorized=False,
  263. correlation_id=correlation_id,
  264. )
  265. assert result["rows_in"] == 2
  266. assert result["rows_out"] == 1
  267. assert result["rows_quarantined"] == 1
  268. assert result["violations"] == [
  269. {"step_id": "mobile_format", "count": 1}
  270. ]
  271. assert result["commit_outcome"] == "not_applicable"
  272. assert "artifact_ref" not in result
  273. assert resolver.registrations == []
  274. def test_polars_adapter_reports_unknown_catalog_commit_outcome():
  275. from app.runner.artifacts import ArtifactCommitUnknown
  276. from app.runner.rule_polars import PolarsRulePlanAdapter
  277. compiled, input_binding = _compiled_plan(
  278. [
  279. {
  280. "id": "trim_name",
  281. "op": "normalize_text",
  282. "column": "name",
  283. "trim": True,
  284. }
  285. ]
  286. )
  287. store = _plan_store(FakeMinio())
  288. correlation_id = new_governance_uid()
  289. source = store.write(
  290. pl.DataFrame(
  291. {"customer_id": [1], "name": [" A "], "mobile": ["1"]}
  292. ),
  293. correlation_id,
  294. 600,
  295. schema_fields=compiled["plan"]["input_fields"],
  296. limits=compiled["plan"]["resource_limits"],
  297. )
  298. resolver = Resolver(
  299. {
  300. input_binding["id"]: {
  301. **source,
  302. "binding_hash": compiled["plan"]["input_binding_hash"],
  303. }
  304. },
  305. store,
  306. )
  307. resolver.publish_error = ArtifactCommitUnknown("lost acknowledgement")
  308. with pytest.raises(NodeExecutionError, match="commit outcome") as error:
  309. PolarsRulePlanAdapter(
  310. artifact_store=store,
  311. artifact_resolver=resolver,
  312. ).execute(
  313. plan=compiled["plan"],
  314. node=_node(compiled),
  315. parameters={},
  316. write_authorized=True,
  317. correlation_id=correlation_id,
  318. )
  319. assert error.value.commit_outcome == "unknown"
  320. def test_polars_adapter_consumes_only_attested_upstream_artifact_ref():
  321. from app.runner.rule_polars import PolarsRulePlanAdapter
  322. compiled, _input_binding = _compiled_plan(
  323. [
  324. {
  325. "id": "trim_name",
  326. "op": "normalize_text",
  327. "column": "name",
  328. "trim": True,
  329. }
  330. ]
  331. )
  332. store = _plan_store(FakeMinio())
  333. correlation_id = new_governance_uid()
  334. source = store.write(
  335. pl.DataFrame(
  336. {
  337. "customer_id": [1],
  338. "name": ["Alice"],
  339. "mobile": ["13800138000"],
  340. }
  341. ),
  342. correlation_id,
  343. 600,
  344. schema_fields=compiled["plan"]["input_fields"],
  345. limits=compiled["plan"]["resource_limits"],
  346. )
  347. resolver = Resolver({}, store)
  348. resolver.handoffs[source["artifact_ref"]] = source
  349. adapter = PolarsRulePlanAdapter(
  350. artifact_store=store,
  351. artifact_resolver=resolver,
  352. artifact_ttl_seconds=300,
  353. )
  354. result = adapter.execute(
  355. plan=compiled["plan"],
  356. node=_node(compiled),
  357. parameters={"input_artifact": source["artifact_ref"]},
  358. write_authorized=True,
  359. correlation_id=correlation_id,
  360. )
  361. assert result["rows_out"] == 1
  362. assert (
  363. "handoff",
  364. source["artifact_ref"],
  365. correlation_id,
  366. ) in resolver.events
  367. with pytest.raises(NodeExecutionError, match="only one artifact"):
  368. adapter.execute(
  369. plan=compiled["plan"],
  370. node=_node(compiled),
  371. parameters={"rows": [{"customer_id": 1}]},
  372. write_authorized=True,
  373. correlation_id=correlation_id,
  374. )
  375. def test_polars_adapter_fails_closed_for_plan_hash_binding_and_authorization():
  376. from app.runner.rule_polars import PolarsRulePlanAdapter
  377. compiled, input_binding = _compiled_plan(
  378. [
  379. {
  380. "id": "trim_name",
  381. "op": "normalize_text",
  382. "column": "name",
  383. "trim": True,
  384. }
  385. ]
  386. )
  387. store = _plan_store(FakeMinio())
  388. correlation_id = new_governance_uid()
  389. source = store.write(
  390. pl.DataFrame(
  391. {"customer_id": [1], "name": [" A "], "mobile": ["1"]}
  392. ).lazy(),
  393. correlation_id,
  394. 600,
  395. schema_fields=compiled["plan"]["input_fields"],
  396. limits=compiled["plan"]["resource_limits"],
  397. )
  398. resolver = Resolver(
  399. {
  400. input_binding["id"]: {
  401. **source,
  402. "binding_hash": "0" * 64,
  403. }
  404. },
  405. store,
  406. )
  407. adapter = PolarsRulePlanAdapter(
  408. artifact_store=store,
  409. artifact_resolver=resolver,
  410. )
  411. node = _node(compiled)
  412. with pytest.raises(NodeExecutionError, match="authorization"):
  413. adapter.execute(
  414. plan=compiled["plan"],
  415. node=node,
  416. parameters={},
  417. write_authorized=False,
  418. correlation_id=correlation_id,
  419. )
  420. with pytest.raises(NodeExecutionError, match="binding"):
  421. adapter.execute(
  422. plan=compiled["plan"],
  423. node=node,
  424. parameters={},
  425. write_authorized=True,
  426. correlation_id=correlation_id,
  427. )
  428. resolver.values[input_binding["id"]]["binding_hash"] = compiled["plan"][
  429. "input_binding_hash"
  430. ]
  431. node["config"]["execution_plan_hash"] = "0" * 64
  432. with pytest.raises(NodeExecutionError, match="hash"):
  433. adapter.execute(
  434. plan=compiled["plan"],
  435. node=node,
  436. parameters={},
  437. write_authorized=True,
  438. correlation_id=correlation_id,
  439. )
  440. tampered = copy.deepcopy(compiled["plan"])
  441. tampered["operations"][0]["callable"] = "unsafe"
  442. node["config"]["execution_plan_hash"] = compiled["plan_hash"]
  443. with pytest.raises(NodeExecutionError, match="invalid"):
  444. adapter.execute(
  445. plan=tampered,
  446. node=node,
  447. parameters={},
  448. write_authorized=True,
  449. correlation_id=correlation_id,
  450. )
  451. def test_polars_adapter_attests_current_output_binding_before_reading_input():
  452. from app.runner.rule_polars import PolarsRulePlanAdapter
  453. compiled, input_binding = _compiled_plan(
  454. [
  455. {
  456. "id": "trim_name",
  457. "op": "normalize_text",
  458. "column": "name",
  459. "trim": True,
  460. }
  461. ]
  462. )
  463. store = _plan_store(FakeMinio())
  464. correlation_id = new_governance_uid()
  465. source = store.write(
  466. pl.DataFrame(
  467. {"customer_id": [1], "name": [" A "], "mobile": ["1"]}
  468. ),
  469. correlation_id,
  470. 600,
  471. schema_fields=compiled["plan"]["input_fields"],
  472. limits=compiled["plan"]["resource_limits"],
  473. )
  474. resolver = Resolver(
  475. {
  476. input_binding["id"]: {
  477. **source,
  478. "binding_hash": compiled["plan"]["input_binding_hash"],
  479. }
  480. },
  481. store,
  482. )
  483. resolver.attest_error = ValueError("binding changed")
  484. reads_before_execute = list(store.client.get_calls)
  485. with pytest.raises(NodeExecutionError, match="output binding"):
  486. PolarsRulePlanAdapter(
  487. artifact_store=store,
  488. artifact_resolver=resolver,
  489. ).execute(
  490. plan=compiled["plan"],
  491. node=_node(compiled),
  492. parameters={},
  493. write_authorized=True,
  494. correlation_id=correlation_id,
  495. )
  496. assert resolver.events == [
  497. (
  498. "attest",
  499. compiled["plan"]["output_binding_id"],
  500. compiled["plan"]["output_binding_hash"],
  501. "write",
  502. )
  503. ]
  504. assert store.client.get_calls == reads_before_execute
  505. def test_polars_adapter_uses_exact_decimal_and_timestamptz_output_contracts():
  506. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  507. from app.core.data_rules.execution_contracts import canonical_schema_hash
  508. from app.runner.rule_polars import PolarsRulePlanAdapter
  509. input_schema = _schema(
  510. "bd:payment:raw",
  511. [
  512. ("amount", "string", False),
  513. ("occurred_at", "string", False),
  514. ],
  515. )
  516. output_schema = _schema(
  517. "bd:payment:clean",
  518. [
  519. ("amount", "decimal", False),
  520. ("occurred_at", "timestamptz", False),
  521. ],
  522. )
  523. output_schema["fields"][0].update({"precision": 12, "scale": 2})
  524. output_schema["fields"][1]["timezone"] = "Asia/Shanghai"
  525. output_schema["schema_hash"] = canonical_schema_hash(
  526. output_schema["fields"]
  527. )
  528. source_binding = _binding(input_schema, access_mode="read")
  529. output_binding = _binding(output_schema, access_mode="write")
  530. rule = _published_rule(
  531. input_schema,
  532. output_schema,
  533. [
  534. {
  535. "id": "cast_amount",
  536. "op": "cast",
  537. "column": "amount",
  538. "to": "decimal",
  539. "on_error": "fail",
  540. },
  541. {
  542. "id": "cast_time",
  543. "op": "cast",
  544. "column": "occurred_at",
  545. "to": "timestamptz",
  546. "on_error": "fail",
  547. },
  548. ],
  549. )
  550. compiled = PolarsRuleCompiler().compile(
  551. rule_version=rule,
  552. input_schema=input_schema,
  553. output_schema=output_schema,
  554. input_binding=source_binding,
  555. output_binding=output_binding,
  556. backend=_backend(memory_limit_bytes=128 * 1024 * 1024),
  557. )
  558. store = _plan_store(FakeMinio())
  559. correlation_id = new_governance_uid()
  560. source = store.write(
  561. pl.DataFrame(
  562. {
  563. "amount": ["12.34"],
  564. "occurred_at": ["2026-07-23T12:30:00+08:00"],
  565. }
  566. ),
  567. correlation_id,
  568. 600,
  569. schema_fields=compiled["plan"]["input_fields"],
  570. limits=compiled["plan"]["resource_limits"],
  571. )
  572. resolver = Resolver(
  573. {
  574. source_binding["id"]: {
  575. **source,
  576. "binding_hash": compiled["plan"]["input_binding_hash"],
  577. }
  578. },
  579. store,
  580. )
  581. result = PolarsRulePlanAdapter(
  582. artifact_store=store,
  583. artifact_resolver=resolver,
  584. ).execute(
  585. plan=compiled["plan"],
  586. node=_node(compiled),
  587. parameters={},
  588. write_authorized=True,
  589. correlation_id=correlation_id,
  590. )
  591. output = store.read(
  592. result["artifact_ref"],
  593. result["digest"],
  594. expected_schema_fields=compiled["plan"]["output_fields"],
  595. limits=compiled["plan"]["resource_limits"],
  596. ).collect()
  597. assert output.schema["amount"] == pl.Decimal(precision=12, scale=2)
  598. assert output.schema["occurred_at"] == pl.Datetime(
  599. time_zone="Asia/Shanghai"
  600. )
  601. def test_polars_expression_date_and_timestamp_use_plan_timezone():
  602. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  603. from app.runner.rule_polars import PolarsRulePlanAdapter
  604. input_schema = _schema(
  605. "bd:event:raw",
  606. [
  607. ("raw_time", "string", False),
  608. ("cast_time", "string", False),
  609. ],
  610. )
  611. output_schema = _schema(
  612. "bd:event:clean",
  613. [
  614. ("raw_time", "string", False),
  615. ("cast_time", "timestamp", False),
  616. ("local_date", "date", False),
  617. ("local_time", "timestamp", False),
  618. ],
  619. )
  620. source_binding = _binding(input_schema, access_mode="read")
  621. output_binding = _binding(output_schema, access_mode="write")
  622. rule = _published_rule(
  623. input_schema,
  624. output_schema,
  625. [
  626. {
  627. "id": "cast_timestamp",
  628. "op": "cast",
  629. "column": "cast_time",
  630. "to": "timestamp",
  631. "on_error": "fail",
  632. },
  633. {
  634. "id": "derive_date",
  635. "op": "derive",
  636. "target": "local_date",
  637. "expression": "date(raw_time)",
  638. },
  639. {
  640. "id": "derive_timestamp",
  641. "op": "derive",
  642. "target": "local_time",
  643. "expression": "timestamp(raw_time)",
  644. },
  645. ],
  646. )
  647. compiled = PolarsRuleCompiler().compile(
  648. rule_version=rule,
  649. input_schema=input_schema,
  650. output_schema=output_schema,
  651. input_binding=source_binding,
  652. output_binding=output_binding,
  653. backend=_backend(memory_limit_bytes=128 * 1024 * 1024),
  654. )
  655. store = _plan_store(FakeMinio())
  656. correlation_id = new_governance_uid()
  657. source = store.write(
  658. pl.DataFrame(
  659. {
  660. "raw_time": ["2026-07-22T16:30:00+00:00"],
  661. "cast_time": ["2026-07-22T16:30:00+00:00"],
  662. }
  663. ),
  664. correlation_id,
  665. 600,
  666. schema_fields=compiled["plan"]["input_fields"],
  667. limits=compiled["plan"]["resource_limits"],
  668. )
  669. resolver = Resolver(
  670. {
  671. source_binding["id"]: {
  672. **source,
  673. "binding_hash": compiled["plan"]["input_binding_hash"],
  674. }
  675. },
  676. store,
  677. )
  678. result = PolarsRulePlanAdapter(
  679. artifact_store=store,
  680. artifact_resolver=resolver,
  681. ).execute(
  682. plan=compiled["plan"],
  683. node=_node(compiled),
  684. parameters={},
  685. write_authorized=True,
  686. correlation_id=correlation_id,
  687. )
  688. output = store.read(
  689. result["artifact_ref"],
  690. result["digest"],
  691. expected_schema_fields=compiled["plan"]["output_fields"],
  692. limits=compiled["plan"]["resource_limits"],
  693. ).collect()
  694. assert output["local_date"].item() == date(2026, 7, 23)
  695. assert output["local_time"].item() == datetime(2026, 7, 23, 0, 30)
  696. assert output["cast_time"].item() == datetime(2026, 7, 23, 0, 30)
  697. assert output.schema["local_time"] == pl.Datetime
  698. def test_rule_executor_attests_polars_canonical_hashes_and_forwards_correlation():
  699. from app.runner.rules import RulePlanExecutor
  700. compiled, _input_binding = _compiled_plan(
  701. [
  702. {
  703. "id": "trim_name",
  704. "op": "normalize_text",
  705. "column": "name",
  706. "trim": True,
  707. }
  708. ]
  709. )
  710. node = _node(compiled)
  711. correlation_id = new_governance_uid()
  712. plan = compiled["plan"]
  713. record = {
  714. "component_binding_id": node["config"]["component_binding_id"],
  715. "rule_version_id": plan["rule_version_id"],
  716. "backend": "polars_batch",
  717. "compiler_version": compiled["compiler_version"],
  718. "plan": plan,
  719. "plan_hash": compiled["plan_hash"],
  720. "schema_hashes": {
  721. "rule_spec_hash": plan["rule_spec_hash"],
  722. "input_schema_snapshot_id": plan["input_schema_snapshot_id"],
  723. "input_schema_hash": plan["input_schema_hash"],
  724. "output_schema_snapshot_id": plan["output_schema_snapshot_id"],
  725. "output_schema_hash": plan["output_schema_hash"],
  726. },
  727. "canonical_rule_spec_hash": plan["rule_spec_hash"],
  728. "canonical_input_schema_snapshot_id": plan[
  729. "input_schema_snapshot_id"
  730. ],
  731. "canonical_input_schema_hash": plan["input_schema_hash"],
  732. "canonical_input_binding_hash": plan["input_binding_hash"],
  733. "canonical_input_object_kind": "parquet_artifact",
  734. "canonical_output_schema_snapshot_id": plan[
  735. "output_schema_snapshot_id"
  736. ],
  737. "canonical_output_schema_hash": plan["output_schema_hash"],
  738. "canonical_output_binding_hash": plan["output_binding_hash"],
  739. "canonical_output_object_kind": "parquet_artifact",
  740. "plan_status": "published",
  741. "rule_status": "published",
  742. "publication_audit_trusted": True,
  743. "logical_evidence_trusted": True,
  744. "physical_evidence_trusted": True,
  745. "component_kind": "rule.apply",
  746. "binding_idempotency": node["idempotency"],
  747. }
  748. class Repository:
  749. def load(self, **_kwargs):
  750. return record
  751. class Adapter:
  752. def __init__(self):
  753. self.kwargs = None
  754. def execute(self, **kwargs):
  755. self.kwargs = kwargs
  756. return {"rows_in": 1, "rows_out": 1, "rows_rejected": 0}
  757. adapter = Adapter()
  758. result = RulePlanExecutor(
  759. Repository(), adapters={"polars_batch": adapter}
  760. ).execute(
  761. node,
  762. {},
  763. write_authorized=True,
  764. correlation_id=correlation_id,
  765. )
  766. assert result["rows_out"] == 1
  767. assert adapter.kwargs["correlation_id"] == correlation_id
  768. record["canonical_input_schema_hash"] = "0" * 64
  769. with pytest.raises(NodeExecutionError, match="attestation"):
  770. RulePlanExecutor(
  771. Repository(), adapters={"polars_batch": adapter}
  772. ).execute(
  773. node,
  774. {},
  775. write_authorized=True,
  776. correlation_id=correlation_id,
  777. )
  778. def test_node_registry_forwards_trusted_correlation_context():
  779. from app.runner.nodes import NodeRegistry
  780. class Executor:
  781. def __init__(self):
  782. self.correlation_id = None
  783. def execute(self, _node, _parameters, **kwargs):
  784. self.correlation_id = kwargs["correlation_id"]
  785. return {"ok": True}
  786. executor = Executor()
  787. correlation_id = new_governance_uid()
  788. assert NodeRegistry({"rule.apply": executor}).execute(
  789. {"type": "rule.apply"},
  790. {},
  791. write_authorized=True,
  792. correlation_id=correlation_id,
  793. ) == {"ok": True}
  794. assert executor.correlation_id == correlation_id