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