apsimo 1.3.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 (614) hide show
  1. apsimo/__init__.py +38 -0
  2. apsimo/__main__.py +6 -0
  3. apsimo/agent/__init__.py +6 -0
  4. apsimo/agent/client.py +276 -0
  5. apsimo/agent/models.py +46 -0
  6. apsimo/agents/__init__.py +20 -0
  7. apsimo/agents/models.py +264 -0
  8. apsimo/agents/store.py +861 -0
  9. apsimo/agents/websocket.py +522 -0
  10. apsimo/api/__init__.py +1 -0
  11. apsimo/api/auth_telemetry.py +287 -0
  12. apsimo/api/authority.py +1203 -0
  13. apsimo/api/contact_grants.py +347 -0
  14. apsimo/api/middleware.py +483 -0
  15. apsimo/api/routers/__init__.py +1 -0
  16. apsimo/api/routers/commitment_work.py +265 -0
  17. apsimo/api/routers/context_gate.py +123 -0
  18. apsimo/api/routers/executions.py +140 -0
  19. apsimo/api/routers/followup_plans.py +147 -0
  20. apsimo/api/routers/governed_actions.py +162 -0
  21. apsimo/api/routers/host.py +14473 -0
  22. apsimo/api/routers/initiative_work.py +115 -0
  23. apsimo/api/routers/mining.py +104 -0
  24. apsimo/api/routers/observations.py +110 -0
  25. apsimo/api/routers/social_state.py +225 -0
  26. apsimo/api/routers/task_queue.py +2715 -0
  27. apsimo/api/routers/temporal_followups.py +251 -0
  28. apsimo/api/routers/transport.py +110 -0
  29. apsimo/api/routers/transport_ingress_api.py +240 -0
  30. apsimo/api/schemas/__init__.py +1 -0
  31. apsimo/api/schemas/host.py +1949 -0
  32. apsimo/autonomy/cli.py +110 -0
  33. apsimo/autonomy/condition_worker.py +437 -0
  34. apsimo/autonomy/config.py +424 -0
  35. apsimo/autonomy/loop.py +4316 -0
  36. apsimo/autonomy/registry.py +339 -0
  37. apsimo/autonomy/scheduler.py +1822 -0
  38. apsimo/autonomy/synthesis.py +449 -0
  39. apsimo/backup.py +962 -0
  40. apsimo/beliefs/__init__.py +23 -0
  41. apsimo/beliefs/contradictions.py +109 -0
  42. apsimo/beliefs/decay.py +61 -0
  43. apsimo/beliefs/engine.py +479 -0
  44. apsimo/beliefs/models.py +67 -0
  45. apsimo/beliefs/promotion.py +41 -0
  46. apsimo/beliefs/resolve.py +58 -0
  47. apsimo/beliefs/source_claims.py +690 -0
  48. apsimo/beliefs/source_projection.py +883 -0
  49. apsimo/beliefs/source_time.py +208 -0
  50. apsimo/beliefs/store.py +133 -0
  51. apsimo/briefings/aggregators.py +824 -0
  52. apsimo/briefings/composer.py +420 -0
  53. apsimo/briefings/config.py +55 -0
  54. apsimo/briefings/delivery.py +439 -0
  55. apsimo/briefings/engagement.py +97 -0
  56. apsimo/briefings/engine.py +274 -0
  57. apsimo/briefings/enhancer.py +99 -0
  58. apsimo/briefings/models.py +183 -0
  59. apsimo/briefings/scheduler.py +382 -0
  60. apsimo/briefings/store.py +435 -0
  61. apsimo/chain/__init__.py +48 -0
  62. apsimo/chain/block.py +100 -0
  63. apsimo/chain/cli.py +704 -0
  64. apsimo/chain/genesis.py +443 -0
  65. apsimo/chain/identity.py +416 -0
  66. apsimo/chain/keys.py +1025 -0
  67. apsimo/chain/local_keys.py +187 -0
  68. apsimo/chain/manager.py +290 -0
  69. apsimo/chain/node.py +163 -0
  70. apsimo/chain/plugin_transactions.py +371 -0
  71. apsimo/chain/protocol.py +220 -0
  72. apsimo/chain/state_machine.py +676 -0
  73. apsimo/chain/storage.py +503 -0
  74. apsimo/chain/transactions.py +250 -0
  75. apsimo/chain/validation.py +397 -0
  76. apsimo/channels/__init__.py +1 -0
  77. apsimo/channels/manifest.py +31 -0
  78. apsimo/channels/migrations/001_channels_schema.sql +12 -0
  79. apsimo/channels/phone_gateways.py +42 -0
  80. apsimo/channels/presence.py +188 -0
  81. apsimo/channels/router.py +235 -0
  82. apsimo/channels/store.py +231 -0
  83. apsimo/cli.py +2688 -0
  84. apsimo/cognition/__init__.py +11 -0
  85. apsimo/cognition/charter.py +398 -0
  86. apsimo/cognition/drive_governance.py +3530 -0
  87. apsimo/cognition/evidence_pipeline.py +1627 -0
  88. apsimo/cognition/external_events.py +932 -0
  89. apsimo/cognition/goal_spine.py +3488 -0
  90. apsimo/cognition/introspection.py +214 -0
  91. apsimo/cognition/prompt.py +150 -0
  92. apsimo/cognition/runtime.py +108 -0
  93. apsimo/cognition/trigger.py +154 -0
  94. apsimo/commitments/__init__.py +18 -0
  95. apsimo/commitments/local_work.py +355 -0
  96. apsimo/commitments/store.py +1052 -0
  97. apsimo/commitments/work.py +91 -0
  98. apsimo/compat.py +53 -0
  99. apsimo/compression/__init__.py +467 -0
  100. apsimo/connectors/__init__.py +21 -0
  101. apsimo/connectors/base.py +152 -0
  102. apsimo/connectors/caldav_calendar.py +125 -0
  103. apsimo/connectors/fs_documents.py +85 -0
  104. apsimo/connectors/imap_email.py +138 -0
  105. apsimo/connectors/manager.py +218 -0
  106. apsimo/connectors/webhook_pull.py +88 -0
  107. apsimo/contacts/__init__.py +33 -0
  108. apsimo/contacts/comms.py +357 -0
  109. apsimo/contacts/config.py +79 -0
  110. apsimo/contacts/exporters/__init__.py +1 -0
  111. apsimo/contacts/exporters/vcard.py +71 -0
  112. apsimo/contacts/identity_links.py +251 -0
  113. apsimo/contacts/importer.py +280 -0
  114. apsimo/contacts/importers/__init__.py +1 -0
  115. apsimo/contacts/importers/batch.py +43 -0
  116. apsimo/contacts/importers/macos_contacts.py +101 -0
  117. apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
  118. apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
  119. apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
  120. apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
  121. apsimo/contacts/migrations/005_identity_links.sql +27 -0
  122. apsimo/contacts/models.py +308 -0
  123. apsimo/contacts/scoring.py +16 -0
  124. apsimo/contacts/store.py +1623 -0
  125. apsimo/contacts/transport_ingress.py +252 -0
  126. apsimo/contacts/world_bridge.py +314 -0
  127. apsimo/contextgate/__init__.py +69 -0
  128. apsimo/contextgate/chunker.py +169 -0
  129. apsimo/contextgate/estimate.py +54 -0
  130. apsimo/contextgate/gate.py +313 -0
  131. apsimo/contextgate/retrieve.py +115 -0
  132. apsimo/delivery/__init__.py +16 -0
  133. apsimo/delivery/bridge.py +1260 -0
  134. apsimo/delivery/channels.py +526 -0
  135. apsimo/delivery/classification.py +50 -0
  136. apsimo/delivery/rate_limiter.py +268 -0
  137. apsimo/delivery/reachout_policy.py +206 -0
  138. apsimo/directed/__init__.py +22 -0
  139. apsimo/directed/audit.py +167 -0
  140. apsimo/directed/intake.py +95 -0
  141. apsimo/directed/models.py +191 -0
  142. apsimo/directed/service.py +509 -0
  143. apsimo/directives/__init__.py +25 -0
  144. apsimo/directives/evidence.py +87 -0
  145. apsimo/directives/extractor.py +188 -0
  146. apsimo/directives/guard.py +364 -0
  147. apsimo/directives/models.py +206 -0
  148. apsimo/directives/service.py +372 -0
  149. apsimo/directives/store.py +167 -0
  150. apsimo/doctor.py +2173 -0
  151. apsimo/environment.py +43 -0
  152. apsimo/events/__init__.py +33 -0
  153. apsimo/events/broadcaster.py +98 -0
  154. apsimo/events/bus.py +217 -0
  155. apsimo/events/journal.py +863 -0
  156. apsimo/events/stream.py +131 -0
  157. apsimo/events/types.py +150 -0
  158. apsimo/execution_results.py +357 -0
  159. apsimo/feedback/__init__.py +5 -0
  160. apsimo/feedback/store.py +76 -0
  161. apsimo/feeds/__init__.py +19 -0
  162. apsimo/feeds/cli.py +84 -0
  163. apsimo/feeds/engine.py +437 -0
  164. apsimo/feeds/example-feed.yaml +77 -0
  165. apsimo/feeds/hermes_cron.py +126 -0
  166. apsimo/feeds/manager.py +235 -0
  167. apsimo/feeds/spec.py +250 -0
  168. apsimo/feeds/template.py +202 -0
  169. apsimo/gate/__init__.py +18 -0
  170. apsimo/gate/audit.py +61 -0
  171. apsimo/gate/communication_policy.py +166 -0
  172. apsimo/gate/config.py +72 -0
  173. apsimo/gate/context_provenance.py +170 -0
  174. apsimo/gate/env_risk.py +226 -0
  175. apsimo/gate/guard_audit.py +353 -0
  176. apsimo/gate/layers/__init__.py +1 -0
  177. apsimo/gate/layers/base.py +15 -0
  178. apsimo/gate/layers/l1_recipient.py +66 -0
  179. apsimo/gate/layers/l2_pii.py +134 -0
  180. apsimo/gate/layers/l3_cross_context.py +50 -0
  181. apsimo/gate/layers/l4_trust_tier.py +78 -0
  182. apsimo/gate/layers/l5_injection.py +199 -0
  183. apsimo/gate/layers/l6_review.py +86 -0
  184. apsimo/gate/layers/l7_delay.py +100 -0
  185. apsimo/gate/layers/tom2_epistemic.py +185 -0
  186. apsimo/gate/models.py +64 -0
  187. apsimo/gate/pending_dispatch.py +5 -0
  188. apsimo/gate/pipeline.py +206 -0
  189. apsimo/gate/rejection.py +259 -0
  190. apsimo/gate/response_guard.py +700 -0
  191. apsimo/gate/rulesets/injection_v1.yaml +51 -0
  192. apsimo/gate/surface_policy.py +189 -0
  193. apsimo/gate/taint.py +226 -0
  194. apsimo/genesis.json +9 -0
  195. apsimo/goals/__init__.py +100 -0
  196. apsimo/goals/config.py +38 -0
  197. apsimo/goals/decomposer.py +421 -0
  198. apsimo/goals/engine.py +617 -0
  199. apsimo/goals/inference.py +354 -0
  200. apsimo/goals/models.py +302 -0
  201. apsimo/goals/priority.py +270 -0
  202. apsimo/goals/queue_bridge.py +149 -0
  203. apsimo/goals/replan.py +450 -0
  204. apsimo/goals/schema.sql +89 -0
  205. apsimo/goals/store.py +692 -0
  206. apsimo/governed_actions.py +1708 -0
  207. apsimo/harness_integration/__init__.py +45 -0
  208. apsimo/harness_integration/context.py +41 -0
  209. apsimo/harness_integration/skills.py +231 -0
  210. apsimo/identity/__init__.py +26 -0
  211. apsimo/identity/participants.py +181 -0
  212. apsimo/identity/resolver.py +329 -0
  213. apsimo/identity_bootstrap/__init__.py +5 -0
  214. apsimo/identity_bootstrap/builder.py +208 -0
  215. apsimo/identity_bootstrap/corpus.py +443 -0
  216. apsimo/identity_bootstrap/models.py +54 -0
  217. apsimo/identity_bootstrap/runner.py +353 -0
  218. apsimo/identity_bootstrap/seeders/__init__.py +25 -0
  219. apsimo/identity_bootstrap/seeders/briefings.py +109 -0
  220. apsimo/identity_bootstrap/seeders/chain.py +57 -0
  221. apsimo/identity_bootstrap/seeders/goals.py +128 -0
  222. apsimo/identity_bootstrap/seeders/memory.py +191 -0
  223. apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
  224. apsimo/identity_bootstrap/seeders/relationship.py +152 -0
  225. apsimo/identity_bootstrap/seeders/sessions.py +67 -0
  226. apsimo/identity_bootstrap/seeders/skills.py +92 -0
  227. apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
  228. apsimo/identity_bootstrap/seeders/world_model.py +143 -0
  229. apsimo/identity_bootstrap/self_query.py +92 -0
  230. apsimo/identity_bootstrap/self_reflection.py +155 -0
  231. apsimo/identity_bootstrap/skill.py +37 -0
  232. apsimo/identity_bootstrap/verifier.py +436 -0
  233. apsimo/initiatives/__init__.py +20 -0
  234. apsimo/initiatives/action_registry.py +454 -0
  235. apsimo/initiatives/approval_authority.py +2105 -0
  236. apsimo/initiatives/approval_policy.py +123 -0
  237. apsimo/initiatives/assignment.py +263 -0
  238. apsimo/initiatives/backup_evidence.py +100 -0
  239. apsimo/initiatives/context_freshness.py +103 -0
  240. apsimo/initiatives/models.py +318 -0
  241. apsimo/initiatives/native_work.py +270 -0
  242. apsimo/initiatives/standing_approvals.py +232 -0
  243. apsimo/initiatives/store.py +1081 -0
  244. apsimo/initiatives/temporal_followup.py +410 -0
  245. apsimo/intelligence/__init__.py +1 -0
  246. apsimo/intelligence/cognition/__init__.py +24 -0
  247. apsimo/intelligence/cognition/gap_detector.py +148 -0
  248. apsimo/intelligence/cognition/metalearner.py +547 -0
  249. apsimo/intelligence/cognition/metrics_collector.py +217 -0
  250. apsimo/intelligence/cognition/performance_index.py +299 -0
  251. apsimo/intelligence/cognition/registry.py +192 -0
  252. apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
  253. apsimo/intelligence/cognition/types.py +16 -0
  254. apsimo/intelligence/components/__init__.py +66 -0
  255. apsimo/intelligence/components/anomaly_detector.py +413 -0
  256. apsimo/intelligence/components/initiative_engine.py +2643 -0
  257. apsimo/intelligence/components/preference_learner.py +521 -0
  258. apsimo/intelligence/components/research_orchestrator.py +358 -0
  259. apsimo/intelligence/components/self_directed_thinker.py +221 -0
  260. apsimo/intelligence/components/self_reflector.py +252 -0
  261. apsimo/intelligence/components/session_continuity.py +154 -0
  262. apsimo/intelligence/components/task_planner.py +320 -0
  263. apsimo/intelligence/components/tool_learner.py +217 -0
  264. apsimo/intelligence/graph/__init__.py +79 -0
  265. apsimo/intelligence/graph/client.py +2483 -0
  266. apsimo/intelligence/graph/consolidator.py +405 -0
  267. apsimo/intelligence/graph/distiller.py +312 -0
  268. apsimo/intelligence/graph/migrations.py +129 -0
  269. apsimo/intelligence/graph/queries.py +248 -0
  270. apsimo/intelligence/graph/recall.py +281 -0
  271. apsimo/intelligence/graph/reconciler.py +144 -0
  272. apsimo/intelligence/graph/schema.py +337 -0
  273. apsimo/intelligence/graph/selection.py +252 -0
  274. apsimo/intelligence/learning/__init__.py +17 -0
  275. apsimo/intelligence/learning/continuous_learner.py +245 -0
  276. apsimo/intelligence/learning/feedback_store.py +321 -0
  277. apsimo/intelligence/mind_model/__init__.py +1 -0
  278. apsimo/intelligence/mind_model/graph_baseline.py +136 -0
  279. apsimo/intelligence/mind_model/signal_collector.py +361 -0
  280. apsimo/intelligence/relationships/__init__.py +11 -0
  281. apsimo/intelligence/relationships/profiler.py +389 -0
  282. apsimo/intelligence/relationships/scorer.py +560 -0
  283. apsimo/intelligence/relationships/signal_floor.py +66 -0
  284. apsimo/intelligence/relationships/trust_tiers.py +300 -0
  285. apsimo/intelligence/synthesis/__init__.py +40 -0
  286. apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
  287. apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
  288. apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
  289. apsimo/intelligence/synthesis/insight_store.py +79 -0
  290. apsimo/intelligence/synthesis/insight_validator.py +183 -0
  291. apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
  292. apsimo/intelligence/turn_middleware/__init__.py +15 -0
  293. apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
  294. apsimo/mcp/__init__.py +41 -0
  295. apsimo/mcp/__main__.py +6 -0
  296. apsimo/mcp/config.py +287 -0
  297. apsimo/mcp/server.py +501 -0
  298. apsimo/migrations.py +187 -0
  299. apsimo/mining/__init__.py +27 -0
  300. apsimo/mining/corpus.py +239 -0
  301. apsimo/mining/escalations.py +289 -0
  302. apsimo/mining/models.py +169 -0
  303. apsimo/mining/store.py +210 -0
  304. apsimo/models/__init__.py +30 -0
  305. apsimo/models/memory.py +80 -0
  306. apsimo/models/mesh.py +72 -0
  307. apsimo/models/person.py +104 -0
  308. apsimo/models/signal.py +108 -0
  309. apsimo/observations/__init__.py +15 -0
  310. apsimo/observations/store.py +277 -0
  311. apsimo/patterns/__init__.py +6 -0
  312. apsimo/patterns/extract.py +187 -0
  313. apsimo/patterns/store.py +227 -0
  314. apsimo/persona/__init__.py +1 -0
  315. apsimo/persona/engine.py +611 -0
  316. apsimo/persona/manifest.py +140 -0
  317. apsimo/projects/__init__.py +28 -0
  318. apsimo/projects/engine.py +1681 -0
  319. apsimo/projects/event_outbox.py +188 -0
  320. apsimo/projects/models.py +216 -0
  321. apsimo/projects/planner.py +181 -0
  322. apsimo/projects/store.py +1446 -0
  323. apsimo/proposals/__init__.py +12 -0
  324. apsimo/proposals/engine.py +114 -0
  325. apsimo/proposals/models.py +207 -0
  326. apsimo/qualification/__init__.py +1 -0
  327. apsimo/qualification/cases.py +75 -0
  328. apsimo/qualification/cli.py +51 -0
  329. apsimo/qualification/memory_cases.py +209 -0
  330. apsimo/qualification/records.py +92 -0
  331. apsimo/qualification/report.py +87 -0
  332. apsimo/qualification/runner.py +311 -0
  333. apsimo/qualification/structured_cases.py +131 -0
  334. apsimo/reasoning/__init__.py +13 -0
  335. apsimo/reasoning/executor.py +506 -0
  336. apsimo/reasoning/loop.py +373 -0
  337. apsimo/reasoning/native_tools/__init__.py +16 -0
  338. apsimo/reasoning/native_tools/calculate.py +141 -0
  339. apsimo/reasoning/native_tools/file_ops.py +150 -0
  340. apsimo/reasoning/native_tools/web_search.py +49 -0
  341. apsimo/reasoning/tool_policy.py +182 -0
  342. apsimo/redact/__init__.py +176 -0
  343. apsimo/repos/__init__.py +5 -0
  344. apsimo/repos/mirrors.py +204 -0
  345. apsimo/research/__init__.py +41 -0
  346. apsimo/research/artifact.py +482 -0
  347. apsimo/research/gatherer.py +387 -0
  348. apsimo/research/pipeline.py +513 -0
  349. apsimo/research/search/__init__.py +7 -0
  350. apsimo/research/search/base.py +41 -0
  351. apsimo/research/search/brave.py +59 -0
  352. apsimo/research/search/cache.py +51 -0
  353. apsimo/research/search/duckduckgo.py +103 -0
  354. apsimo/research/search/orchestrator.py +119 -0
  355. apsimo/research/search/serpapi.py +59 -0
  356. apsimo/research/search/tavily.py +59 -0
  357. apsimo/research/synthesizer.py +309 -0
  358. apsimo/router/__init__.py +30 -0
  359. apsimo/router/complexity_scorer.py +148 -0
  360. apsimo/router/endpoints.py +153 -0
  361. apsimo/router/fallback.py +58 -0
  362. apsimo/router/functions.py +243 -0
  363. apsimo/router/native_policy.py +52 -0
  364. apsimo/router/router.py +762 -0
  365. apsimo/router/self_learning.py +174 -0
  366. apsimo/router/tiers.py +677 -0
  367. apsimo/sandbox/__init__.py +21 -0
  368. apsimo/sandbox/backend.py +195 -0
  369. apsimo/sandbox/manager.py +173 -0
  370. apsimo/scope_bounds.py +7 -0
  371. apsimo/secrets/__init__.py +6 -0
  372. apsimo/secrets/backends/__init__.py +8 -0
  373. apsimo/secrets/backends/base.py +42 -0
  374. apsimo/secrets/backends/env.py +110 -0
  375. apsimo/secrets/backends/keyring.py +72 -0
  376. apsimo/secrets/backends/onepassword.py +232 -0
  377. apsimo/secrets/cli.py +191 -0
  378. apsimo/secrets/manager.py +160 -0
  379. apsimo/secrets/migration.py +101 -0
  380. apsimo/secrets/types.py +98 -0
  381. apsimo/seed.py +41 -0
  382. apsimo/self_model/__init__.py +37 -0
  383. apsimo/self_model/appraisals.py +673 -0
  384. apsimo/self_model/benchmark.py +1314 -0
  385. apsimo/self_model/brief.py +40 -0
  386. apsimo/self_model/event_concerns.py +1128 -0
  387. apsimo/self_model/execution_forecasts.py +353 -0
  388. apsimo/self_model/expectations.py +1595 -0
  389. apsimo/self_model/experiments.py +1150 -0
  390. apsimo/self_model/journal.py +148 -0
  391. apsimo/self_model/judgments.py +705 -0
  392. apsimo/self_model/native_outcomes.py +55 -0
  393. apsimo/self_model/params.py +220 -0
  394. apsimo/self_model/perspective.py +246 -0
  395. apsimo/self_model/reconcile.py +183 -0
  396. apsimo/self_model/reply_forecasts.py +381 -0
  397. apsimo/self_model/runtime_forecasts.py +296 -0
  398. apsimo/self_model/runtime_models.py +67 -0
  399. apsimo/self_model/settlement.py +207 -0
  400. apsimo/self_model/situation.py +1731 -0
  401. apsimo/self_model/store.py +883 -0
  402. apsimo/self_model/supervised.py +137 -0
  403. apsimo/self_model/thinker.py +99 -0
  404. apsimo/self_model/trust.py +388 -0
  405. apsimo/self_model/workspace.py +2388 -0
  406. apsimo/server.py +4197 -0
  407. apsimo/services/__init__.py +1 -0
  408. apsimo/services/agent_bridge.py +474 -0
  409. apsimo/services/initiative_executor.py +914 -0
  410. apsimo/services/instance.py +297 -0
  411. apsimo/sessions/__init__.py +22 -0
  412. apsimo/sessions/config.py +13 -0
  413. apsimo/sessions/context_loader.py +88 -0
  414. apsimo/sessions/federation_session.py +75 -0
  415. apsimo/sessions/isolated_session.py +98 -0
  416. apsimo/sessions/reports.py +84 -0
  417. apsimo/sessions/store.py +148 -0
  418. apsimo/setup.py +2818 -0
  419. apsimo/setup_hermes.py +879 -0
  420. apsimo/setup_local_work.py +218 -0
  421. apsimo/setup_native_goals.py +134 -0
  422. apsimo/setup_native_reviews.py +115 -0
  423. apsimo/skills/__init__.py +10 -0
  424. apsimo/skills/base.py +108 -0
  425. apsimo/skills/budget.py +28 -0
  426. apsimo/skills/executor.py +493 -0
  427. apsimo/skills/executors/__init__.py +1 -0
  428. apsimo/skills/executors/behavioral_correction.py +75 -0
  429. apsimo/skills/executors/capability_gap.py +38 -0
  430. apsimo/skills/executors/data_quality.py +163 -0
  431. apsimo/skills/executors/knowledge_acquisition.py +41 -0
  432. apsimo/skills/executors/operational_hygiene.py +185 -0
  433. apsimo/skills/executors/subsystem_health.py +169 -0
  434. apsimo/skills/hermes_export.py +431 -0
  435. apsimo/skills/index.py +123 -0
  436. apsimo/skills/learning/__init__.py +21 -0
  437. apsimo/skills/learning/novelty_detector.py +206 -0
  438. apsimo/skills/learning/pattern_extractor.py +199 -0
  439. apsimo/skills/learning/triggers.py +159 -0
  440. apsimo/skills/loader.py +246 -0
  441. apsimo/skills/migrations/002_progressive_loading.sql +6 -0
  442. apsimo/skills/migrations/backfill_triggers.py +20 -0
  443. apsimo/skills/models.py +202 -0
  444. apsimo/skills/packager.py +128 -0
  445. apsimo/skills/protocols.py +70 -0
  446. apsimo/skills/registry.py +191 -0
  447. apsimo/skills/runtime.py +58 -0
  448. apsimo/skills/sandbox_runner.py +229 -0
  449. apsimo/skills/scheduler.py +129 -0
  450. apsimo/skills/schema.py +79 -0
  451. apsimo/skills/security/__init__.py +12 -0
  452. apsimo/skills/security/guards.py +53 -0
  453. apsimo/skills/security/scanner.py +223 -0
  454. apsimo/skills_memory/__init__.py +26 -0
  455. apsimo/skills_memory/distill.py +159 -0
  456. apsimo/skills_memory/models.py +85 -0
  457. apsimo/skills_memory/retrieve.py +62 -0
  458. apsimo/skills_memory/store.py +172 -0
  459. apsimo/surprise/__init__.py +6 -0
  460. apsimo/surprise/accumulation.py +57 -0
  461. apsimo/surprise/scorer.py +102 -0
  462. apsimo/surprise/store.py +203 -0
  463. apsimo/task_queue/__init__.py +69 -0
  464. apsimo/task_queue/action_receipts.py +148 -0
  465. apsimo/task_queue/approval_relay_canary.py +108 -0
  466. apsimo/task_queue/config.py +85 -0
  467. apsimo/task_queue/contract.py +361 -0
  468. apsimo/task_queue/events.py +130 -0
  469. apsimo/task_queue/governor.py +1031 -0
  470. apsimo/task_queue/handlers/__init__.py +16 -0
  471. apsimo/task_queue/handlers/base.py +37 -0
  472. apsimo/task_queue/handlers/inference.py +640 -0
  473. apsimo/task_queue/handlers/monitoring.py +116 -0
  474. apsimo/task_queue/handlers/registry.py +75 -0
  475. apsimo/task_queue/handlers/subtask_handler.py +173 -0
  476. apsimo/task_queue/handlers/system_maintenance.py +147 -0
  477. apsimo/task_queue/mesh_integration.py +111 -0
  478. apsimo/task_queue/models.py +317 -0
  479. apsimo/task_queue/queue_manager.py +8286 -0
  480. apsimo/task_queue/routing.py +287 -0
  481. apsimo/task_queue/scheduler.py +252 -0
  482. apsimo/task_queue/schema.sql +197 -0
  483. apsimo/task_queue/work_control.py +342 -0
  484. apsimo/task_queue/worker.py +993 -0
  485. apsimo/telemetry.py +145 -0
  486. apsimo/tom/__init__.py +6 -0
  487. apsimo/tom/affect.py +387 -0
  488. apsimo/tom/approvals.py +171 -0
  489. apsimo/tom/arcs.py +896 -0
  490. apsimo/tom/asymmetry.py +131 -0
  491. apsimo/tom/eligibility.py +248 -0
  492. apsimo/tom/engagement.py +214 -0
  493. apsimo/tom/exposure.py +214 -0
  494. apsimo/tom/extractor.py +306 -0
  495. apsimo/tom/fact_adapters.py +144 -0
  496. apsimo/tom/facts.py +326 -0
  497. apsimo/tom/integration.py +592 -0
  498. apsimo/tom/leveled.py +118 -0
  499. apsimo/tom/levels.py +247 -0
  500. apsimo/tom/recipient_audit.py +995 -0
  501. apsimo/tom/recipient_simulator.py +593 -0
  502. apsimo/tom/source_lineage.py +93 -0
  503. apsimo/tom/tom2.py +277 -0
  504. apsimo/tom/visibility.py +559 -0
  505. apsimo/tom/visibility_store.py +414 -0
  506. apsimo/tools/__init__.py +0 -0
  507. apsimo/tools/definitions.py +740 -0
  508. apsimo/tools/handlers.py +943 -0
  509. apsimo/toolsmith/__init__.py +26 -0
  510. apsimo/toolsmith/authority.py +166 -0
  511. apsimo/toolsmith/engine.py +559 -0
  512. apsimo/toolsmith/integrity.py +100 -0
  513. apsimo/toolsmith/miner.py +145 -0
  514. apsimo/toolsmith/policy.py +110 -0
  515. apsimo/toolsmith/registry.py +635 -0
  516. apsimo/turns/__init__.py +17 -0
  517. apsimo/turns/audio.py +134 -0
  518. apsimo/turns/documents.py +235 -0
  519. apsimo/turns/executions.py +486 -0
  520. apsimo/turns/hermes_history.py +245 -0
  521. apsimo/turns/hermes_kanban.py +268 -0
  522. apsimo/turns/hermes_work.py +96 -0
  523. apsimo/turns/idempotency.py +752 -0
  524. apsimo/turns/local_work.py +115 -0
  525. apsimo/turns/media.py +581 -0
  526. apsimo/turns/reported_workers.py +196 -0
  527. apsimo/turns/source_annotations.py +283 -0
  528. apsimo/turns/source_attribution.py +154 -0
  529. apsimo/turns/source_read.py +351 -0
  530. apsimo/turns/source_vectors.py +263 -0
  531. apsimo/turns/video.py +210 -0
  532. apsimo/util/autonomy_preset.py +220 -0
  533. apsimo/util/instance.py +92 -0
  534. apsimo/util/model_output.py +25 -0
  535. apsimo/util/quiet_hours.py +27 -0
  536. apsimo/util/session_safety.py +37 -0
  537. apsimo/util/temporal.py +343 -0
  538. apsimo/vector/__init__.py +75 -0
  539. apsimo/vector/backfill.py +171 -0
  540. apsimo/vector/caption.py +114 -0
  541. apsimo/vector/collections.py +51 -0
  542. apsimo/vector/config.py +102 -0
  543. apsimo/vector/embedder.py +670 -0
  544. apsimo/vector/image_preprocess.py +406 -0
  545. apsimo/vector/image_store.py +296 -0
  546. apsimo/vector/indexes.py +162 -0
  547. apsimo/vector/migrate.py +334 -0
  548. apsimo/vector/multimodal_provider.py +417 -0
  549. apsimo/vector/multimodal_types.py +87 -0
  550. apsimo/vector/openai_provider.py +119 -0
  551. apsimo/vector/query.py +49 -0
  552. apsimo/vector/reranker.py +565 -0
  553. apsimo/vector/safety_image.py +159 -0
  554. apsimo/vector/scanner.py +197 -0
  555. apsimo/vector/setup.py +289 -0
  556. apsimo/vector/store.py +533 -0
  557. apsimo/vector/tiers.py +263 -0
  558. apsimo/work_orders.py +925 -0
  559. apsimo/workers/__init__.py +21 -0
  560. apsimo/workers/agent_bridge.py +640 -0
  561. apsimo/workers/colony_worker.py +382 -0
  562. apsimo/workers/queue_worker.py +441 -0
  563. apsimo/workers/skills_sync.py +152 -0
  564. apsimo/world_model/__init__.py +71 -0
  565. apsimo/world_model/causal_maintenance.py +131 -0
  566. apsimo/world_model/causal_policy.py +43 -0
  567. apsimo/world_model/causal_query.py +125 -0
  568. apsimo/world_model/confidence.py +54 -0
  569. apsimo/world_model/config.py +64 -0
  570. apsimo/world_model/constants.py +97 -0
  571. apsimo/world_model/entities.py +145 -0
  572. apsimo/world_model/expectation_resolvers.py +177 -0
  573. apsimo/world_model/extraction/__init__.py +7 -0
  574. apsimo/world_model/extraction/base.py +62 -0
  575. apsimo/world_model/extraction/conversation_extractor.py +262 -0
  576. apsimo/world_model/extraction/detector.py +74 -0
  577. apsimo/world_model/extraction/document_extractor.py +78 -0
  578. apsimo/world_model/extraction/formats/__init__.py +24 -0
  579. apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
  580. apsimo/world_model/extraction/formats/html_fmt.py +72 -0
  581. apsimo/world_model/extraction/formats/json_fmt.py +68 -0
  582. apsimo/world_model/extraction/formats/pdf.py +43 -0
  583. apsimo/world_model/extraction/formats/text.py +27 -0
  584. apsimo/world_model/extraction/llm_extractor.py +164 -0
  585. apsimo/world_model/extraction/pipeline.py +73 -0
  586. apsimo/world_model/integrations/__init__.py +5 -0
  587. apsimo/world_model/integrations/mind_model_bridge.py +115 -0
  588. apsimo/world_model/integrations/social_intel_bridge.py +120 -0
  589. apsimo/world_model/jobs/__init__.py +4 -0
  590. apsimo/world_model/jobs/extraction_job.py +168 -0
  591. apsimo/world_model/llm_extract.py +572 -0
  592. apsimo/world_model/neo4j/__init__.py +5 -0
  593. apsimo/world_model/neo4j/backend.py +654 -0
  594. apsimo/world_model/observations.py +155 -0
  595. apsimo/world_model/populator.py +307 -0
  596. apsimo/world_model/postgres/__init__.py +1 -0
  597. apsimo/world_model/postgres/backend.py +683 -0
  598. apsimo/world_model/relationships.py +25 -0
  599. apsimo/world_model/resolution/__init__.py +13 -0
  600. apsimo/world_model/resolution/entity_resolver.py +232 -0
  601. apsimo/world_model/resolution/merge_audit.py +16 -0
  602. apsimo/world_model/resolution/merge_workflow.py +117 -0
  603. apsimo/world_model/source_reports.py +121 -0
  604. apsimo/world_model/sqlite/__init__.py +4 -0
  605. apsimo/world_model/sqlite/backend.py +855 -0
  606. apsimo/world_model/sqlite/schema.sql +132 -0
  607. apsimo/world_model/store.py +545 -0
  608. apsimo-1.3.0.dist-info/METADATA +78 -0
  609. apsimo-1.3.0.dist-info/RECORD +614 -0
  610. apsimo-1.3.0.dist-info/WHEEL +5 -0
  611. apsimo-1.3.0.dist-info/entry_points.txt +11 -0
  612. apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
  613. apsimo-1.3.0.dist-info/top_level.txt +2 -0
  614. colony_sidecar/__init__.py +4 -0
