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,504 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Evidence Result v0 emitter -- ``report/`` -> ``result`` projection.
3
+
4
+ The wire shape and every closed vocabulary below mirror
5
+ ``agent-action-capsule``'s ``spec/evidence-result-v0.md`` /
6
+ ``schemas/evidence-result-v0.json`` field-for-field -- that document governs,
7
+ this module is the encoding. A drift between the two is a defect here, never
8
+ a second legitimate spelling. At the time this module was written, that
9
+ schema had landed on an ``agent-action-capsule`` branch (ruled, no longer
10
+ DRAFT) but was not yet on its main branch -- not yet importable from the ``agent-action-capsule``
11
+ dependency this repo already pins -- so ``capsule_engine/schemas/vendor/
12
+ evidence-result-v0.json`` carries a vendored copy (see
13
+ ``capsule_engine/schemas/vendor/README.md`` for its
14
+ provenance and the drop-vendoring-once-shipped note).
15
+
16
+ **Claims never self-declare.** Exactly like ``EvidenceContract`` upstream
17
+ (``capsule_engine/schemas/evidence-contract-v0.json``'s ``$comment``: "the evidence record
18
+ never self-declares that it satisfies a requirement"), nothing in this module
19
+ computes sufficiency or verdict from data it also emits as evidence -- both
20
+ are supplied by the caller, who is expected to have derived them from real
21
+ Evidence Contract evaluation (a fold's own verdict, or a capsule-judge
22
+ verdict record). This module's job is projection and validation: build a
23
+ schema-shaped ``Claim``/``EvidenceResult``, refuse to construct one that
24
+ violates the ruled vocabulary rules, and compute the aggregate (coverage +
25
+ buckets) FROM the claims actually built -- never accept a caller-supplied
26
+ aggregate. See ``result_from_folds.py`` for the adapters that produce claims
27
+ from this repo's own deterministic folds.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import re
33
+ from dataclasses import dataclass, field
34
+ from importlib import resources
35
+ from typing import TYPE_CHECKING, Any
36
+
37
+ import jsonschema
38
+
39
+ from .errors import (
40
+ BUCKET_CLAIM_MISMATCH,
41
+ COVERAGE_CLAIM_MISMATCH,
42
+ DISCLOSURE_NOT_LEGAL_FOR_STATUS,
43
+ DUPLICATE_CLAIM_ID,
44
+ INVALID_DISCLOSED_STATUS,
45
+ INVALID_EVIDENCE_STATUS,
46
+ INVALID_GRADE,
47
+ INVALID_HEX_DIGEST,
48
+ INVALID_SUFFICIENCY,
49
+ INVALID_TIER,
50
+ INVALID_VERDICT,
51
+ SUFFICIENCY_VERDICT_MISMATCH,
52
+ ResultError,
53
+ )
54
+
55
+ if TYPE_CHECKING:
56
+ from .coverage import CoverageReport
57
+
58
+ __all__ = [
59
+ "RESULT_VERSION",
60
+ "TIER_VALUES",
61
+ "GRADE_VALUES",
62
+ "SUFFICIENCY_VALUES",
63
+ "VERDICT_VALUES",
64
+ "EVIDENCE_STATUS_VALUES",
65
+ "DISCLOSED_STATUS_VALUES",
66
+ "DigestRef",
67
+ "ProofRef",
68
+ "DisclosureCarrier",
69
+ "AnalysisCarrier",
70
+ "StoryCarrier",
71
+ "Presentation",
72
+ "Claim",
73
+ "Coverage",
74
+ "Buckets",
75
+ "Aggregate",
76
+ "View",
77
+ "EvidenceResult",
78
+ "build_result",
79
+ "SCHEMA_PATH",
80
+ "load_schema",
81
+ "validate_against_schema",
82
+ "verify_result",
83
+ ]
84
+
85
+ RESULT_VERSION = "evidence-result-v0"
86
+
87
+ # Closed vocabularies -- spec/evidence-result-v0.md sections 1, 2, 4, 6.
88
+ TIER_VALUES = frozenset({"recomputed", "judged"})
89
+ GRADE_VALUES = frozenset({"self-attested", "witnessed", "countersigned"})
90
+ SUFFICIENCY_VALUES = frozenset({"SATISFIED", "GAP", "INSUFFICIENT", "UNKNOWN"})
91
+ VERDICT_VALUES = frozenset({"met", "not_met", "not_evaluable"})
92
+ EVIDENCE_STATUS_VALUES = frozenset(
93
+ {"SATISFIED", "INSUFFICIENT", "NOT_FOUND", "NOT_COMMITTED", "WITHHELD", "CONTRADICTED", "NOT_APPLICABLE", "UNKNOWN"}
94
+ )
95
+ # EvidenceStatus minus the two the disclosure policy reserves for analysis/story (spec section 2).
96
+ DISCLOSED_STATUS_VALUES = EVIDENCE_STATUS_VALUES - {"WITHHELD", "NOT_COMMITTED"}
97
+
98
+ _HEX_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
99
+
100
+ # Resolved inside the installed package, so it is present in a wheel install
101
+ # and not only in a source checkout (scripts/clean_room_wheel.sh checks this).
102
+ SCHEMA_PATH = resources.files("capsule_engine") / "schemas" / "vendor" / "evidence-result-v0.json"
103
+
104
+
105
+ def load_schema() -> dict[str, Any]:
106
+ return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
107
+
108
+
109
+ def _check_hex_digest(digest: str) -> None:
110
+ if not _HEX_DIGEST_RE.fullmatch(digest):
111
+ raise ResultError(INVALID_HEX_DIGEST, f"{digest!r} is not 64 lowercase hex characters")
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class DigestRef:
116
+ """Evidence-by-digest-only ref (spec section 5) -- never inline bytes."""
117
+
118
+ digest: str
119
+ digest_alg: str = "SHA-256"
120
+
121
+ def __post_init__(self) -> None:
122
+ if self.digest_alg != "SHA-256":
123
+ raise ResultError(INVALID_HEX_DIGEST, f"digest_alg must be 'SHA-256', got {self.digest_alg!r}")
124
+ _check_hex_digest(self.digest)
125
+
126
+ def to_dict(self) -> dict:
127
+ return {"digest_alg": self.digest_alg, "digest": self.digest}
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class ProofRef:
132
+ """An inclusion-proof/receipt ref, by digest only (spec section 5)."""
133
+
134
+ kind: str # "inclusion_proof" | "receipt"
135
+ digest: str
136
+ digest_alg: str = "SHA-256"
137
+
138
+ def __post_init__(self) -> None:
139
+ if self.kind not in ("inclusion_proof", "receipt"):
140
+ raise ResultError(INVALID_HEX_DIGEST, f"proof kind must be inclusion_proof|receipt, got {self.kind!r}")
141
+ if self.digest_alg != "SHA-256":
142
+ raise ResultError(INVALID_HEX_DIGEST, f"digest_alg must be 'SHA-256', got {self.digest_alg!r}")
143
+ _check_hex_digest(self.digest)
144
+
145
+ def to_dict(self) -> dict:
146
+ return {"kind": self.kind, "digest_alg": self.digest_alg, "digest": self.digest}
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class DisclosureCarrier:
151
+ """spec section 2/6 -- the claim's evidence, by digest, plus the status
152
+ that licensed showing it. Legal ONLY when ``status`` is not WITHHELD or
153
+ NOT_COMMITTED (the disclosure gate) -- enforced in ``__post_init__``, not
154
+ left to a caller to remember."""
155
+
156
+ status: str
157
+ evidence: tuple[DigestRef, ...] = ()
158
+ kind: str = field(default="disclosure", init=False)
159
+
160
+ def __post_init__(self) -> None:
161
+ if self.status not in DISCLOSED_STATUS_VALUES:
162
+ raise ResultError(
163
+ INVALID_DISCLOSED_STATUS,
164
+ f"disclosure carrier status must be one of {sorted(DISCLOSED_STATUS_VALUES)}, got {self.status!r}",
165
+ )
166
+
167
+ def to_dict(self) -> dict:
168
+ return {"kind": "disclosure", "status": self.status, "evidence": [e.to_dict() for e in self.evidence]}
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class AnalysisCarrier:
173
+ """spec section 2/6 -- a derived characterization, never a quote of the
174
+ underlying payload. Accepts any of the eight EvidenceStatus values."""
175
+
176
+ status: str
177
+ summary: str
178
+ kind: str = field(default="analysis", init=False)
179
+
180
+ def __post_init__(self) -> None:
181
+ if self.status not in EVIDENCE_STATUS_VALUES:
182
+ raise ResultError(INVALID_EVIDENCE_STATUS, f"status must be one of {sorted(EVIDENCE_STATUS_VALUES)}, got {self.status!r}")
183
+ if not self.summary:
184
+ raise ResultError(INVALID_EVIDENCE_STATUS, "analysis carrier summary must be non-empty")
185
+
186
+ def to_dict(self) -> dict:
187
+ return {"kind": "analysis", "status": self.status, "summary": self.summary}
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class StoryCarrier:
192
+ """spec section 2/6 -- narrative-only, the weakest carrier."""
193
+
194
+ status: str
195
+ narrative: str
196
+ kind: str = field(default="story", init=False)
197
+
198
+ def __post_init__(self) -> None:
199
+ if self.status not in EVIDENCE_STATUS_VALUES:
200
+ raise ResultError(INVALID_EVIDENCE_STATUS, f"status must be one of {sorted(EVIDENCE_STATUS_VALUES)}, got {self.status!r}")
201
+ if not self.narrative:
202
+ raise ResultError(INVALID_EVIDENCE_STATUS, "story carrier narrative must be non-empty")
203
+
204
+ def to_dict(self) -> dict:
205
+ return {"kind": "story", "status": self.status, "narrative": self.narrative}
206
+
207
+
208
+ Presentation = DisclosureCarrier | AnalysisCarrier | StoryCarrier
209
+
210
+
211
+ @dataclass(frozen=True)
212
+ class Claim:
213
+ """spec section 4 -- one requirement of one contract version, evaluated
214
+ once. ``sufficiency``/``verdict`` are never derived here (see module
215
+ docstring) -- only checked for internal consistency (spec section 1's
216
+ rule) at construction time, so a malformed claim never gets built in the
217
+ first place: "an untiered claim is a bug here, not a rendering problem."
218
+
219
+ ``contract_ref`` is the compact ``<contract_id>@<version>`` shape
220
+ (evidence-plan-ir-v0.md section 2's convention, mirrored by
221
+ evidence-result-v0.md section 1) -- not two separate
222
+ ``contract_id``/``contract_version`` fields; the frozen schema uses
223
+ ``contract_ref`` (a rename applied before the schema froze), so this
224
+ module follows the shipped schema.
225
+ """
226
+
227
+ id: str
228
+ contract_ref: str
229
+ requirement_ref: str
230
+ tier: str
231
+ grade: str
232
+ sufficiency: str
233
+ verdict: str
234
+ evidence: tuple[DigestRef, ...]
235
+ proofs: tuple[ProofRef, ...]
236
+ presentation: Presentation
237
+
238
+ def __post_init__(self) -> None:
239
+ if not self.id:
240
+ raise ResultError(INVALID_TIER, "claim id must be non-empty")
241
+ if "@" not in self.contract_ref or self.contract_ref.startswith("@") or self.contract_ref.endswith("@"):
242
+ raise ResultError(INVALID_TIER, f"contract_ref must be <contract_id>@<version>, got {self.contract_ref!r}")
243
+ if not self.requirement_ref:
244
+ raise ResultError(INVALID_TIER, "requirement_ref must be non-empty")
245
+ if self.tier not in TIER_VALUES:
246
+ raise ResultError(INVALID_TIER, f"tier must be one of {sorted(TIER_VALUES)}, got {self.tier!r}")
247
+ if self.grade not in GRADE_VALUES:
248
+ raise ResultError(INVALID_GRADE, f"grade must be one of {sorted(GRADE_VALUES)}, got {self.grade!r}")
249
+ if self.sufficiency not in SUFFICIENCY_VALUES:
250
+ raise ResultError(INVALID_SUFFICIENCY, f"sufficiency must be one of {sorted(SUFFICIENCY_VALUES)}, got {self.sufficiency!r}")
251
+ if self.verdict not in VERDICT_VALUES:
252
+ raise ResultError(INVALID_VERDICT, f"verdict must be one of {sorted(VERDICT_VALUES)}, got {self.verdict!r}")
253
+ # spec section 1's rule, restated normatively there from contract v3 section 8:
254
+ # verdict is met/not_met ONLY when sufficiency == SATISFIED; otherwise not_evaluable.
255
+ if self.sufficiency == "SATISFIED":
256
+ if self.verdict not in ("met", "not_met"):
257
+ raise ResultError(
258
+ SUFFICIENCY_VERDICT_MISMATCH,
259
+ f"sufficiency SATISFIED requires verdict met|not_met, got {self.verdict!r}",
260
+ )
261
+ elif self.verdict != "not_evaluable":
262
+ raise ResultError(
263
+ SUFFICIENCY_VERDICT_MISMATCH,
264
+ f"sufficiency {self.sufficiency!r} (not SATISFIED) requires verdict not_evaluable, got {self.verdict!r}",
265
+ )
266
+ # spec section 2's gate: disclosure is not a legal carrier when the
267
+ # carrier's own status is WITHHELD/NOT_COMMITTED -- DisclosureCarrier
268
+ # itself already refuses that status, so this is a second, cheap
269
+ # belt-and-suspenders check against a carrier built via replace()/
270
+ # object.__new__ bypassing __post_init__.
271
+ if isinstance(self.presentation, DisclosureCarrier) and self.presentation.status not in DISCLOSED_STATUS_VALUES:
272
+ raise ResultError(
273
+ DISCLOSURE_NOT_LEGAL_FOR_STATUS,
274
+ f"disclosure carrier illegal for status {self.presentation.status!r}",
275
+ )
276
+
277
+ def to_dict(self) -> dict:
278
+ return {
279
+ "id": self.id,
280
+ "contract_ref": self.contract_ref,
281
+ "requirement_ref": self.requirement_ref,
282
+ "tier": self.tier,
283
+ "grade": self.grade,
284
+ "sufficiency": self.sufficiency,
285
+ "verdict": self.verdict,
286
+ "evidence": [e.to_dict() for e in self.evidence],
287
+ "proofs": [p.to_dict() for p in self.proofs],
288
+ "presentation": self.presentation.to_dict(),
289
+ }
290
+
291
+
292
+ @dataclass(frozen=True)
293
+ class Coverage:
294
+ """spec section 3/7 -- mandatory, computed. Never constructed with
295
+ values the caller invented; see ``build_result``."""
296
+
297
+ evaluated_population: int
298
+ excluded_not_applicable: int
299
+ unknown_count: int
300
+
301
+ def __post_init__(self) -> None:
302
+ for name, value in (
303
+ ("evaluated_population", self.evaluated_population),
304
+ ("excluded_not_applicable", self.excluded_not_applicable),
305
+ ("unknown_count", self.unknown_count),
306
+ ):
307
+ if value < 0:
308
+ raise ResultError(INVALID_SUFFICIENCY, f"coverage.{name} must be >= 0, got {value}")
309
+
310
+ def to_dict(self) -> dict:
311
+ return {
312
+ "evaluated_population": self.evaluated_population,
313
+ "excluded_not_applicable": self.excluded_not_applicable,
314
+ "unknown_count": self.unknown_count,
315
+ }
316
+
317
+
318
+ @dataclass(frozen=True)
319
+ class Buckets:
320
+ """spec section 3/7 -- always three keys, each an array. Never
321
+ constructed by a caller; see ``build_result``."""
322
+
323
+ met: tuple[str, ...]
324
+ not_met: tuple[str, ...]
325
+ not_evaluable: tuple[str, ...]
326
+
327
+ def to_dict(self) -> dict:
328
+ return {"met": list(self.met), "not_met": list(self.not_met), "not_evaluable": list(self.not_evaluable)}
329
+
330
+
331
+ @dataclass(frozen=True)
332
+ class Aggregate:
333
+ coverage: Coverage
334
+ buckets: Buckets
335
+
336
+ def to_dict(self) -> dict:
337
+ return {"coverage": self.coverage.to_dict(), "buckets": self.buckets.to_dict()}
338
+
339
+
340
+ @dataclass(frozen=True)
341
+ class View:
342
+ """spec section 8 -- presentation hints only, never data."""
343
+
344
+ producer_name: str | None = None
345
+ logo_data_url: str | None = None
346
+ title: str | None = None
347
+ spec_version: str = field(default="presentation/v1", init=False)
348
+
349
+ def to_dict(self) -> dict:
350
+ out: dict[str, Any] = {"spec_version": "presentation/v1"}
351
+ if self.producer_name is not None:
352
+ out["producer_name"] = self.producer_name
353
+ if self.logo_data_url is not None:
354
+ out["logo_data_url"] = self.logo_data_url
355
+ if self.title is not None:
356
+ out["title"] = self.title
357
+ return out
358
+
359
+
360
+ @dataclass(frozen=True)
361
+ class EvidenceResult:
362
+ generated_at: str
363
+ claims: tuple[Claim, ...]
364
+ aggregate: Aggregate
365
+ view: View | None = None
366
+ coverage_report: CoverageReport | None = None
367
+ result_version: str = field(default=RESULT_VERSION, init=False)
368
+
369
+ def to_dict(self) -> dict:
370
+ out: dict[str, Any] = {
371
+ "result_version": RESULT_VERSION,
372
+ "generated_at": self.generated_at,
373
+ "claims": [c.to_dict() for c in self.claims],
374
+ "aggregate": self.aggregate.to_dict(),
375
+ }
376
+ if self.view is not None:
377
+ out["view"] = self.view.to_dict()
378
+ if self.coverage_report is not None:
379
+ out["coverage_report"] = self.coverage_report.to_dict()
380
+ return out
381
+
382
+
383
+ def build_result(
384
+ claims: list[Claim],
385
+ *,
386
+ generated_at: str,
387
+ excluded_not_applicable: int = 0,
388
+ view: View | None = None,
389
+ coverage_report: CoverageReport | None = None,
390
+ ) -> EvidenceResult:
391
+ """The projection's aggregate step (spec section 3): coverage and
392
+ buckets are COMPUTED from ``claims``, never templated or accepted
393
+ pre-summed from a caller.
394
+
395
+ ``excluded_not_applicable`` is the one count that cannot be derived from
396
+ ``claims`` -- a requirement excluded as NOT_APPLICABLE never becomes a
397
+ claim at all (spec section 3), so the caller (typically a
398
+ ``result_from_folds`` adapter, which sees the excluded requirements
399
+ directly) supplies the count of what it declined to emit as claims.
400
+ Every other coverage/bucket field is counted off ``claims`` itself.
401
+
402
+ ``coverage_report`` is the optional per-requirement section built by
403
+ ``coverage.build_coverage_report`` over the same claims; it is carried
404
+ as built, and ``verify_result`` re-checks it against the claims.
405
+ """
406
+ if excluded_not_applicable < 0:
407
+ raise ResultError(INVALID_SUFFICIENCY, f"excluded_not_applicable must be >= 0, got {excluded_not_applicable}")
408
+
409
+ met: list[str] = []
410
+ not_met: list[str] = []
411
+ not_evaluable: list[str] = []
412
+ unknown_count = 0
413
+ seen_ids: set[str] = set()
414
+ for claim in claims:
415
+ if claim.id in seen_ids:
416
+ raise ResultError(DUPLICATE_CLAIM_ID, f"claim id {claim.id!r} appears more than once")
417
+ seen_ids.add(claim.id)
418
+ if claim.verdict == "met":
419
+ met.append(claim.id)
420
+ elif claim.verdict == "not_met":
421
+ not_met.append(claim.id)
422
+ else:
423
+ not_evaluable.append(claim.id)
424
+ if claim.sufficiency == "UNKNOWN":
425
+ unknown_count += 1
426
+
427
+ coverage = Coverage(
428
+ evaluated_population=len(claims),
429
+ excluded_not_applicable=excluded_not_applicable,
430
+ unknown_count=unknown_count,
431
+ )
432
+ buckets = Buckets(met=tuple(met), not_met=tuple(not_met), not_evaluable=tuple(not_evaluable))
433
+ return EvidenceResult(
434
+ generated_at=generated_at,
435
+ claims=tuple(claims),
436
+ aggregate=Aggregate(coverage=coverage, buckets=buckets),
437
+ view=view,
438
+ coverage_report=coverage_report,
439
+ )
440
+
441
+
442
+ def validate_against_schema(doc: dict[str, Any]) -> None:
443
+ """Validate a Result document against the vendored public JSON Schema
444
+ (item 4: "Validate every emitted Result against the public schema in
445
+ CI"). Raises ``jsonschema.exceptions.ValidationError`` on the first
446
+ violation."""
447
+ schema = load_schema()
448
+ jsonschema.Draft202012Validator(schema).validate(doc)
449
+
450
+
451
+ def verify_result(doc: dict[str, Any]) -> None:
452
+ """The cross-element checks spec/evidence-result-v0.md section 4 names
453
+ as "normative, not schema-enforced in v0": claim ``id`` uniqueness, and
454
+ every ``aggregate.buckets`` entry naming a claim that actually exists
455
+ with the matching verdict. Then the two coverage counts a reader can
456
+ recompute from the claims: ``evaluated_population`` (the number of
457
+ claims) and ``unknown_count`` (claims whose sufficiency is UNKNOWN).
458
+ ``excluded_not_applicable`` is not checked -- excluded requirements
459
+ never become claims, so nothing here can recount it. A missing
460
+ ``coverage`` is the schema's to reject. Plus, when the document carries a
461
+ ``coverage_report``, ``coverage.verify_coverage_report``'s checks
462
+ against the same claims. Raises ``ResultError`` on the first
463
+ violation. Callers wanting full conformance run this AND
464
+ ``validate_against_schema`` -- neither alone is the whole check."""
465
+ claims_by_id: dict[str, dict] = {}
466
+ for claim in doc.get("claims", []):
467
+ claim_id = claim.get("id")
468
+ if claim_id in claims_by_id:
469
+ raise ResultError(DUPLICATE_CLAIM_ID, f"claim id {claim_id!r} appears more than once")
470
+ claims_by_id[claim_id] = claim
471
+
472
+ buckets = doc.get("aggregate", {}).get("buckets", {})
473
+ for verdict, ids in (("met", buckets.get("met", [])), ("not_met", buckets.get("not_met", [])), ("not_evaluable", buckets.get("not_evaluable", []))):
474
+ for claim_id in ids:
475
+ claim = claims_by_id.get(claim_id)
476
+ if claim is None:
477
+ raise ResultError(BUCKET_CLAIM_MISMATCH, f"bucket {verdict!r} names claim id {claim_id!r}, which has no claim")
478
+ if claim.get("verdict") != verdict:
479
+ raise ResultError(
480
+ BUCKET_CLAIM_MISMATCH,
481
+ f"claim {claim_id!r} is in bucket {verdict!r} but its own verdict is {claim.get('verdict')!r}",
482
+ )
483
+ bucketed_ids = {cid for ids in buckets.values() for cid in ids}
484
+ for claim_id, claim in claims_by_id.items():
485
+ if claim_id not in bucketed_ids:
486
+ raise ResultError(BUCKET_CLAIM_MISMATCH, f"claim {claim_id!r} (verdict {claim.get('verdict')!r}) is in no bucket")
487
+
488
+ coverage = doc.get("aggregate", {}).get("coverage")
489
+ if coverage is not None:
490
+ recounted = (
491
+ ("evaluated_population", len(claims_by_id)),
492
+ ("unknown_count", sum(1 for claim in claims_by_id.values() if claim.get("sufficiency") == "UNKNOWN")),
493
+ )
494
+ for name, count in recounted:
495
+ if coverage.get(name) != count:
496
+ raise ResultError(
497
+ COVERAGE_CLAIM_MISMATCH,
498
+ f"coverage {name} {coverage.get(name)!r} disagrees with the {count} counted from claims",
499
+ )
500
+
501
+ if "coverage_report" in doc:
502
+ from .coverage import verify_coverage_report
503
+
504
+ verify_coverage_report(doc["coverage_report"], claims_by_id)