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,1150 @@
1
+ """Receipt-attributed controlled experiments for adaptive parameters.
2
+
3
+ P4 replaces the historical "move a global knob, then compare a later weekly
4
+ rollup" heuristic with an inspectable chain:
5
+
6
+ ``proposal -> bounded authority -> exposure assignment -> receipt outcome ->
7
+ minimum-sample/power gate -> explicit causal status -> adopt or revert``
8
+
9
+ ``COLONY_COGNITION_P4_MODE`` defaults to ``off``. In ``shadow`` the complete
10
+ evidence path runs but no adaptive parameter is changed. In ``live`` every
11
+ behavioral variant must fit a configured reversible pregrant or consume the
12
+ existing bounded owner-approval authority. The old global-rollup evaluator is
13
+ retained only while the mode is ``off`` for rollback compatibility.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ import math
22
+ import os
23
+ import sqlite3
24
+ import threading
25
+ import time
26
+ import uuid
27
+ from statistics import NormalDist
28
+ from typing import Any, Dict, List, Mapping, Optional, Tuple
29
+
30
+ from apsimo.self_model.benchmark import cognition_p4_mode
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ STATUSES = (
35
+ "proposed", "starting", "running", "completed", "adopted", "reverted",
36
+ "aborted",
37
+ )
38
+ CAUSAL_STATUSES = ("observed", "suggestive", "supported")
39
+ OWNER_REACTIONS = ("", "positive", "neutral", "negative")
40
+
41
+
42
+ def experiments_enabled() -> bool:
43
+ return os.environ.get(
44
+ "COLONY_EXPERIMENTS_ENABLED", "true").strip().lower() != "false"
45
+
46
+
47
+ def _max_running() -> int:
48
+ try:
49
+ return max(1, min(10, int(os.environ.get(
50
+ "COLONY_EXPERIMENTS_MAX_RUNNING", "2"))))
51
+ except ValueError:
52
+ return 2
53
+
54
+
55
+ def experiment_pregrants_from_env() -> Dict[str, Tuple[float, float]]:
56
+ """Load exact reversible ranges; an absent/invalid policy grants nothing."""
57
+
58
+ raw = os.environ.get("COLONY_EXPERIMENT_PREGRANTS_JSON", "").strip()
59
+ if not raw:
60
+ return {}
61
+ try:
62
+ decoded = json.loads(raw)
63
+ except (TypeError, ValueError) as exc:
64
+ raise ValueError("COLONY_EXPERIMENT_PREGRANTS_JSON is invalid JSON") from exc
65
+ if not isinstance(decoded, dict) or len(decoded) > 100:
66
+ raise ValueError("experiment pregrants must be a small JSON object")
67
+ result: Dict[str, Tuple[float, float]] = {}
68
+ for ref, bounds in decoded.items():
69
+ if not isinstance(ref, str) or not ref.strip() or len(ref) > 192:
70
+ raise ValueError("experiment pregrant contains an invalid parameter")
71
+ if not isinstance(bounds, list) or len(bounds) != 2:
72
+ raise ValueError("experiment pregrant range must be [lo, hi]")
73
+ lo = _finite(bounds[0], field="pregrant lo")
74
+ hi = _finite(bounds[1], field="pregrant hi")
75
+ result[ref.strip()] = (min(lo, hi), max(lo, hi))
76
+ return result
77
+
78
+
79
+ def _canonical(value: Any) -> str:
80
+ return json.dumps(value, sort_keys=True, separators=(",", ":"),
81
+ ensure_ascii=True)
82
+
83
+
84
+ def _digest(value: Any) -> str:
85
+ return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
86
+
87
+
88
+ def _finite(value: Any, *, field: str) -> float:
89
+ try:
90
+ number = float(value)
91
+ except (TypeError, ValueError) as exc:
92
+ raise ValueError(f"{field} must be numeric") from exc
93
+ if not math.isfinite(number):
94
+ raise ValueError(f"{field} must be finite")
95
+ return number
96
+
97
+
98
+ class ExperimentApprovalRequired(ValueError):
99
+ """A proposal is durable but cannot start until its exact digest wins."""
100
+
101
+ def __init__(self, experiment: Dict[str, Any]) -> None:
102
+ self.experiment = experiment
103
+ request_id = experiment.get("approval_request_id") or "unknown"
104
+ super().__init__(f"bounded owner approval required ({request_id})")
105
+
106
+
107
+ class ExperimentStore:
108
+ """Additive SQLite ledger for experiments, exposures, and outcomes."""
109
+
110
+ _EXPERIMENT_ADDITIONS = {
111
+ "metric_version": "TEXT",
112
+ "execution_mode": "TEXT",
113
+ "assignment_mode": "TEXT",
114
+ "control_ratio": "REAL",
115
+ "min_control_samples": "INTEGER",
116
+ "min_variant_samples": "INTEGER",
117
+ "min_total_samples": "INTEGER",
118
+ "min_power": "REAL",
119
+ "min_effect": "REAL",
120
+ "owner_negative_limit": "INTEGER",
121
+ "causal_status": "TEXT",
122
+ "authority_mode": "TEXT",
123
+ "approval_request_id": "TEXT",
124
+ "action_digest": "TEXT",
125
+ "mutation_applied": "INTEGER DEFAULT 0",
126
+ "source_ref": "TEXT",
127
+ "evidence_summary": "TEXT",
128
+ }
129
+
130
+ def __init__(self, db_path: str) -> None:
131
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
132
+ self._conn.row_factory = sqlite3.Row
133
+ self._conn.execute("PRAGMA foreign_keys=ON")
134
+ self._conn.execute("PRAGMA journal_mode=WAL")
135
+ self._lock = threading.RLock()
136
+ self._conn.executescript(
137
+ """
138
+ CREATE TABLE IF NOT EXISTS experiments (
139
+ id TEXT PRIMARY KEY,
140
+ hypothesis TEXT NOT NULL,
141
+ kind TEXT NOT NULL,
142
+ ref TEXT NOT NULL,
143
+ variant REAL NOT NULL,
144
+ baseline_param REAL,
145
+ metric TEXT NOT NULL,
146
+ baseline_metric REAL,
147
+ baseline_week TEXT,
148
+ max_regression REAL NOT NULL,
149
+ window_days INTEGER NOT NULL,
150
+ status TEXT NOT NULL DEFAULT 'proposed',
151
+ created_at REAL NOT NULL,
152
+ started_at REAL,
153
+ ends_at REAL,
154
+ decided_at REAL,
155
+ decision_reason TEXT,
156
+ source TEXT
157
+ );
158
+ CREATE INDEX IF NOT EXISTS idx_exp_status ON experiments(status);
159
+ CREATE TABLE IF NOT EXISTS experiment_exposures (
160
+ exposure_id TEXT PRIMARY KEY,
161
+ experiment_id TEXT NOT NULL,
162
+ unit_hash TEXT NOT NULL,
163
+ cohort TEXT NOT NULL,
164
+ selected_value REAL NOT NULL,
165
+ sample_principal TEXT NOT NULL,
166
+ source_ref TEXT NOT NULL,
167
+ receipt_ref TEXT,
168
+ exposed_at REAL NOT NULL,
169
+ exposure_digest TEXT NOT NULL,
170
+ UNIQUE(experiment_id, unit_hash),
171
+ FOREIGN KEY(experiment_id) REFERENCES experiments(id)
172
+ );
173
+ CREATE INDEX IF NOT EXISTS idx_exposure_exp_cohort
174
+ ON experiment_exposures(experiment_id,cohort,exposed_at);
175
+ CREATE TABLE IF NOT EXISTS experiment_outcomes (
176
+ outcome_id TEXT PRIMARY KEY,
177
+ experiment_id TEXT NOT NULL,
178
+ exposure_id TEXT NOT NULL,
179
+ metric TEXT NOT NULL,
180
+ metric_version TEXT NOT NULL,
181
+ value REAL NOT NULL,
182
+ sample_principal TEXT NOT NULL,
183
+ source_ref TEXT NOT NULL,
184
+ receipt_ref TEXT NOT NULL,
185
+ owner_reaction TEXT,
186
+ recorded_at REAL NOT NULL,
187
+ outcome_digest TEXT NOT NULL,
188
+ UNIQUE(experiment_id, exposure_id),
189
+ FOREIGN KEY(experiment_id) REFERENCES experiments(id),
190
+ FOREIGN KEY(exposure_id) REFERENCES experiment_exposures(exposure_id)
191
+ );
192
+ CREATE INDEX IF NOT EXISTS idx_outcome_exp
193
+ ON experiment_outcomes(experiment_id,recorded_at);
194
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_outcome_receipt_once
195
+ ON experiment_outcomes(experiment_id,receipt_ref);
196
+ CREATE TABLE IF NOT EXISTS experiment_mutations (
197
+ mutation_id TEXT PRIMARY KEY,
198
+ experiment_id TEXT NOT NULL,
199
+ ref TEXT NOT NULL,
200
+ prior_value REAL,
201
+ requested_value REAL NOT NULL,
202
+ applied_value REAL,
203
+ operation TEXT NOT NULL,
204
+ authority_mode TEXT NOT NULL,
205
+ created_at REAL NOT NULL,
206
+ mutation_digest TEXT NOT NULL,
207
+ FOREIGN KEY(experiment_id) REFERENCES experiments(id)
208
+ );
209
+ CREATE TRIGGER IF NOT EXISTS experiment_exposure_no_update
210
+ BEFORE UPDATE ON experiment_exposures BEGIN
211
+ SELECT RAISE(ABORT, 'experiment exposure is immutable');
212
+ END;
213
+ CREATE TRIGGER IF NOT EXISTS experiment_exposure_no_delete
214
+ BEFORE DELETE ON experiment_exposures BEGIN
215
+ SELECT RAISE(ABORT, 'experiment exposure is immutable');
216
+ END;
217
+ CREATE TRIGGER IF NOT EXISTS experiment_outcome_no_update
218
+ BEFORE UPDATE ON experiment_outcomes BEGIN
219
+ SELECT RAISE(ABORT, 'experiment outcome is immutable');
220
+ END;
221
+ CREATE TRIGGER IF NOT EXISTS experiment_outcome_no_delete
222
+ BEFORE DELETE ON experiment_outcomes BEGIN
223
+ SELECT RAISE(ABORT, 'experiment outcome is immutable');
224
+ END;
225
+ CREATE TRIGGER IF NOT EXISTS experiment_mutation_no_update
226
+ BEFORE UPDATE ON experiment_mutations BEGIN
227
+ SELECT RAISE(ABORT, 'experiment mutation is immutable');
228
+ END;
229
+ CREATE TRIGGER IF NOT EXISTS experiment_mutation_no_delete
230
+ BEFORE DELETE ON experiment_mutations BEGIN
231
+ SELECT RAISE(ABORT, 'experiment mutation is immutable');
232
+ END;
233
+ """
234
+ )
235
+ columns = {str(row[1]) for row in self._conn.execute(
236
+ "PRAGMA table_info(experiments)").fetchall()}
237
+ for name, sql_type in self._EXPERIMENT_ADDITIONS.items():
238
+ if name not in columns:
239
+ self._conn.execute(
240
+ f"ALTER TABLE experiments ADD COLUMN {name} {sql_type}")
241
+ self._conn.commit()
242
+
243
+ @staticmethod
244
+ def _decode(row: sqlite3.Row | None) -> Optional[Dict[str, Any]]:
245
+ if row is None:
246
+ return None
247
+ result = dict(row)
248
+ if result.get("evidence_summary"):
249
+ try:
250
+ result["evidence_summary"] = json.loads(
251
+ result["evidence_summary"])
252
+ except ValueError:
253
+ pass
254
+ return result
255
+
256
+ def add(self, row: Dict[str, Any]) -> None:
257
+ columns = ",".join(row.keys())
258
+ placeholders = ",".join("?" for _ in row)
259
+ with self._lock:
260
+ self._conn.execute(
261
+ f"INSERT INTO experiments ({columns}) VALUES ({placeholders})",
262
+ list(row.values()))
263
+ self._conn.commit()
264
+
265
+ def update(self, exp_id: str, **fields: Any) -> None:
266
+ if not fields:
267
+ return
268
+ encoded = dict(fields)
269
+ if isinstance(encoded.get("evidence_summary"), (dict, list)):
270
+ encoded["evidence_summary"] = _canonical(
271
+ encoded["evidence_summary"])
272
+ sets = ",".join(f"{key}=?" for key in encoded)
273
+ with self._lock:
274
+ self._conn.execute(
275
+ f"UPDATE experiments SET {sets} WHERE id=?",
276
+ [*encoded.values(), exp_id])
277
+ self._conn.commit()
278
+
279
+ def get(self, exp_id: str) -> Optional[Dict[str, Any]]:
280
+ with self._lock:
281
+ row = self._conn.execute(
282
+ "SELECT * FROM experiments WHERE id=?", (exp_id,)).fetchone()
283
+ return self._decode(row)
284
+
285
+ def list(self, status: Optional[str] = None,
286
+ limit: int = 50) -> List[Dict[str, Any]]:
287
+ query = "SELECT * FROM experiments"
288
+ params: List[Any] = []
289
+ if status:
290
+ query += " WHERE status=?"
291
+ params.append(status)
292
+ query += " ORDER BY created_at DESC LIMIT ?"
293
+ params.append(max(1, min(int(limit), 1000)))
294
+ with self._lock:
295
+ rows = self._conn.execute(query, params).fetchall()
296
+ return [self._decode(row) or {} for row in rows]
297
+
298
+ def running_for_ref(self, ref: str) -> Optional[Dict[str, Any]]:
299
+ with self._lock:
300
+ row = self._conn.execute(
301
+ "SELECT * FROM experiments WHERE ref=? AND status IN "
302
+ "('proposed','starting','running') ORDER BY created_at DESC LIMIT 1",
303
+ (ref,),
304
+ ).fetchone()
305
+ return self._decode(row)
306
+
307
+ def running_count(self) -> int:
308
+ with self._lock:
309
+ row = self._conn.execute(
310
+ "SELECT COUNT(*) AS n FROM experiments WHERE status IN "
311
+ "('starting','running')"
312
+ ).fetchone()
313
+ return int(row["n"])
314
+
315
+ def add_exposure(self, row: Dict[str, Any]) -> Dict[str, Any]:
316
+ with self._lock:
317
+ existing = self._conn.execute(
318
+ "SELECT * FROM experiment_exposures WHERE experiment_id=? "
319
+ "AND unit_hash=?", (row["experiment_id"], row["unit_hash"])
320
+ ).fetchone()
321
+ if existing is not None:
322
+ stable = (
323
+ "experiment_id", "unit_hash", "cohort", "selected_value",
324
+ "sample_principal", "source_ref", "receipt_ref",
325
+ )
326
+ if any(existing[key] != row[key] for key in stable):
327
+ raise ValueError("exposure retry changed immutable assignment")
328
+ return dict(existing)
329
+ columns = ",".join(row.keys())
330
+ placeholders = ",".join("?" for _ in row)
331
+ self._conn.execute(
332
+ f"INSERT INTO experiment_exposures ({columns}) "
333
+ f"VALUES ({placeholders})", list(row.values()))
334
+ self._conn.commit()
335
+ saved = self._conn.execute(
336
+ "SELECT * FROM experiment_exposures WHERE exposure_id=?",
337
+ (row["exposure_id"],)).fetchone()
338
+ assert saved is not None
339
+ return dict(saved)
340
+
341
+ def exposure(self, exposure_id: str) -> Optional[Dict[str, Any]]:
342
+ with self._lock:
343
+ row = self._conn.execute(
344
+ "SELECT * FROM experiment_exposures WHERE exposure_id=?",
345
+ (exposure_id,),).fetchone()
346
+ return dict(row) if row else None
347
+
348
+ def exposure_for_unit(self, experiment_id: str,
349
+ unit_hash: str) -> Optional[Dict[str, Any]]:
350
+ with self._lock:
351
+ row = self._conn.execute(
352
+ "SELECT * FROM experiment_exposures WHERE experiment_id=? "
353
+ "AND unit_hash=?", (experiment_id, unit_hash)).fetchone()
354
+ return dict(row) if row else None
355
+
356
+ def exposures(self, experiment_id: str) -> List[Dict[str, Any]]:
357
+ with self._lock:
358
+ rows = self._conn.execute(
359
+ "SELECT * FROM experiment_exposures WHERE experiment_id=? "
360
+ "ORDER BY exposed_at,exposure_id", (experiment_id,)).fetchall()
361
+ return [dict(row) for row in rows]
362
+
363
+ def add_outcome(self, row: Dict[str, Any]) -> Dict[str, Any]:
364
+ with self._lock:
365
+ existing = self._conn.execute(
366
+ "SELECT * FROM experiment_outcomes WHERE experiment_id=? "
367
+ "AND exposure_id=?",
368
+ (row["experiment_id"], row["exposure_id"]),
369
+ ).fetchone()
370
+ if existing is not None:
371
+ stable = (
372
+ "experiment_id", "exposure_id", "metric",
373
+ "metric_version", "value", "sample_principal",
374
+ "source_ref", "receipt_ref", "owner_reaction",
375
+ )
376
+ if any(existing[key] != row[key] for key in stable):
377
+ raise ValueError("outcome retry changed immutable evidence")
378
+ return dict(existing)
379
+ reused = self._conn.execute(
380
+ "SELECT exposure_id FROM experiment_outcomes "
381
+ "WHERE experiment_id=? AND receipt_ref=?",
382
+ (row["experiment_id"], row["receipt_ref"]),
383
+ ).fetchone()
384
+ if reused is not None:
385
+ raise ValueError(
386
+ "one verifier receipt cannot judge multiple exposures")
387
+ columns = ",".join(row.keys())
388
+ placeholders = ",".join("?" for _ in row)
389
+ self._conn.execute(
390
+ f"INSERT INTO experiment_outcomes ({columns}) "
391
+ f"VALUES ({placeholders})", list(row.values()))
392
+ self._conn.commit()
393
+ saved = self._conn.execute(
394
+ "SELECT * FROM experiment_outcomes WHERE outcome_id=?",
395
+ (row["outcome_id"],)).fetchone()
396
+ assert saved is not None
397
+ return dict(saved)
398
+
399
+ def outcomes(self, experiment_id: str) -> List[Dict[str, Any]]:
400
+ with self._lock:
401
+ rows = self._conn.execute(
402
+ "SELECT o.*,e.cohort,e.selected_value "
403
+ "FROM experiment_outcomes o JOIN experiment_exposures e "
404
+ "ON e.exposure_id=o.exposure_id WHERE o.experiment_id=? "
405
+ "ORDER BY o.recorded_at,o.outcome_id", (experiment_id,)
406
+ ).fetchall()
407
+ return [dict(row) for row in rows]
408
+
409
+ def outcome_for_exposure(self, experiment_id: str,
410
+ exposure_id: str) -> Optional[Dict[str, Any]]:
411
+ with self._lock:
412
+ row = self._conn.execute(
413
+ "SELECT * FROM experiment_outcomes WHERE experiment_id=? "
414
+ "AND exposure_id=?", (experiment_id, exposure_id)).fetchone()
415
+ return dict(row) if row else None
416
+
417
+ def add_mutation(self, row: Dict[str, Any]) -> None:
418
+ columns = ",".join(row.keys())
419
+ placeholders = ",".join("?" for _ in row)
420
+ with self._lock:
421
+ self._conn.execute(
422
+ f"INSERT INTO experiment_mutations ({columns}) "
423
+ f"VALUES ({placeholders})", list(row.values()))
424
+ self._conn.commit()
425
+
426
+ def mutations(self, experiment_id: str) -> List[Dict[str, Any]]:
427
+ with self._lock:
428
+ rows = self._conn.execute(
429
+ "SELECT * FROM experiment_mutations WHERE experiment_id=? "
430
+ "ORDER BY created_at,mutation_id", (experiment_id,)).fetchall()
431
+ return [dict(row) for row in rows]
432
+
433
+
434
+ class ExperimentEngine:
435
+ """The sole adaptive-parameter writer and causal evidence reducer."""
436
+
437
+ def __init__(
438
+ self,
439
+ store: ExperimentStore,
440
+ *,
441
+ params: Any = None,
442
+ benchmark: Any = None,
443
+ journal: Any = None,
444
+ approval_authority: Any = None,
445
+ pregranted_ranges: Optional[Mapping[str, Tuple[float, float]]] = None,
446
+ ) -> None:
447
+ self.store = store
448
+ self._params = params
449
+ self._benchmark = benchmark
450
+ self._journal = journal
451
+ self._approval_authority = approval_authority
452
+ self._pregrants = self._normalize_pregrants(pregranted_ranges or {})
453
+ self._writer_token = None
454
+ if params is not None and hasattr(params, "claim_experiment_writer"):
455
+ self._writer_token = params.claim_experiment_writer(
456
+ f"ExperimentEngine:{id(self)}")
457
+ self._recover_incomplete_starts()
458
+
459
+ @staticmethod
460
+ def _normalize_pregrants(raw: Mapping[str, Tuple[float, float]]
461
+ ) -> Dict[str, Tuple[float, float]]:
462
+ result: Dict[str, Tuple[float, float]] = {}
463
+ for ref, bounds in raw.items():
464
+ if not isinstance(bounds, (tuple, list)) or len(bounds) != 2:
465
+ raise ValueError("experiment pregrant bounds must be [lo, hi]")
466
+ lo, hi = _finite(bounds[0], field="pregrant lo"), _finite(
467
+ bounds[1], field="pregrant hi")
468
+ result[str(ref)] = (min(lo, hi), max(lo, hi))
469
+ return result
470
+
471
+ def _p(self) -> Any:
472
+ if self._params is not None:
473
+ return self._params
474
+ try:
475
+ from apsimo.api.routers import host
476
+ return getattr(host, "_adaptive_params", None)
477
+ except Exception:
478
+ return None
479
+
480
+ def _b(self) -> Any:
481
+ if self._benchmark is not None:
482
+ return self._benchmark
483
+ try:
484
+ from apsimo.api.routers import host
485
+ return getattr(host, "_benchmark", None)
486
+ except Exception:
487
+ return None
488
+
489
+ def _j(self) -> Any:
490
+ if self._journal is not None:
491
+ return self._journal
492
+ try:
493
+ from apsimo.api.routers import host
494
+ model = getattr(host, "_self_model", None)
495
+ return getattr(model, "journal", None) if model is not None else None
496
+ except Exception:
497
+ return None
498
+
499
+ def _log(self, description: str, *, reasoning: str = "",
500
+ outcome: str = "", ref: str = "") -> None:
501
+ journal = self._j()
502
+ if journal is None:
503
+ return
504
+ try:
505
+ journal.record(
506
+ "meta_learning", description, reasoning=reasoning,
507
+ decision="acted", outcome=outcome, ref=ref)
508
+ except Exception:
509
+ logger.debug("experiment journal write failed", exc_info=True)
510
+
511
+ def _known_param(self, ref: str) -> Tuple[Any, Dict[str, Any]]:
512
+ params = self._p()
513
+ if params is None:
514
+ raise ValueError("adaptive params store not wired")
515
+ known = {row["name"]: row for row in params.snapshot()}
516
+ if ref not in known:
517
+ raise ValueError(f"unknown adaptive param {ref!r}")
518
+ return params, known[ref]
519
+
520
+ def propose(
521
+ self,
522
+ *,
523
+ hypothesis: str,
524
+ ref: str,
525
+ variant: float,
526
+ metric: str,
527
+ max_regression: float = 0.05,
528
+ window_days: int = 7,
529
+ kind: str = "param",
530
+ source: str = "api",
531
+ metric_version: str = "",
532
+ assignment_mode: str = "",
533
+ control_ratio: float = 0.5,
534
+ min_control_samples: int = 20,
535
+ min_variant_samples: int = 20,
536
+ min_total_samples: int = 40,
537
+ min_power: float = 0.8,
538
+ min_effect: float = 0.0,
539
+ owner_negative_limit: int = 1,
540
+ ) -> Dict[str, Any]:
541
+ if not experiments_enabled():
542
+ raise ValueError("experiments disabled "
543
+ "(COLONY_EXPERIMENTS_ENABLED=false)")
544
+ if kind != "param":
545
+ raise ValueError(f"kind {kind!r} not runnable (param only)")
546
+ normalized_ref = (ref or "").strip()
547
+ normalized_metric = (metric or "").strip().lower()
548
+ params, param = self._known_param(normalized_ref)
549
+ if self.store.running_for_ref(normalized_ref) is not None:
550
+ raise ValueError(f"an experiment on {normalized_ref!r} is already open")
551
+ if self.store.running_count() >= _max_running():
552
+ raise ValueError(f"running-experiment cap reached ({_max_running()})")
553
+
554
+ mode = cognition_p4_mode()
555
+ bench = self._b()
556
+ if bench is None:
557
+ raise ValueError("benchmark not wired (no evidence judge)")
558
+ baseline_week: Optional[str] = None
559
+ baseline_metric: Optional[float] = None
560
+ version = (metric_version or "").strip().lower()
561
+ if mode == "off":
562
+ for week in sorted(bench.store.rollups(weeks=4), reverse=True):
563
+ row = bench.store.rollups(weeks=4)[week].get(normalized_metric)
564
+ if row and row.get("value") is not None:
565
+ baseline_week = week
566
+ baseline_metric = float(row["value"])
567
+ break
568
+ if baseline_metric is None:
569
+ raise ValueError(
570
+ f"no rollup exists for metric {normalized_metric!r}; "
571
+ "a baseline is required before experimenting")
572
+ assignment = "global"
573
+ version = version or "legacy.unversioned"
574
+ else:
575
+ if not version or bench.store.definition(
576
+ normalized_metric, version) is None:
577
+ raise ValueError("registered metric definition/version is required")
578
+ assignment = (assignment_mode or "cohort").strip().lower()
579
+ if assignment not in {"cohort", "global"}:
580
+ raise ValueError("assignment_mode must be cohort or global")
581
+
582
+ variant_value = _finite(variant, field="variant")
583
+ max_reg = _finite(max_regression, field="max_regression")
584
+ if max_reg < 0:
585
+ raise ValueError("max_regression must be non-negative")
586
+ ratio = _finite(control_ratio, field="control_ratio")
587
+ if not 0.05 <= ratio <= 0.95:
588
+ raise ValueError("control_ratio must be between 0.05 and 0.95")
589
+ power = _finite(min_power, field="min_power")
590
+ if not 0.0 <= power <= 1.0:
591
+ raise ValueError("min_power must be between 0 and 1")
592
+ effect = _finite(min_effect, field="min_effect")
593
+ if effect < 0:
594
+ raise ValueError("min_effect must be non-negative")
595
+ control_n = max(1, min(100000, int(min_control_samples)))
596
+ variant_n = max(1, min(100000, int(min_variant_samples)))
597
+ total_n = max(control_n + variant_n,
598
+ min(200000, int(min_total_samples)))
599
+ negative_limit = max(1, min(1000, int(owner_negative_limit)))
600
+ duration = max(1, min(28, int(window_days)))
601
+
602
+ exp_id = f"exp-{uuid.uuid4().hex[:12]}"
603
+ now = time.time()
604
+ self.store.add({
605
+ "id": exp_id,
606
+ "hypothesis": (hypothesis or "")[:500],
607
+ "kind": kind,
608
+ "ref": normalized_ref,
609
+ "variant": variant_value,
610
+ "baseline_param": float(param["effective"]),
611
+ "metric": normalized_metric,
612
+ "metric_version": version,
613
+ "baseline_metric": baseline_metric,
614
+ "baseline_week": baseline_week,
615
+ "max_regression": max_reg,
616
+ "window_days": duration,
617
+ "status": "proposed",
618
+ "created_at": now,
619
+ "source": (source or "unknown")[:128],
620
+ "source_ref": (source or "unknown")[:512],
621
+ "execution_mode": mode,
622
+ "assignment_mode": assignment,
623
+ "control_ratio": ratio,
624
+ "min_control_samples": control_n,
625
+ "min_variant_samples": variant_n,
626
+ "min_total_samples": total_n,
627
+ "min_power": power,
628
+ "min_effect": effect,
629
+ "owner_negative_limit": negative_limit,
630
+ "causal_status": "observed",
631
+ "authority_mode": "pending",
632
+ "mutation_applied": 0,
633
+ })
634
+ return self.store.get(exp_id) or {}
635
+
636
+ def propose_and_start(self, **kwargs: Any) -> Dict[str, Any]:
637
+ proposal = self.propose(**kwargs)
638
+ return self.start(proposal["id"])
639
+
640
+ def _binding(self, exp: Mapping[str, Any]) -> Any:
641
+ from apsimo.initiatives.approval_authority import ActionBinding
642
+
643
+ constraints = {
644
+ name: _digest(exp.get(name)) for name in (
645
+ "ref", "variant", "metric", "metric_version",
646
+ "max_regression", "window_days", "assignment_mode",
647
+ )
648
+ }
649
+ scope = {
650
+ "version": 1,
651
+ "job_type": "cognition_experiment",
652
+ "action_name": "cognition_experiment_mutation",
653
+ "risk": "mutating",
654
+ "constraints": constraints,
655
+ }
656
+ action = {
657
+ "version": 1,
658
+ "experiment_id": exp["id"],
659
+ "hypothesis": exp["hypothesis"],
660
+ "ref": exp["ref"],
661
+ "baseline_param": exp["baseline_param"],
662
+ "variant": exp["variant"],
663
+ "metric": exp["metric"],
664
+ "metric_version": exp.get("metric_version"),
665
+ "max_regression": exp["max_regression"],
666
+ "window_days": exp["window_days"],
667
+ "assignment_mode": exp.get("assignment_mode"),
668
+ }
669
+ return ActionBinding(
670
+ action_digest=_digest(action), scope=scope,
671
+ scope_digest=_digest(scope))
672
+
673
+ def _authorize(self, exp: Dict[str, Any]) -> str:
674
+ mode = exp.get("execution_mode") or cognition_p4_mode()
675
+ if mode == "off":
676
+ return "legacy_compatibility"
677
+ if mode == "shadow":
678
+ return "shadow_no_effect"
679
+ bounds = self._pregrants.get(str(exp["ref"]))
680
+ if bounds and bounds[0] <= float(exp["variant"]) <= bounds[1]:
681
+ return "bounded_pregrant"
682
+ if self._approval_authority is None:
683
+ raise ValueError(
684
+ "bounded approval authority is required for a live mutation")
685
+ binding = self._binding(exp)
686
+ self.store.update(exp["id"], action_digest=binding.action_digest)
687
+ if exp.get("approval_request_id"):
688
+ request = self._approval_authority.get_request(
689
+ exp["approval_request_id"])
690
+ if (request and request.get("status") == "approved"
691
+ and request.get("action_digest") == binding.action_digest):
692
+ return "owner_approved"
693
+ grant = self._approval_authority.consume_grant(
694
+ binding=binding, operation_id=exp["id"])
695
+ if grant is not None:
696
+ return "bounded_grant"
697
+ request = self._approval_authority.ensure_request(
698
+ job_id=exp["id"], binding=binding)
699
+ self.store.update(
700
+ exp["id"], approval_request_id=request["request_id"],
701
+ action_digest=binding.action_digest)
702
+ raise ExperimentApprovalRequired(self.store.get(exp["id"]) or exp)
703
+
704
+ def _set_param(self, exp: Mapping[str, Any], value: float,
705
+ *, operation: str, authority_mode: str) -> float:
706
+ params = self._p()
707
+ if params is None:
708
+ raise ValueError("adaptive params store not wired")
709
+ prior_value = self._current_param(exp["ref"])
710
+ kwargs = {
711
+ "reason": f"experiment {exp['id']} {operation}",
712
+ "source": f"experiment:{exp['id']}",
713
+ }
714
+ if self._writer_token is not None:
715
+ kwargs["writer_token"] = self._writer_token
716
+ applied = params.set(exp["ref"], float(value), **kwargs)
717
+ if applied is None:
718
+ raise ValueError(f"param store refused {exp['ref']!r}")
719
+ mutation = {
720
+ "experiment_id": exp["id"], "ref": exp["ref"],
721
+ "prior_value": prior_value,
722
+ "requested_value": float(value), "applied_value": float(applied),
723
+ "operation": operation, "authority_mode": authority_mode,
724
+ "created_at": time.time(),
725
+ }
726
+ mutation["mutation_digest"] = _digest(mutation)
727
+ mutation["mutation_id"] = "mut-" + mutation["mutation_digest"][:24]
728
+ self.store.add_mutation(mutation)
729
+ return float(applied)
730
+
731
+ def _current_param(self, ref: str) -> Optional[float]:
732
+ params = self._p()
733
+ if params is None:
734
+ return None
735
+ for row in params.snapshot():
736
+ if row["name"] == ref:
737
+ return float(row["effective"])
738
+ return None
739
+
740
+ def _recover_incomplete_starts(self) -> None:
741
+ """Fail safe across a crash between mutation intent and running state."""
742
+
743
+ for exp in self.store.list(status="starting", limit=100):
744
+ current = self._current_param(exp["ref"])
745
+ if current is None:
746
+ raise RuntimeError(
747
+ f"cannot recover incomplete experiment {exp['id']}: "
748
+ "adaptive parameter is unavailable")
749
+ reason = "recovered incomplete experiment start"
750
+ if (current is not None and exp.get("baseline_param") is not None
751
+ and abs(current - float(exp["variant"])) <= 1e-9):
752
+ try:
753
+ self._set_param(
754
+ exp, float(exp["baseline_param"]),
755
+ operation="recover_incomplete_start",
756
+ authority_mode=exp.get("authority_mode") or "recovery")
757
+ reason += "; restored baseline"
758
+ except Exception as exc:
759
+ # Keep the starting record visible and fail construction;
760
+ # silently booting with an untracked variant is unsafe.
761
+ raise RuntimeError(
762
+ f"could not restore incomplete experiment {exp['id']}: "
763
+ f"{exc}") from exc
764
+ elif (current is not None and exp.get("baseline_param") is not None
765
+ and abs(current - float(exp["baseline_param"])) > 1e-9):
766
+ reason += "; parameter was superseded and was not overwritten"
767
+ self.store.update(
768
+ exp["id"], status="aborted", decided_at=time.time(),
769
+ decision_reason=reason, mutation_applied=0,
770
+ causal_status="observed")
771
+
772
+ def start(self, exp_id: str) -> Dict[str, Any]:
773
+ exp = self.store.get(exp_id)
774
+ if not exp or exp["status"] != "proposed":
775
+ raise ValueError("experiment is not proposed")
776
+ current = self._current_param(exp["ref"])
777
+ if current is None:
778
+ raise ValueError("adaptive parameter disappeared")
779
+ if abs(current - float(exp["baseline_param"])) > 1e-9:
780
+ self.store.update(
781
+ exp_id, status="aborted", decided_at=time.time(),
782
+ decision_reason="superseded before start: parameter changed")
783
+ raise ValueError("parameter changed after proposal")
784
+ authority_mode = self._authorize(exp)
785
+ mutation_applied = 0
786
+ now = time.time()
787
+ self.store.update(
788
+ exp_id, status="starting", started_at=now,
789
+ ends_at=now + int(exp["window_days"]) * 86400,
790
+ authority_mode=authority_mode,
791
+ mutation_applied=0)
792
+ try:
793
+ if exp["execution_mode"] in {"off", "live"} and\
794
+ exp["assignment_mode"] == "global":
795
+ self._set_param(
796
+ exp, float(exp["variant"]), operation="apply_variant",
797
+ authority_mode=authority_mode)
798
+ mutation_applied = 1
799
+ except Exception:
800
+ self.store.update(
801
+ exp_id, status="aborted", decided_at=time.time(),
802
+ decision_reason="variant application failed", mutation_applied=0)
803
+ raise
804
+ self.store.update(
805
+ exp_id, status="running", mutation_applied=mutation_applied)
806
+ started = self.store.get(exp_id) or {}
807
+ self._log(
808
+ f"experiment {exp_id} started ({started['execution_mode']}, "
809
+ f"{started['assignment_mode']})",
810
+ reasoning=started["hypothesis"],
811
+ outcome=f"authority={authority_mode}", ref=exp_id)
812
+ return started
813
+
814
+ def abort(self, exp_id: str, reason: str = "manual abort") -> bool:
815
+ exp = self.store.get(exp_id)
816
+ if not exp or exp["status"] not in {"proposed", "starting", "running"}:
817
+ return False
818
+ self._revert(exp, status="aborted", reason=reason)
819
+ return True
820
+
821
+ def assign_exposure(
822
+ self,
823
+ exp_id: str,
824
+ *,
825
+ unit_id: str,
826
+ sample_principal: str,
827
+ source_ref: str,
828
+ receipt_ref: str = "",
829
+ exposed_at: Optional[float] = None,
830
+ ) -> Dict[str, Any]:
831
+ exp = self.store.get(exp_id)
832
+ if not exp:
833
+ raise ValueError("experiment was not found")
834
+ unit = (unit_id or "").strip()
835
+ principal = (sample_principal or "").strip()
836
+ source = (source_ref or "").strip()
837
+ if not unit or not principal or not source:
838
+ raise ValueError("unit_id, sample_principal, and source_ref are required")
839
+ unit_hash = hashlib.sha256(unit.encode("utf-8")).hexdigest()
840
+ if exp["assignment_mode"] == "global":
841
+ cohort = "variant"
842
+ else:
843
+ draw = int(hashlib.sha256(
844
+ f"{exp_id}:{unit_hash}".encode("utf-8")).hexdigest()[:13], 16)
845
+ fraction = draw / float(0xFFFFFFFFFFFFF)
846
+ cohort = "control" if fraction < float(exp["control_ratio"])\
847
+ else "variant"
848
+ selected = (float(exp["baseline_param"]) if cohort == "control"
849
+ else float(exp["variant"]))
850
+ payload = {
851
+ "experiment_id": exp_id, "unit_hash": unit_hash,
852
+ "cohort": cohort, "selected_value": selected,
853
+ "sample_principal": principal[:192], "source_ref": source[:512],
854
+ "receipt_ref": (receipt_ref or "")[:512] or None,
855
+ "exposed_at": float(exposed_at) if exposed_at is not None else time.time(),
856
+ }
857
+ payload["exposure_digest"] = _digest(payload)
858
+ payload["exposure_id"] = "xps-" + _digest(
859
+ {"experiment_id": exp_id, "unit_hash": unit_hash})[:24]
860
+ if exp["status"] != "running" and self.store.exposure_for_unit(
861
+ exp_id, unit_hash) is None:
862
+ raise ValueError("experiment is not running")
863
+ return self.store.add_exposure(payload)
864
+
865
+ def record_outcome(
866
+ self,
867
+ exp_id: str,
868
+ *,
869
+ exposure_id: str,
870
+ value: float,
871
+ sample_principal: str,
872
+ source_ref: str,
873
+ receipt_ref: str,
874
+ owner_reaction: str = "",
875
+ outcome_id: str = "",
876
+ recorded_at: Optional[float] = None,
877
+ ) -> Dict[str, Any]:
878
+ exp = self.store.get(exp_id)
879
+ if not exp:
880
+ raise ValueError("experiment was not found")
881
+ exposure = self.store.exposure(exposure_id)
882
+ if not exposure or exposure["experiment_id"] != exp_id:
883
+ raise ValueError("outcome must name an exposure from this experiment")
884
+ principal = (sample_principal or "").strip()
885
+ source = (source_ref or "").strip()
886
+ receipt = (receipt_ref or "").strip()
887
+ if not principal or not source or not receipt:
888
+ raise ValueError(
889
+ "sample_principal, source_ref, and receipt_ref are required")
890
+ reaction = (owner_reaction or "").strip().lower()
891
+ if reaction not in OWNER_REACTIONS:
892
+ raise ValueError("owner_reaction is invalid")
893
+ payload = {
894
+ "experiment_id": exp_id, "exposure_id": exposure_id,
895
+ "metric": exp["metric"],
896
+ "metric_version": exp.get("metric_version") or "legacy.unversioned",
897
+ "value": _finite(value, field="outcome value"),
898
+ "sample_principal": principal[:192], "source_ref": source[:512],
899
+ "receipt_ref": receipt[:512], "owner_reaction": reaction or None,
900
+ "recorded_at": (float(recorded_at) if recorded_at is not None
901
+ else time.time()),
902
+ }
903
+ payload["outcome_digest"] = _digest(payload)
904
+ payload["outcome_id"] = ((outcome_id or "").strip()
905
+ or "out-" + payload["outcome_digest"][:24])
906
+ if (exp["status"] != "running"
907
+ and self.store.outcome_for_exposure(
908
+ exp_id, exposure_id) is None):
909
+ raise ValueError("experiment is not running")
910
+ saved = self.store.add_outcome(payload)
911
+ if exp["status"] != "running":
912
+ return saved
913
+ negative = sum(
914
+ 1 for row in self.store.outcomes(exp_id)
915
+ if row.get("owner_reaction") == "negative")
916
+ if negative >= int(exp.get("owner_negative_limit") or 1):
917
+ self._revert(
918
+ exp, status="aborted",
919
+ reason=(f"owner-negative abort: {negative} receipt-linked "
920
+ "negative reaction(s)"))
921
+ return saved
922
+
923
+ def evidence(self, exp_id: str) -> Dict[str, Any]:
924
+ return {
925
+ "experiment": self.store.get(exp_id),
926
+ "exposures": self.store.exposures(exp_id),
927
+ "outcomes": self.store.outcomes(exp_id),
928
+ "mutations": self.store.mutations(exp_id),
929
+ }
930
+
931
+ @staticmethod
932
+ def _stats(outcomes: List[Dict[str, Any]]) -> Dict[str, Any]:
933
+ groups: Dict[str, List[float]] = {"control": [], "variant": []}
934
+ for row in outcomes:
935
+ cohort = row.get("cohort")
936
+ if cohort in groups:
937
+ groups[cohort].append(float(row["value"]))
938
+
939
+ def summarize(values: List[float]) -> Dict[str, float | int | None]:
940
+ if not values:
941
+ return {"n": 0, "mean": None, "variance": None}
942
+ mean = sum(values) / len(values)
943
+ variance = (sum((value - mean) ** 2 for value in values)
944
+ / (len(values) - 1)) if len(values) > 1 else 0.0
945
+ return {"n": len(values), "mean": mean, "variance": variance}
946
+
947
+ return {key: summarize(values) for key, values in groups.items()}
948
+
949
+ @staticmethod
950
+ def _power(control: Dict[str, Any], variant: Dict[str, Any],
951
+ effect: float) -> float:
952
+ n_control, n_variant = int(control["n"]), int(variant["n"])
953
+ if n_control < 2 or n_variant < 2:
954
+ return 0.0
955
+ variance = (float(control["variance"] or 0.0) / n_control
956
+ + float(variant["variance"] or 0.0) / n_variant)
957
+ if variance <= 1e-15:
958
+ return 1.0 if effect > 0 else 0.05
959
+ z_effect = effect / math.sqrt(variance)
960
+ return max(0.0, min(1.0, NormalDist().cdf(z_effect - 1.9599639845)))
961
+
962
+ def _directional_effect(self, exp: Dict[str, Any], stats: Dict[str, Any]
963
+ ) -> Tuple[Optional[float], str]:
964
+ control = stats["control"]["mean"]
965
+ variant = stats["variant"]["mean"]
966
+ if control is None or variant is None:
967
+ return None, "higher"
968
+ direction = "higher"
969
+ bench = self._b()
970
+ if bench is not None:
971
+ definition = bench.store.definition(
972
+ exp["metric"], exp.get("metric_version") or "")
973
+ if definition:
974
+ direction = definition["direction"]
975
+ raw = float(variant) - float(control)
976
+ return (raw if direction == "higher" else -raw), direction
977
+
978
+ def evaluate(self) -> List[Dict[str, Any]]:
979
+ legacy_decided = self._evaluate_legacy()
980
+ decided: List[Dict[str, Any]] = []
981
+ now = time.time()
982
+ for exp in self.store.list(status="running", limit=100):
983
+ if (exp.get("execution_mode") or "off") == "off":
984
+ continue
985
+ if exp.get("mutation_applied"):
986
+ current = self._current_param(exp["ref"])
987
+ if current is not None and abs(
988
+ current - float(exp["variant"])) > 1e-9:
989
+ self.store.update(
990
+ exp["id"], status="aborted", decided_at=now,
991
+ causal_status="observed",
992
+ decision_reason="superseded: parameter changed outside "
993
+ "ExperimentEngine")
994
+ decided.append(self.store.get(exp["id"]) or {})
995
+ continue
996
+
997
+ outcomes = self.store.outcomes(exp["id"])
998
+ negative = sum(
999
+ 1 for row in outcomes if row.get("owner_reaction") == "negative")
1000
+ if negative >= int(exp.get("owner_negative_limit") or 1):
1001
+ self._revert(
1002
+ exp, status="aborted",
1003
+ reason=f"owner-negative abort: {negative} reaction(s)")
1004
+ decided.append(self.store.get(exp["id"]) or {})
1005
+ continue
1006
+ stats = self._stats(outcomes)
1007
+ effect, direction = self._directional_effect(exp, stats)
1008
+ control_n = int(stats["control"]["n"])
1009
+ variant_n = int(stats["variant"]["n"])
1010
+ total = control_n + variant_n
1011
+ minimums_met = (
1012
+ control_n >= int(exp["min_control_samples"] or 1)
1013
+ and variant_n >= int(exp["min_variant_samples"] or 1)
1014
+ and total >= int(exp["min_total_samples"] or 2))
1015
+ power = self._power(stats["control"], stats["variant"],
1016
+ abs(effect or 0.0))
1017
+ causal = "observed"
1018
+ if effect is not None and control_n >= 2 and variant_n >= 2:
1019
+ causal = "suggestive"
1020
+ if (minimums_met and power >= float(exp["min_power"] or 0.0)
1021
+ and effect is not None
1022
+ and effect >= float(exp["min_effect"] or 0.0)):
1023
+ causal = "supported"
1024
+ summary = {
1025
+ "control": stats["control"], "variant": stats["variant"],
1026
+ "total": total, "direction": direction,
1027
+ "directional_effect": effect, "estimated_power": power,
1028
+ "minimums_met": minimums_met,
1029
+ }
1030
+ self.store.update(
1031
+ exp["id"], causal_status=causal, evidence_summary=summary)
1032
+
1033
+ # A sufficiently sampled regression is a safety decision and does
1034
+ # not wait for the nominal end date.
1035
+ if (minimums_met and effect is not None
1036
+ and effect < -float(exp["max_regression"])):
1037
+ self._revert(
1038
+ exp, status="reverted",
1039
+ reason=(f"receipt-linked variant regression "
1040
+ f"{abs(effect):.6f} exceeded guard "
1041
+ f"{float(exp['max_regression']):.6f}"))
1042
+ decided.append(self.store.get(exp["id"]) or {})
1043
+ continue
1044
+ if now < float(exp.get("ends_at") or 0):
1045
+ continue
1046
+ if exp.get("assignment_mode") != "cohort":
1047
+ self._revert(
1048
+ exp, status="reverted",
1049
+ reason="no control cohort; causal attribution unavailable")
1050
+ elif not minimums_met:
1051
+ self._revert(
1052
+ exp, status="reverted",
1053
+ reason=("minimum sample gate unmet: "
1054
+ f"control={control_n}, variant={variant_n}, total={total}"))
1055
+ elif power < float(exp["min_power"] or 0.0):
1056
+ self._revert(
1057
+ exp, status="reverted",
1058
+ reason=(f"minimum power gate unmet: {power:.4f} < "
1059
+ f"{float(exp['min_power']):.4f}"))
1060
+ else:
1061
+ reason = (f"controlled evidence complete; causal_status={causal}; "
1062
+ f"directional_effect={float(effect or 0.0):.6f}; "
1063
+ f"power={power:.4f}")
1064
+ self.store.update(
1065
+ exp["id"], status="completed", decided_at=now,
1066
+ decision_reason=reason, causal_status=causal)
1067
+ self._log(
1068
+ f"experiment {exp['id']} completed controlled evidence",
1069
+ outcome=reason, ref=exp["id"])
1070
+ decided.append(self.store.get(exp["id"]) or {})
1071
+ return [*legacy_decided, *decided]
1072
+
1073
+ def _evaluate_legacy(self) -> List[Dict[str, Any]]:
1074
+ decided: List[Dict[str, Any]] = []
1075
+ bench = self._b()
1076
+ for exp in self.store.list(status="running", limit=20):
1077
+ if (exp.get("execution_mode") or "off") != "off":
1078
+ continue
1079
+ current = self._current_param(exp["ref"])
1080
+ if current is not None and abs(current - float(exp["variant"])) > 1e-9:
1081
+ self.store.update(
1082
+ exp["id"], status="aborted", decided_at=time.time(),
1083
+ decision_reason="superseded: parameter changed outside the experiment")
1084
+ decided.append(self.store.get(exp["id"]) or {})
1085
+ continue
1086
+ if time.time() < float(exp.get("ends_at") or 0) or bench is None:
1087
+ continue
1088
+ week, value = self._latest_metric(bench, exp["metric"])
1089
+ if value is None or week == exp.get("baseline_week"):
1090
+ continue
1091
+ higher_is_better = not str(exp["metric"]).startswith("latency.")
1092
+ delta = value - float(exp["baseline_metric"])
1093
+ regression = -delta if higher_is_better else delta
1094
+ if regression > float(exp["max_regression"]):
1095
+ self._revert(
1096
+ exp, status="reverted",
1097
+ reason=(f"{exp['metric']} {exp['baseline_metric']:.3f} -> "
1098
+ f"{value:.3f} ({week}); regression {regression:.3f} "
1099
+ f"> guard {exp['max_regression']}"))
1100
+ else:
1101
+ self.store.update(
1102
+ exp["id"], status="adopted", decided_at=time.time(),
1103
+ causal_status="observed",
1104
+ decision_reason=(f"legacy non-causal comparison: {exp['metric']} "
1105
+ f"{exp['baseline_metric']:.3f} -> "
1106
+ f"{value:.3f} ({week}); within guard"))
1107
+ decided.append(self.store.get(exp["id"]) or {})
1108
+ return decided
1109
+
1110
+ @staticmethod
1111
+ def _latest_metric(bench: Any, metric: str) -> Tuple[Optional[str],
1112
+ Optional[float]]:
1113
+ rolls = bench.store.rollups(weeks=4)
1114
+ for week in sorted(rolls, reverse=True):
1115
+ row = rolls[week].get(metric)
1116
+ if row and row.get("value") is not None:
1117
+ return week, float(row["value"])
1118
+ return None, None
1119
+
1120
+ def _revert(self, exp: Dict[str, Any], *, status: str,
1121
+ reason: str) -> None:
1122
+ if exp.get("mutation_applied") and exp.get("baseline_param") is not None:
1123
+ current = self._current_param(exp["ref"])
1124
+ # Never overwrite a third-party superseding mutation.
1125
+ if current is not None and abs(
1126
+ current - float(exp["variant"])) <= 1e-9:
1127
+ self._set_param(
1128
+ exp, float(exp["baseline_param"]), operation="revert",
1129
+ authority_mode=exp.get("authority_mode") or "unknown")
1130
+ self.store.update(
1131
+ exp["id"], status=status, decided_at=time.time(),
1132
+ decision_reason=reason[:1000], mutation_applied=0)
1133
+ self._log(
1134
+ f"experiment {exp['id']} {status}", reasoning=reason[:500],
1135
+ ref=exp["id"])
1136
+
1137
+ def snapshot(self, limit: int = 30) -> Dict[str, Any]:
1138
+ experiments = self.store.list(limit=limit)
1139
+ return {
1140
+ "mode": cognition_p4_mode(),
1141
+ "running": [row for row in experiments
1142
+ if row["status"] in {"starting", "running"}],
1143
+ "proposed": [row for row in experiments if row["status"] == "proposed"],
1144
+ "recent": [row for row in experiments
1145
+ if row["status"] not in {
1146
+ "starting", "running", "proposed"}],
1147
+ "enabled": experiments_enabled(),
1148
+ "max_running": _max_running(),
1149
+ "adaptive_parameter_writer": "ExperimentEngine",
1150
+ }