capsule-engine 0.1.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 (288) hide show
  1. capsule_engine/__init__.py +17 -0
  2. capsule_engine/bundle_viewer/__init__.py +21 -0
  3. capsule_engine/bundle_viewer/static/offline_shell.html +1509 -0
  4. capsule_engine/bundle_viewer/viewer.py +57 -0
  5. capsule_engine/cli/__init__.py +5 -0
  6. capsule_engine/cli/bundle_cmd.py +269 -0
  7. capsule_engine/cli/console_cmd.py +70 -0
  8. capsule_engine/cli/constraints_cmd.py +74 -0
  9. capsule_engine/cli/format.py +173 -0
  10. capsule_engine/cli/guard_cmds.py +361 -0
  11. capsule_engine/cli/init_cmds.py +169 -0
  12. capsule_engine/cli/main.py +120 -0
  13. capsule_engine/cli/packs_cmds.py +96 -0
  14. capsule_engine/cli/telemetry_cmd.py +80 -0
  15. capsule_engine/cli/tenant_cmds.py +199 -0
  16. capsule_engine/cli/verify_cmd.py +110 -0
  17. capsule_engine/compiler/__init__.py +13 -0
  18. capsule_engine/compiler/effect_model.py +79 -0
  19. capsule_engine/compiler/vocabulary.py +253 -0
  20. capsule_engine/console/__init__.py +10 -0
  21. capsule_engine/console/api.py +251 -0
  22. capsule_engine/console/components.css +406 -0
  23. capsule_engine/console/console.css +130 -0
  24. capsule_engine/console/console.html +51 -0
  25. capsule_engine/console/console.js +330 -0
  26. capsule_engine/console/gallery.html +274 -0
  27. capsule_engine/console/server.py +137 -0
  28. capsule_engine/console/tokens.css +68 -0
  29. capsule_engine/conversation/__init__.py +88 -0
  30. capsule_engine/conversation/capsules.py +268 -0
  31. capsule_engine/conversation/exchange.py +279 -0
  32. capsule_engine/conversation/merkle.py +80 -0
  33. capsule_engine/conversation/session.py +101 -0
  34. capsule_engine/envcompat.py +15 -0
  35. capsule_engine/events/__init__.py +14 -0
  36. capsule_engine/events/capsule.py +133 -0
  37. capsule_engine/examples/__init__.py +9 -0
  38. capsule_engine/examples/data/tau2_airline/LICENSE-tau2-bench +21 -0
  39. capsule_engine/examples/data/tau2_airline/PROVENANCE.json +70 -0
  40. capsule_engine/examples/data/tau2_airline/hand_labels.json +893 -0
  41. capsule_engine/examples/data/tau2_airline/pilot1_live_gemini-2-5-flash_airline_shift1.jsonl +190 -0
  42. capsule_engine/examples/data/tau2_airline/tau2_committed_claude-3-7-sonnet_airline_trial0.jsonl +556 -0
  43. capsule_engine/examples/data/tau2_airline/tau2_committed_gpt-4-1-mini_airline_trial0.jsonl +480 -0
  44. capsule_engine/examples/data/tau2_airline/tau2_committed_gpt-4-1_airline_trial0.jsonl +496 -0
  45. capsule_engine/examples/data/tau2_airline/tau2_committed_o4-mini_airline_trial0.jsonl +392 -0
  46. capsule_engine/examples/data/tau2_airline/tau2_conversations_claude-3-7-sonnet_airline_4trials.jsonl +200 -0
  47. capsule_engine/examples/tau2_airline_reference.py +460 -0
  48. capsule_engine/examples/tau2_conversation_exchange.py +92 -0
  49. capsule_engine/folds/__init__.py +104 -0
  50. capsule_engine/folds/account_core.py +89 -0
  51. capsule_engine/folds/approval_latency.py +86 -0
  52. capsule_engine/folds/catalog.py +79 -0
  53. capsule_engine/folds/catalog_defs/actions.count_by_developer.yaml +10 -0
  54. capsule_engine/folds/catalog_defs/actions.executed_count.yaml +16 -0
  55. capsule_engine/folds/catalog_defs/actions.last_decision.yaml +12 -0
  56. capsule_engine/folds/catalog_defs/counterparty.seen_before.v2.yaml +34 -0
  57. capsule_engine/folds/catalog_defs/counterparty.seen_before.v3.yaml +38 -0
  58. capsule_engine/folds/catalog_defs/counterparty.seen_before.yaml +28 -0
  59. capsule_engine/folds/catalog_defs/disclosure.class_seen.yaml +29 -0
  60. capsule_engine/folds/catalog_defs/disclosure.statement_seen.yaml +32 -0
  61. capsule_engine/folds/catalog_defs/hold.active_exposure.yaml +44 -0
  62. capsule_engine/folds/catalog_defs/spend.weekly.v2.yaml +38 -0
  63. capsule_engine/folds/catalog_defs/spend.weekly.v3.1.yaml +71 -0
  64. capsule_engine/folds/catalog_defs/spend.weekly.v3.yaml +76 -0
  65. capsule_engine/folds/catalog_defs/spend.weekly.yaml +33 -0
  66. capsule_engine/folds/counterparty_signals.py +179 -0
  67. capsule_engine/folds/definition.py +421 -0
  68. capsule_engine/folds/duration.py +16 -0
  69. capsule_engine/folds/engine.py +436 -0
  70. capsule_engine/folds/errors.py +44 -0
  71. capsule_engine/folds/loader.py +24 -0
  72. capsule_engine/folds/ordering.py +143 -0
  73. capsule_engine/folds/paths.py +25 -0
  74. capsule_engine/folds/record_type_coverage.py +58 -0
  75. capsule_engine/folds/reducers.py +75 -0
  76. capsule_engine/folds/retention_continuity.py +117 -0
  77. capsule_engine/folds/set_membership.py +59 -0
  78. capsule_engine/folds/taxonomy.py +231 -0
  79. capsule_engine/guards/__init__.py +59 -0
  80. capsule_engine/guards/action.py +446 -0
  81. capsule_engine/guards/action_taxonomy.json +27 -0
  82. capsule_engine/guards/action_taxonomy_v2.json +27 -0
  83. capsule_engine/guards/action_taxonomy_v3.json +27 -0
  84. capsule_engine/guards/action_taxonomy_v4.json +27 -0
  85. capsule_engine/guards/action_taxonomy_v5.json +27 -0
  86. capsule_engine/guards/capsule.py +377 -0
  87. capsule_engine/guards/checks/__init__.py +268 -0
  88. capsule_engine/guards/checks/action_class_gate.py +199 -0
  89. capsule_engine/guards/checks/base.py +33 -0
  90. capsule_engine/guards/checks/caps.py +517 -0
  91. capsule_engine/guards/checks/channel_change.py +64 -0
  92. capsule_engine/guards/checks/commitment.py +41 -0
  93. capsule_engine/guards/checks/counterparty_identity_change.py +95 -0
  94. capsule_engine/guards/checks/counterparty_list.py +176 -0
  95. capsule_engine/guards/checks/counterparty_seen_before.py +160 -0
  96. capsule_engine/guards/checks/credential_pattern.py +73 -0
  97. capsule_engine/guards/checks/dedupe.py +259 -0
  98. capsule_engine/guards/checks/destination_rail.py +120 -0
  99. capsule_engine/guards/checks/field_change_count.py +100 -0
  100. capsule_engine/guards/checks/offer_expiry.py +134 -0
  101. capsule_engine/guards/checks/plan_containment.py +180 -0
  102. capsule_engine/guards/checks/price_floor.py +211 -0
  103. capsule_engine/guards/checks/promise_never.py +236 -0
  104. capsule_engine/guards/checks/promise_requires_approval.py +214 -0
  105. capsule_engine/guards/checks/recipient_role.py +117 -0
  106. capsule_engine/guards/checks/recurring_charge.py +49 -0
  107. capsule_engine/guards/checks/refundability.py +46 -0
  108. capsule_engine/guards/checks/release_on_acceptance.py +286 -0
  109. capsule_engine/guards/checks/required_disclosure.py +233 -0
  110. capsule_engine/guards/checks/single_commitment.py +221 -0
  111. capsule_engine/guards/checks/task_authority.py +182 -0
  112. capsule_engine/guards/checks/upfront_amount.py +71 -0
  113. capsule_engine/guards/checks/verify_before_dispatch.py +75 -0
  114. capsule_engine/guards/classes.py +393 -0
  115. capsule_engine/guards/engine.py +926 -0
  116. capsule_engine/guards/history_state.py +127 -0
  117. capsule_engine/guards/plan.py +237 -0
  118. capsule_engine/guards/revocation.py +120 -0
  119. capsule_engine/guards/signing.py +60 -0
  120. capsule_engine/guards/statements.py +62 -0
  121. capsule_engine/guards/tool_call.py +83 -0
  122. capsule_engine/guards/wickets/__init__.py +23 -0
  123. capsule_engine/guards/wickets/catalog.py +75 -0
  124. capsule_engine/guards/wickets/catalog_defs/action_class_gate.v1.1.yaml +38 -0
  125. capsule_engine/guards/wickets/catalog_defs/action_class_gate.v1.2.yaml +52 -0
  126. capsule_engine/guards/wickets/catalog_defs/action_class_gate.yaml +34 -0
  127. capsule_engine/guards/wickets/catalog_defs/caps.v2.yaml +30 -0
  128. capsule_engine/guards/wickets/catalog_defs/caps.v3.yaml +49 -0
  129. capsule_engine/guards/wickets/catalog_defs/caps.v4.yaml +45 -0
  130. capsule_engine/guards/wickets/catalog_defs/caps.v5.1.yaml +53 -0
  131. capsule_engine/guards/wickets/catalog_defs/caps.v5.yaml +43 -0
  132. capsule_engine/guards/wickets/catalog_defs/caps.yaml +17 -0
  133. capsule_engine/guards/wickets/catalog_defs/caps_holds.yaml +18 -0
  134. capsule_engine/guards/wickets/catalog_defs/channel_change.v1.1.yaml +18 -0
  135. capsule_engine/guards/wickets/catalog_defs/channel_change.yaml +10 -0
  136. capsule_engine/guards/wickets/catalog_defs/counterparty_identity_change.yaml +9 -0
  137. capsule_engine/guards/wickets/catalog_defs/counterparty_list.yaml +20 -0
  138. capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v2.yaml +15 -0
  139. capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v3.yaml +23 -0
  140. capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v4.yaml +26 -0
  141. capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.yaml +14 -0
  142. capsule_engine/guards/wickets/catalog_defs/credential_pattern.yaml +14 -0
  143. capsule_engine/guards/wickets/catalog_defs/dedupe.yaml +19 -0
  144. capsule_engine/guards/wickets/catalog_defs/destination_rail.seller.v1.1.yaml +32 -0
  145. capsule_engine/guards/wickets/catalog_defs/destination_rail.seller.yaml +10 -0
  146. capsule_engine/guards/wickets/catalog_defs/destination_rail.yaml +9 -0
  147. capsule_engine/guards/wickets/catalog_defs/hold_reconcile.yaml +14 -0
  148. capsule_engine/guards/wickets/catalog_defs/material_fields_changed.v1.1.yaml +22 -0
  149. capsule_engine/guards/wickets/catalog_defs/material_fields_changed.yaml +14 -0
  150. capsule_engine/guards/wickets/catalog_defs/offer_expiry.v1.1.yaml +31 -0
  151. capsule_engine/guards/wickets/catalog_defs/offer_expiry.yaml +24 -0
  152. capsule_engine/guards/wickets/catalog_defs/offer_fields_changed.v1.1.yaml +22 -0
  153. capsule_engine/guards/wickets/catalog_defs/offer_fields_changed.yaml +10 -0
  154. capsule_engine/guards/wickets/catalog_defs/price_floor.yaml +32 -0
  155. capsule_engine/guards/wickets/catalog_defs/promise_never.v1.1.yaml +64 -0
  156. capsule_engine/guards/wickets/catalog_defs/promise_never.yaml +16 -0
  157. capsule_engine/guards/wickets/catalog_defs/promise_requires_approval.v1.1.yaml +26 -0
  158. capsule_engine/guards/wickets/catalog_defs/promise_requires_approval.yaml +21 -0
  159. capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.v1.1.yaml +23 -0
  160. capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.v1.2.yaml +24 -0
  161. capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.yaml +10 -0
  162. capsule_engine/guards/wickets/catalog_defs/recipient_role.v1.1.yaml +26 -0
  163. capsule_engine/guards/wickets/catalog_defs/recipient_role.yaml +11 -0
  164. capsule_engine/guards/wickets/catalog_defs/recipient_seen_before.yaml +16 -0
  165. capsule_engine/guards/wickets/catalog_defs/recurring_charge.yaml +9 -0
  166. capsule_engine/guards/wickets/catalog_defs/refundability.v1.1.yaml +15 -0
  167. capsule_engine/guards/wickets/catalog_defs/refundability.yaml +7 -0
  168. capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.v1.1.yaml +44 -0
  169. capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.v1.2.yaml +63 -0
  170. capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.yaml +36 -0
  171. capsule_engine/guards/wickets/catalog_defs/required_disclosure.v1.1.yaml +36 -0
  172. capsule_engine/guards/wickets/catalog_defs/required_disclosure.v1.2.yaml +42 -0
  173. capsule_engine/guards/wickets/catalog_defs/required_disclosure.yaml +25 -0
  174. capsule_engine/guards/wickets/catalog_defs/single_commitment.seller.yaml +14 -0
  175. capsule_engine/guards/wickets/catalog_defs/task_authority.v1.1.yaml +20 -0
  176. capsule_engine/guards/wickets/catalog_defs/task_authority.yaml +8 -0
  177. capsule_engine/guards/wickets/catalog_defs/upfront_amount.v1.1.yaml +19 -0
  178. capsule_engine/guards/wickets/catalog_defs/upfront_amount.yaml +11 -0
  179. capsule_engine/guards/wickets/catalog_defs/verify_before_dispatch.yaml +9 -0
  180. capsule_engine/guards/wickets/definition.py +164 -0
  181. capsule_engine/guards/wickets/errors.py +18 -0
  182. capsule_engine/guards/wickets/loader.py +26 -0
  183. capsule_engine/guards/wickets/retired.py +62 -0
  184. capsule_engine/holds/__init__.py +35 -0
  185. capsule_engine/holds/capsules.py +266 -0
  186. capsule_engine/holds/engine.py +476 -0
  187. capsule_engine/holds/errors.py +29 -0
  188. capsule_engine/holds/policy.py +62 -0
  189. capsule_engine/holds/scope.py +47 -0
  190. capsule_engine/mcp/__init__.py +16 -0
  191. capsule_engine/mcp/config.py +117 -0
  192. capsule_engine/mcp/server.py +338 -0
  193. capsule_engine/mcp/tools.py +424 -0
  194. capsule_engine/packaging.py +60 -0
  195. capsule_engine/packs/__init__.py +45 -0
  196. capsule_engine/packs/backfill_coverage.py +238 -0
  197. capsule_engine/packs/catalog/airline-engagement/AI-BOOTSTRAP.md +53 -0
  198. capsule_engine/packs/catalog/airline-engagement/folds/session_close.yaml +8 -0
  199. capsule_engine/packs/catalog/airline-engagement/pack.yaml +146 -0
  200. capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/good_execution.json +36 -0
  201. capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/oversight_declared.json +36 -0
  202. capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/tampered_execution.json +36 -0
  203. capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/trusted_issuer.jwk.json +5 -0
  204. capsule_engine/packs/catalog/eu-ai-act/obligations-pack-note.md +68 -0
  205. capsule_engine/packs/catalog/eu-ai-act/obligations-register.yaml +761 -0
  206. capsule_engine/packs/catalog/eu-ai-act/pack.yaml +88 -0
  207. capsule_engine/packs/catalog/eu-ai-act/register.yaml +96 -0
  208. capsule_engine/packs/catalog/eu-ai-act-deterministic/fixtures/synthetic_internal_assist_sample.jsonl +6 -0
  209. capsule_engine/packs/catalog/eu-ai-act-deterministic/pack.yaml +580 -0
  210. capsule_engine/packs/catalog/everyday/AI-BOOTSTRAP.md +194 -0
  211. capsule_engine/packs/catalog/everyday/fixtures/mini_ledger.jsonl +45 -0
  212. capsule_engine/packs/catalog/everyday/pack.yaml +480 -0
  213. capsule_engine/packs/catalog/payments-safety/AI-BOOTSTRAP.md +83 -0
  214. capsule_engine/packs/catalog/payments-safety/fixtures/mini_ledger.jsonl +8 -0
  215. capsule_engine/packs/catalog/payments-safety/folds/spend_weekly.yaml +29 -0
  216. capsule_engine/packs/catalog/payments-safety/pack.yaml +112 -0
  217. capsule_engine/packs/catalog/seller/fixtures/mini_ledger.jsonl +83 -0
  218. capsule_engine/packs/catalog/seller/pack.yaml +503 -0
  219. capsule_engine/packs/catalog/standard-vendor/AI-BOOTSTRAP.md +70 -0
  220. capsule_engine/packs/catalog/standard-vendor/folds/session_close.yaml +8 -0
  221. capsule_engine/packs/catalog/standard-vendor/pack.yaml +466 -0
  222. capsule_engine/packs/contract_diff.py +809 -0
  223. capsule_engine/packs/contract_validate.py +185 -0
  224. capsule_engine/packs/corpus_verify.py +126 -0
  225. capsule_engine/packs/enforce.py +81 -0
  226. capsule_engine/packs/errors.py +159 -0
  227. capsule_engine/packs/eu_ai_act_trace_field_map.py +46 -0
  228. capsule_engine/packs/install.py +266 -0
  229. capsule_engine/packs/loader.py +1462 -0
  230. capsule_engine/packs/measurability_report.py +280 -0
  231. capsule_engine/packs/obligation_results.py +72 -0
  232. capsule_engine/packs/pins.py +92 -0
  233. capsule_engine/packs/schema.py +1053 -0
  234. capsule_engine/packs/thresholds.py +146 -0
  235. capsule_engine/packs/trace_attainment.py +188 -0
  236. capsule_engine/packs/trace_reader.py +104 -0
  237. capsule_engine/policy/__init__.py +37 -0
  238. capsule_engine/policy/activation.py +87 -0
  239. capsule_engine/policy/catalog_defs/default.yaml +23 -0
  240. capsule_engine/policy/catalog_defs/holds.yaml +27 -0
  241. capsule_engine/policy/errors.py +46 -0
  242. capsule_engine/policy/limits.py +287 -0
  243. capsule_engine/policy/loader.py +26 -0
  244. capsule_engine/policy/manifest.py +289 -0
  245. capsule_engine/policy/profile.py +281 -0
  246. capsule_engine/policy/resolve.py +224 -0
  247. capsule_engine/register/__init__.py +39 -0
  248. capsule_engine/register/compiler.py +219 -0
  249. capsule_engine/register/errors.py +40 -0
  250. capsule_engine/register/loader.py +247 -0
  251. capsule_engine/register/schema.py +129 -0
  252. capsule_engine/registry/__init__.py +19 -0
  253. capsule_engine/registry/conventions.json +162 -0
  254. capsule_engine/registry/conventions.py +188 -0
  255. capsule_engine/registry/cpb_registry.json +78 -0
  256. capsule_engine/report/__init__.py +62 -0
  257. capsule_engine/report/build.py +267 -0
  258. capsule_engine/report/coverage.py +777 -0
  259. capsule_engine/report/errors.py +35 -0
  260. capsule_engine/report/live_check.py +126 -0
  261. capsule_engine/report/live_history.py +420 -0
  262. capsule_engine/report/model.py +95 -0
  263. capsule_engine/report/render.py +467 -0
  264. capsule_engine/report/replay.py +1677 -0
  265. capsule_engine/report/result.py +504 -0
  266. capsule_engine/report/result_from_folds.py +222 -0
  267. capsule_engine/report/result_from_layers.py +670 -0
  268. capsule_engine/report/sale_bundle.py +864 -0
  269. capsule_engine/report/static/verify.js +451 -0
  270. capsule_engine/schemas/evidence-contract-v0.json +383 -0
  271. capsule_engine/schemas/vendor/epistemic-types.json +21 -0
  272. capsule_engine/schemas/vendor/evidence-result-v0.json +463 -0
  273. capsule_engine/telemetry/__init__.py +9 -0
  274. capsule_engine/telemetry/consent.py +39 -0
  275. capsule_engine/telemetry/events.py +93 -0
  276. capsule_engine/telemetry/fixtures/synthetic_events.jsonl +45 -0
  277. capsule_engine/telemetry/funnel.py +147 -0
  278. capsule_engine/telemetry/record.py +106 -0
  279. capsule_engine/telemetry/sink.py +61 -0
  280. capsule_engine/telemetry/state.py +60 -0
  281. capsule_engine/tenants.py +327 -0
  282. capsule_engine-0.1.0.dist-info/METADATA +47 -0
  283. capsule_engine-0.1.0.dist-info/RECORD +288 -0
  284. capsule_engine-0.1.0.dist-info/WHEEL +5 -0
  285. capsule_engine-0.1.0.dist-info/entry_points.txt +2 -0
  286. capsule_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
  287. capsule_engine-0.1.0.dist-info/licenses/capsule_engine/examples/data/tau2_airline/LICENSE-tau2-bench +21 -0
  288. capsule_engine-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,421 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Fold definitions (spec §2): parsing, validation, and the definition_digest.
