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.
- apsimo/__init__.py +38 -0
- apsimo/__main__.py +6 -0
- apsimo/agent/__init__.py +6 -0
- apsimo/agent/client.py +276 -0
- apsimo/agent/models.py +46 -0
- apsimo/agents/__init__.py +20 -0
- apsimo/agents/models.py +264 -0
- apsimo/agents/store.py +861 -0
- apsimo/agents/websocket.py +522 -0
- apsimo/api/__init__.py +1 -0
- apsimo/api/auth_telemetry.py +287 -0
- apsimo/api/authority.py +1203 -0
- apsimo/api/contact_grants.py +347 -0
- apsimo/api/middleware.py +483 -0
- apsimo/api/routers/__init__.py +1 -0
- apsimo/api/routers/commitment_work.py +265 -0
- apsimo/api/routers/context_gate.py +123 -0
- apsimo/api/routers/executions.py +140 -0
- apsimo/api/routers/followup_plans.py +147 -0
- apsimo/api/routers/governed_actions.py +162 -0
- apsimo/api/routers/host.py +14473 -0
- apsimo/api/routers/initiative_work.py +115 -0
- apsimo/api/routers/mining.py +104 -0
- apsimo/api/routers/observations.py +110 -0
- apsimo/api/routers/social_state.py +225 -0
- apsimo/api/routers/task_queue.py +2715 -0
- apsimo/api/routers/temporal_followups.py +251 -0
- apsimo/api/routers/transport.py +110 -0
- apsimo/api/routers/transport_ingress_api.py +240 -0
- apsimo/api/schemas/__init__.py +1 -0
- apsimo/api/schemas/host.py +1949 -0
- apsimo/autonomy/cli.py +110 -0
- apsimo/autonomy/condition_worker.py +437 -0
- apsimo/autonomy/config.py +424 -0
- apsimo/autonomy/loop.py +4316 -0
- apsimo/autonomy/registry.py +339 -0
- apsimo/autonomy/scheduler.py +1822 -0
- apsimo/autonomy/synthesis.py +449 -0
- apsimo/backup.py +962 -0
- apsimo/beliefs/__init__.py +23 -0
- apsimo/beliefs/contradictions.py +109 -0
- apsimo/beliefs/decay.py +61 -0
- apsimo/beliefs/engine.py +479 -0
- apsimo/beliefs/models.py +67 -0
- apsimo/beliefs/promotion.py +41 -0
- apsimo/beliefs/resolve.py +58 -0
- apsimo/beliefs/source_claims.py +690 -0
- apsimo/beliefs/source_projection.py +883 -0
- apsimo/beliefs/source_time.py +208 -0
- apsimo/beliefs/store.py +133 -0
- apsimo/briefings/aggregators.py +824 -0
- apsimo/briefings/composer.py +420 -0
- apsimo/briefings/config.py +55 -0
- apsimo/briefings/delivery.py +439 -0
- apsimo/briefings/engagement.py +97 -0
- apsimo/briefings/engine.py +274 -0
- apsimo/briefings/enhancer.py +99 -0
- apsimo/briefings/models.py +183 -0
- apsimo/briefings/scheduler.py +382 -0
- apsimo/briefings/store.py +435 -0
- apsimo/chain/__init__.py +48 -0
- apsimo/chain/block.py +100 -0
- apsimo/chain/cli.py +704 -0
- apsimo/chain/genesis.py +443 -0
- apsimo/chain/identity.py +416 -0
- apsimo/chain/keys.py +1025 -0
- apsimo/chain/local_keys.py +187 -0
- apsimo/chain/manager.py +290 -0
- apsimo/chain/node.py +163 -0
- apsimo/chain/plugin_transactions.py +371 -0
- apsimo/chain/protocol.py +220 -0
- apsimo/chain/state_machine.py +676 -0
- apsimo/chain/storage.py +503 -0
- apsimo/chain/transactions.py +250 -0
- apsimo/chain/validation.py +397 -0
- apsimo/channels/__init__.py +1 -0
- apsimo/channels/manifest.py +31 -0
- apsimo/channels/migrations/001_channels_schema.sql +12 -0
- apsimo/channels/phone_gateways.py +42 -0
- apsimo/channels/presence.py +188 -0
- apsimo/channels/router.py +235 -0
- apsimo/channels/store.py +231 -0
- apsimo/cli.py +2688 -0
- apsimo/cognition/__init__.py +11 -0
- apsimo/cognition/charter.py +398 -0
- apsimo/cognition/drive_governance.py +3530 -0
- apsimo/cognition/evidence_pipeline.py +1627 -0
- apsimo/cognition/external_events.py +932 -0
- apsimo/cognition/goal_spine.py +3488 -0
- apsimo/cognition/introspection.py +214 -0
- apsimo/cognition/prompt.py +150 -0
- apsimo/cognition/runtime.py +108 -0
- apsimo/cognition/trigger.py +154 -0
- apsimo/commitments/__init__.py +18 -0
- apsimo/commitments/local_work.py +355 -0
- apsimo/commitments/store.py +1052 -0
- apsimo/commitments/work.py +91 -0
- apsimo/compat.py +53 -0
- apsimo/compression/__init__.py +467 -0
- apsimo/connectors/__init__.py +21 -0
- apsimo/connectors/base.py +152 -0
- apsimo/connectors/caldav_calendar.py +125 -0
- apsimo/connectors/fs_documents.py +85 -0
- apsimo/connectors/imap_email.py +138 -0
- apsimo/connectors/manager.py +218 -0
- apsimo/connectors/webhook_pull.py +88 -0
- apsimo/contacts/__init__.py +33 -0
- apsimo/contacts/comms.py +357 -0
- apsimo/contacts/config.py +79 -0
- apsimo/contacts/exporters/__init__.py +1 -0
- apsimo/contacts/exporters/vcard.py +71 -0
- apsimo/contacts/identity_links.py +251 -0
- apsimo/contacts/importer.py +280 -0
- apsimo/contacts/importers/__init__.py +1 -0
- apsimo/contacts/importers/batch.py +43 -0
- apsimo/contacts/importers/macos_contacts.py +101 -0
- apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
- apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
- apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
- apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
- apsimo/contacts/migrations/005_identity_links.sql +27 -0
- apsimo/contacts/models.py +308 -0
- apsimo/contacts/scoring.py +16 -0
- apsimo/contacts/store.py +1623 -0
- apsimo/contacts/transport_ingress.py +252 -0
- apsimo/contacts/world_bridge.py +314 -0
- apsimo/contextgate/__init__.py +69 -0
- apsimo/contextgate/chunker.py +169 -0
- apsimo/contextgate/estimate.py +54 -0
- apsimo/contextgate/gate.py +313 -0
- apsimo/contextgate/retrieve.py +115 -0
- apsimo/delivery/__init__.py +16 -0
- apsimo/delivery/bridge.py +1260 -0
- apsimo/delivery/channels.py +526 -0
- apsimo/delivery/classification.py +50 -0
- apsimo/delivery/rate_limiter.py +268 -0
- apsimo/delivery/reachout_policy.py +206 -0
- apsimo/directed/__init__.py +22 -0
- apsimo/directed/audit.py +167 -0
- apsimo/directed/intake.py +95 -0
- apsimo/directed/models.py +191 -0
- apsimo/directed/service.py +509 -0
- apsimo/directives/__init__.py +25 -0
- apsimo/directives/evidence.py +87 -0
- apsimo/directives/extractor.py +188 -0
- apsimo/directives/guard.py +364 -0
- apsimo/directives/models.py +206 -0
- apsimo/directives/service.py +372 -0
- apsimo/directives/store.py +167 -0
- apsimo/doctor.py +2173 -0
- apsimo/environment.py +43 -0
- apsimo/events/__init__.py +33 -0
- apsimo/events/broadcaster.py +98 -0
- apsimo/events/bus.py +217 -0
- apsimo/events/journal.py +863 -0
- apsimo/events/stream.py +131 -0
- apsimo/events/types.py +150 -0
- apsimo/execution_results.py +357 -0
- apsimo/feedback/__init__.py +5 -0
- apsimo/feedback/store.py +76 -0
- apsimo/feeds/__init__.py +19 -0
- apsimo/feeds/cli.py +84 -0
- apsimo/feeds/engine.py +437 -0
- apsimo/feeds/example-feed.yaml +77 -0
- apsimo/feeds/hermes_cron.py +126 -0
- apsimo/feeds/manager.py +235 -0
- apsimo/feeds/spec.py +250 -0
- apsimo/feeds/template.py +202 -0
- apsimo/gate/__init__.py +18 -0
- apsimo/gate/audit.py +61 -0
- apsimo/gate/communication_policy.py +166 -0
- apsimo/gate/config.py +72 -0
- apsimo/gate/context_provenance.py +170 -0
- apsimo/gate/env_risk.py +226 -0
- apsimo/gate/guard_audit.py +353 -0
- apsimo/gate/layers/__init__.py +1 -0
- apsimo/gate/layers/base.py +15 -0
- apsimo/gate/layers/l1_recipient.py +66 -0
- apsimo/gate/layers/l2_pii.py +134 -0
- apsimo/gate/layers/l3_cross_context.py +50 -0
- apsimo/gate/layers/l4_trust_tier.py +78 -0
- apsimo/gate/layers/l5_injection.py +199 -0
- apsimo/gate/layers/l6_review.py +86 -0
- apsimo/gate/layers/l7_delay.py +100 -0
- apsimo/gate/layers/tom2_epistemic.py +185 -0
- apsimo/gate/models.py +64 -0
- apsimo/gate/pending_dispatch.py +5 -0
- apsimo/gate/pipeline.py +206 -0
- apsimo/gate/rejection.py +259 -0
- apsimo/gate/response_guard.py +700 -0
- apsimo/gate/rulesets/injection_v1.yaml +51 -0
- apsimo/gate/surface_policy.py +189 -0
- apsimo/gate/taint.py +226 -0
- apsimo/genesis.json +9 -0
- apsimo/goals/__init__.py +100 -0
- apsimo/goals/config.py +38 -0
- apsimo/goals/decomposer.py +421 -0
- apsimo/goals/engine.py +617 -0
- apsimo/goals/inference.py +354 -0
- apsimo/goals/models.py +302 -0
- apsimo/goals/priority.py +270 -0
- apsimo/goals/queue_bridge.py +149 -0
- apsimo/goals/replan.py +450 -0
- apsimo/goals/schema.sql +89 -0
- apsimo/goals/store.py +692 -0
- apsimo/governed_actions.py +1708 -0
- apsimo/harness_integration/__init__.py +45 -0
- apsimo/harness_integration/context.py +41 -0
- apsimo/harness_integration/skills.py +231 -0
- apsimo/identity/__init__.py +26 -0
- apsimo/identity/participants.py +181 -0
- apsimo/identity/resolver.py +329 -0
- apsimo/identity_bootstrap/__init__.py +5 -0
- apsimo/identity_bootstrap/builder.py +208 -0
- apsimo/identity_bootstrap/corpus.py +443 -0
- apsimo/identity_bootstrap/models.py +54 -0
- apsimo/identity_bootstrap/runner.py +353 -0
- apsimo/identity_bootstrap/seeders/__init__.py +25 -0
- apsimo/identity_bootstrap/seeders/briefings.py +109 -0
- apsimo/identity_bootstrap/seeders/chain.py +57 -0
- apsimo/identity_bootstrap/seeders/goals.py +128 -0
- apsimo/identity_bootstrap/seeders/memory.py +191 -0
- apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
- apsimo/identity_bootstrap/seeders/relationship.py +152 -0
- apsimo/identity_bootstrap/seeders/sessions.py +67 -0
- apsimo/identity_bootstrap/seeders/skills.py +92 -0
- apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
- apsimo/identity_bootstrap/seeders/world_model.py +143 -0
- apsimo/identity_bootstrap/self_query.py +92 -0
- apsimo/identity_bootstrap/self_reflection.py +155 -0
- apsimo/identity_bootstrap/skill.py +37 -0
- apsimo/identity_bootstrap/verifier.py +436 -0
- apsimo/initiatives/__init__.py +20 -0
- apsimo/initiatives/action_registry.py +454 -0
- apsimo/initiatives/approval_authority.py +2105 -0
- apsimo/initiatives/approval_policy.py +123 -0
- apsimo/initiatives/assignment.py +263 -0
- apsimo/initiatives/backup_evidence.py +100 -0
- apsimo/initiatives/context_freshness.py +103 -0
- apsimo/initiatives/models.py +318 -0
- apsimo/initiatives/native_work.py +270 -0
- apsimo/initiatives/standing_approvals.py +232 -0
- apsimo/initiatives/store.py +1081 -0
- apsimo/initiatives/temporal_followup.py +410 -0
- apsimo/intelligence/__init__.py +1 -0
- apsimo/intelligence/cognition/__init__.py +24 -0
- apsimo/intelligence/cognition/gap_detector.py +148 -0
- apsimo/intelligence/cognition/metalearner.py +547 -0
- apsimo/intelligence/cognition/metrics_collector.py +217 -0
- apsimo/intelligence/cognition/performance_index.py +299 -0
- apsimo/intelligence/cognition/registry.py +192 -0
- apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
- apsimo/intelligence/cognition/types.py +16 -0
- apsimo/intelligence/components/__init__.py +66 -0
- apsimo/intelligence/components/anomaly_detector.py +413 -0
- apsimo/intelligence/components/initiative_engine.py +2643 -0
- apsimo/intelligence/components/preference_learner.py +521 -0
- apsimo/intelligence/components/research_orchestrator.py +358 -0
- apsimo/intelligence/components/self_directed_thinker.py +221 -0
- apsimo/intelligence/components/self_reflector.py +252 -0
- apsimo/intelligence/components/session_continuity.py +154 -0
- apsimo/intelligence/components/task_planner.py +320 -0
- apsimo/intelligence/components/tool_learner.py +217 -0
- apsimo/intelligence/graph/__init__.py +79 -0
- apsimo/intelligence/graph/client.py +2483 -0
- apsimo/intelligence/graph/consolidator.py +405 -0
- apsimo/intelligence/graph/distiller.py +312 -0
- apsimo/intelligence/graph/migrations.py +129 -0
- apsimo/intelligence/graph/queries.py +248 -0
- apsimo/intelligence/graph/recall.py +281 -0
- apsimo/intelligence/graph/reconciler.py +144 -0
- apsimo/intelligence/graph/schema.py +337 -0
- apsimo/intelligence/graph/selection.py +252 -0
- apsimo/intelligence/learning/__init__.py +17 -0
- apsimo/intelligence/learning/continuous_learner.py +245 -0
- apsimo/intelligence/learning/feedback_store.py +321 -0
- apsimo/intelligence/mind_model/__init__.py +1 -0
- apsimo/intelligence/mind_model/graph_baseline.py +136 -0
- apsimo/intelligence/mind_model/signal_collector.py +361 -0
- apsimo/intelligence/relationships/__init__.py +11 -0
- apsimo/intelligence/relationships/profiler.py +389 -0
- apsimo/intelligence/relationships/scorer.py +560 -0
- apsimo/intelligence/relationships/signal_floor.py +66 -0
- apsimo/intelligence/relationships/trust_tiers.py +300 -0
- apsimo/intelligence/synthesis/__init__.py +40 -0
- apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
- apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
- apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
- apsimo/intelligence/synthesis/insight_store.py +79 -0
- apsimo/intelligence/synthesis/insight_validator.py +183 -0
- apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
- apsimo/intelligence/turn_middleware/__init__.py +15 -0
- apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
- apsimo/mcp/__init__.py +41 -0
- apsimo/mcp/__main__.py +6 -0
- apsimo/mcp/config.py +287 -0
- apsimo/mcp/server.py +501 -0
- apsimo/migrations.py +187 -0
- apsimo/mining/__init__.py +27 -0
- apsimo/mining/corpus.py +239 -0
- apsimo/mining/escalations.py +289 -0
- apsimo/mining/models.py +169 -0
- apsimo/mining/store.py +210 -0
- apsimo/models/__init__.py +30 -0
- apsimo/models/memory.py +80 -0
- apsimo/models/mesh.py +72 -0
- apsimo/models/person.py +104 -0
- apsimo/models/signal.py +108 -0
- apsimo/observations/__init__.py +15 -0
- apsimo/observations/store.py +277 -0
- apsimo/patterns/__init__.py +6 -0
- apsimo/patterns/extract.py +187 -0
- apsimo/patterns/store.py +227 -0
- apsimo/persona/__init__.py +1 -0
- apsimo/persona/engine.py +611 -0
- apsimo/persona/manifest.py +140 -0
- apsimo/projects/__init__.py +28 -0
- apsimo/projects/engine.py +1681 -0
- apsimo/projects/event_outbox.py +188 -0
- apsimo/projects/models.py +216 -0
- apsimo/projects/planner.py +181 -0
- apsimo/projects/store.py +1446 -0
- apsimo/proposals/__init__.py +12 -0
- apsimo/proposals/engine.py +114 -0
- apsimo/proposals/models.py +207 -0
- apsimo/qualification/__init__.py +1 -0
- apsimo/qualification/cases.py +75 -0
- apsimo/qualification/cli.py +51 -0
- apsimo/qualification/memory_cases.py +209 -0
- apsimo/qualification/records.py +92 -0
- apsimo/qualification/report.py +87 -0
- apsimo/qualification/runner.py +311 -0
- apsimo/qualification/structured_cases.py +131 -0
- apsimo/reasoning/__init__.py +13 -0
- apsimo/reasoning/executor.py +506 -0
- apsimo/reasoning/loop.py +373 -0
- apsimo/reasoning/native_tools/__init__.py +16 -0
- apsimo/reasoning/native_tools/calculate.py +141 -0
- apsimo/reasoning/native_tools/file_ops.py +150 -0
- apsimo/reasoning/native_tools/web_search.py +49 -0
- apsimo/reasoning/tool_policy.py +182 -0
- apsimo/redact/__init__.py +176 -0
- apsimo/repos/__init__.py +5 -0
- apsimo/repos/mirrors.py +204 -0
- apsimo/research/__init__.py +41 -0
- apsimo/research/artifact.py +482 -0
- apsimo/research/gatherer.py +387 -0
- apsimo/research/pipeline.py +513 -0
- apsimo/research/search/__init__.py +7 -0
- apsimo/research/search/base.py +41 -0
- apsimo/research/search/brave.py +59 -0
- apsimo/research/search/cache.py +51 -0
- apsimo/research/search/duckduckgo.py +103 -0
- apsimo/research/search/orchestrator.py +119 -0
- apsimo/research/search/serpapi.py +59 -0
- apsimo/research/search/tavily.py +59 -0
- apsimo/research/synthesizer.py +309 -0
- apsimo/router/__init__.py +30 -0
- apsimo/router/complexity_scorer.py +148 -0
- apsimo/router/endpoints.py +153 -0
- apsimo/router/fallback.py +58 -0
- apsimo/router/functions.py +243 -0
- apsimo/router/native_policy.py +52 -0
- apsimo/router/router.py +762 -0
- apsimo/router/self_learning.py +174 -0
- apsimo/router/tiers.py +677 -0
- apsimo/sandbox/__init__.py +21 -0
- apsimo/sandbox/backend.py +195 -0
- apsimo/sandbox/manager.py +173 -0
- apsimo/scope_bounds.py +7 -0
- apsimo/secrets/__init__.py +6 -0
- apsimo/secrets/backends/__init__.py +8 -0
- apsimo/secrets/backends/base.py +42 -0
- apsimo/secrets/backends/env.py +110 -0
- apsimo/secrets/backends/keyring.py +72 -0
- apsimo/secrets/backends/onepassword.py +232 -0
- apsimo/secrets/cli.py +191 -0
- apsimo/secrets/manager.py +160 -0
- apsimo/secrets/migration.py +101 -0
- apsimo/secrets/types.py +98 -0
- apsimo/seed.py +41 -0
- apsimo/self_model/__init__.py +37 -0
- apsimo/self_model/appraisals.py +673 -0
- apsimo/self_model/benchmark.py +1314 -0
- apsimo/self_model/brief.py +40 -0
- apsimo/self_model/event_concerns.py +1128 -0
- apsimo/self_model/execution_forecasts.py +353 -0
- apsimo/self_model/expectations.py +1595 -0
- apsimo/self_model/experiments.py +1150 -0
- apsimo/self_model/journal.py +148 -0
- apsimo/self_model/judgments.py +705 -0
- apsimo/self_model/native_outcomes.py +55 -0
- apsimo/self_model/params.py +220 -0
- apsimo/self_model/perspective.py +246 -0
- apsimo/self_model/reconcile.py +183 -0
- apsimo/self_model/reply_forecasts.py +381 -0
- apsimo/self_model/runtime_forecasts.py +296 -0
- apsimo/self_model/runtime_models.py +67 -0
- apsimo/self_model/settlement.py +207 -0
- apsimo/self_model/situation.py +1731 -0
- apsimo/self_model/store.py +883 -0
- apsimo/self_model/supervised.py +137 -0
- apsimo/self_model/thinker.py +99 -0
- apsimo/self_model/trust.py +388 -0
- apsimo/self_model/workspace.py +2388 -0
- apsimo/server.py +4197 -0
- apsimo/services/__init__.py +1 -0
- apsimo/services/agent_bridge.py +474 -0
- apsimo/services/initiative_executor.py +914 -0
- apsimo/services/instance.py +297 -0
- apsimo/sessions/__init__.py +22 -0
- apsimo/sessions/config.py +13 -0
- apsimo/sessions/context_loader.py +88 -0
- apsimo/sessions/federation_session.py +75 -0
- apsimo/sessions/isolated_session.py +98 -0
- apsimo/sessions/reports.py +84 -0
- apsimo/sessions/store.py +148 -0
- apsimo/setup.py +2818 -0
- apsimo/setup_hermes.py +879 -0
- apsimo/setup_local_work.py +218 -0
- apsimo/setup_native_goals.py +134 -0
- apsimo/setup_native_reviews.py +115 -0
- apsimo/skills/__init__.py +10 -0
- apsimo/skills/base.py +108 -0
- apsimo/skills/budget.py +28 -0
- apsimo/skills/executor.py +493 -0
- apsimo/skills/executors/__init__.py +1 -0
- apsimo/skills/executors/behavioral_correction.py +75 -0
- apsimo/skills/executors/capability_gap.py +38 -0
- apsimo/skills/executors/data_quality.py +163 -0
- apsimo/skills/executors/knowledge_acquisition.py +41 -0
- apsimo/skills/executors/operational_hygiene.py +185 -0
- apsimo/skills/executors/subsystem_health.py +169 -0
- apsimo/skills/hermes_export.py +431 -0
- apsimo/skills/index.py +123 -0
- apsimo/skills/learning/__init__.py +21 -0
- apsimo/skills/learning/novelty_detector.py +206 -0
- apsimo/skills/learning/pattern_extractor.py +199 -0
- apsimo/skills/learning/triggers.py +159 -0
- apsimo/skills/loader.py +246 -0
- apsimo/skills/migrations/002_progressive_loading.sql +6 -0
- apsimo/skills/migrations/backfill_triggers.py +20 -0
- apsimo/skills/models.py +202 -0
- apsimo/skills/packager.py +128 -0
- apsimo/skills/protocols.py +70 -0
- apsimo/skills/registry.py +191 -0
- apsimo/skills/runtime.py +58 -0
- apsimo/skills/sandbox_runner.py +229 -0
- apsimo/skills/scheduler.py +129 -0
- apsimo/skills/schema.py +79 -0
- apsimo/skills/security/__init__.py +12 -0
- apsimo/skills/security/guards.py +53 -0
- apsimo/skills/security/scanner.py +223 -0
- apsimo/skills_memory/__init__.py +26 -0
- apsimo/skills_memory/distill.py +159 -0
- apsimo/skills_memory/models.py +85 -0
- apsimo/skills_memory/retrieve.py +62 -0
- apsimo/skills_memory/store.py +172 -0
- apsimo/surprise/__init__.py +6 -0
- apsimo/surprise/accumulation.py +57 -0
- apsimo/surprise/scorer.py +102 -0
- apsimo/surprise/store.py +203 -0
- apsimo/task_queue/__init__.py +69 -0
- apsimo/task_queue/action_receipts.py +148 -0
- apsimo/task_queue/approval_relay_canary.py +108 -0
- apsimo/task_queue/config.py +85 -0
- apsimo/task_queue/contract.py +361 -0
- apsimo/task_queue/events.py +130 -0
- apsimo/task_queue/governor.py +1031 -0
- apsimo/task_queue/handlers/__init__.py +16 -0
- apsimo/task_queue/handlers/base.py +37 -0
- apsimo/task_queue/handlers/inference.py +640 -0
- apsimo/task_queue/handlers/monitoring.py +116 -0
- apsimo/task_queue/handlers/registry.py +75 -0
- apsimo/task_queue/handlers/subtask_handler.py +173 -0
- apsimo/task_queue/handlers/system_maintenance.py +147 -0
- apsimo/task_queue/mesh_integration.py +111 -0
- apsimo/task_queue/models.py +317 -0
- apsimo/task_queue/queue_manager.py +8286 -0
- apsimo/task_queue/routing.py +287 -0
- apsimo/task_queue/scheduler.py +252 -0
- apsimo/task_queue/schema.sql +197 -0
- apsimo/task_queue/work_control.py +342 -0
- apsimo/task_queue/worker.py +993 -0
- apsimo/telemetry.py +145 -0
- apsimo/tom/__init__.py +6 -0
- apsimo/tom/affect.py +387 -0
- apsimo/tom/approvals.py +171 -0
- apsimo/tom/arcs.py +896 -0
- apsimo/tom/asymmetry.py +131 -0
- apsimo/tom/eligibility.py +248 -0
- apsimo/tom/engagement.py +214 -0
- apsimo/tom/exposure.py +214 -0
- apsimo/tom/extractor.py +306 -0
- apsimo/tom/fact_adapters.py +144 -0
- apsimo/tom/facts.py +326 -0
- apsimo/tom/integration.py +592 -0
- apsimo/tom/leveled.py +118 -0
- apsimo/tom/levels.py +247 -0
- apsimo/tom/recipient_audit.py +995 -0
- apsimo/tom/recipient_simulator.py +593 -0
- apsimo/tom/source_lineage.py +93 -0
- apsimo/tom/tom2.py +277 -0
- apsimo/tom/visibility.py +559 -0
- apsimo/tom/visibility_store.py +414 -0
- apsimo/tools/__init__.py +0 -0
- apsimo/tools/definitions.py +740 -0
- apsimo/tools/handlers.py +943 -0
- apsimo/toolsmith/__init__.py +26 -0
- apsimo/toolsmith/authority.py +166 -0
- apsimo/toolsmith/engine.py +559 -0
- apsimo/toolsmith/integrity.py +100 -0
- apsimo/toolsmith/miner.py +145 -0
- apsimo/toolsmith/policy.py +110 -0
- apsimo/toolsmith/registry.py +635 -0
- apsimo/turns/__init__.py +17 -0
- apsimo/turns/audio.py +134 -0
- apsimo/turns/documents.py +235 -0
- apsimo/turns/executions.py +486 -0
- apsimo/turns/hermes_history.py +245 -0
- apsimo/turns/hermes_kanban.py +268 -0
- apsimo/turns/hermes_work.py +96 -0
- apsimo/turns/idempotency.py +752 -0
- apsimo/turns/local_work.py +115 -0
- apsimo/turns/media.py +581 -0
- apsimo/turns/reported_workers.py +196 -0
- apsimo/turns/source_annotations.py +283 -0
- apsimo/turns/source_attribution.py +154 -0
- apsimo/turns/source_read.py +351 -0
- apsimo/turns/source_vectors.py +263 -0
- apsimo/turns/video.py +210 -0
- apsimo/util/autonomy_preset.py +220 -0
- apsimo/util/instance.py +92 -0
- apsimo/util/model_output.py +25 -0
- apsimo/util/quiet_hours.py +27 -0
- apsimo/util/session_safety.py +37 -0
- apsimo/util/temporal.py +343 -0
- apsimo/vector/__init__.py +75 -0
- apsimo/vector/backfill.py +171 -0
- apsimo/vector/caption.py +114 -0
- apsimo/vector/collections.py +51 -0
- apsimo/vector/config.py +102 -0
- apsimo/vector/embedder.py +670 -0
- apsimo/vector/image_preprocess.py +406 -0
- apsimo/vector/image_store.py +296 -0
- apsimo/vector/indexes.py +162 -0
- apsimo/vector/migrate.py +334 -0
- apsimo/vector/multimodal_provider.py +417 -0
- apsimo/vector/multimodal_types.py +87 -0
- apsimo/vector/openai_provider.py +119 -0
- apsimo/vector/query.py +49 -0
- apsimo/vector/reranker.py +565 -0
- apsimo/vector/safety_image.py +159 -0
- apsimo/vector/scanner.py +197 -0
- apsimo/vector/setup.py +289 -0
- apsimo/vector/store.py +533 -0
- apsimo/vector/tiers.py +263 -0
- apsimo/work_orders.py +925 -0
- apsimo/workers/__init__.py +21 -0
- apsimo/workers/agent_bridge.py +640 -0
- apsimo/workers/colony_worker.py +382 -0
- apsimo/workers/queue_worker.py +441 -0
- apsimo/workers/skills_sync.py +152 -0
- apsimo/world_model/__init__.py +71 -0
- apsimo/world_model/causal_maintenance.py +131 -0
- apsimo/world_model/causal_policy.py +43 -0
- apsimo/world_model/causal_query.py +125 -0
- apsimo/world_model/confidence.py +54 -0
- apsimo/world_model/config.py +64 -0
- apsimo/world_model/constants.py +97 -0
- apsimo/world_model/entities.py +145 -0
- apsimo/world_model/expectation_resolvers.py +177 -0
- apsimo/world_model/extraction/__init__.py +7 -0
- apsimo/world_model/extraction/base.py +62 -0
- apsimo/world_model/extraction/conversation_extractor.py +262 -0
- apsimo/world_model/extraction/detector.py +74 -0
- apsimo/world_model/extraction/document_extractor.py +78 -0
- apsimo/world_model/extraction/formats/__init__.py +24 -0
- apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
- apsimo/world_model/extraction/formats/html_fmt.py +72 -0
- apsimo/world_model/extraction/formats/json_fmt.py +68 -0
- apsimo/world_model/extraction/formats/pdf.py +43 -0
- apsimo/world_model/extraction/formats/text.py +27 -0
- apsimo/world_model/extraction/llm_extractor.py +164 -0
- apsimo/world_model/extraction/pipeline.py +73 -0
- apsimo/world_model/integrations/__init__.py +5 -0
- apsimo/world_model/integrations/mind_model_bridge.py +115 -0
- apsimo/world_model/integrations/social_intel_bridge.py +120 -0
- apsimo/world_model/jobs/__init__.py +4 -0
- apsimo/world_model/jobs/extraction_job.py +168 -0
- apsimo/world_model/llm_extract.py +572 -0
- apsimo/world_model/neo4j/__init__.py +5 -0
- apsimo/world_model/neo4j/backend.py +654 -0
- apsimo/world_model/observations.py +155 -0
- apsimo/world_model/populator.py +307 -0
- apsimo/world_model/postgres/__init__.py +1 -0
- apsimo/world_model/postgres/backend.py +683 -0
- apsimo/world_model/relationships.py +25 -0
- apsimo/world_model/resolution/__init__.py +13 -0
- apsimo/world_model/resolution/entity_resolver.py +232 -0
- apsimo/world_model/resolution/merge_audit.py +16 -0
- apsimo/world_model/resolution/merge_workflow.py +117 -0
- apsimo/world_model/source_reports.py +121 -0
- apsimo/world_model/sqlite/__init__.py +4 -0
- apsimo/world_model/sqlite/backend.py +855 -0
- apsimo/world_model/sqlite/schema.sql +132 -0
- apsimo/world_model/store.py +545 -0
- apsimo-1.3.0.dist-info/METADATA +78 -0
- apsimo-1.3.0.dist-info/RECORD +614 -0
- apsimo-1.3.0.dist-info/WHEEL +5 -0
- apsimo-1.3.0.dist-info/entry_points.txt +11 -0
- apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
- apsimo-1.3.0.dist-info/top_level.txt +2 -0
- 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
|
+
}
|