@@ -0,0 +1,1595 @@
1
+ """Expectation engine: predictions with teeth (Mind M3a).
2
+
3
+ The agent forms explicit predictions ("this commitment gets done by its due
4
+ date", "this contact replies within a day"), and a checker resolves them at
5
+ their horizon against reality. A miss is a SURPRISE, the attention signal
6
+ biological cognition runs on: surprises raise workspace salience, and the
7
+ hit/miss record yields a per-domain calibration score (Brier) that feeds the
8
+ selfhood benchmark. Without this, "she has a world model" is a claim; with
9
+ it, calibration is a number that can improve.
10
+
11
+ Resolvers are pluggable by subject prefix so the store stays generic: a
12
+ deployment (or another subsystem) registers how to check a class of
13
+ predictions. One built-in resolver covers commitment predictions.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ import math
22
+ import re
23
+ import statistics
24
+ import sqlite3
25
+ import threading
26
+ import time
27
+ import uuid
28
+ from dataclasses import dataclass, field
29
+ from datetime import datetime, timezone
30
+ from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence
31
+
32
+ from apsimo.scope_bounds import VIEWER_SCOPE_MAX_CHARS
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ OUTCOMES = ("pending", "hit", "miss", "unresolved")
37
+ EXPECTATION_VERSION = 2
38
+ DURATION_METHOD_V1 = "receipt-duration-median-prior4-v1"
39
+ DURATION_METHOD_V2 = "receipt-duration-median-prior4-v2"
40
+ # Superseded predictions stay in history and scoring, but only the newest
41
+ # pending revision governs current context. Apply before the query limit.
42
+ _CURRENT_FORECAST_SQL = """ AND (outcome!='pending' OR prediction_id NOT IN (
43
+ SELECT old.prediction_id FROM forecast_revisions old
44
+ WHERE EXISTS (SELECT 1 FROM forecast_revisions newer
45
+ WHERE newer.forecast_id=old.forecast_id AND newer.revision>old.revision))) """
46
+ _SHAREABILITY = frozenset(
47
+ {"owner_private", "subject_private", "shared", "public"}
48
+ )
49
+ _OUTCOME_SOURCE_KINDS = frozenset({
50
+ "colony_event", "host_event", "transport_receipt", "service_probe",
51
+ "commitment_receipt", "task_receipt", "relationship_receipt",
52
+ "project_receipt", "work_receipt",
53
+ })
54
+ _SAFE_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/+\-]{0,255}$")
55
+ _EVIDENCE_PREFIXES = frozenset({
56
+ "journal", "event", "receipt", "host-event", "colony-event",
57
+ "commitment", "contact", "task", "service", "relationship",
58
+ "project", "work-order", "observation", "expectation", "health",
59
+ "execution-result", "execution-attempt", "project-terminal",
60
+ "presence", "approval", "channel", "device", "capability",
61
+ })
62
+
63
+
64
+ def expectations_mode() -> str:
65
+ """Effective COLONY_EXPECTATIONS: explicit env > autonomy preset > off.
66
+
67
+ Expectations are binary in behavior (they only measure), so "on" is the
68
+ canonical enabled value; the historical "shadow"/"live" spellings remain
69
+ accepted aliases. An explicitly-set invalid value falls back to "off",
70
+ exactly as the legacy reader treated any unrecognized value.
71
+ """
72
+ from apsimo.util.autonomy_preset import resolve
73
+ return resolve("COLONY_EXPECTATIONS", ("off", "on", "shadow", "live"),
74
+ "off")
75
+
76
+
77
+ def expectations_enabled() -> bool:
78
+ return expectations_mode() != "off"
79
+
80
+
81
+ def _now() -> float:
82
+ return time.time()
83
+
84
+
85
+ @dataclass
86
+ class ExpectationV2:
87
+ prediction_id: str
88
+ subject: str # e.g. "commitment:cid-..", "contact:cid-.."
89
+ domain: str # calibration bucket, e.g. "commitment", "cadence"
90
+ expectation: str # human-readable
91
+ confidence: float # 0..1
92
+ horizon: float # epoch when it resolves
93
+ source: str
94
+ outcome: str = "pending"
95
+ resolved_at: Optional[float] = None
96
+ detail: Dict[str, Any] = field(default_factory=dict)
97
+ created_at: float = 0.0
98
+ schema_version: int = 1
99
+ subject_person_id: str = "owner"
100
+ viewer_scope: str = "owner"
101
+ shareability: str = "owner_private"
102
+ evidence_refs: tuple[str, ...] = ()
103
+ source_kind: str = "legacy"
104
+ cohort: str = "legacy"
105
+ outcome_observation_id: str = ""
106
+ outcome_observed_at: Optional[float] = None
107
+ outcome_evidence_refs: tuple[str, ...] = ()
108
+ resolution_digest: str = ""
109
+
110
+ def public(self) -> Dict[str, Any]:
111
+ d = dict(self.__dict__)
112
+ d["schema"] = "ExpectationV2" if self.schema_version == 2 else "PredictionV1"
113
+ d["evidence_refs"] = list(self.evidence_refs)
114
+ d["outcome_evidence_refs"] = list(self.outcome_evidence_refs)
115
+ d["horizon_iso"] = datetime.fromtimestamp(
116
+ self.horizon, tz=timezone.utc).isoformat()
117
+ if self.outcome_observed_at is not None:
118
+ d["outcome_observed_at_iso"] = datetime.fromtimestamp(
119
+ self.outcome_observed_at, tz=timezone.utc,
120
+ ).isoformat()
121
+ return d
122
+
123
+
124
+ # Backwards-compatible public name used throughout Colony. Existing V1 rows
125
+ # are adapted into the expanded record with ``schema_version=1``.
126
+ Prediction = ExpectationV2
127
+
128
+
129
+ def _canonical(value: Any) -> str:
130
+ return json.dumps(
131
+ value, ensure_ascii=False, separators=(",", ":"), sort_keys=True,
132
+ default=str,
133
+ )
134
+
135
+
136
+ def _digest(value: Any) -> str:
137
+ return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
138
+
139
+
140
+ def _refs(values: Iterable[Any], *, required: bool = True) -> tuple[str, ...]:
141
+ result: list[str] = []
142
+ for raw in values or ():
143
+ value = str(raw or "").strip()
144
+ if not _SAFE_REF.fullmatch(value):
145
+ raise ValueError("invalid expectation evidence reference")
146
+ if value.split(":", 1)[0] not in _EVIDENCE_PREFIXES:
147
+ raise ValueError("unsupported expectation evidence origin")
148
+ if value not in result:
149
+ result.append(value)
150
+ if len(result) > 40:
151
+ raise ValueError("expectation evidence exceeds 40 references")
152
+ if required and not result:
153
+ raise ValueError("ExpectationV2 requires durable evidence")
154
+ return tuple(result)
155
+
156
+
157
+ def _scope(
158
+ subject_person_id: str, viewer_scope: str, shareability: str,
159
+ ) -> tuple[str, str, str]:
160
+ subject = str(subject_person_id or "").strip()
161
+ viewer = str(viewer_scope or "").strip()
162
+ if len(subject) > 128 or len(viewer) > VIEWER_SCOPE_MAX_CHARS:
163
+ raise ValueError("expectation subject/viewer scope exceeds safe bounds")
164
+ sharing = str(shareability or "").strip().lower()
165
+ if not _SAFE_REF.fullmatch(subject) or not _SAFE_REF.fullmatch(viewer):
166
+ raise ValueError("expectation subject/viewer scope is invalid")
167
+ if sharing not in _SHAREABILITY:
168
+ raise ValueError("expectation shareability is invalid")
169
+ if sharing == "owner_private" and viewer != "owner":
170
+ raise ValueError("owner-private expectation requires owner viewer")
171
+ if sharing == "subject_private" and viewer != f"person:{subject}":
172
+ raise ValueError("subject-private expectation requires exact subject viewer")
173
+ if sharing == "public" and viewer != "public":
174
+ raise ValueError("public expectation requires public viewer")
175
+ if sharing == "shared" and not (
176
+ viewer == "shared" or viewer.startswith("shared:")
177
+ ):
178
+ raise ValueError("shared expectation requires shared viewer")
179
+ return subject, viewer, sharing
180
+
181
+
182
+ def _event_epoch(value: Any, field_name: str) -> float:
183
+ if isinstance(value, bool):
184
+ raise ValueError(f"{field_name} must be a timestamp")
185
+ if isinstance(value, (int, float)):
186
+ result = float(value)
187
+ else:
188
+ try:
189
+ parsed = datetime.fromisoformat(str(value or "").replace("Z", "+00:00"))
190
+ except (TypeError, ValueError) as exc:
191
+ raise ValueError(f"{field_name} must be ISO-8601 or epoch") from exc
192
+ if parsed.tzinfo is None:
193
+ parsed = parsed.replace(tzinfo=timezone.utc)
194
+ result = parsed.timestamp()
195
+ if not math.isfinite(result) or result <= 0:
196
+ raise ValueError(f"{field_name} must be positive")
197
+ return result
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class OutcomeObservationV1:
202
+ """Receipt-bearing binary outcome. Narrative text is intentionally absent."""
203
+
204
+ observation_id: str
205
+ prediction_id: str
206
+ value: bool
207
+ observed_at: float
208
+ evidence_refs: tuple[str, ...]
209
+ source_kind: str
210
+ subject_person_id: str
211
+ viewer_scope: str
212
+ shareability: str
213
+ schema: str = "OutcomeObservationV1"
214
+ version: int = 1
215
+
216
+ def __post_init__(self) -> None:
217
+ if not _SAFE_REF.fullmatch(self.observation_id):
218
+ raise ValueError("outcome observation id is invalid")
219
+ if not _SAFE_REF.fullmatch(self.prediction_id):
220
+ raise ValueError("outcome prediction id is invalid")
221
+ if type(self.value) is not bool:
222
+ raise ValueError("outcome value must be boolean")
223
+ _event_epoch(self.observed_at, "observed_at")
224
+ if _refs(self.evidence_refs) != self.evidence_refs:
225
+ raise ValueError("outcome evidence references are not canonical")
226
+ if self.source_kind not in _OUTCOME_SOURCE_KINDS:
227
+ raise ValueError("outcome source is not a trusted structured source")
228
+ if _scope(
229
+ self.subject_person_id, self.viewer_scope, self.shareability,
230
+ ) != (self.subject_person_id, self.viewer_scope, self.shareability):
231
+ raise ValueError("outcome scope is not canonical")
232
+ if self.schema != "OutcomeObservationV1" or self.version != 1:
233
+ raise ValueError("invalid outcome observation schema")
234
+
235
+ @classmethod
236
+ def create(
237
+ cls, *, observation_id: str, prediction_id: str, value: bool,
238
+ observed_at: Any, evidence_refs: Iterable[Any], source_kind: str,
239
+ subject_person_id: str, viewer_scope: str, shareability: str,
240
+ ) -> "OutcomeObservationV1":
241
+ subject, viewer, sharing = _scope(
242
+ subject_person_id, viewer_scope, shareability,
243
+ )
244
+ return cls(
245
+ observation_id=str(observation_id or "").strip(),
246
+ prediction_id=str(prediction_id or "").strip(),
247
+ value=value,
248
+ observed_at=_event_epoch(observed_at, "observed_at"),
249
+ evidence_refs=_refs(evidence_refs),
250
+ source_kind=str(source_kind or "").strip().lower(),
251
+ subject_person_id=subject,
252
+ viewer_scope=viewer,
253
+ shareability=sharing,
254
+ )
255
+
256
+ @property
257
+ def digest(self) -> str:
258
+ return _digest({
259
+ "schema": self.schema,
260
+ "version": self.version,
261
+ "observation_id": self.observation_id,
262
+ "prediction_id": self.prediction_id,
263
+ "value": self.value,
264
+ "observed_at": self.observed_at,
265
+ "evidence_refs": list(self.evidence_refs),
266
+ "source_kind": self.source_kind,
267
+ "subject_person_id": self.subject_person_id,
268
+ "viewer_scope": self.viewer_scope,
269
+ "shareability": self.shareability,
270
+ })
271
+
272
+
273
+ class ExpectationStore:
274
+ def __init__(self, db_path: str) -> None:
275
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
276
+ self._conn.row_factory = sqlite3.Row
277
+ self._conn.execute("PRAGMA journal_mode=WAL")
278
+ self._lock = threading.Lock()
279
+ self._conn.executescript(
280
+ """
281
+ CREATE TABLE IF NOT EXISTS predictions (
282
+ prediction_id TEXT PRIMARY KEY,
283
+ subject TEXT NOT NULL,
284
+ domain TEXT NOT NULL,
285
+ expectation TEXT NOT NULL,
286
+ confidence REAL NOT NULL,
287
+ horizon REAL NOT NULL,
288
+ source TEXT,
289
+ outcome TEXT DEFAULT 'pending',
290
+ resolved_at REAL,
291
+ detail TEXT,
292
+ dedup_key TEXT,
293
+ created_at REAL NOT NULL
294
+ );
295
+ CREATE INDEX IF NOT EXISTS idx_pred_outcome_horizon
296
+ ON predictions(outcome, horizon);
297
+ CREATE INDEX IF NOT EXISTS idx_pred_domain ON predictions(domain);
298
+ CREATE INDEX IF NOT EXISTS idx_pred_dedup ON predictions(dedup_key);
299
+ CREATE TABLE IF NOT EXISTS expectation_outcome_observations (
300
+ observation_id TEXT PRIMARY KEY,
301
+ observation_digest TEXT NOT NULL,
302
+ prediction_id TEXT NOT NULL,
303
+ payload TEXT NOT NULL,
304
+ ingested_at REAL NOT NULL
305
+ );
306
+ CREATE TABLE IF NOT EXISTS forecast_revisions (
307
+ forecast_id TEXT NOT NULL,
308
+ revision INTEGER NOT NULL,
309
+ prediction_id TEXT NOT NULL UNIQUE,
310
+ material_digest TEXT NOT NULL,
311
+ PRIMARY KEY (forecast_id, revision)
312
+ );
313
+ CREATE TABLE IF NOT EXISTS forecast_outcomes (
314
+ forecast_id TEXT NOT NULL,
315
+ revision INTEGER NOT NULL,
316
+ receipt_ref TEXT NOT NULL,
317
+ material_digest TEXT NOT NULL,
318
+ payload TEXT NOT NULL,
319
+ recorded_at REAL NOT NULL,
320
+ PRIMARY KEY (forecast_id, revision),
321
+ UNIQUE (forecast_id, receipt_ref)
322
+ );
323
+ """
324
+ )
325
+ self._migrate_v2()
326
+ self._conn.execute(
327
+ "CREATE INDEX IF NOT EXISTS idx_pred_scope ON predictions "
328
+ "(subject_person_id,viewer_scope,outcome,created_at)",
329
+ )
330
+ self._conn.execute(
331
+ "CREATE INDEX IF NOT EXISTS idx_outcome_prediction ON "
332
+ "expectation_outcome_observations(prediction_id)",
333
+ )
334
+ self._conn.commit()
335
+
336
+ def _migrate_v2(self) -> None:
337
+ """Additive/read-compatible migration; no V1 row is rewritten."""
338
+
339
+ columns = {
340
+ row["name"] for row in self._conn.execute(
341
+ "PRAGMA table_info(predictions)",
342
+ ).fetchall()
343
+ }
344
+ additions = {
345
+ "schema_version": "INTEGER NOT NULL DEFAULT 1",
346
+ "subject_person_id": "TEXT NOT NULL DEFAULT 'owner'",
347
+ "viewer_scope": "TEXT NOT NULL DEFAULT 'owner'",
348
+ "shareability": "TEXT NOT NULL DEFAULT 'owner_private'",
349
+ "evidence_refs": "TEXT NOT NULL DEFAULT '[]'",
350
+ "source_kind": "TEXT NOT NULL DEFAULT 'legacy'",
351
+ "cohort": "TEXT NOT NULL DEFAULT 'legacy'",
352
+ "outcome_observation_id": "TEXT",
353
+ "outcome_observed_at": "REAL",
354
+ "outcome_evidence_refs": "TEXT NOT NULL DEFAULT '[]'",
355
+ "resolution_digest": "TEXT",
356
+ }
357
+ for name, declaration in additions.items():
358
+ if name not in columns:
359
+ self._conn.execute(
360
+ f"ALTER TABLE predictions ADD COLUMN {name} {declaration}",
361
+ )
362
+
363
+ def _row(self, r: sqlite3.Row) -> Prediction:
364
+ keys = set(r.keys())
365
+ return Prediction(
366
+ prediction_id=r["prediction_id"], subject=r["subject"],
367
+ domain=r["domain"], expectation=r["expectation"],
368
+ confidence=r["confidence"], horizon=r["horizon"],
369
+ source=r["source"] or "", outcome=r["outcome"],
370
+ resolved_at=r["resolved_at"],
371
+ detail=json.loads(r["detail"] or "{}"), created_at=r["created_at"],
372
+ schema_version=int(r["schema_version"]) if "schema_version" in keys else 1,
373
+ subject_person_id=(r["subject_person_id"] if "subject_person_id" in keys else "owner"),
374
+ viewer_scope=(r["viewer_scope"] if "viewer_scope" in keys else "owner"),
375
+ shareability=(r["shareability"] if "shareability" in keys else "owner_private"),
376
+ evidence_refs=tuple(json.loads(r["evidence_refs"] or "[]"))
377
+ if "evidence_refs" in keys else (),
378
+ source_kind=(r["source_kind"] if "source_kind" in keys else "legacy"),
379
+ cohort=(r["cohort"] if "cohort" in keys else "legacy"),
380
+ outcome_observation_id=(r["outcome_observation_id"] or "")
381
+ if "outcome_observation_id" in keys else "",
382
+ outcome_observed_at=(r["outcome_observed_at"]
383
+ if "outcome_observed_at" in keys else None),
384
+ outcome_evidence_refs=tuple(json.loads(
385
+ r["outcome_evidence_refs"] or "[]",
386
+ )) if "outcome_evidence_refs" in keys else (),
387
+ resolution_digest=(r["resolution_digest"] or "")
388
+ if "resolution_digest" in keys else "",
389
+ )
390
+
391
+ def create(self, *, subject: str, domain: str, expectation: str,
392
+ confidence: float, horizon: float, source: str,
393
+ dedup_key: str, detail: Optional[Dict[str, Any]] = None,
394
+ schema_version: int = 1,
395
+ subject_person_id: str = "owner",
396
+ viewer_scope: str = "owner",
397
+ shareability: str = "owner_private",
398
+ evidence_refs: Iterable[Any] = (),
399
+ source_kind: str = "legacy",
400
+ cohort: str = "legacy",
401
+ ) -> Optional[Prediction]:
402
+ version = int(schema_version)
403
+ if version not in {1, 2}:
404
+ raise ValueError("unsupported expectation schema version")
405
+ if isinstance(confidence, bool) or not isinstance(confidence, (int, float)):
406
+ raise ValueError("expectation confidence must be numeric")
407
+ probability = float(confidence)
408
+ if not math.isfinite(probability):
409
+ raise ValueError("expectation confidence must be finite")
410
+ if version == 2 and not 0.0 <= probability <= 1.0:
411
+ raise ValueError("ExpectationV2 confidence must be between 0 and 1")
412
+ if isinstance(horizon, bool) or not isinstance(horizon, (int, float)):
413
+ raise ValueError("expectation horizon must be epoch seconds")
414
+ horizon_value = float(horizon)
415
+ if not math.isfinite(horizon_value) or horizon_value <= 0:
416
+ raise ValueError("expectation horizon must be positive and finite")
417
+ person, viewer, sharing = _scope(
418
+ subject_person_id, viewer_scope, shareability,
419
+ )
420
+ refs = _refs(evidence_refs, required=version == 2)
421
+ origin = str(source_kind or "").strip().lower()
422
+ if version == 2 and origin not in _OUTCOME_SOURCE_KINDS:
423
+ raise ValueError("ExpectationV2 source is not evidence-bearing")
424
+ cohort_name = str(cohort or domain or "default").strip()[:100]
425
+ if not cohort_name:
426
+ raise ValueError("expectation cohort is required")
427
+ with self._lock:
428
+ try:
429
+ self._conn.execute("BEGIN IMMEDIATE")
430
+ # Dedup against a live prediction, AND against an already-
431
+ # resolved one for the same horizon. The write transaction
432
+ # makes the check idempotent across sidecar connections.
433
+ exists = self._conn.execute(
434
+ "SELECT 1 FROM predictions WHERE dedup_key=? AND "
435
+ "subject_person_id=? AND viewer_scope=? AND "
436
+ "(outcome='pending' OR horizon=?)",
437
+ (dedup_key, person, viewer, horizon_value),
438
+ ).fetchone()
439
+ if exists:
440
+ self._conn.commit()
441
+ return None
442
+ pid = f"p-{uuid.uuid4().hex[:12]}"
443
+ now = _now()
444
+ self._conn.execute(
445
+ "INSERT INTO predictions (prediction_id,subject,domain,"
446
+ "expectation,confidence,horizon,source,detail,dedup_key,"
447
+ "created_at,schema_version,subject_person_id,viewer_scope,"
448
+ "shareability,evidence_refs,source_kind,cohort) "
449
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
450
+ (pid, subject, domain, expectation,
451
+ max(0.0, min(1.0, probability)), horizon_value, source,
452
+ json.dumps(detail or {}), dedup_key, now, version, person,
453
+ viewer, sharing, json.dumps(list(refs)), origin, cohort_name))
454
+ r = self._conn.execute(
455
+ "SELECT * FROM predictions WHERE prediction_id=?",
456
+ (pid,),
457
+ ).fetchone()
458
+ self._conn.commit()
459
+ except Exception:
460
+ self._conn.rollback()
461
+ raise
462
+ return self._row(r)
463
+
464
+ def create_v2(
465
+ self,
466
+ *,
467
+ subject: str,
468
+ domain: str,
469
+ expectation: str,
470
+ confidence: float,
471
+ horizon: float,
472
+ source: str,
473
+ dedup_key: str,
474
+ evidence_refs: Iterable[Any],
475
+ source_kind: str,
476
+ subject_person_id: str,
477
+ viewer_scope: str,
478
+ shareability: str,
479
+ cohort: str,
480
+ detail: Optional[Dict[str, Any]] = None,
481
+ ) -> Optional[ExpectationV2]:
482
+ return self.create(
483
+ subject=subject,
484
+ domain=domain,
485
+ expectation=expectation,
486
+ confidence=confidence,
487
+ horizon=horizon,
488
+ source=source,
489
+ dedup_key=dedup_key,
490
+ detail=detail,
491
+ schema_version=2,
492
+ subject_person_id=subject_person_id,
493
+ viewer_scope=viewer_scope,
494
+ shareability=shareability,
495
+ evidence_refs=evidence_refs,
496
+ source_kind=source_kind,
497
+ cohort=cohort,
498
+ )
499
+
500
+ def issue_forecast(
501
+ self, *, forecast_id: str, subject: str, domain: str, expectation: str,
502
+ confidence: float, horizon: float, origin_at: float,
503
+ evidence_refs: Iterable[str], source_versions: Mapping[str, str],
504
+ source_kind: str, cohort: str, method: str,
505
+ model_provenance: Mapping[str, Any], subject_person_id: str,
506
+ viewer_scope: str, shareability: str, previous_revision: int = 0,
507
+ issued_at: Optional[float] = None, conditions: Optional[Mapping] = None,
508
+ ) -> Prediction:
509
+ """Append a prospective revision; never replace an earlier forecast.
510
+
511
+ ``issued_at`` is supplied by the trusted producer clock, not model text.
512
+ Deterministic methods explicitly use served_model=None. Unknown model
513
+ revision/capabilities remain null instead of borrowing a role's label.
514
+ """
515
+ if not _SAFE_REF.fullmatch(forecast_id) or previous_revision < 0:
516
+ raise ValueError("invalid forecast identity/revision")
517
+ person, viewer, sharing = _scope(subject_person_id, viewer_scope, shareability)
518
+ refs = _refs(evidence_refs)
519
+ if set(source_versions) != set(refs) or any(not isinstance(v, str) or not v for v in source_versions.values()):
520
+ raise ValueError("forecast requires an exact version for every source")
521
+ issued = _event_epoch(_now() if issued_at is None else issued_at, "issued_at")
522
+ origin = _event_epoch(origin_at, "origin_at")
523
+ deadline = _event_epoch(horizon, "horizon")
524
+ if origin > issued or deadline <= issued:
525
+ raise ValueError("forecast must precede its horizon and follow its origin")
526
+ if isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not math.isfinite(confidence) or not 0 <= confidence <= 1:
527
+ raise ValueError("forecast probability must be between 0 and 1")
528
+ if domain not in {"task_duration", "expected_reply"} or source_kind not in _OUTCOME_SOURCE_KINDS:
529
+ raise ValueError("unsupported forecast domain/source")
530
+ if not method or not cohort or not expectation or not subject:
531
+ raise ValueError("forecast method, cohort, question and subject required")
532
+ provenance = {key: model_provenance.get(key) for key in (
533
+ "requested_role", "served_model", "model_revision", "capabilities", "fallback")}
534
+ detail = {"forecast_id": forecast_id, "revision": previous_revision + 1,
535
+ "supersedes_revision": previous_revision or None,
536
+ "origin_at": origin, "method": method,
537
+ "model_provenance": provenance, "source_versions": dict(source_versions),
538
+ "conditions": dict(conditions or {})}
539
+ material = {"subject": subject, "domain": domain, "expectation": expectation,
540
+ "confidence": confidence, "horizon": deadline, "detail": detail,
541
+ "source_kind": source_kind, "cohort": cohort,
542
+ "subject_person_id": person, "viewer_scope": viewer,
543
+ "shareability": sharing, "evidence_refs": list(refs)}
544
+ digest = _digest(material)
545
+ revision = previous_revision + 1
546
+ with self._lock:
547
+ try:
548
+ self._conn.execute("BEGIN IMMEDIATE")
549
+ existing = self._conn.execute(
550
+ "SELECT * FROM forecast_revisions WHERE forecast_id=? AND revision=?",
551
+ (forecast_id, revision)).fetchone()
552
+ if existing:
553
+ if existing["material_digest"] != digest:
554
+ raise ValueError("forecast revision conflict")
555
+ row = self._conn.execute("SELECT * FROM predictions WHERE prediction_id=?", (existing["prediction_id"],)).fetchone()
556
+ self._conn.commit()
557
+ return self._row(row)
558
+ latest = self._conn.execute(
559
+ "SELECT p.* FROM forecast_revisions f JOIN predictions p USING(prediction_id) "
560
+ "WHERE forecast_id=? ORDER BY revision DESC LIMIT 1", (forecast_id,)).fetchone()
561
+ if (latest is None and previous_revision != 0) or (latest is not None and json.loads(latest["detail"])["revision"] != previous_revision):
562
+ raise ValueError("forecast revision conflict")
563
+ if latest is not None and (latest["subject"] != subject or latest["domain"] != domain or latest["subject_person_id"] != person or latest["viewer_scope"] != viewer or latest["shareability"] != sharing or json.loads(latest["detail"])["origin_at"] != origin):
564
+ raise ValueError("forecast subject/scope/origin is immutable")
565
+ if self._conn.execute("SELECT 1 FROM forecast_outcomes WHERE forecast_id=?", (forecast_id,)).fetchone():
566
+ raise ValueError("cannot forecast after observing an outcome")
567
+ pid = "p-" + uuid.uuid4().hex[:12]
568
+ self._conn.execute(
569
+ "INSERT INTO predictions (prediction_id,subject,domain,expectation,confidence,horizon,source,detail,dedup_key,created_at,schema_version,subject_person_id,viewer_scope,shareability,evidence_refs,source_kind,cohort) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
570
+ (pid, subject, domain, expectation, confidence, deadline, method,
571
+ _canonical(detail), f"forecast:{forecast_id}:{revision}", issued, 2,
572
+ person, viewer, sharing, _canonical(list(refs)), source_kind, cohort))
573
+ self._conn.execute("INSERT INTO forecast_revisions VALUES (?,?,?,?)", (forecast_id, revision, pid, digest))
574
+ row = self._conn.execute("SELECT * FROM predictions WHERE prediction_id=?", (pid,)).fetchone()
575
+ self._conn.commit()
576
+ except Exception:
577
+ self._conn.rollback()
578
+ raise
579
+ return self._row(row)
580
+
581
+ def forecast_history(self, forecast_id: str) -> Dict[str, Any]:
582
+ with self._lock:
583
+ rows = self._conn.execute("SELECT p.* FROM forecast_revisions f JOIN predictions p USING(prediction_id) WHERE forecast_id=? ORDER BY revision", (forecast_id,)).fetchall()
584
+ outcomes = self._conn.execute("SELECT payload,recorded_at FROM forecast_outcomes WHERE forecast_id=? ORDER BY revision", (forecast_id,)).fetchall()
585
+ return {"forecasts": [self._row(r).public() for r in rows],
586
+ "outcomes": [{**json.loads(r["payload"]), "recorded_at": r["recorded_at"]} for r in outcomes]}
587
+
588
+ def record_forecast_outcome(
589
+ self, *, forecast_id: str, receipt_ref: str, evidence_refs: Iterable[str],
590
+ source_versions: Mapping[str, str], source_kind: str, observed_at: float,
591
+ subject_person_id: str, viewer_scope: str, shareability: str,
592
+ status: str = "observed", value: Optional[bool] = None,
593
+ coverage_until: Optional[float] = None, reason: str = "",
594
+ previous_revision: int = 0, recorded_at: Optional[float] = None,
595
+ ) -> Dict[str, Any]:
596
+ """Append independent evidence (or its explicit correction), then score.
597
+
598
+ A negative observation requires coverage through the forecast horizon.
599
+ Censored/unavailable work is not a miss. Corrections append evidence;
600
+ original forecast values and prior outcome payloads remain inspectable.
601
+ """
602
+ refs = _refs(evidence_refs)
603
+ _refs([receipt_ref])
604
+ if receipt_ref not in refs or set(source_versions) != set(refs) or any(not isinstance(v, str) or not v for v in source_versions.values()):
605
+ raise ValueError("outcome requires exact receipt/source versions")
606
+ if source_kind not in {"transport_receipt", "task_receipt", "work_receipt", "commitment_receipt"}:
607
+ raise ValueError("outcome must come from independent work or transport evidence")
608
+ if status not in {"observed", "censored", "unresolved"} or (status == "observed" and type(value) is not bool) or (status != "observed" and value is not None):
609
+ raise ValueError("invalid forecast outcome")
610
+ if status != "observed" and reason not in {"cancelled", "intervened", "paused", "unavailable", "source_retracted", "unknown"}:
611
+ raise ValueError("censor/unresolved reason required")
612
+ observed = _event_epoch(observed_at, "observed_at")
613
+ recorded = _event_epoch(_now() if recorded_at is None else recorded_at, "recorded_at")
614
+ coverage = _event_epoch(coverage_until, "coverage_until") if coverage_until is not None else None
615
+ if observed > recorded or (coverage is not None and coverage > recorded):
616
+ raise ValueError("outcome cannot observe the future")
617
+ scope = _scope(subject_person_id, viewer_scope, shareability)
618
+ payload = {"forecast_id": forecast_id, "revision": previous_revision + 1,
619
+ "supersedes_revision": previous_revision or None,
620
+ "receipt_ref": receipt_ref, "evidence_refs": list(refs),
621
+ "source_versions": dict(source_versions), "source_kind": source_kind,
622
+ "observed_at": observed, "coverage_until": coverage,
623
+ "status": status, "value": value, "reason": reason,
624
+ "subject_person_id": scope[0], "viewer_scope": scope[1], "shareability": scope[2]}
625
+ digest = _digest(payload)
626
+ with self._lock:
627
+ try:
628
+ self._conn.execute("BEGIN IMMEDIATE")
629
+ existing = self._conn.execute("SELECT * FROM forecast_outcomes WHERE forecast_id=? AND receipt_ref=?", (forecast_id, receipt_ref)).fetchone()
630
+ if existing:
631
+ if existing["material_digest"] != digest:
632
+ raise ValueError("outcome receipt conflict")
633
+ self._conn.commit()
634
+ return {**json.loads(existing["payload"]), "disposition": "duplicate"}
635
+ latest = self._conn.execute("SELECT MAX(revision) FROM forecast_outcomes WHERE forecast_id=?", (forecast_id,)).fetchone()[0] or 0
636
+ if latest != previous_revision:
637
+ raise ValueError("outcome correction revision conflict")
638
+ rows = self._conn.execute("SELECT p.* FROM forecast_revisions f JOIN predictions p USING(prediction_id) WHERE forecast_id=? ORDER BY revision", (forecast_id,)).fetchall()
639
+ if not rows:
640
+ raise ValueError("unknown forecast")
641
+ if any((r["subject_person_id"], r["viewer_scope"], r["shareability"]) != scope for r in rows):
642
+ raise ValueError("outcome scope mismatch")
643
+ # A late-arriving pre-origin reply may be retained only as
644
+ # unknown, never as prospective outcome credit.
645
+ if (observed < json.loads(rows[0]["detail"])["origin_at"]
646
+ and not (status == "unresolved" and reason == "unknown")):
647
+ raise ValueError("outcome precedes task/reply origin")
648
+ self._conn.execute("INSERT INTO forecast_outcomes VALUES (?,?,?,?,?,?)", (forecast_id, previous_revision + 1, receipt_ref, digest, _canonical(payload), recorded))
649
+ for row in rows:
650
+ # No retrospective prediction credit for revisions issued
651
+ # after an event that arrived late at the ledger.
652
+ eligible = status == "observed" and row["created_at"] <= observed
653
+ if value is False:
654
+ eligible = eligible and coverage is not None and coverage >= row["horizon"]
655
+ result = "unresolved" if not eligible else "hit" if value and observed <= row["horizon"] else "miss"
656
+ self._conn.execute("UPDATE predictions SET outcome=?,resolved_at=?,outcome_observation_id=?,outcome_observed_at=?,outcome_evidence_refs=?,resolution_digest=? WHERE prediction_id=?", (result, recorded, receipt_ref, observed, _canonical(list(refs)), digest, row["prediction_id"]))
657
+ self._conn.commit()
658
+ except Exception:
659
+ self._conn.rollback()
660
+ raise
661
+ return {**payload, "disposition": "recorded"}
662
+
663
+ def estimate_duration(
664
+ self, *, domain: str, cohort: str, subject_person_id: str,
665
+ viewer_scope: str, prior_seconds: float, prior_confidence: float = 0.7,
666
+ now: Optional[float] = None,
667
+ evidence_is_current: Optional[Callable[[Prediction, Dict[str, Any]], bool]] = None,
668
+ method: str = DURATION_METHOD_V1,
669
+ ) -> Dict[str, Any]:
670
+ """Prospective, bounded empirical update from independent durations.
671
+
672
+ One sample per original forecast, latest corrected outcome only. A
673
+ fixed four-sample prior keeps single conversations from flipping the
674
+ estimate. V2 removes V1's permanent ratio clamp and retains its
675
+ counterfactual on exactly the same samples. Callers must use a new
676
+ cohort and current evidence predicate when adopting V2. Neither
677
+ empirical hit fractions nor duration estimates confer timing authority.
678
+ """
679
+ if method not in {DURATION_METHOD_V1, DURATION_METHOD_V2}:
680
+ raise ValueError("unknown duration method")
681
+ if not math.isfinite(prior_seconds) or prior_seconds <= 0 or not 0 <= prior_confidence <= 1:
682
+ raise ValueError("invalid duration prior")
683
+ stamp = _now() if now is None else now
684
+ with self._lock:
685
+ rows = self._conn.execute("SELECT p.*,o.payload,o.recorded_at FROM forecast_revisions f JOIN predictions p USING(prediction_id) JOIN forecast_outcomes o ON o.forecast_id=f.forecast_id WHERE f.revision=1 AND p.domain=? AND p.cohort=? AND p.subject_person_id=? AND p.viewer_scope=? AND o.revision=(SELECT MAX(x.revision) FROM forecast_outcomes x WHERE x.forecast_id=f.forecast_id AND x.recorded_at<=?) AND o.recorded_at<=? ORDER BY o.recorded_at DESC LIMIT 50", (domain, cohort, subject_person_id, viewer_scope, stamp, stamp)).fetchall()
686
+ samples = []
687
+ hits = []
688
+ receipts = []
689
+ for row in rows:
690
+ observation = json.loads(row["payload"])
691
+ from .reply_forecasts import evidence_current
692
+ if not evidence_current(self._row(row), observation):
693
+ continue
694
+ if evidence_is_current is not None and not evidence_is_current(self._row(row), observation):
695
+ continue
696
+ origin = json.loads(row["detail"])["origin_at"]
697
+ if observation["status"] == "observed" and observation["observed_at"] >= row["created_at"]:
698
+ if observation["value"] is True:
699
+ duration = observation["observed_at"] - origin
700
+ if method == DURATION_METHOD_V2 and (not math.isfinite(duration) or duration <= 0):
701
+ continue
702
+ samples.append(duration)
703
+ hits.append(observation["observed_at"] <= row["horizon"])
704
+ receipts.append(observation["receipt_ref"])
705
+ elif observation["coverage_until"] is not None and observation["coverage_until"] >= row["horizon"]:
706
+ hits.append(False)
707
+ receipts.append(observation["receipt_ref"])
708
+ n = len(samples)
709
+ median = statistics.median(samples) if samples else None
710
+ weight = n / (n + 4)
711
+ learned = prior_seconds if median is None else prior_seconds + weight * (median - prior_seconds)
712
+ clamped = max(prior_seconds * .5, min(prior_seconds * 2, learned))
713
+ seconds = learned if method == DURATION_METHOD_V2 else clamped
714
+ confidence = prior_confidence if not hits else (4 * prior_confidence + sum(hits)) / (4 + len(hits))
715
+ return {"prior_seconds": prior_seconds, "seconds": round(seconds, 3),
716
+ "prior_confidence": prior_confidence, "confidence": round(max(prior_confidence - .15, min(prior_confidence + .15, confidence)), 4),
717
+ "sample_n": n, "confidence_sample_n": len(hits), "sample_median_seconds": median,
718
+ "uncertain": n < 10, "method": method,
719
+ "evidence_refs": receipts, "as_of": stamp,
720
+ **({"clamped_comparison_seconds": round(clamped, 3),
721
+ "clamped_comparison_method": DURATION_METHOD_V1,
722
+ "confidence_is_calibrated": False} if method == DURATION_METHOD_V2 else {})}
723
+
724
+ def forecast_coverage(self, *, subject_person_id: str, viewer_scope: str) -> Dict[str, Any]:
725
+ with self._lock:
726
+ rows = self._conn.execute("SELECT p.*,o.payload FROM forecast_revisions f JOIN predictions p USING(prediction_id) LEFT JOIN forecast_outcomes o ON o.forecast_id=f.forecast_id AND o.revision=(SELECT MAX(x.revision) FROM forecast_outcomes x WHERE x.forecast_id=f.forecast_id) WHERE f.revision=1 AND p.subject_person_id=? AND p.viewer_scope=?", (subject_person_id, viewer_scope)).fetchall()
727
+ counts = {"issued": len(rows), "resolved": 0, "censored": 0, "unresolved": 0, "pending": 0, "eligible": 0}
728
+ groups = {}
729
+ for row in rows:
730
+ observation = json.loads(row["payload"]) if row["payload"] else None
731
+ state = "pending" if not observation else "censored" if observation["status"] == "censored" else "resolved" if row["outcome"] in {"hit", "miss"} else "unresolved"
732
+ from .reply_forecasts import evidence_current
733
+ if not evidence_current(self._row(row), observation):
734
+ state = "unresolved"
735
+ counts[state] += 1
736
+ counts["eligible"] += int(state == "resolved")
737
+ detail = json.loads(row["detail"])
738
+ key = (row["domain"], row["cohort"], ExpectationEngine._horizon_bucket(self._row(row)), detail["model_provenance"].get("served_model") or "unattributed-or-deterministic")
739
+ item = groups.setdefault(key, {"domain": key[0], "cohort": key[1], "horizon_bucket": key[2], "served_model": detail["model_provenance"].get("served_model"), "issued": 0, "resolved": 0, "censored": 0, "unresolved": 0, "pending": 0})
740
+ item["issued"] += 1
741
+ item[state] += 1
742
+ return {**counts, "unit": "original_forecast", "groups": list(groups.values())}
743
+
744
+ def _current_predictions(self, rows):
745
+ # Only the declared-reply adapter adds a current-evidence predicate.
746
+ # Raw get()/forecast_history() remain available for historical audit.
747
+ from .reply_forecasts import evidence_current
748
+ return [p for row in rows if evidence_current(p := self._row(row))]
749
+
750
+ def due(self, now: Optional[float] = None) -> List[Prediction]:
751
+ now = now or _now()
752
+ with self._lock:
753
+ rows = self._conn.execute(
754
+ "SELECT * FROM predictions WHERE outcome='pending' AND "
755
+ "horizon <= ? ORDER BY horizon ASC LIMIT 200", (now,)).fetchall()
756
+ return self._current_predictions(rows)
757
+
758
+ def pending(self, limit: int = 100) -> List[Prediction]:
759
+ with self._lock:
760
+ rows = self._conn.execute(
761
+ "SELECT * FROM predictions WHERE outcome='pending' "
762
+ + _CURRENT_FORECAST_SQL + "ORDER BY horizon ASC LIMIT ?", (limit,)).fetchall()
763
+ return self._current_predictions(rows)
764
+
765
+ def for_subjects(
766
+ self, subjects: Iterable[str], *, limit: int = 100,
767
+ ) -> List[Prediction]:
768
+ """Return a stable bounded cohort, including already-resolved rows.
769
+
770
+ Evidence reducers need resolved rows on crash replay so the same
771
+ OutcomeObservation can be presented again and recognized as a
772
+ duplicate instead of disappearing from the sink receipt.
773
+ """
774
+
775
+ normalized = tuple(sorted(dict.fromkeys(
776
+ str(subject or "").strip() for subject in subjects
777
+ if str(subject or "").strip()
778
+ )))
779
+ if not normalized:
780
+ return []
781
+ if len(normalized) > 20:
782
+ raise ValueError("expectation subject cohort exceeds 20 entries")
783
+ bound = max(1, min(500, int(limit)))
784
+ placeholders = ",".join("?" for _ in normalized)
785
+ with self._lock:
786
+ rows = self._conn.execute(
787
+ f"SELECT * FROM predictions WHERE subject IN ({placeholders}) "
788
+ "ORDER BY created_at ASC,prediction_id ASC LIMIT ?",
789
+ (*normalized, bound),
790
+ ).fetchall()
791
+ return self._current_predictions(rows)
792
+
793
+ def get(self, prediction_id: str) -> Optional[Prediction]:
794
+ with self._lock:
795
+ row = self._conn.execute(
796
+ "SELECT * FROM predictions WHERE prediction_id=?",
797
+ (str(prediction_id or ""),),
798
+ ).fetchone()
799
+ return self._row(row) if row is not None else None
800
+
801
+ def resolve(self, prediction_id: str, outcome: str) -> None:
802
+ if outcome not in OUTCOMES:
803
+ return
804
+ with self._lock:
805
+ row = self._conn.execute(
806
+ "SELECT schema_version FROM predictions WHERE prediction_id=?",
807
+ (prediction_id,),
808
+ ).fetchone()
809
+ if (
810
+ row is not None and int(row["schema_version"] or 1) == 2
811
+ and outcome in {"hit", "miss"}
812
+ ):
813
+ raise ValueError(
814
+ "ExpectationV2 hit/miss requires an outcome observation",
815
+ )
816
+ self._conn.execute(
817
+ "UPDATE predictions SET outcome=?, resolved_at=? "
818
+ "WHERE prediction_id=? AND outcome='pending'",
819
+ (outcome, _now(), prediction_id))
820
+ self._conn.commit()
821
+
822
+ def resolve_v2(
823
+ self,
824
+ observation: OutcomeObservationV1,
825
+ ) -> Dict[str, Any]:
826
+ """Ingest one outcome receipt and seal the first valid resolution.
827
+
828
+ A positive event after the horizon is a miss. This closes the legacy
829
+ bug where a late commitment could look on-time merely because the
830
+ checker ran after fulfillment.
831
+ """
832
+
833
+ with self._lock:
834
+ try:
835
+ self._conn.execute("BEGIN IMMEDIATE")
836
+ prediction_row = self._conn.execute(
837
+ "SELECT * FROM predictions WHERE prediction_id=?",
838
+ (observation.prediction_id,),
839
+ ).fetchone()
840
+ if prediction_row is None:
841
+ raise ValueError("outcome references an unknown expectation")
842
+ prediction = self._row(prediction_row)
843
+ if prediction.detail.get("forecast_id"):
844
+ raise ValueError("versioned forecasts require record_forecast_outcome")
845
+ if prediction.schema_version != 2:
846
+ raise ValueError(
847
+ "receipt-bound resolution requires ExpectationV2",
848
+ )
849
+ if (
850
+ prediction.subject_person_id != observation.subject_person_id
851
+ or prediction.viewer_scope != observation.viewer_scope
852
+ or prediction.shareability != observation.shareability
853
+ ):
854
+ raise ValueError(
855
+ "outcome scope does not match expectation scope",
856
+ )
857
+
858
+ prior = self._conn.execute(
859
+ "SELECT observation_digest,prediction_id FROM "
860
+ "expectation_outcome_observations WHERE observation_id=?",
861
+ (observation.observation_id,),
862
+ ).fetchone()
863
+ if prior is not None:
864
+ if (
865
+ prior["observation_digest"] != observation.digest
866
+ or prior["prediction_id"] != observation.prediction_id
867
+ ):
868
+ raise ValueError("outcome observation replay conflict")
869
+ self._conn.commit()
870
+ return {
871
+ "disposition": "duplicate",
872
+ "prediction_id": observation.prediction_id,
873
+ "outcome": prediction.outcome,
874
+ "late": bool(
875
+ observation.value
876
+ and observation.observed_at > prediction.horizon
877
+ ),
878
+ }
879
+ if prediction.outcome != "pending":
880
+ raise ValueError("expectation resolution is already sealed")
881
+
882
+ late = bool(
883
+ observation.value
884
+ and observation.observed_at > prediction.horizon
885
+ )
886
+ outcome = "hit" if observation.value and not late else "miss"
887
+ resolution = {
888
+ "prediction_id": prediction.prediction_id,
889
+ "observation_id": observation.observation_id,
890
+ "observation_digest": observation.digest,
891
+ "outcome": outcome,
892
+ "late": late,
893
+ "horizon": prediction.horizon,
894
+ "observed_at": observation.observed_at,
895
+ }
896
+ self._conn.execute(
897
+ "INSERT INTO expectation_outcome_observations "
898
+ "(observation_id,observation_digest,prediction_id,payload,"
899
+ "ingested_at) VALUES (?,?,?,?,?)",
900
+ (
901
+ observation.observation_id, observation.digest,
902
+ observation.prediction_id,
903
+ _canonical({
904
+ "schema": observation.schema,
905
+ "version": observation.version,
906
+ "observation_id": observation.observation_id,
907
+ "prediction_id": observation.prediction_id,
908
+ "value": observation.value,
909
+ "observed_at": observation.observed_at,
910
+ "evidence_refs": list(observation.evidence_refs),
911
+ "source_kind": observation.source_kind,
912
+ "subject_person_id": observation.subject_person_id,
913
+ "viewer_scope": observation.viewer_scope,
914
+ "shareability": observation.shareability,
915
+ }),
916
+ _now(),
917
+ ),
918
+ )
919
+ updated = self._conn.execute(
920
+ "UPDATE predictions SET outcome=?,resolved_at=?,"
921
+ "outcome_observation_id=?,outcome_observed_at=?,"
922
+ "outcome_evidence_refs=?,resolution_digest=? "
923
+ "WHERE prediction_id=? AND outcome='pending'",
924
+ (
925
+ outcome, _now(), observation.observation_id,
926
+ observation.observed_at,
927
+ json.dumps(list(observation.evidence_refs)),
928
+ _digest(resolution), prediction.prediction_id,
929
+ ),
930
+ ).rowcount
931
+ if updated != 1:
932
+ raise ValueError("expectation resolution lost first-writer race")
933
+ self._conn.commit()
934
+ except Exception:
935
+ self._conn.rollback()
936
+ raise
937
+ return {
938
+ "disposition": "resolved",
939
+ "prediction_id": prediction.prediction_id,
940
+ "outcome": outcome,
941
+ "late": late,
942
+ "resolution_digest": _digest(resolution),
943
+ }
944
+
945
+ def resolved_since(self, since: float,
946
+ domain: Optional[str] = None) -> List[Prediction]:
947
+ q = ("SELECT * FROM predictions WHERE outcome IN ('hit','miss') "
948
+ "AND resolved_at >= ?")
949
+ params: List[Any] = [since]
950
+ if domain:
951
+ q += " AND domain=?"
952
+ params.append(domain)
953
+ with self._lock:
954
+ rows = self._conn.execute(q, params).fetchall()
955
+ return self._current_predictions(rows)
956
+
957
+ def domains(self) -> List[str]:
958
+ with self._lock:
959
+ rows = self._conn.execute(
960
+ "SELECT DISTINCT domain FROM predictions WHERE "
961
+ "outcome IN ('hit','miss')").fetchall()
962
+ return [r["domain"] for r in rows]
963
+
964
+ def projected(
965
+ self,
966
+ *,
967
+ subject_person_id: str,
968
+ viewer_scope: str,
969
+ outcomes: Sequence[str] = ("pending", "hit", "miss", "unresolved"),
970
+ limit: int = 100,
971
+ ) -> List[Prediction]:
972
+ """Strict observer projection; no cross-subject/scope fallback."""
973
+
974
+ person = str(subject_person_id or "").strip()[:128]
975
+ viewer = str(viewer_scope or "").strip()
976
+ if len(viewer) > VIEWER_SCOPE_MAX_CHARS:
977
+ raise ValueError("viewer scope exceeds the safe bound")
978
+ allowed = tuple(item for item in outcomes if item in OUTCOMES)
979
+ if not allowed:
980
+ return []
981
+ marks = ",".join("?" for _ in allowed)
982
+ with self._lock:
983
+ rows = self._conn.execute(
984
+ f"SELECT * FROM predictions WHERE subject_person_id=? AND "
985
+ f"(viewer_scope=? OR shareability='public') AND outcome IN ({marks}) "
986
+ + _CURRENT_FORECAST_SQL + "ORDER BY created_at DESC LIMIT ?",
987
+ (person, viewer, *allowed, max(1, min(200, int(limit)))),
988
+ ).fetchall()
989
+ return self._current_predictions(rows)
990
+
991
+ def resolved_projected_since(
992
+ self,
993
+ since: float,
994
+ *,
995
+ subject_person_id: str,
996
+ viewer_scope: str,
997
+ ) -> List[Prediction]:
998
+ person = str(subject_person_id or "").strip()[:128]
999
+ viewer = str(viewer_scope or "").strip()
1000
+ if len(viewer) > VIEWER_SCOPE_MAX_CHARS:
1001
+ raise ValueError("viewer scope exceeds the safe bound")
1002
+ with self._lock:
1003
+ rows = self._conn.execute(
1004
+ "SELECT * FROM predictions WHERE subject_person_id=? AND "
1005
+ "(viewer_scope=? OR shareability='public') AND "
1006
+ "outcome IN ('hit','miss') AND resolved_at>=? "
1007
+ "ORDER BY resolved_at ASC",
1008
+ (person, viewer, float(since)),
1009
+ ).fetchall()
1010
+ return self._current_predictions(rows)
1011
+
1012
+ def projected_for_viewer(
1013
+ self,
1014
+ *,
1015
+ viewer_scope: str,
1016
+ outcomes: Sequence[str] = ("pending", "hit", "miss", "unresolved"),
1017
+ limit: int = 100,
1018
+ ) -> List[Prediction]:
1019
+ """All subjects explicitly addressed to one authenticated viewer."""
1020
+
1021
+ viewer = str(viewer_scope or "").strip()
1022
+ if len(viewer) > VIEWER_SCOPE_MAX_CHARS:
1023
+ raise ValueError("viewer scope exceeds the safe bound")
1024
+ allowed = tuple(item for item in outcomes if item in OUTCOMES)
1025
+ if not allowed:
1026
+ return []
1027
+ marks = ",".join("?" for _ in allowed)
1028
+ with self._lock:
1029
+ rows = self._conn.execute(
1030
+ f"SELECT * FROM predictions WHERE "
1031
+ f"(viewer_scope=? OR shareability='public') AND outcome IN ({marks}) "
1032
+ + _CURRENT_FORECAST_SQL + "ORDER BY created_at DESC LIMIT ?",
1033
+ (viewer, *allowed, max(1, min(200, int(limit)))),
1034
+ ).fetchall()
1035
+ return self._current_predictions(rows)
1036
+
1037
+ def resolved_for_viewer_since(
1038
+ self, since: float, *, viewer_scope: str,
1039
+ ) -> List[Prediction]:
1040
+ viewer = str(viewer_scope or "").strip()
1041
+ if len(viewer) > VIEWER_SCOPE_MAX_CHARS:
1042
+ raise ValueError("viewer scope exceeds the safe bound")
1043
+ with self._lock:
1044
+ rows = self._conn.execute(
1045
+ "SELECT * FROM predictions WHERE "
1046
+ "(viewer_scope=? OR shareability='public') AND "
1047
+ "outcome IN ('hit','miss') AND resolved_at>=? "
1048
+ "ORDER BY resolved_at ASC",
1049
+ (viewer, float(since)),
1050
+ ).fetchall()
1051
+ return self._current_predictions(rows)
1052
+
1053
+
1054
+ class ExpectationEngine:
1055
+ """Generate, check, and score predictions.
1056
+
1057
+ Resolvers: `register_resolver(prefix, fn)` where fn(prediction) returns
1058
+ True (hit), False (miss), or None (cannot resolve yet -> left pending).
1059
+ """
1060
+
1061
+ def __init__(self, store: ExpectationStore, *,
1062
+ workspace: Any = None, journal: Any = None) -> None:
1063
+ self.store = store
1064
+ self._workspace = workspace
1065
+ self._journal = journal
1066
+ self._resolvers: Dict[
1067
+ str, Callable[[Prediction], Optional[Any]]
1068
+ ] = {}
1069
+ self.register_resolver("commitment:", self._resolve_commitment)
1070
+
1071
+ def register_resolver(self, prefix: str,
1072
+ fn: Callable[[Prediction], Optional[Any]]) -> None:
1073
+ self._resolvers[prefix] = fn
1074
+
1075
+ def ingest_outcome(
1076
+ self, observation: OutcomeObservationV1,
1077
+ ) -> Dict[str, Any]:
1078
+ """Public idempotent path for a subsystem's structured outcome."""
1079
+
1080
+ result = self.store.resolve_v2(observation)
1081
+ if result.get("disposition") == "resolved":
1082
+ prediction = self.store.get(observation.prediction_id)
1083
+ if prediction is not None:
1084
+ outcome = str(result["outcome"])
1085
+ self._log(
1086
+ f"prediction {outcome}: {prediction.expectation}",
1087
+ outcome=outcome,
1088
+ )
1089
+ if outcome == "miss":
1090
+ self._surprise(prediction)
1091
+ return result
1092
+
1093
+ # -- generation -------------------------------------------------------
1094
+ def generate_from_commitments(self) -> int:
1095
+ """A commitment with a due date -> a prediction it gets fulfilled by
1096
+ then. Confidence from the agent's commitment track record."""
1097
+ cstore = self._commitments()
1098
+ if cstore is None:
1099
+ return 0
1100
+ try:
1101
+ pend = cstore.list(status=["pending"], limit=100).get(
1102
+ "commitments", [])
1103
+ except Exception:
1104
+ return 0
1105
+ conf = self._commitment_confidence()
1106
+ n = 0
1107
+ for c in pend:
1108
+ due = c.get("due_at")
1109
+ cid = c.get("id")
1110
+ if not due or not cid:
1111
+ continue
1112
+ try:
1113
+ horizon = datetime.fromisoformat(
1114
+ str(due).replace("Z", "+00:00")).timestamp()
1115
+ except ValueError:
1116
+ continue
1117
+ if horizon <= _now():
1118
+ # the due date already passed: there is nothing left to
1119
+ # predict, and an instantly-due prediction would just be
1120
+ # scored the moment it is created
1121
+ continue
1122
+ desc = (c.get("description") or "commitment")[:120]
1123
+ p = self.store.create_v2(
1124
+ subject=f"commitment:{cid}", domain="commitment",
1125
+ expectation=f"'{desc}' fulfilled by its due date",
1126
+ confidence=conf, horizon=horizon, source="cadence-model",
1127
+ dedup_key=f"commitment:{cid}",
1128
+ evidence_refs=(f"commitment:{cid}",),
1129
+ source_kind="commitment_receipt",
1130
+ subject_person_id=str(c.get("person_id") or "owner")[:128],
1131
+ viewer_scope="owner",
1132
+ shareability="owner_private",
1133
+ cohort="commitment:due-date",
1134
+ detail={"commitment_id": cid, "due_at": str(due)[:64]})
1135
+ if p is not None:
1136
+ n += 1
1137
+ return n
1138
+
1139
+ def _generate_structured(
1140
+ self,
1141
+ records: Sequence[Mapping[str, Any]],
1142
+ *,
1143
+ kind: str,
1144
+ id_field: str,
1145
+ horizon_field: str,
1146
+ domain: str,
1147
+ expectation_template: str,
1148
+ source_kind: str,
1149
+ maximum: int = 100,
1150
+ ) -> int:
1151
+ """Bounded adapter for already-structured subsystem records.
1152
+
1153
+ Callers must supply ``evidence_refs`` and a concrete horizon. Text is
1154
+ display-only; it never resolves the prediction.
1155
+ """
1156
+
1157
+ count = 0
1158
+ bound = max(0, min(100, int(maximum)))
1159
+ for record in records[:bound]:
1160
+ native_id = str(record.get(id_field) or "").strip()
1161
+ if not native_id or not _SAFE_REF.fullmatch(native_id):
1162
+ continue
1163
+ try:
1164
+ horizon = _event_epoch(record.get(horizon_field), horizon_field)
1165
+ if horizon <= _now():
1166
+ continue
1167
+ evidence = _refs(record.get("evidence_refs") or ())
1168
+ confidence = float(record.get("confidence", 0.65))
1169
+ subject_person_id = str(
1170
+ record.get("subject_person_id")
1171
+ or record.get("person_id") or "owner"
1172
+ )[:128]
1173
+ viewer = str(record.get("viewer_scope") or "owner")
1174
+ sharing = str(record.get("shareability") or "owner_private")
1175
+ label = str(record.get("label") or native_id)[:120]
1176
+ prediction = self.store.create_v2(
1177
+ subject=f"{kind}:{native_id}",
1178
+ domain=domain,
1179
+ expectation=expectation_template.format(label=label),
1180
+ confidence=confidence,
1181
+ horizon=horizon,
1182
+ source=f"{kind}-adapter-v2",
1183
+ dedup_key=f"{kind}:{native_id}",
1184
+ evidence_refs=evidence,
1185
+ source_kind=source_kind,
1186
+ subject_person_id=subject_person_id,
1187
+ viewer_scope=viewer,
1188
+ shareability=sharing,
1189
+ cohort=f"{domain}:{kind}",
1190
+ detail={
1191
+ f"{kind}_id": native_id,
1192
+ "generator": f"{kind}-adapter-v2",
1193
+ },
1194
+ )
1195
+ except (TypeError, ValueError):
1196
+ continue
1197
+ if prediction is not None:
1198
+ count += 1
1199
+ return count
1200
+
1201
+ def generate_contact_cadence(
1202
+ self, records: Sequence[Mapping[str, Any]], *, maximum: int = 100,
1203
+ ) -> int:
1204
+ return self._generate_structured(
1205
+ records, kind="contact", id_field="contact_id",
1206
+ horizon_field="next_contact_due_at", domain="contact_cadence",
1207
+ expectation_template="contact cadence for {label} is satisfied",
1208
+ source_kind="relationship_receipt", maximum=maximum,
1209
+ )
1210
+
1211
+ def generate_task_duration(
1212
+ self, records: Sequence[Mapping[str, Any]], *, maximum: int = 100,
1213
+ ) -> int:
1214
+ return self._generate_structured(
1215
+ records, kind="task", id_field="task_id",
1216
+ horizon_field="expected_complete_at", domain="task_duration",
1217
+ expectation_template="task {label} completes within its estimate",
1218
+ source_kind="task_receipt", maximum=maximum,
1219
+ )
1220
+
1221
+ def generate_service_recovery(
1222
+ self, records: Sequence[Mapping[str, Any]], *, maximum: int = 100,
1223
+ ) -> int:
1224
+ return self._generate_structured(
1225
+ records, kind="service", id_field="service_id",
1226
+ horizon_field="expected_recovery_at", domain="service_recovery",
1227
+ expectation_template="service {label} recovers by its horizon",
1228
+ source_kind="service_probe", maximum=maximum,
1229
+ )
1230
+
1231
+ def generate_relationship_followup(
1232
+ self, records: Sequence[Mapping[str, Any]], *, maximum: int = 100,
1233
+ ) -> int:
1234
+ return self._generate_structured(
1235
+ records, kind="relationship", id_field="relationship_id",
1236
+ horizon_field="followup_due_at", domain="relationship_followup",
1237
+ expectation_template="relationship follow-up for {label} occurs",
1238
+ source_kind="relationship_receipt", maximum=maximum,
1239
+ )
1240
+
1241
+ def _commitment_confidence(self) -> float:
1242
+ """Historical fulfillment rate as the prior; defaults to 0.7."""
1243
+ cstore = self._commitments()
1244
+ if cstore is None:
1245
+ return 0.7
1246
+ try:
1247
+ done = cstore.list(status=["fulfilled"], limit=200).get("total", 0)
1248
+ missed = cstore.list(status=["overdue"], limit=200).get("total", 0)
1249
+ if done + missed >= 5:
1250
+ return max(0.1, min(0.95, (done + 1) / (done + missed + 2)))
1251
+ except Exception:
1252
+ pass
1253
+ return 0.7
1254
+
1255
+ # -- checking ---------------------------------------------------------
1256
+ def check(self, now: Optional[float] = None) -> Dict[str, int]:
1257
+ """Resolve every due prediction. Misses emit surprise. Returns
1258
+ {"hit": n, "miss": n, "unresolved": n}."""
1259
+ counts = {"hit": 0, "miss": 0, "unresolved": 0}
1260
+ point = float(now) if now is not None else _now()
1261
+ for p in self.store.due(now=point):
1262
+ if p.detail.get("forecast_id"):
1263
+ # Task/transport producers explicitly declare outcome coverage.
1264
+ # The clock alone is not evidence of absence or failure.
1265
+ continue
1266
+ verdict = self._resolve(p)
1267
+ if verdict is None:
1268
+ # give it one grace period then mark unresolved so it stops
1269
+ # recurring; unresolved is excluded from calibration
1270
+ if point - p.horizon > 86400:
1271
+ self.store.resolve(p.prediction_id, "unresolved")
1272
+ counts["unresolved"] += 1
1273
+ continue
1274
+ if isinstance(verdict, OutcomeObservationV1):
1275
+ try:
1276
+ result = self.store.resolve_v2(verdict)
1277
+ except ValueError:
1278
+ logger.debug(
1279
+ "receipt-bound expectation resolution rejected",
1280
+ exc_info=True,
1281
+ )
1282
+ continue
1283
+ outcome = str(result["outcome"])
1284
+ elif p.schema_version == 1 and isinstance(verdict, bool):
1285
+ # Compatibility adapter for registered legacy resolvers. A
1286
+ # bare boolean is never sufficient to settle ExpectationV2.
1287
+ outcome = "hit" if verdict else "miss"
1288
+ self.store.resolve(p.prediction_id, outcome)
1289
+ else:
1290
+ continue
1291
+ counts[outcome] += 1
1292
+ self._log(f"prediction {outcome}: {p.expectation}",
1293
+ outcome=outcome)
1294
+ if outcome == "miss":
1295
+ self._surprise(p)
1296
+ return counts
1297
+
1298
+ def _resolve(self, p: Prediction) -> Optional[Any]:
1299
+ for prefix, fn in self._resolvers.items():
1300
+ if p.subject.startswith(prefix):
1301
+ try:
1302
+ return fn(p)
1303
+ except Exception:
1304
+ logger.debug("resolver %s failed", prefix, exc_info=True)
1305
+ return None
1306
+ return None
1307
+
1308
+ def _resolve_commitment(self, p: Prediction) -> Optional[Any]:
1309
+ cstore = self._commitments()
1310
+ cid = p.detail.get("commitment_id")
1311
+ if cstore is None or not cid:
1312
+ return None
1313
+ c = cstore.get(cid)
1314
+ if c is None:
1315
+ return None
1316
+ status = c.get("status")
1317
+ if p.schema_version == 2:
1318
+ if status == "fulfilled":
1319
+ observed = c.get("fulfilled_at")
1320
+ if not observed:
1321
+ resolution = (c.get("metadata") or {}).get("resolution") or {}
1322
+ observed = resolution.get("at")
1323
+ if not observed:
1324
+ return None
1325
+ value = True
1326
+ elif status in ("overdue", "cancelled"):
1327
+ resolution = (c.get("metadata") or {}).get("resolution") or {}
1328
+ observed = resolution.get("at") or max(_now(), p.horizon)
1329
+ value = False
1330
+ elif status == "pending":
1331
+ # The structured store still says pending at/after the due
1332
+ # horizon. That is explicit negative state, not prose.
1333
+ observed = max(_now(), p.horizon)
1334
+ value = False
1335
+ else:
1336
+ return None
1337
+ evidence = (f"commitment:{cid}",)
1338
+ identity = _digest({
1339
+ "prediction_id": p.prediction_id,
1340
+ "status": status,
1341
+ "observed_at": str(observed),
1342
+ "evidence_refs": evidence,
1343
+ })
1344
+ return OutcomeObservationV1.create(
1345
+ observation_id=f"eo-{identity[:24]}",
1346
+ prediction_id=p.prediction_id,
1347
+ value=value,
1348
+ observed_at=observed,
1349
+ evidence_refs=evidence,
1350
+ source_kind="commitment_receipt",
1351
+ subject_person_id=p.subject_person_id,
1352
+ viewer_scope=p.viewer_scope,
1353
+ shareability=p.shareability,
1354
+ )
1355
+ if status == "fulfilled":
1356
+ return True
1357
+ if status in ("overdue", "cancelled"):
1358
+ return False
1359
+ # still pending past its due date -> missed
1360
+ return False
1361
+
1362
+ def _surprise(self, p: Prediction) -> None:
1363
+ ws = self._workspace
1364
+ if ws is None:
1365
+ return
1366
+ try:
1367
+ # keyed by SUBJECT, not prediction id: repeated misses about the
1368
+ # same thing merge into one strengthening concern instead of
1369
+ # spawning a fresh anomaly per scoring pass
1370
+ ws.bump(kind="anomaly",
1371
+ summary=f"surprise: expected {p.expectation} (conf "
1372
+ f"{p.confidence:.2f}) but it did not hold",
1373
+ dedup_key=f"surprise:{p.subject}",
1374
+ salience=min(0.9, 0.5 + p.confidence),
1375
+ sources=[p.subject])
1376
+ except Exception:
1377
+ logger.debug("surprise -> workspace failed", exc_info=True)
1378
+
1379
+ # -- calibration ------------------------------------------------------
1380
+ def calibration(self, domain: Optional[str] = None, *,
1381
+ since: Optional[float] = None) -> Dict[str, Any]:
1382
+ """Brier score per domain over resolved predictions. Lower is better
1383
+ (0 = perfect, 0.25 = a coin flip at p=0.5). Feeds the benchmark."""
1384
+ since = since if since is not None else 0.0
1385
+ domains = [domain] if domain else self.store.domains()
1386
+ out: Dict[str, Any] = {}
1387
+ for d in domains:
1388
+ resolved = [p for p in self.store.resolved_since(since, domain=d)
1389
+ if self._predictive_score_eligible(p)]
1390
+ if not resolved:
1391
+ continue
1392
+ brier = sum((p.confidence - (1.0 if p.outcome == "hit" else 0.0)) ** 2
1393
+ for p in resolved) / len(resolved)
1394
+ hits = sum(1 for p in resolved if p.outcome == "hit")
1395
+ out[d] = {"brier": round(brier, 4), "n": len(resolved),
1396
+ "hit_rate": round(hits / len(resolved), 4),
1397
+ "scoring_rule": "brier",
1398
+ "formula": "mean((confidence - observed_binary)^2)"}
1399
+ return out
1400
+
1401
+ @staticmethod
1402
+ def _confidence_bin(confidence: float) -> str:
1403
+ low = min(9, max(0, int(float(confidence) * 10))) / 10.0
1404
+ return f"{low:.1f}-{low + 0.1:.1f}"
1405
+
1406
+ @staticmethod
1407
+ def _horizon_bucket(prediction: Prediction) -> str:
1408
+ seconds = max(0.0, prediction.horizon - prediction.created_at)
1409
+ if seconds <= 3600:
1410
+ return "lte_1h"
1411
+ if seconds <= 86400:
1412
+ return "1h_1d"
1413
+ if seconds <= 604800:
1414
+ return "1d_7d"
1415
+ return "gt_7d"
1416
+
1417
+ def calibration_report(
1418
+ self,
1419
+ *,
1420
+ since: Optional[float] = None,
1421
+ subject_person_id: Optional[str] = None,
1422
+ viewer_scope: Optional[str] = None,
1423
+ ) -> Dict[str, Any]:
1424
+ """Transparent proper-score report by declared and derived cohorts."""
1425
+
1426
+ start = since if since is not None else 0.0
1427
+ if subject_person_id is not None or viewer_scope is not None:
1428
+ if not subject_person_id or not viewer_scope:
1429
+ raise ValueError("scoped calibration requires subject and viewer")
1430
+ rows = self.store.resolved_projected_since(
1431
+ start,
1432
+ subject_person_id=subject_person_id,
1433
+ viewer_scope=viewer_scope,
1434
+ )
1435
+ else:
1436
+ rows = []
1437
+ for domain in self.store.domains():
1438
+ rows.extend(self.store.resolved_since(start, domain=domain))
1439
+ return self._calibration_report_for_rows(rows)
1440
+
1441
+ @staticmethod
1442
+ def _predictive_score_eligible(prediction: Prediction) -> bool:
1443
+ # Retain historical edge-survival rows as self-consistency evidence.
1444
+ # They do not establish that an external causal prediction was right.
1445
+ return (not prediction.subject.startswith("world-causal:")
1446
+ and prediction.detail.get("revision", 1) == 1)
1447
+
1448
+ def _calibration_report_for_rows(
1449
+ self, rows: Sequence[Prediction],
1450
+ ) -> Dict[str, Any]:
1451
+ excluded = len([p for p in rows if not self._predictive_score_eligible(p)])
1452
+ rows = [p for p in rows if self._predictive_score_eligible(p)]
1453
+ groups: Dict[tuple[str, str, str, str], list[Prediction]] = {}
1454
+ for prediction in rows:
1455
+ key = (
1456
+ prediction.domain,
1457
+ prediction.cohort or "legacy",
1458
+ self._confidence_bin(prediction.confidence),
1459
+ self._horizon_bucket(prediction),
1460
+ )
1461
+ groups.setdefault(key, []).append(prediction)
1462
+ cohorts: list[Dict[str, Any]] = []
1463
+ for key in sorted(groups):
1464
+ values = groups[key]
1465
+ errors = [
1466
+ (item.confidence - (1.0 if item.outcome == "hit" else 0.0)) ** 2
1467
+ for item in values
1468
+ ]
1469
+ cohorts.append({
1470
+ "domain": key[0],
1471
+ "cohort": key[1],
1472
+ "confidence_bin": key[2],
1473
+ "horizon_bucket": key[3],
1474
+ "n": len(values),
1475
+ "brier": round(sum(errors) / len(errors), 4),
1476
+ "hit_rate": round(
1477
+ sum(1 for item in values if item.outcome == "hit") / len(values),
1478
+ 4,
1479
+ ),
1480
+ })
1481
+ domains: Dict[str, Any] = {}
1482
+ by_domain: Dict[str, list[Prediction]] = {}
1483
+ for item in rows:
1484
+ by_domain.setdefault(item.domain, []).append(item)
1485
+ for name, values in sorted(by_domain.items()):
1486
+ errors = [
1487
+ (item.confidence - (1.0 if item.outcome == "hit" else 0.0)) ** 2
1488
+ for item in values
1489
+ ]
1490
+ domains[name] = {
1491
+ "brier": round(sum(errors) / len(errors), 4),
1492
+ "n": len(values),
1493
+ "hit_rate": round(
1494
+ sum(1 for item in values if item.outcome == "hit") / len(values),
1495
+ 4,
1496
+ ),
1497
+ "scoring_rule": "brier",
1498
+ "formula": "mean((confidence - observed_binary)^2)",
1499
+ }
1500
+ return {
1501
+ "scoring_rule": "brier",
1502
+ "proper": True,
1503
+ "lower_is_better": True,
1504
+ "formula": "mean((forecast_probability - observed_binary)^2)",
1505
+ "resolved_n": len(rows),
1506
+ "historical_self_consistency_or_revision_excluded_n": excluded,
1507
+ "domains": domains,
1508
+ "cohorts": cohorts,
1509
+ }
1510
+
1511
+ # -- wiring -----------------------------------------------------------
1512
+ def _commitments(self) -> Any:
1513
+ try:
1514
+ from apsimo.api.routers import host
1515
+ return getattr(host, "_commitment_store", None)
1516
+ except Exception:
1517
+ return None
1518
+
1519
+ def _log(self, desc: str, *, outcome: str) -> None:
1520
+ if self._journal is None:
1521
+ return
1522
+ try:
1523
+ self._journal.record("expectation", desc, decision="noted",
1524
+ outcome=outcome)
1525
+ except Exception:
1526
+ logger.debug("expectation journal write failed", exc_info=True)
1527
+
1528
+ def observer_projection(
1529
+ self,
1530
+ *,
1531
+ subject_person_id: str,
1532
+ viewer_scope: str,
1533
+ limit: int = 50,
1534
+ ) -> Dict[str, Any]:
1535
+ records = self.store.projected(
1536
+ subject_person_id=subject_person_id,
1537
+ viewer_scope=viewer_scope,
1538
+ limit=limit,
1539
+ )
1540
+ return {
1541
+ "mode": expectations_mode(),
1542
+ "subject_person_id": subject_person_id,
1543
+ "viewer_scope": viewer_scope,
1544
+ "pending": [p.public() for p in records if p.outcome == "pending"],
1545
+ "resolved": [p.public() for p in records if p.outcome != "pending"],
1546
+ "calibration": self.calibration_report(
1547
+ subject_person_id=subject_person_id,
1548
+ viewer_scope=viewer_scope,
1549
+ ),
1550
+ }
1551
+
1552
+ def viewer_projection(
1553
+ self, *, viewer_scope: str, limit: int = 50,
1554
+ ) -> Dict[str, Any]:
1555
+ """Compatibility projection for an authenticated owner/deck viewer."""
1556
+
1557
+ records = self.store.projected_for_viewer(
1558
+ viewer_scope=viewer_scope, limit=limit,
1559
+ )
1560
+ start = 0.0
1561
+ resolved = self.store.resolved_for_viewer_since(
1562
+ start, viewer_scope=viewer_scope,
1563
+ )
1564
+ calibration = self._calibration_report_for_rows(resolved)
1565
+ return {
1566
+ "mode": expectations_mode(),
1567
+ "viewer_scope": viewer_scope,
1568
+ "pending": [p.public() for p in records if p.outcome == "pending"],
1569
+ "resolved": [p.public() for p in records if p.outcome != "pending"],
1570
+ "calibration": calibration,
1571
+ }
1572
+
1573
+ def snapshot(
1574
+ self,
1575
+ limit: int = 50,
1576
+ *,
1577
+ subject_person_id: Optional[str] = None,
1578
+ viewer_scope: str = "owner",
1579
+ ) -> Dict[str, Any]:
1580
+ # Legacy endpoint shape retained while its read is now scope-filtered.
1581
+ projection = (
1582
+ self.observer_projection(
1583
+ subject_person_id=subject_person_id,
1584
+ viewer_scope=viewer_scope,
1585
+ limit=limit,
1586
+ )
1587
+ if subject_person_id is not None
1588
+ else self.viewer_projection(viewer_scope=viewer_scope, limit=limit)
1589
+ )
1590
+ return {
1591
+ "mode": projection["mode"],
1592
+ "pending": projection["pending"],
1593
+ "calibration": projection["calibration"]["domains"],
1594
+ "calibration_report": projection["calibration"],
1595
+ }