3
+
4
+ A definition is a plain dict on the wire (YAML in, via ``loader.py``). This
5
+ module turns that dict into a validated, immutable ``FoldDefinition`` and
6
+ computes its ``definition_digest`` — SHA-256 over the JCS bytes of the
7
+ canonical form, reusing ``agent_action_capsule.canonical`` rather than
8
+ reimplementing JCS (the same canonicalization the capsule format itself uses,
9
+ per spec §2: "canonicalized (JCS) and identified by its digest").
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from agent_action_capsule.canonical import FloatInDigestError, UnsafeIntegerError, json_digest
18
+
19
+ from .account_core import (
20
+ DEFAULT_DERIVATION_CLASS,
21
+ DERIVATION_CLASSES,
22
+ SELECTION_RANGE,
23
+ AccountDefinition,
24
+ parse_account_definition,
25
+ )
26
+ from .duration import parse_duration_seconds
27
+ from .errors import (
28
+ DUPLICATE_READ_PATH,
29
+ FLOAT_IN_DEFINITION,
30
+ INVALID_FOLD_ID,
31
+ INVALID_REVERSAL,
32
+ MALFORMED_DEFINITION,
33
+ MISSING_REDUCE_FIELD,
34
+ UNBOUNDED_FILTER_OP,
35
+ UNDECLARED_FIELD_READ,
36
+ UNKNOWN_DERIVATION_CLASS,
37
+ UNKNOWN_ERASURE_CLASS,
38
+ UNKNOWN_REDUCER,
39
+ UNSAFE_INTEGER_IN_DEFINITION,
40
+ WALL_CLOCK_REFERENCE,
41
+ FoldDefinitionError,
42
+ )
43
+ from .paths import validate_path
44
+
45
+ # fold_id (spec §2): "human name + semver", namespace-dotted, e.g. "spend.weekly/1.0.0".
46
+ FOLD_ID_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*/\d+\.\d+\.\d+$")
47
+
48
+ ERASURE_CLASSES = frozenset({"commitment-ok", "preimage"})
49
+
50
+ # Reducers: a closed, registered set (spec §2). distinct_count is deliberately
51
+ # excluded (spec §7 open question 1: sketches break byte-exact determinism).
52
+ FIELD_REDUCERS = frozenset({"sum", "min", "max", "last"})
53
+ NO_FIELD_REDUCERS = frozenset({"count"})
54
+ KNOWN_REDUCERS = FIELD_REDUCERS | NO_FIELD_REDUCERS
55
+
56
+ # filter (spec §2): "Bounded operations only: equality, ranges, set membership,
57
+ # prefix. No regex, no user-supplied code."
58
+ BOUNDED_FILTER_OPS = frozenset({"eq", "ne", "in", "not_in", "prefix", "gt", "gte", "lt", "lte"})
59
+
60
+ # Pseudo-fields that would make a fold consult something other than the
61
+ # declared capsule range (spec §3 rule 1). `evaluated_at` is explicitly
62
+ # output-only per spec §4 ("informational, never an input").
63
+ WALL_CLOCK_TOKENS = frozenset({"now", "current_time", "wall_clock", "_now", "evaluated_at", "system_time"})
64
+
65
+ # The chain relations a reversal may cite its charge under. ``supersedes`` is
66
+ # the only terminal relation in the chain.relation registry (Agent Action
67
+ # Capsule REGISTRY §6): it closes the parent's open state, which is what a
68
+ # cancel or refund does to a charge. The non-terminal ones (``confirms``,
69
+ # ``epoch_opens``, ``duplicates``) leave the parent standing.
70
+ REVERSAL_RELATIONS = frozenset({"supersedes"})
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class ReadField:
75
+ path: str
76
+ erasure_class: str
77
+ default: Any = None
78
+ has_default: bool = False
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class FilterClause:
83
+ field: str
84
+ op: str
85
+ value: Any
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Window:
90
+ mode: str # "rolling" | "explicit"
91
+ duration: str | None = None # rolling mode: e.g. "7d"
92
+ start: str | None = None # explicit mode: ISO-8601 timestamp
93
+ end: str | None = None # explicit mode: ISO-8601 timestamp
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Reduce:
98
+ reducer: str
99
+ field: str | None = None
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Reversal:
104
+ """Records whose ``class_field`` is in ``classes`` take back a prior
105
+ charge instead of adding to the sum (engine.py, ``_reverse``). One is
106
+ linked when its ``relation_field`` is ``relation``, its ``parent_field``
107
+ names a charge this fold already counted in the same group, and its
108
+ amount is positive; it then subtracts its amount, up to what that charge
109
+ has left. Any other is unlinked and contributes nothing. Every other
110
+ record is a charge, and a negative charge counts as zero."""
111
+
112
+ class_field: str
113
+ classes: tuple[str, ...]
114
+ parent_field: str
115
+ relation_field: str
116
+ relation: str
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class FoldDefinition:
121
+ fold_id: str
122
+ reads: tuple[ReadField, ...]
123
+ reduce: Reduce
124
+ emit: str
125
+ filter: tuple[FilterClause, ...] = ()
126
+ key: str | None = None
127
+ window: Window | None = None
128
+ # The derivation-class marker, mapped onto the neutral core's
129
+ # ``derivation_class`` (deterministic | model_assisted). Hand-authored /
130
+ # precondition-decomposable folds are ``deterministic`` (recompute+match);
131
+ # the compiler's model-judgment branch sets ``model_assisted`` on the fold
132
+ # it emits (see compiler/compile.py::_model_assisted_fold). Defaulted so
133
+ # every existing catalog YAML and fixture parses unchanged — the ledger's
134
+ # own ``definition_digest()`` (its authoring digest) does not include this
135
+ # field, so existing digests/vectors are untouched.
136
+ derivation_class: str = DEFAULT_DERIVATION_CLASS
137
+ # Optional, and absent from ``canonical_dict`` when unset, so a
138
+ # definition without one keeps its digest.
139
+ reversal: Reversal | None = None
140
+
141
+ def read_paths(self) -> frozenset[str]:
142
+ return frozenset(r.path for r in self.reads)
143
+
144
+ def to_account_definition(self) -> AccountDefinition:
145
+ """Project this fold onto a neutral ``capsule_emit.account.AccountDefinition``.
146
+
147
+ This is the de-fork bridge: the SAME definition document,
148
+ evaluated by the ledger or via the core, yields the identical CORE
149
+ ``definition_digest`` and identical result — the §7 cross-repo replay
150
+ property. The projection carries the fields the neutral account document
151
+ recognizes (name, selection_kind, reads, derivation_class, bounded
152
+ predicate); the ledger's authoring-only structure (erasure_class,
153
+ window, reduce, defaults) is NOT part of the neutral document by design,
154
+ so it cannot move the core digest.
155
+
156
+ A ledger fold selects over a contiguous ledger range, so its neutral
157
+ selection_kind is ``range``. The predicate is the fold's bounded filter,
158
+ whose op grammar is byte-identical to the core's bounded predicate ops.
159
+ """
160
+ return parse_account_definition(
161
+ {
162
+ "name": self.fold_id,
163
+ "selection_kind": SELECTION_RANGE,
164
+ "reads": [r.path for r in self.reads],
165
+ "derivation_class": self.derivation_class,
166
+ "predicate": [{"field": f.field, "op": f.op, "value": f.value} for f in self.filter],
167
+ }
168
+ )
169
+
170
+ def core_definition_digest(self) -> str:
171
+ """The neutral ``definition_digest`` for cross-repo replay — computed by
172
+ the core over ``to_account_definition()``. There is exactly one
173
+ implementation of this digest (in ``capsule_emit.account``); the ledger
174
+ never re-implements it."""
175
+ return self.to_account_definition().definition_digest()
176
+
177
+ def canonical_dict(self) -> dict:
178
+ """The JCS-canonicalizable form of this definition — drives definition_digest."""
179
+ out: dict[str, Any] = {
180
+ "fold_id": self.fold_id,
181
+ "reads": [
182
+ {
183
+ "path": r.path,
184
+ "erasure_class": r.erasure_class,
185
+ **({"default": r.default} if r.has_default else {}),
186
+ }
187
+ for r in self.reads
188
+ ],
189
+ "reduce": {
190
+ "reducer": self.reduce.reducer,
191
+ **({"field": self.reduce.field} if self.reduce.field else {}),
192
+ },
193
+ "emit": self.emit,
194
+ }
195
+ if self.filter:
196
+ out["filter"] = [{"field": f.field, "op": f.op, "value": f.value} for f in self.filter]
197
+ if self.key is not None:
198
+ out["key"] = self.key
199
+ if self.window is not None:
200
+ w: dict[str, Any] = {"mode": self.window.mode}
201
+ if self.window.duration is not None:
202
+ w["duration"] = self.window.duration
203
+ if self.window.start is not None:
204
+ w["start"] = self.window.start
205
+ if self.window.end is not None:
206
+ w["end"] = self.window.end
207
+ out["window"] = w
208
+ if self.reversal is not None:
209
+ out["reversal"] = {
210
+ "class_field": self.reversal.class_field,
211
+ "classes": list(self.reversal.classes),
212
+ "parent_field": self.reversal.parent_field,
213
+ "relation_field": self.reversal.relation_field,
214
+ "relation": self.reversal.relation,
215
+ }
216
+ return out
217
+
218
+ def definition_digest(self) -> str:
219
+ """SHA-256 over the JCS bytes of the canonical definition (spec §2)."""
220
+ try:
221
+ return json_digest(self.canonical_dict())
222
+ except FloatInDigestError as exc:
223
+ raise FoldDefinitionError(FLOAT_IN_DEFINITION, str(exc)) from exc
224
+ except UnsafeIntegerError as exc:
225
+ raise FoldDefinitionError(UNSAFE_INTEGER_IN_DEFINITION, str(exc)) from exc
226
+
227
+
228
+ def _reject_wall_clock(path: str) -> None:
229
+ for part in path.split("."):
230
+ if part in WALL_CLOCK_TOKENS:
231
+ raise FoldDefinitionError(
232
+ WALL_CLOCK_REFERENCE,
233
+ f"reads path {path!r} references a wall-clock/output-only pseudo-field {part!r}; "
234
+ "folds MUST NOT consult a wall clock or anything outside the declared capsule "
235
+ "range (spec §3 rule 1)",
236
+ )
237
+
238
+
239
+ def _reject_float(value: Any, context: str) -> None:
240
+ if isinstance(value, float):
241
+ raise FoldDefinitionError(FLOAT_IN_DEFINITION, f"{context} is a float ({value!r}); floats are forbidden (spec §3 rule 2)")
242
+ if isinstance(value, list):
243
+ for item in value:
244
+ _reject_float(item, context)
245
+
246
+
247
+ def parse_definition(data: Any) -> FoldDefinition:
248
+ """Validate a plain dict (as loaded from YAML) into a ``FoldDefinition``."""
249
+ if not isinstance(data, dict):
250
+ raise FoldDefinitionError(MALFORMED_DEFINITION, "definition must be a mapping")
251
+
252
+ fold_id = data.get("fold_id")
253
+ if not isinstance(fold_id, str) or not FOLD_ID_RE.match(fold_id):
254
+ raise FoldDefinitionError(
255
+ INVALID_FOLD_ID,
256
+ f"fold_id {fold_id!r} must match '<namespace>[.<namespace>...]/<major>.<minor>.<patch>' "
257
+ "(e.g. 'spend.weekly/1.0.0')",
258
+ )
259
+
260
+ raw_reads = data.get("reads")
261
+ if not isinstance(raw_reads, list) or not raw_reads:
262
+ raise FoldDefinitionError(MALFORMED_DEFINITION, "reads must be a non-empty list")
263
+
264
+ reads: list[ReadField] = []
265
+ seen_paths: set[str] = set()
266
+ for entry in raw_reads:
267
+ if not isinstance(entry, dict) or "path" not in entry:
268
+ raise FoldDefinitionError(MALFORMED_DEFINITION, f"each reads entry needs a 'path': {entry!r}")
269
+ path = entry["path"]
270
+ try:
271
+ validate_path(path)
272
+ except ValueError as exc:
273
+ raise FoldDefinitionError(MALFORMED_DEFINITION, str(exc)) from exc
274
+ _reject_wall_clock(path)
275
+ if path in seen_paths:
276
+ raise FoldDefinitionError(DUPLICATE_READ_PATH, f"path {path!r} declared more than once in reads")
277
+ seen_paths.add(path)
278
+ erasure_class = entry.get("erasure_class")
279
+ if erasure_class not in ERASURE_CLASSES:
280
+ raise FoldDefinitionError(
281
+ UNKNOWN_ERASURE_CLASS,
282
+ f"reads[{path!r}].erasure_class must be one of {sorted(ERASURE_CLASSES)}, got {erasure_class!r}",
283
+ )
284
+ has_default = "default" in entry
285
+ if has_default:
286
+ _reject_float(entry["default"], f"reads[{path!r}].default")
287
+ reads.append(
288
+ ReadField(path=path, erasure_class=erasure_class, default=entry.get("default"), has_default=has_default)
289
+ )
290
+
291
+ declared = {r.path for r in reads}
292
+
293
+ raw_filter = data.get("filter") or []
294
+ if not isinstance(raw_filter, list):
295
+ raise FoldDefinitionError(MALFORMED_DEFINITION, "filter must be a list")
296
+ filters: list[FilterClause] = []
297
+ for clause in raw_filter:
298
+ if not isinstance(clause, dict):
299
+ raise FoldDefinitionError(MALFORMED_DEFINITION, f"filter clause must be a mapping: {clause!r}")
300
+ f_field, op, value = clause.get("field"), clause.get("op"), clause.get("value")
301
+ if op not in BOUNDED_FILTER_OPS:
302
+ raise FoldDefinitionError(
303
+ UNBOUNDED_FILTER_OP,
304
+ f"filter op {op!r} is not a bounded operation; allowed: {sorted(BOUNDED_FILTER_OPS)} "
305
+ "(spec §2: no regex, no user-supplied code)",
306
+ )
307
+ if f_field not in declared:
308
+ raise FoldDefinitionError(UNDECLARED_FIELD_READ, f"filter references undeclared field {f_field!r}")
309
+ if op in ("in", "not_in") and not isinstance(value, list):
310
+ raise FoldDefinitionError(UNBOUNDED_FILTER_OP, f"filter op {op!r} requires a list value")
311
+ _reject_float(value, f"filter[{f_field!r}].value")
312
+ filters.append(FilterClause(field=f_field, op=op, value=value))
313
+
314
+ key = data.get("key")
315
+ if key is not None:
316
+ if not isinstance(key, str):
317
+ raise FoldDefinitionError(MALFORMED_DEFINITION, "key must be a string field path")
318
+ if key not in declared:
319
+ raise FoldDefinitionError(UNDECLARED_FIELD_READ, f"key references undeclared field {key!r}")
320
+
321
+ window: Window | None = None
322
+ raw_window = data.get("window")
323
+ if raw_window is not None:
324
+ if not isinstance(raw_window, dict) or "mode" not in raw_window:
325
+ raise FoldDefinitionError(MALFORMED_DEFINITION, "window must be a mapping with a 'mode'")
326
+ mode = raw_window["mode"]
327
+ if mode == "rolling":
328
+ duration = raw_window.get("duration")
329
+ if not isinstance(duration, str):
330
+ raise FoldDefinitionError(MALFORMED_DEFINITION, "window.duration is required for mode 'rolling'")
331
+ try:
332
+ parse_duration_seconds(duration)
333
+ except ValueError as exc:
334
+ raise FoldDefinitionError(MALFORMED_DEFINITION, str(exc)) from exc
335
+ window = Window(mode="rolling", duration=duration)
336
+ elif mode == "explicit":
337
+ start, end = raw_window.get("start"), raw_window.get("end")
338
+ if not isinstance(start, str) or not isinstance(end, str):
339
+ raise FoldDefinitionError(
340
+ MALFORMED_DEFINITION, "window.start/end (ISO-8601 timestamp strings) are required for mode 'explicit'"
341
+ )
342
+ window = Window(mode="explicit", start=start, end=end)
343
+ else:
344
+ raise FoldDefinitionError(MALFORMED_DEFINITION, f"window.mode {mode!r} must be 'rolling' or 'explicit'")
345
+ if "timestamp" not in declared:
346
+ raise FoldDefinitionError(
347
+ UNDECLARED_FIELD_READ,
348
+ "a window is evaluated against record timestamps (spec §2); declare 'timestamp' in reads",
349
+ )
350
+
351
+ raw_reduce = data.get("reduce")
352
+ if not isinstance(raw_reduce, dict) or "reducer" not in raw_reduce:
353
+ raise FoldDefinitionError(MALFORMED_DEFINITION, "reduce.reducer is required")
354
+ reducer = raw_reduce["reducer"]
355
+ if reducer not in KNOWN_REDUCERS:
356
+ raise FoldDefinitionError(
357
+ UNKNOWN_REDUCER,
358
+ f"reducer {reducer!r} is not in the closed registered set {sorted(KNOWN_REDUCERS)} "
359
+ "(e.g. distinct_count is deliberately excluded — spec §7 open question 1)",
360
+ )
361
+ reduce_field = raw_reduce.get("field")
362
+ if reducer in FIELD_REDUCERS:
363
+ if not isinstance(reduce_field, str):
364
+ raise FoldDefinitionError(MISSING_REDUCE_FIELD, f"reducer {reducer!r} requires reduce.field")
365
+ if reduce_field not in declared:
366
+ raise FoldDefinitionError(UNDECLARED_FIELD_READ, f"reduce.field {reduce_field!r} is not declared in reads")
367
+ elif reduce_field is not None:
368
+ raise FoldDefinitionError(MISSING_REDUCE_FIELD, f"reducer {reducer!r} does not take a reduce.field")
369
+
370
+ emit = data.get("emit")
371
+ if not isinstance(emit, str) or not emit:
372
+ raise FoldDefinitionError(MALFORMED_DEFINITION, "emit is required and must be a non-empty string")
373
+
374
+ # derivation_class: optional on the wire (defaults to deterministic so every
375
+ # existing catalog YAML parses unchanged). When present it MUST be one of the
376
+ # neutral core's classes — validated against the re-imported vocabulary, not
377
+ # a ledger-local copy.
378
+ derivation_class = data.get("derivation_class", DEFAULT_DERIVATION_CLASS)
379
+ if derivation_class not in DERIVATION_CLASSES:
380
+ raise FoldDefinitionError(
381
+ UNKNOWN_DERIVATION_CLASS,
382
+ f"derivation_class {derivation_class!r} must be one of {sorted(DERIVATION_CLASSES)}",
383
+ )
384
+
385
+ raw_reversal = data.get("reversal")
386
+ reversal = None if raw_reversal is None else _parse_reversal(raw_reversal, declared, reducer)
387
+
388
+ return FoldDefinition(
389
+ fold_id=fold_id,
390
+ reads=tuple(reads),
391
+ filter=tuple(filters),
392
+ key=key,
393
+ window=window,
394
+ reduce=Reduce(reducer=reducer, field=reduce_field),
395
+ emit=emit,
396
+ derivation_class=derivation_class,
397
+ reversal=reversal,
398
+ )
399
+
400
+
401
+ def _parse_reversal(raw: Any, declared: set[str], reducer: str) -> Reversal:
402
+ if not isinstance(raw, dict):
403
+ raise FoldDefinitionError(INVALID_REVERSAL, "reversal must be a mapping")
404
+ if reducer != "sum":
405
+ raise FoldDefinitionError(INVALID_REVERSAL, f"a reversal takes back part of a sum; reducer {reducer!r} is not sum")
406
+ fields: dict[str, str] = {}
407
+ for name in ("class_field", "parent_field", "relation_field"):
408
+ path = raw.get(name)
409
+ if not isinstance(path, str) or path not in declared:
410
+ raise FoldDefinitionError(UNDECLARED_FIELD_READ, f"reversal.{name} {path!r} is undeclared in reads")
411
+ fields[name] = path
412
+ classes = raw.get("classes")
413
+ if not isinstance(classes, list) or not classes or not all(isinstance(c, str) and c for c in classes):
414
+ raise FoldDefinitionError(INVALID_REVERSAL, "reversal.classes must be a non-empty list of class names")
415
+ relation = raw.get("relation")
416
+ if relation not in REVERSAL_RELATIONS:
417
+ raise FoldDefinitionError(
418
+ INVALID_REVERSAL,
419
+ f"reversal.relation {relation!r} must be a registered terminal chain relation: {sorted(REVERSAL_RELATIONS)}",
420
+ )
421
+ return Reversal(classes=tuple(classes), relation=relation, **fields)
@@ -0,0 +1,16 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Duration string parsing for rolling windows (spec §2: "rolling duration")."""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _DURATION_RE = re.compile(r"^(\d+)([smhd])$")
8
+ _UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
9
+
10
+
11
+ def parse_duration_seconds(duration: str) -> int:
12
+ match = _DURATION_RE.match(duration)
13
+ if not match:
14
+ raise ValueError(f"duration {duration!r} must match '<int>[smhd]' (e.g. '7d', '24h')")
15
+ count, unit = match.groups()
16
+ return int(count) * _UNIT_SECONDS[unit]