From ee0bd94b2739b0b6cc32b70c87cbd2cb940f3421 Mon Sep 17 00:00:00 2001 From: Perseus Computing <51974392+tcconnally@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:41:15 +0000 Subject: [PATCH] feat(context): add evidence-aware execution and runtime contracts Implements the capability evidence matrix, resource-aware execution profiles, portable runtime adapter envelopes, and deterministic evidence/abstention projections. Closes #979 Closes #980 Closes #981 Closes #982 --- claims.json | 218 +++- docs/CAPABILITY-EVIDENCE.md | 20 + docs/capability-evidence.json | 234 ++++ docs/claims-pipeline.md | 23 + docs/context-contracts.md | 12 + docs/context-evidence-projection.md | 69 ++ docs/execution-profiles.md | 85 ++ docs/runtime-adapter-contract.md | 82 ++ perseus.py | 1428 ++++++++++++++++++++++++- schemas/context-contract.schema.yaml | 12 + schemas/context-evidence.schema.yaml | 65 ++ schemas/execution-profile.schema.yaml | 26 + schemas/runtime-adapter.schema.yaml | 80 ++ scripts/build.py | 3 + scripts/render_claims.py | 259 ++++- src/perseus/context_contract.py | 9 + src/perseus/context_dag.py | 115 +- src/perseus/context_evidence.py | 359 +++++++ src/perseus/execution_profiles.py | 414 +++++++ src/perseus/mcp.py | 41 + src/perseus/runtime_adapter.py | 498 +++++++++ tests/test_capability_evidence.py | 89 ++ tests/test_context_evidence.py | 118 ++ tests/test_contract_schemas.py | 68 ++ tests/test_execution_profile_dag.py | 88 ++ tests/test_execution_profiles.py | 107 ++ tests/test_runtime_adapter.py | 139 +++ 27 files changed, 4606 insertions(+), 55 deletions(-) create mode 100644 docs/CAPABILITY-EVIDENCE.md create mode 100644 docs/capability-evidence.json create mode 100644 docs/context-evidence-projection.md create mode 100644 docs/execution-profiles.md create mode 100644 docs/runtime-adapter-contract.md create mode 100644 schemas/context-evidence.schema.yaml create mode 100644 schemas/execution-profile.schema.yaml create mode 100644 schemas/runtime-adapter.schema.yaml create mode 100644 src/perseus/context_evidence.py create mode 100644 src/perseus/execution_profiles.py create mode 100644 src/perseus/runtime_adapter.py create mode 100644 tests/test_capability_evidence.py create mode 100644 tests/test_context_evidence.py create mode 100644 tests/test_contract_schemas.py create mode 100644 tests/test_execution_profile_dag.py create mode 100644 tests/test_execution_profiles.py create mode 100644 tests/test_runtime_adapter.py diff --git a/claims.json b/claims.json index a4e69d8e..b1de025d 100644 --- a/claims.json +++ b/claims.json @@ -1,7 +1,7 @@ { "_meta": { "description": "Canonical claim registry for Perseus Computing public surfaces. Every public figure derives from here. When a benchmark re-runs, update THIS file only; scripts/render_claims.py regenerates manifest strings and tests/test_claims_sync.py fails CI if any surface drifts.", - "updated": "2026-08-15", + "updated": "2026-08-17", "public_headline": "longmemeval_cot", "public_headline_policy": "The validated official-CoT result is the current public QA headline. The plain-prompt result remains separately labeled methodology context.", "public_site_positioning": "Perseus is the system around the model: Perseus resolves live context, Vault persists memory, and Ledger records evidence." @@ -272,5 +272,219 @@ "label": "measured-baseline", "publishable": true } - } + }, + "capabilities": [ + { + "id": "context-rendering", + "capability": "Deterministic live context rendering", + "owner": "Perseus renderer", + "lifecycle": "operational", + "evidence_class": "code_and_tests", + "evidence_refs": [ + "src/perseus/renderer.py", + "tests/test_renderer.py" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-verify when renderer, resolver, or generated artifact changes.", + "claim_ceiling": "The shipped local renderer resolves supported directives deterministically for the tested contract.", + "non_claims": [ + "This does not certify arbitrary third-party directives or downstream model behavior." + ], + "dependencies": [ + "Python standard library", + "PyYAML" + ], + "activation": "perseus render ", + "proof_surface": "docs/PRODUCT_CONTRACT.md" + }, + { + "id": "portable-context-artifacts", + "capability": "Hash-bound portable context artifacts", + "owner": "Context artifact compiler", + "lifecycle": "implemented", + "evidence_class": "code_and_tests", + "evidence_refs": [ + "src/perseus/context_artifacts.py", + "tests/test_context_artifacts.py" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-verify when artifact schemas or budget accounting changes.", + "claim_ceiling": "Artifacts carry deterministic schema, source commitments, and bounded sections.", + "non_claims": [ + "A commitment proves integrity of supplied inputs, not their truth." + ], + "dependencies": [ + "Python standard library" + ], + "activation": "build_agent_context_artifact(...) or build_memento_artifact(...)", + "proof_surface": "docs/context-artifact-contract.md" + }, + { + "id": "versioned-context-contracts", + "capability": "Versioned evidence-aware context contracts", + "owner": "Context contract boundary", + "lifecycle": "tested", + "evidence_class": "contract_tests", + "evidence_refs": [ + "src/perseus/context_contract.py", + "tests/test_context_contracts_916_917.py" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-verify when context-contract schema or projection fields change.", + "claim_ceiling": "The bounded rank, ask, and projection paths expose sanitized evidence metadata and explicit non-success states.", + "non_claims": [ + "Ranking is not a truth oracle and does not replace Vault or Ledger authority." + ], + "dependencies": [ + "Caller-supplied records", + "Context contract schema" + ], + "activation": "context_rank(...) or context_ask(...)", + "proof_surface": "docs/context-contracts.md" + }, + { + "id": "context-dag-compilation", + "capability": "Auditable budgeted context-DAG compilation", + "owner": "Context DAG compiler", + "lifecycle": "tested", + "evidence_class": "offline_tests", + "evidence_refs": [ + "src/perseus/context_dag.py", + "tests/test_context_dag.py" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-verify when graph, expansion, verdict, or budget logic changes.", + "claim_ceiling": "The compiler emits digest-sealed graph and packet decisions under explicit hard limits.", + "non_claims": [ + "Rendered accounting is not provider billing or a hardware benchmark." + ], + "dependencies": [ + "Caller fetch function", + "Explicit compilation policy" + ], + "activation": "compile_context_dag(...)", + "proof_surface": "docs/context-dag.md" + }, + { + "id": "vault-context-fallback", + "capability": "Vault-backed context with explicit degraded fallback", + "owner": "Vault connector", + "lifecycle": "degraded", + "evidence_class": "integration_contract", + "evidence_refs": [ + "src/perseus/vault_connector.py", + "tests/test_vault_served_contract.py" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-verify when connector transport, fallback, or serving policy changes.", + "claim_ceiling": "Unavailable Vault integration is surfaced as a bounded degraded path rather than an equivalent complete result.", + "non_claims": [ + "The fallback is not a replacement for Vault durability, provenance, or retrieval authority." + ], + "dependencies": [ + "Optional Perseus Vault service", + "Workspace configuration" + ], + "activation": "perseus_vault configuration with context rendering", + "proof_surface": "docs/DEPLOYMENT.md" + }, + { + "id": "optional-context-extensions", + "capability": "Optional extension and plugin adapters", + "owner": "Registry and adapter boundary", + "lifecycle": "omitted", + "evidence_class": "boundary_documentation", + "evidence_refs": [ + "src/perseus/registry.py", + "docs/context-cartridges.md" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-verify when extension loading or adapter registration changes.", + "claim_ceiling": "Optional extensions are isolated from the dependency-free core and require their declared boundary.", + "non_claims": [ + "No unreviewed extension is part of the default runtime guarantee." + ], + "dependencies": [ + "Explicit extension configuration" + ], + "activation": "Opt-in registry or adapter configuration", + "proof_surface": "docs/CONTEXT-ADAPTER-SDK.md" + }, + { + "id": "retired-synthetic-headline", + "capability": "Retired asymmetric benchmark headline", + "owner": "Claims registry", + "lifecycle": "historical", + "evidence_class": "historical_artifact", + "evidence_refs": [ + "benchmark/ultimate_suite_results.json", + "docs/claims-pipeline.md" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Retain only while the historical audit record is useful; never promote to current state.", + "claim_ceiling": "This row records why the former headline was retired and is not a current capability claim.", + "non_claims": [ + "The historical figure is not a current performance result." + ], + "dependencies": [ + "Claims audit history" + ], + "activation": "Historical documentation only", + "proof_surface": "docs/claims-pipeline.md" + }, + { + "id": "external-model-inference", + "capability": "External model inference owned by Perseus", + "owner": "Runtime integration boundary", + "lifecycle": "not_demonstrated", + "evidence_class": "non_claim", + "evidence_refs": [ + "docs/RESOLVER_VS_GENERATOR.md", + "docs/context-contracts.md" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-evaluate only when a separately verified runtime adapter is published.", + "claim_ceiling": "Perseus supplies bounded context and evidence contracts; no owned inference capability is asserted.", + "non_claims": [ + "No vendor, accelerator, model quality, or embedded execution result is demonstrated here." + ], + "dependencies": [ + "A separately qualified runtime adapter" + ], + "activation": "Not available in the current core contract", + "proof_surface": "docs/RESOLVER_VS_GENERATOR.md" + } + ] } diff --git a/docs/CAPABILITY-EVIDENCE.md b/docs/CAPABILITY-EVIDENCE.md new file mode 100644 index 00000000..f3aeaf90 --- /dev/null +++ b/docs/CAPABILITY-EVIDENCE.md @@ -0,0 +1,20 @@ +# Capability evidence matrix + +Generated from [`claims.json`](../claims.json) by `scripts/render_claims.py`; edit the registry, not this file. + +Lifecycle states are intentionally distinct: `implemented`, `tested`, `operational`, `degraded`, `omitted`, `historical`, and `not_demonstrated`. + +| Capability | Owner | Lifecycle | Evidence | Verification | Claim ceiling | Non-claims | Dependencies / activation | Proof surface | +|---|---|---|---|---|---|---|---|---| +| Auditable budgeted context-DAG compilation | Context DAG compiler | `tested` | [src/perseus/context_dag.py](../src/perseus/context_dag.py), [tests/test_context_dag.py](../tests/test_context_dag.py) | 2026-08-17 / `a33591e` / perseus v1.0.26 | The compiler emits digest-sealed graph and packet decisions under explicit hard limits. | Rendered accounting is not provider billing or a hardware benchmark. | Caller fetch function, Explicit compilation policy; compile_context_dag(...) | [docs/context-dag.md](../docs/context-dag.md) | +| Deterministic live context rendering | Perseus renderer | `operational` | [src/perseus/renderer.py](../src/perseus/renderer.py), [tests/test_renderer.py](../tests/test_renderer.py) | 2026-08-17 / `a33591e` / perseus v1.0.26 | The shipped local renderer resolves supported directives deterministically for the tested contract. | This does not certify arbitrary third-party directives or downstream model behavior. | Python standard library, PyYAML; perseus render | [docs/PRODUCT_CONTRACT.md](../docs/PRODUCT_CONTRACT.md) | +| External model inference owned by Perseus | Runtime integration boundary | `not_demonstrated` | [docs/RESOLVER_VS_GENERATOR.md](../docs/RESOLVER_VS_GENERATOR.md), [docs/context-contracts.md](../docs/context-contracts.md) | 2026-08-17 / `a33591e` / perseus v1.0.26 | Perseus supplies bounded context and evidence contracts; no owned inference capability is asserted. | No vendor, accelerator, model quality, or embedded execution result is demonstrated here. | A separately qualified runtime adapter; Not available in the current core contract | [docs/RESOLVER_VS_GENERATOR.md](../docs/RESOLVER_VS_GENERATOR.md) | +| Optional extension and plugin adapters | Registry and adapter boundary | `omitted` | [src/perseus/registry.py](../src/perseus/registry.py), [docs/context-cartridges.md](../docs/context-cartridges.md) | 2026-08-17 / `a33591e` / perseus v1.0.26 | Optional extensions are isolated from the dependency-free core and require their declared boundary. | No unreviewed extension is part of the default runtime guarantee. | Explicit extension configuration; Opt-in registry or adapter configuration | [docs/CONTEXT-ADAPTER-SDK.md](../docs/CONTEXT-ADAPTER-SDK.md) | +| Hash-bound portable context artifacts | Context artifact compiler | `implemented` | [src/perseus/context_artifacts.py](../src/perseus/context_artifacts.py), [tests/test_context_artifacts.py](../tests/test_context_artifacts.py) | 2026-08-17 / `a33591e` / perseus v1.0.26 | Artifacts carry deterministic schema, source commitments, and bounded sections. | A commitment proves integrity of supplied inputs, not their truth. | Python standard library; build_agent_context_artifact(...) or build_memento_artifact(...) | [docs/context-artifact-contract.md](../docs/context-artifact-contract.md) | +| Retired asymmetric benchmark headline | Claims registry | `historical` | [benchmark/ultimate_suite_results.json](../benchmark/ultimate_suite_results.json), [docs/claims-pipeline.md](../docs/claims-pipeline.md) | 2026-08-17 / `a33591e` / perseus v1.0.26 | This row records why the former headline was retired and is not a current capability claim. | The historical figure is not a current performance result. | Claims audit history; Historical documentation only | [docs/claims-pipeline.md](../docs/claims-pipeline.md) | +| Vault-backed context with explicit degraded fallback | Vault connector | `degraded` | [src/perseus/vault_connector.py](../src/perseus/vault_connector.py), [tests/test_vault_served_contract.py](../tests/test_vault_served_contract.py) | 2026-08-17 / `a33591e` / perseus v1.0.26 | Unavailable Vault integration is surfaced as a bounded degraded path rather than an equivalent complete result. | The fallback is not a replacement for Vault durability, provenance, or retrieval authority. | Optional Perseus Vault service, Workspace configuration; perseus_vault configuration with context rendering | [docs/DEPLOYMENT.md](../docs/DEPLOYMENT.md) | +| Versioned evidence-aware context contracts | Context contract boundary | `tested` | [src/perseus/context_contract.py](../src/perseus/context_contract.py), [tests/test_context_contracts_916_917.py](../tests/test_context_contracts_916_917.py) | 2026-08-17 / `a33591e` / perseus v1.0.26 | The bounded rank, ask, and projection paths expose sanitized evidence metadata and explicit non-success states. | Ranking is not a truth oracle and does not replace Vault or Ledger authority. | Caller-supplied records, Context contract schema; context_rank(...) or context_ask(...) | [docs/context-contracts.md](../docs/context-contracts.md) | + +## Freshness and scope + +Each row carries its own freshness rule and claim ceiling. A lifecycle value is not a certification: it describes the evidence state of this repository at the recorded verification point. diff --git a/docs/capability-evidence.json b/docs/capability-evidence.json new file mode 100644 index 00000000..84bfe235 --- /dev/null +++ b/docs/capability-evidence.json @@ -0,0 +1,234 @@ +{ + "schema_version": "perseus-capability-evidence/v1", + "source": "claims.json", + "capabilities": [ + { + "id": "context-dag-compilation", + "capability": "Auditable budgeted context-DAG compilation", + "owner": "Context DAG compiler", + "lifecycle": "tested", + "evidence_class": "offline_tests", + "evidence_refs": [ + "src/perseus/context_dag.py", + "tests/test_context_dag.py" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-verify when graph, expansion, verdict, or budget logic changes.", + "claim_ceiling": "The compiler emits digest-sealed graph and packet decisions under explicit hard limits.", + "non_claims": [ + "Rendered accounting is not provider billing or a hardware benchmark." + ], + "dependencies": [ + "Caller fetch function", + "Explicit compilation policy" + ], + "activation": "compile_context_dag(...)", + "proof_surface": [ + "docs/context-dag.md" + ] + }, + { + "id": "context-rendering", + "capability": "Deterministic live context rendering", + "owner": "Perseus renderer", + "lifecycle": "operational", + "evidence_class": "code_and_tests", + "evidence_refs": [ + "src/perseus/renderer.py", + "tests/test_renderer.py" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-verify when renderer, resolver, or generated artifact changes.", + "claim_ceiling": "The shipped local renderer resolves supported directives deterministically for the tested contract.", + "non_claims": [ + "This does not certify arbitrary third-party directives or downstream model behavior." + ], + "dependencies": [ + "Python standard library", + "PyYAML" + ], + "activation": "perseus render ", + "proof_surface": [ + "docs/PRODUCT_CONTRACT.md" + ] + }, + { + "id": "external-model-inference", + "capability": "External model inference owned by Perseus", + "owner": "Runtime integration boundary", + "lifecycle": "not_demonstrated", + "evidence_class": "non_claim", + "evidence_refs": [ + "docs/RESOLVER_VS_GENERATOR.md", + "docs/context-contracts.md" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-evaluate only when a separately verified runtime adapter is published.", + "claim_ceiling": "Perseus supplies bounded context and evidence contracts; no owned inference capability is asserted.", + "non_claims": [ + "No vendor, accelerator, model quality, or embedded execution result is demonstrated here." + ], + "dependencies": [ + "A separately qualified runtime adapter" + ], + "activation": "Not available in the current core contract", + "proof_surface": [ + "docs/RESOLVER_VS_GENERATOR.md" + ] + }, + { + "id": "optional-context-extensions", + "capability": "Optional extension and plugin adapters", + "owner": "Registry and adapter boundary", + "lifecycle": "omitted", + "evidence_class": "boundary_documentation", + "evidence_refs": [ + "src/perseus/registry.py", + "docs/context-cartridges.md" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-verify when extension loading or adapter registration changes.", + "claim_ceiling": "Optional extensions are isolated from the dependency-free core and require their declared boundary.", + "non_claims": [ + "No unreviewed extension is part of the default runtime guarantee." + ], + "dependencies": [ + "Explicit extension configuration" + ], + "activation": "Opt-in registry or adapter configuration", + "proof_surface": [ + "docs/CONTEXT-ADAPTER-SDK.md" + ] + }, + { + "id": "portable-context-artifacts", + "capability": "Hash-bound portable context artifacts", + "owner": "Context artifact compiler", + "lifecycle": "implemented", + "evidence_class": "code_and_tests", + "evidence_refs": [ + "src/perseus/context_artifacts.py", + "tests/test_context_artifacts.py" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-verify when artifact schemas or budget accounting changes.", + "claim_ceiling": "Artifacts carry deterministic schema, source commitments, and bounded sections.", + "non_claims": [ + "A commitment proves integrity of supplied inputs, not their truth." + ], + "dependencies": [ + "Python standard library" + ], + "activation": "build_agent_context_artifact(...) or build_memento_artifact(...)", + "proof_surface": [ + "docs/context-artifact-contract.md" + ] + }, + { + "id": "retired-synthetic-headline", + "capability": "Retired asymmetric benchmark headline", + "owner": "Claims registry", + "lifecycle": "historical", + "evidence_class": "historical_artifact", + "evidence_refs": [ + "benchmark/ultimate_suite_results.json", + "docs/claims-pipeline.md" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Retain only while the historical audit record is useful; never promote to current state.", + "claim_ceiling": "This row records why the former headline was retired and is not a current capability claim.", + "non_claims": [ + "The historical figure is not a current performance result." + ], + "dependencies": [ + "Claims audit history" + ], + "activation": "Historical documentation only", + "proof_surface": [ + "docs/claims-pipeline.md" + ] + }, + { + "id": "vault-context-fallback", + "capability": "Vault-backed context with explicit degraded fallback", + "owner": "Vault connector", + "lifecycle": "degraded", + "evidence_class": "integration_contract", + "evidence_refs": [ + "src/perseus/vault_connector.py", + "tests/test_vault_served_contract.py" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-verify when connector transport, fallback, or serving policy changes.", + "claim_ceiling": "Unavailable Vault integration is surfaced as a bounded degraded path rather than an equivalent complete result.", + "non_claims": [ + "The fallback is not a replacement for Vault durability, provenance, or retrieval authority." + ], + "dependencies": [ + "Optional Perseus Vault service", + "Workspace configuration" + ], + "activation": "perseus_vault configuration with context rendering", + "proof_surface": [ + "docs/DEPLOYMENT.md" + ] + }, + { + "id": "versioned-context-contracts", + "capability": "Versioned evidence-aware context contracts", + "owner": "Context contract boundary", + "lifecycle": "tested", + "evidence_class": "contract_tests", + "evidence_refs": [ + "src/perseus/context_contract.py", + "tests/test_context_contracts_916_917.py" + ], + "last_verified": { + "commit": "a33591e", + "build": "perseus v1.0.26", + "date": "2026-08-17" + }, + "freshness_rule": "Re-verify when context-contract schema or projection fields change.", + "claim_ceiling": "The bounded rank, ask, and projection paths expose sanitized evidence metadata and explicit non-success states.", + "non_claims": [ + "Ranking is not a truth oracle and does not replace Vault or Ledger authority." + ], + "dependencies": [ + "Caller-supplied records", + "Context contract schema" + ], + "activation": "context_rank(...) or context_ask(...)", + "proof_surface": [ + "docs/context-contracts.md" + ] + } + ] +} diff --git a/docs/claims-pipeline.md b/docs/claims-pipeline.md index da82de36..fe6b9e92 100644 --- a/docs/claims-pipeline.md +++ b/docs/claims-pipeline.md @@ -55,6 +55,29 @@ public figure derives from here. Each claim records: tokens are absent from public surfaces, and (c) `publishable: false` claims do not leak onto public marketing surfaces. +## Capability evidence matrix (#979) + +The top-level `capabilities` array in `claims.json` is the canonical source for +capability-level evidence. Each row records the owning component, one of the +explicit lifecycle states (`implemented`, `tested`, `operational`, `degraded`, +`omitted`, `historical`, or `not_demonstrated`), evidence references, +verification commit/build/date, a freshness rule, claim ceiling, non-claims, +dependencies/activation, and a proof-surface link. + +Generate the machine-readable and human-readable projections with: + +```bash +python scripts/render_claims.py --write +``` + +The command writes `docs/capability-evidence.json` and +`docs/CAPABILITY-EVIDENCE.md`. CI/test code should use `--check` and +`tests/test_capability_evidence.py`; local evidence references must resolve, +current-state rows must carry a complete evidence envelope and claim ceiling, +and generated surfaces reject credential/raw-material fields. The matrix is a +capability-state ledger, not independent certification and not a replacement +for Ledger receipt evidence or Vault provenance. + ## Follow-up The Perseus efficiency artifacts (`token_reduction_pct`, `semantic_equivalence`, diff --git a/docs/context-contracts.md b/docs/context-contracts.md index 2cc827ef..c16fd358 100644 --- a/docs/context-contracts.md +++ b/docs/context-contracts.md @@ -42,6 +42,18 @@ metadata-only release receipt. provenance class, and valid/recorded times. It does not contain source bodies, private fields, prompts, credentials, or tool arguments. +## Evidence coverage projection + +`context_rank` adds an `evidence_projection` field from +`perseus-context-evidence/v1`. It keeps provider status separate from item +coverage (`evidence_backed`, `partial`, `conflicted`, `stale`, `empty`, +`unavailable`, or `timeout`) and turns non-backed states into +`abstention_required` when the caller sets `policy.evidence_required=true`. +Relevance scores remain ordering diagnostics and never upgrade uncertain +evidence. The projection contains only sanitized source references, evidence +digests, valid/transaction timestamps, uncertainty, and bounded inclusion or +exclusion reasons. + ## Projection and consent A projection is bound to `agent_id`, tenant/workspace/topic scope, request class, diff --git a/docs/context-evidence-projection.md b/docs/context-evidence-projection.md new file mode 100644 index 00000000..2fd2d86d --- /dev/null +++ b/docs/context-evidence-projection.md @@ -0,0 +1,69 @@ +# Context evidence, uncertainty, and abstention projection (#982) + +Perseus now exposes a deterministic projection layer for normalized evidence +already supplied by Vault/Ledger integrations or a caller. It does not retrieve, +store, adjudicate, or duplicate either authority. The implementation is +`src/perseus/context_evidence.py`; the machine contract is +`schemas/context-evidence.schema.yaml`. + +## Coverage vocabulary + +| State | Meaning | Evidence-required behavior | +|---|---|---| +| `evidence_backed` | selected items have sanitized source references and a digest | may complete | +| `partial` | provider or selected evidence is incomplete/degraded | abstain | +| `conflicted` | selected evidence contains an unresolved contradiction | abstain/review | +| `stale` | selected evidence is outside its freshness boundary | abstain | +| `empty` | no evidence-backed item was selected | abstain | +| `unavailable` | a provider could not supply evidence | abstain | +| `timeout` | a provider exceeded its bounded window | abstain | +| `abstention_required` | output status when `evidence_required=True` and coverage is not backed | refusal signal | + +Provider status is carried separately from item coverage. This preserves the +difference between “no record exists” (`empty`) and “the provider could not be +queried” (`unavailable`/`timeout`). + +## Python surface + +```python +import perseus + +projection = perseus.project_context_evidence( + entries, + provider_states={"vault": "active", "ledger": "active"}, + evidence_required=True, +) + +assert projection["coverage"]["state"] in { + "evidence_backed", "partial", "conflicted", "stale", "empty", + "unavailable", "timeout", +} +if projection["coverage"]["abstention_required"]: + # Do not turn this path into a best-guess answer. + refuse_or_escalate(projection) +``` + +Each selected item contains only a bounded candidate ID, sanitized source +references, an evidence digest, valid/transaction/recorded timestamps when they +match the contract, an uncertainty descriptor, and a bounded inclusion reason. +The projection never emits source bodies, prompts, credentials, or raw tool +arguments. If a caller supplies a body solely to establish a commitment, only +its SHA-256 crosses the boundary. + +`verify_context_evidence()` recomputes the digest. `render_context_evidence()` +produces deterministic Markdown after verification. The digest excludes no +meaningful evidence field; it simply omits volatile execution timestamps because +this projection does not generate any. + +## Context-contract composition + +`context_rank()` includes an additive `evidence_projection` field. The existing +rank score still orders candidates, but `relevance_is_not_truth_gate` is an +explicit diagnostic and scores never upgrade uncertain evidence. With +`policy={"evidence_required": True}`, stale, partial, conflicted, empty, +unavailable, and timeout states produce an explicit abstention projection. + +The projection is a view over caller-owned normalized records. Vault remains the +durable memory/retrieval authority; Ledger remains the evidence/provenance +surface; the adapter seam in #981 consumes commitments rather than copying +private context. diff --git a/docs/execution-profiles.md b/docs/execution-profiles.md new file mode 100644 index 00000000..042e1806 --- /dev/null +++ b/docs/execution-profiles.md @@ -0,0 +1,85 @@ +# Resource-aware execution profiles (#980) + +Perseus can resolve a bounded context plan against a portable execution profile +without owning inference or choosing a hardware/vendor backend. The contract is +implemented by `src/perseus/execution_profiles.py` and described by +`schemas/execution-profile.schema.yaml`. + +## Modes + +| Mode | Intended boundary | Default network policy | +|---|---|---| +| `standard-local` | ordinary local execution | `local` | +| `constrained-edge` | bounded memory/latency edge execution | `local` | +| `air-gapped` | disconnected or explicitly isolated execution | `offline` | + +Modes are labels for limits and policy, not hardware claims. A profile never +contains credentials, raw context, provider payloads, or measured performance. + +## Profile and resolution + +```python +import perseus + +profile = perseus.ExecutionProfile.from_mapping({ + "schema_version": "perseus-execution-profile/v1", + "profile_id": "edge-small", + "mode": "constrained-edge", + "max_context_tokens": 2048, + "max_context_bytes": 8192, + "max_items": 16, + "max_depth": 2, + "latency_target_ms": 500, + "resource_class": "edge", + "network_mode": "local", + "runtime_capabilities": ["streaming"], + "degradation_policy": "partial", + "auth_mode": "none", +}) + +resolved = perseus.resolve_execution_profile( + profile, + requirements={"max_context_tokens": 1200}, + resources={"memory_class": "small"}, +) +``` + +Resolution takes the intersection of profile and caller hard limits. It returns +an explicit `effective` profile, `compilation_budget`, `resource_state`, +`status`, `diagnostics`, and a stable `profile_digest`. Missing resource +metadata is reported as `resource_state: unknown`; the resolver does not invent +memory, compute, power, or latency measurements. + +`negotiate_context_budget(...)` is a convenience wrapper for callers that have +only requested context limits. `verify_execution_profile(...)` recomputes the +manifest commitment before a downstream compiler trusts it. + +## Context-DAG composition + +`compile_context_dag(...)` accepts an optional `execution_profile`, +`profile_requirements`, and `profile_retrieval_status`. The profile tightens an +existing `CompilationBudget` but can never widen it. A compiled artifact then +contains: + +- `execution_profile` — the sanitized resolved manifest; +- `execution_profile_digest` — the profile commitment; +- `profile_diagnostics` — explicit degraded/abstention reasons; +- `status` — `complete` or `degraded` when profile negotiation reports a + partial/unavailable retrieval state. + +The existing DAG digest seals this profile projection. `verify_compiled_dag` +rechecks it, so a changed effective budget or diagnostic cannot masquerade as +the original compilation. + +## Fail-closed boundaries + +- An air-gapped profile rejects an approved-network requirement. +- Missing runtime capabilities reject a hard capability requirement. +- Resource metadata is optional but never fabricated. +- Partial, unavailable, and timeout retrieval states are visible in + diagnostics; they are not converted into a complete result. +- Profile limits are rendered-accounting limits, not provider-billed savings or + low-SWAP/hardware evidence. + +The profile is a context-planning seam. Model inference, provider selection, +accelerator code, and neuromorphic research remain outside Perseus. diff --git a/docs/runtime-adapter-contract.md b/docs/runtime-adapter-contract.md new file mode 100644 index 00000000..63650628 --- /dev/null +++ b/docs/runtime-adapter-contract.md @@ -0,0 +1,82 @@ +# Portable local/edge runtime adapter contract (#981) + +Perseus supplies bounded, evidence-aware context and consumes a runtime result. +It does not bundle a model runtime, select a vendor, choose an accelerator, or +claim spiking/neuromorphic inference. The contract is implemented in +`src/perseus/runtime_adapter.py` and described by +`schemas/runtime-adapter.schema.yaml`. + +## Envelopes + +### Capabilities — `perseus-runtime-capabilities/v1` + +A backend advertises only: + +- backend/runtime and model/tokenizer identifiers and versions; +- context capacity in tokens; +- supported execution modes: `offline`, `local`, or `approved_network`; +- streaming/tool booleans; +- optional hardware class and resource-metric names; +- authentication mode and provider provenance. + +Authentication values, API keys, bearer material, prompts, context bodies, and +private memory are not fields in this contract. + +### Request — `perseus-runtime-request/v1` + +An `AdapterRequest` carries a verified #980 resolved execution-profile +manifest, its profile digest, context/evidence/input digests, an execution mode, +required capability flags, and a maximum output size. It carries commitments, +not the underlying private context or evidence. + +```python +request = perseus.AdapterRequest.from_mapping({ + "schema_version": "perseus-runtime-request/v1", + "request_id": "run-1", + "execution_profile": resolved_profile, + "context_digest": "a" * 64, + "evidence_digest": "b" * 64, + "input_digest": "c" * 64, + "execution_mode": "offline", + "required_capabilities": {"streaming": True}, + "max_output_chars": 2048, +}) +``` + +### Result — `perseus-runtime-result/v1` + +Every result has an explicit status: + +- `success` — bounded output is available; +- `partial` — bounded output is available but the runtime reports degradation; +- `unavailable` — the backend could not provide a result; +- `timeout` — the bounded execution window expired; +- `cancelled` — execution was cancelled; +- `malformed` — the backend output failed envelope validation. + +Results carry bounded output/usage and sanitized runtime/model provenance. The +`external_fallback_allowed` field is always `false`; a caller must explicitly +select and negotiate another qualified adapter rather than silently switching +providers. + +## Negotiation + +`negotiate_runtime_capabilities(requirements, offered)` returns a deterministic +`perseus-runtime-negotiation/v1` manifest. Unsupported execution modes, +insufficient context capacity, missing streaming/tools support, missing resource +metrics, and backend identity mismatches produce `status: rejected` and a +non-empty `missing` list. No network request or fallback selection occurs. + +## Reference adapter + +`ReferenceRuntimeAdapter` is deterministic and offline. Its configurable +`behavior` exercises every result status for tests and integration bring-up; +it is not an inference implementation or a performance benchmark. The adapter +never calls a provider and does not require credentials. + +## Safety boundary + +The adapter seam composes with #980 profile resolution and #982 evidence +projection through digests and explicit statuses. Vault remains the durable +memory/retrieval authority, Ledger remains the evidence/provenance authority, +and Perseus remains the bounded context compiler. diff --git a/perseus.py b/perseus.py index 50ce4d63..757a82e1 100644 --- a/perseus.py +++ b/perseus.py @@ -79,7 +79,7 @@ def _perseus_lazy_urllib_request(name, _urllib=urllib): # ── Build provenance (injected by scripts/build.py at build time) ─────────── # Short git SHA of the source revision the artifact was built from (#853). # Empty when unknown (unbuilt source tree without git metadata). -_PERSEUS_BUILD_SHA = "8fff46d" # replaced at build time by scripts/build.py — see #853 +_PERSEUS_BUILD_SHA = "a33591e-dirty" # replaced at build time by scripts/build.py — see #853 def _perseus_build_sha() -> str: @@ -11806,6 +11806,46 @@ def obj(properties: dict, required: tuple[str, ...] = ()) -> dict: "revocation_epoch": {"type": "integer", "minimum": 0}, }, required=("schema_version", "receipt_id", "projection_digest", "release_decision", "status")) + evidence_coverage = obj({ + "state": {"type": "string", "enum": ["evidence_backed", "partial", "conflicted", "stale", "empty", "unavailable", "timeout"]}, + "reason": {"type": "string", "maxLength": 256}, + "provider_states": obj({ + "vault": {"type": "string", "enum": ["active", "partial", "degraded", "unavailable", "timeout", "not_configured"]}, + "ledger": {"type": "string", "enum": ["active", "partial", "degraded", "unavailable", "timeout", "not_configured"]}, + }), + "evidence_required": {"type": "boolean"}, + "abstention_required": {"type": "boolean"}, + }, required=("state", "reason", "provider_states", "evidence_required", "abstention_required")) + evidence_item = obj({ + "candidate_id": string_id, + "source_refs": {"type": "array", "maxItems": 64, "items": string_id}, + "evidence_digest": sha256, + "coverage_state": {"type": "string", "enum": ["evidence_backed", "partial", "conflicted", "stale", "empty", "unavailable", "timeout"]}, + "uncertainty": uncertainty, + "inclusion_reason": {"type": "string", "maxLength": 256}, + "valid_at": {"type": "string", "maxLength": 64}, + "transaction_time": {"type": "string", "maxLength": 64}, + "recorded_at": {"type": "string", "maxLength": 64}, + "observed_at": {"type": "string", "maxLength": 64}, + }, required=("candidate_id", "source_refs", "evidence_digest", "coverage_state", "uncertainty", "inclusion_reason")) + evidence_excluded = obj({ + "candidate_id": string_id, + "reason": {"type": "string", "maxLength": 256}, + }, required=("candidate_id", "reason")) + evidence_projection = obj({ + "schema_version": {"type": "string", "const": "perseus-context-evidence/v1"}, + "status": {"type": "string", "enum": ["complete", "degraded", "review", "empty", "unavailable", "abstention_required"]}, + "coverage": evidence_coverage, + "selected": {"type": "array", "maxItems": 64, "items": evidence_item}, + "excluded": {"type": "array", "maxItems": 128, "items": evidence_excluded}, + "diagnostics": obj({ + "relevance_is_not_truth_gate": {"type": "boolean", "const": True}, + "selected_count": {"type": "integer", "minimum": 0}, + "excluded_count": {"type": "integer", "minimum": 0}, + }, required=("relevance_is_not_truth_gate", "selected_count", "excluded_count")), + "projection_digest": sha256, + }, required=("schema_version", "status", "coverage", "selected", "excluded", "diagnostics", "projection_digest")) + return obj({ "schema_version": {"type": "string", "enum": [ "perseus-context-rank/v1", "perseus-context-ask/v1", @@ -11855,6 +11895,7 @@ def obj(properties: dict, required: tuple[str, ...] = ()) -> dict: "evidence": {"type": "array", "maxItems": 64, "items": evidence}, "projection": projection, "projection_digest": sha256, + "evidence_projection": evidence_projection, "selection": {"type": "array", "maxItems": 64, "items": selection}, "provenance": {"type": "array", "maxItems": 64, "items": {"type": "array", "maxItems": 64, "items": evidence}}, "release_decision": {"type": "string", "enum": [ @@ -35722,6 +35763,1273 @@ def decision_from_prompt_size(report: dict, **policy: Any) -> dict: return decide_context_route(actual_tokens=actual, counterfactual_tokens=counterfactual, **policy) +"""Versioned, resource-aware execution profiles for context compilation (#980). + +This module defines a deterministic planning contract only. It does not select a +model, call a provider, measure hardware, or persist resource telemetry. A +profile describes hard context limits and explicit degradation/network policy; +resolution returns a sanitized manifest that a compiler or runtime adapter can +consume. +""" + +import hashlib +import json +import re +from dataclasses import dataclass +from typing import Any, Mapping + +_EP_SCHEMA_VERSION = "perseus-execution-profile/v1" +_EP_DIAGNOSTIC_SCHEMA_VERSION = "perseus-execution-diagnostics/v1" +_EP_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:/#@+\-]{0,159}$") +_EP_ALLOWED_FIELDS = frozenset({ + "schema_version", "profile_id", "mode", "max_context_tokens", + "max_context_bytes", "max_items", "max_depth", "latency_target_ms", + "resource_class", "network_mode", "runtime_capabilities", + "degradation_policy", "auth_mode", "runtime_ref", "model_ref", +}) +_EP_REQUIREMENT_FIELDS = frozenset({ + "network_mode", "require_offline", "required_capabilities", + "max_context_tokens", "max_context_bytes", "max_items", "max_depth", + "latency_target_ms", +}) +_EP_RESOURCE_FIELDS = frozenset({ + "memory_class", "compute_class", "network_available", + "available_memory_mb", "available_compute_units", "resource_metrics", +}) +_EP_NETWORK_MODES = frozenset({"offline", "local", "approved_network"}) +_EP_DEGRADATION_POLICIES = frozenset({"fail_closed", "partial", "omit_low_priority"}) +_EP_RETRIEVAL_STATES = frozenset({"complete", "partial", "degraded", "unavailable", "timeout"}) +_EP_MODE_DEFAULTS = { + "standard-local": { + "max_context_tokens": 8192, + "max_context_bytes": 32768, + "max_items": 64, + "max_depth": 4, + "latency_target_ms": None, + "resource_class": "unknown", + "network_mode": "local", + "degradation_policy": "partial", + }, + "constrained-edge": { + "max_context_tokens": 2048, + "max_context_bytes": 8192, + "max_items": 16, + "max_depth": 2, + "latency_target_ms": 500, + "resource_class": "edge", + "network_mode": "local", + "degradation_policy": "partial", + }, + "air-gapped": { + "max_context_tokens": 4096, + "max_context_bytes": 16384, + "max_items": 32, + "max_depth": 3, + "latency_target_ms": None, + "resource_class": "isolated", + "network_mode": "offline", + "degradation_policy": "fail_closed", + }, +} +_EP_FORBIDDEN_KEYS = frozenset({ + "api_key", "authorization", "body", "content", "credential", "credentials", + "password", "private_body", "prompt", "raw", "raw_payload", "secret", + "token", "tool_args", "tool_arguments", +}) + + +class ExecutionProfileError(ValueError): + """Raised when a profile or hard capability requirement is invalid.""" + + +def _ep_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False) + + +def _ep_sha(value: Any) -> str: + return hashlib.sha256(_ep_json(value).encode("utf-8")).hexdigest() + + +def _ep_text(value: Any, field: str, *, max_length: int = 160) -> str: + if not isinstance(value, str) or not value.strip(): + raise ExecutionProfileError(f"{field} must be a non-empty string") + text = value.strip() + if len(text) > max_length: + raise ExecutionProfileError(f"{field} is too long") + if any(key in text.casefold().replace("-", "_") for key in _EP_FORBIDDEN_KEYS): + raise ExecutionProfileError(f"{field} contains forbidden credential/raw-data marker") + return text + + +def _ep_id(value: Any, field: str, *, default: str = "") -> str: + text = _ep_text(value if value is not None else default, field) + if not _EP_ID_RE.fullmatch(text): + raise ExecutionProfileError(f"{field} must be a bounded identifier") + return text + + +def _ep_limit(value: Any, field: str, *, maximum: int = 10_000_000) -> int: + if isinstance(value, bool): + raise ExecutionProfileError(f"{field} must be a positive integer") + try: + number = int(value) + except (TypeError, ValueError): + raise ExecutionProfileError(f"{field} must be a positive integer") from None + if number < 1 or number > maximum: + raise ExecutionProfileError(f"{field} must be between 1 and {maximum}") + return number + + +def _ep_optional_limit(value: Any, field: str, *, maximum: int = 10_000_000) -> int | None: + if value is None or value == "": + return None + return _ep_limit(value, field, maximum=maximum) + + +def _ep_forbidden_keys(value: Any, path: str = "profile") -> None: + if isinstance(value, Mapping): + for key, nested in value.items(): + normalized = str(key).casefold().replace("-", "_") + if normalized in _EP_FORBIDDEN_KEYS: + raise ExecutionProfileError(f"{path}.{key} is not permitted") + _ep_forbidden_keys(nested, f"{path}.{key}") + elif isinstance(value, (list, tuple)): + for index, nested in enumerate(value): + _ep_forbidden_keys(nested, f"{path}[{index}]") + + +@dataclass(frozen=True) +class ExecutionProfile: + """A bounded, portable description of the resources a compiler may use.""" + + schema_version: str + profile_id: str + mode: str + max_context_tokens: int + max_context_bytes: int + max_items: int + max_depth: int + latency_target_ms: int | None + resource_class: str + network_mode: str + runtime_capabilities: tuple[str, ...] + degradation_policy: str + auth_mode: str + runtime_ref: str = "" + model_ref: str = "" + + @classmethod + def from_mapping(cls, value: Mapping[str, Any] | "ExecutionProfile" | None) -> "ExecutionProfile": + if isinstance(value, cls): + return value + if value is None: + value = {"mode": "standard-local", "profile_id": "default-local"} + if not isinstance(value, Mapping): + raise ExecutionProfileError("execution profile must be an object") + _ep_forbidden_keys(value) + unknown = set(value) - _EP_ALLOWED_FIELDS + if unknown: + raise ExecutionProfileError(f"unsupported execution profile fields: {sorted(map(str, unknown))}") + mode = str(value.get("mode", "standard-local")).strip() + if mode not in _EP_MODE_DEFAULTS: + raise ExecutionProfileError(f"unsupported execution profile mode: {mode!r}") + defaults = _EP_MODE_DEFAULTS[mode] + schema_version = str(value.get("schema_version", _EP_SCHEMA_VERSION)).strip() + if schema_version != _EP_SCHEMA_VERSION: + raise ExecutionProfileError("unsupported execution profile schema version") + profile_id = _ep_id(value.get("profile_id", mode), "profile_id") + network_mode = str(value.get("network_mode", defaults["network_mode"])).strip() + if network_mode not in _EP_NETWORK_MODES: + raise ExecutionProfileError("network_mode must be offline, local, or approved_network") + if mode == "air-gapped" and network_mode != "offline": + raise ExecutionProfileError("air-gapped mode requires offline network_mode") + degradation_policy = str(value.get("degradation_policy", defaults["degradation_policy"])).strip() + if degradation_policy not in _EP_DEGRADATION_POLICIES: + raise ExecutionProfileError("unsupported degradation_policy") + capabilities = value.get("runtime_capabilities", ()) + if isinstance(capabilities, str): + capabilities = [capabilities] + if not isinstance(capabilities, (list, tuple)) or len(capabilities) > 32: + raise ExecutionProfileError("runtime_capabilities must contain at most 32 identifiers") + capability_values = tuple(sorted({_ep_id(item, "runtime_capability") for item in capabilities})) + auth_mode = _ep_text(value.get("auth_mode", "none"), "auth_mode", max_length=64) + runtime_ref = _ep_id(value.get("runtime_ref", "ref:none"), "runtime_ref") + model_ref = _ep_id(value.get("model_ref", "ref:none"), "model_ref") + return cls( + schema_version=schema_version, + profile_id=profile_id, + mode=mode, + max_context_tokens=_ep_limit(value.get("max_context_tokens", defaults["max_context_tokens"]), "max_context_tokens"), + max_context_bytes=_ep_limit(value.get("max_context_bytes", defaults["max_context_bytes"]), "max_context_bytes"), + max_items=_ep_limit(value.get("max_items", defaults["max_items"]), "max_items", maximum=4096), + max_depth=_ep_limit(value.get("max_depth", defaults["max_depth"]), "max_depth", maximum=128), + latency_target_ms=_ep_optional_limit(value.get("latency_target_ms", defaults["latency_target_ms"]), "latency_target_ms", maximum=86_400_000), + resource_class=_ep_text(value.get("resource_class", defaults["resource_class"]), "resource_class", max_length=64), + network_mode=network_mode, + runtime_capabilities=capability_values, + degradation_policy=degradation_policy, + auth_mode=auth_mode, + runtime_ref=runtime_ref, + model_ref=model_ref, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "profile_id": self.profile_id, + "mode": self.mode, + "max_context_tokens": self.max_context_tokens, + "max_context_bytes": self.max_context_bytes, + "max_items": self.max_items, + "max_depth": self.max_depth, + "latency_target_ms": self.latency_target_ms, + "resource_class": self.resource_class, + "network_mode": self.network_mode, + "runtime_capabilities": list(self.runtime_capabilities), + "degradation_policy": self.degradation_policy, + "auth_mode": self.auth_mode, + "runtime_ref": self.runtime_ref, + "model_ref": self.model_ref, + } + + +def _ep_requirements(value: Mapping[str, Any] | None) -> dict[str, Any]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise ExecutionProfileError("profile requirements must be an object") + _ep_forbidden_keys(value, "requirements") + unknown = set(value) - _EP_REQUIREMENT_FIELDS + if unknown: + raise ExecutionProfileError(f"unsupported profile requirements: {sorted(map(str, unknown))}") + result: dict[str, Any] = {} + for field in ("max_context_tokens", "max_context_bytes", "max_items", "max_depth", "latency_target_ms"): + if field in value and value[field] is not None: + result[field] = _ep_limit(value[field], f"requirements.{field}", maximum=86_400_000 if field == "latency_target_ms" else 10_000_000) + required = value.get("required_capabilities", ()) + if isinstance(required, str): + required = [required] + if not isinstance(required, (list, tuple)) or len(required) > 32: + raise ExecutionProfileError("required_capabilities must contain at most 32 identifiers") + result["required_capabilities"] = sorted({_ep_id(item, "required_capability") for item in required}) + if "network_mode" in value: + network_mode = str(value["network_mode"]).strip() + if network_mode not in _EP_NETWORK_MODES: + raise ExecutionProfileError("requirements.network_mode is unsupported") + result["network_mode"] = network_mode + result["require_offline"] = value.get("require_offline", False) + if not isinstance(result["require_offline"], bool): + raise ExecutionProfileError("requirements.require_offline must be boolean") + return result + + +def _ep_resources(value: Mapping[str, Any] | None) -> tuple[dict[str, Any], str]: + if value is None: + return {}, "unknown" + if not isinstance(value, Mapping): + raise ExecutionProfileError("resources must be an object") + _ep_forbidden_keys(value, "resources") + unknown = set(value) - _EP_RESOURCE_FIELDS + if unknown: + raise ExecutionProfileError(f"unsupported resource fields: {sorted(map(str, unknown))}") + result: dict[str, Any] = {} + for field in ("memory_class", "compute_class"): + if value.get(field) is not None: + result[field] = _ep_text(value[field], f"resources.{field}", max_length=64) + if value.get("network_available") is not None: + if not isinstance(value["network_available"], bool): + raise ExecutionProfileError("resources.network_available must be boolean") + result["network_available"] = value["network_available"] + for field in ("available_memory_mb", "available_compute_units"): + if value.get(field) is not None: + result[field] = _ep_limit(value[field], f"resources.{field}", maximum=10_000_000_000) + metrics = value.get("resource_metrics") + if metrics is not None: + if isinstance(metrics, str): + metrics = [metrics] + if not isinstance(metrics, (list, tuple)) or len(metrics) > 32: + raise ExecutionProfileError("resources.resource_metrics must contain at most 32 names") + result["resource_metrics"] = sorted({_ep_id(item, "resource_metric") for item in metrics}) + return result, "known" if result else "unknown" + + +def execution_profile_compilation_budget(resolved: Mapping[str, Any]) -> dict[str, Any]: + """Project a resolved profile into the existing DAG budget vocabulary.""" + if not isinstance(resolved, Mapping) or not isinstance(resolved.get("effective"), Mapping): + raise ExecutionProfileError("resolved execution profile is malformed") + effective = resolved["effective"] + max_tokens = min(int(effective["max_context_tokens"]), max(1, int(effective["max_context_bytes"]) // 4)) + latency = effective.get("latency_target_ms") + return { + "max_nodes": max(1, int(effective["max_items"])), + "max_depth": max(1, int(effective["max_depth"])), + "max_fanout": max(1, int(effective["max_items"])), + "max_tokens": max_tokens, + "deadline_s": max(0.001, float(latency) / 1000.0) if latency is not None else 30.0, + } + + +def _ep_resolve_execution_profile_impl( + profile: Mapping[str, Any] | ExecutionProfile | None = None, + *, + requirements: Mapping[str, Any] | None = None, + resources: Mapping[str, Any] | None = None, + retrieval_status: str = "complete", +) -> dict[str, Any]: + """Resolve hard limits and capabilities into a digest-sealed manifest.""" + base = ExecutionProfile.from_mapping(profile) + req = _ep_requirements(requirements) + if retrieval_status not in _EP_RETRIEVAL_STATES: + raise ExecutionProfileError("retrieval_status is unsupported") + if req.get("network_mode") and base.network_mode == "offline" and req["network_mode"] != "offline": + raise ExecutionProfileError("offline profile cannot satisfy a network requirement") + if req.get("require_offline") and base.network_mode != "offline": + raise ExecutionProfileError("profile does not satisfy required offline mode") + missing = sorted(set(req.get("required_capabilities", ())) - set(base.runtime_capabilities)) + if missing: + raise ExecutionProfileError(f"required capabilities are unsupported: {', '.join(missing)}") + safe_resources, resource_state = _ep_resources(resources) + effective = base.to_dict() + for field in ("max_context_tokens", "max_context_bytes", "max_items", "max_depth", "latency_target_ms"): + if field in req and req[field] is not None: + effective[field] = min(int(effective[field]), int(req[field])) + if effective["max_context_tokens"] < 1 or effective["max_context_bytes"] < 1 or effective["max_items"] < 1 or effective["max_depth"] < 1: + raise ExecutionProfileError("requirements leave no usable context budget") + reasons: list[str] = [] + status = "complete" + abstention_required = False + if retrieval_status in {"partial", "degraded"}: + status = "degraded" + reasons.append("retrieval_partial") + elif retrieval_status in {"unavailable", "timeout"}: + status = "degraded" + reasons.append(f"retrieval_{retrieval_status}") + abstention_required = True + diagnostics = { + "schema_version": _EP_DIAGNOSTIC_SCHEMA_VERSION, + "degraded": bool(reasons), + "reasons": reasons, + "resource_state": resource_state, + "abstention_required": abstention_required, + "degradation_policy": base.degradation_policy, + } + resolved: dict[str, Any] = { + "schema_version": _EP_SCHEMA_VERSION, + "profile": base.to_dict(), + "effective": effective, + "requirements": req, + "resources": safe_resources, + "resource_state": resource_state, + "status": status, + "diagnostics": diagnostics, + } + resolved["compilation_budget"] = execution_profile_compilation_budget(resolved) + resolved["profile_digest"] = _ep_sha(resolved) + return resolved + + +class _EPExecutionProfileResolver: + """Callable API object kept outside the directive resolver registry.""" + + def __call__(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + return _ep_resolve_execution_profile_impl(*args, **kwargs) + + +resolve_execution_profile = _EPExecutionProfileResolver() + + +def negotiate_context_budget( + profile: Mapping[str, Any] | ExecutionProfile | None = None, + *, + requested_tokens: int | None = None, + requested_bytes: int | None = None, + requested_items: int | None = None, + requested_depth: int | None = None, + retrieval_status: str = "complete", + resources: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + requirements: dict[str, Any] = {} + for name, value in ( + ("max_context_tokens", requested_tokens), + ("max_context_bytes", requested_bytes), + ("max_items", requested_items), + ("max_depth", requested_depth), + ): + if value is not None: + requirements[name] = value + return resolve_execution_profile( + profile, + requirements=requirements, + resources=resources, + retrieval_status=retrieval_status, + ) + + +def verify_execution_profile(resolved: Mapping[str, Any]) -> dict[str, Any]: + """Verify the commitment over a resolved, sanitized profile manifest.""" + if not isinstance(resolved, Mapping) or not isinstance(resolved.get("profile_digest"), str): + return {"valid": False, "error": "missing profile digest"} + unsigned = dict(resolved) + supplied = unsigned.pop("profile_digest") + try: + expected = _ep_sha(unsigned) + except (TypeError, ValueError): + return {"valid": False, "error": "profile manifest is not canonical JSON"} + return {"valid": expected == supplied, "profile_digest": supplied, "expected_digest": expected} +"""Portable local/edge runtime adapter envelopes (#981). + +The adapter seam is an integration contract, not an inference engine. Requests +carry only bounded context/profile commitments; results carry explicit status, +sanitary output, usage, and runtime provenance. The bundled reference adapter +never opens a network connection and exists only to exercise the contract. +""" + +import hashlib +import json +import re +from dataclasses import dataclass +from typing import Any, Mapping + + +_RA_CAPABILITIES_SCHEMA = "perseus-runtime-capabilities/v1" +_RA_REQUEST_SCHEMA = "perseus-runtime-request/v1" +_RA_RESULT_SCHEMA = "perseus-runtime-result/v1" +_RA_NEGOTIATION_SCHEMA = "perseus-runtime-negotiation/v1" +_RA_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:/#@+\-]{0,159}$") +_RA_DIGEST_RE = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$") +_RA_EXECUTION_MODES = frozenset({"offline", "local", "approved_network"}) +_RA_RESULT_STATUSES = frozenset({"success", "partial", "unavailable", "timeout", "cancelled", "malformed"}) +_RA_CAPABILITY_FIELDS = frozenset({ + "schema_version", "backend_id", "backend_version", "model_id", "model_version", + "tokenizer_id", "context_capacity_tokens", "execution_modes", "streaming", "tools", + "hardware_class", "resource_metrics", "auth_mode", "provider_ref", +}) +_RA_REQUEST_FIELDS = frozenset({ + "schema_version", "request_id", "execution_profile", "execution_profile_digest", "context_digest", + "evidence_digest", "input_digest", "execution_mode", "required_capabilities", + "max_output_chars", +}) +_RA_RESULT_FIELDS = frozenset({ + "schema_version", "request_id", "status", "output", "usage", "runtime", + "error_code", "error_message", "external_fallback_allowed", +}) +_RA_FORBIDDEN_KEYS = frozenset({ + "api_key", "authorization", "body", "content", "credential", "credentials", + "password", "private_body", "prompt", "raw", "raw_payload", "secret", "token", + "tool_args", "tool_arguments", +}) +_RA_RUNTIME_FIELDS = frozenset({ + "backend_id", "backend_version", "model_id", "model_version", "auth_mode", + "provider_ref", "execution_mode", +}) +_RA_USAGE_FIELDS = frozenset({"input_tokens", "output_tokens", "latency_ms"}) + + +class RuntimeAdapterError(ValueError): + """Raised when an adapter envelope cannot be admitted safely.""" + + +def _ra_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False) + + +def _ra_sha(value: Any) -> str: + return hashlib.sha256(_ra_json(value).encode("utf-8")).hexdigest() + + +def _ra_text(value: Any, field: str, *, max_length: int = 160, allow_empty: bool = False) -> str: + if not isinstance(value, str): + raise RuntimeAdapterError(f"{field} must be text") + text = value.strip() + if not text and not allow_empty: + raise RuntimeAdapterError(f"{field} must not be empty") + if len(text) > max_length: + raise RuntimeAdapterError(f"{field} is too long") + return text + + +def _ra_id(value: Any, field: str, *, allow_empty: bool = False) -> str: + text = _ra_text(value, field, allow_empty=allow_empty) + if text and not _RA_ID_RE.fullmatch(text): + raise RuntimeAdapterError(f"{field} must be a bounded identifier") + return text + + +def _ra_digest(value: Any, field: str) -> str: + text = _ra_text(value, field, max_length=71).lower() + if not _RA_DIGEST_RE.fullmatch(text): + raise RuntimeAdapterError(f"{field} must be a SHA-256 digest") + return text.removeprefix("sha256:") + + +def _ra_limit(value: Any, field: str, *, maximum: int = 10_000_000) -> int: + if isinstance(value, bool): + raise RuntimeAdapterError(f"{field} must be a positive integer") + try: + number = int(value) + except (TypeError, ValueError): + raise RuntimeAdapterError(f"{field} must be a positive integer") from None + if number < 1 or number > maximum: + raise RuntimeAdapterError(f"{field} must be between 1 and {maximum}") + return number + + +def _ra_forbidden_keys(value: Any, path: str = "envelope") -> None: + if isinstance(value, Mapping): + for key, nested in value.items(): + normalized = str(key).casefold().replace("-", "_") + if normalized in _RA_FORBIDDEN_KEYS: + raise RuntimeAdapterError(f"{path}.{key} is not permitted") + _ra_forbidden_keys(nested, f"{path}.{key}") + elif isinstance(value, (list, tuple)): + for index, nested in enumerate(value): + _ra_forbidden_keys(nested, f"{path}[{index}]") + + +def _ra_string_list(value: Any, field: str, *, maximum: int = 32) -> tuple[str, ...]: + if isinstance(value, str): + value = [value] + if not isinstance(value, (list, tuple)) or len(value) > maximum: + raise RuntimeAdapterError(f"{field} must contain at most {maximum} identifiers") + return tuple(sorted({_ra_id(item, field) for item in value})) + + +@dataclass(frozen=True) +class RuntimeCapabilities: + """Sanitized capabilities advertised by one runtime/backend.""" + + schema_version: str + backend_id: str + backend_version: str + model_id: str + model_version: str + tokenizer_id: str + context_capacity_tokens: int + execution_modes: tuple[str, ...] + streaming: bool + tools: bool + hardware_class: str + resource_metrics: tuple[str, ...] + auth_mode: str + provider_ref: str + + @classmethod + def from_mapping(cls, value: Mapping[str, Any] | "RuntimeCapabilities") -> "RuntimeCapabilities": + if isinstance(value, cls): + return value + if not isinstance(value, Mapping): + raise RuntimeAdapterError("runtime capabilities must be an object") + _ra_forbidden_keys(value, "capabilities") + unknown = set(value) - _RA_CAPABILITY_FIELDS + if unknown: + raise RuntimeAdapterError(f"unsupported capability fields: {sorted(map(str, unknown))}") + if value.get("schema_version", _RA_CAPABILITIES_SCHEMA) != _RA_CAPABILITIES_SCHEMA: + raise RuntimeAdapterError("unsupported runtime capabilities schema version") + modes = _ra_string_list(value.get("execution_modes", ()), "execution_modes") + if not modes or not set(modes).issubset(_RA_EXECUTION_MODES): + raise RuntimeAdapterError("execution_modes must contain offline, local, or approved_network") + metrics = _ra_string_list(value.get("resource_metrics", ()), "resource_metrics") + for field in ("backend_id", "backend_version", "model_id", "tokenizer_id", "auth_mode", "provider_ref"): + _ra_id(value.get(field, ""), field) + model_version = _ra_id(value.get("model_version", "unknown"), "model_version") + hardware_class = _ra_id(value.get("hardware_class", "unknown"), "hardware_class") + if not isinstance(value.get("streaming", False), bool) or not isinstance(value.get("tools", False), bool): + raise RuntimeAdapterError("streaming and tools must be booleans") + return cls( + schema_version=_RA_CAPABILITIES_SCHEMA, + backend_id=_ra_id(value["backend_id"], "backend_id"), + backend_version=_ra_id(value["backend_version"], "backend_version"), + model_id=_ra_id(value["model_id"], "model_id"), + model_version=model_version, + tokenizer_id=_ra_id(value["tokenizer_id"], "tokenizer_id"), + context_capacity_tokens=_ra_limit(value["context_capacity_tokens"], "context_capacity_tokens"), + execution_modes=modes, + streaming=value.get("streaming", False), + tools=value.get("tools", False), + hardware_class=hardware_class, + resource_metrics=metrics, + auth_mode=_ra_id(value["auth_mode"], "auth_mode"), + provider_ref=_ra_id(value["provider_ref"], "provider_ref"), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "backend_id": self.backend_id, + "backend_version": self.backend_version, + "model_id": self.model_id, + "model_version": self.model_version, + "tokenizer_id": self.tokenizer_id, + "context_capacity_tokens": self.context_capacity_tokens, + "execution_modes": list(self.execution_modes), + "streaming": self.streaming, + "tools": self.tools, + "hardware_class": self.hardware_class, + "resource_metrics": list(self.resource_metrics), + "auth_mode": self.auth_mode, + "provider_ref": self.provider_ref, + } + + +@dataclass(frozen=True) +class AdapterRequest: + """A digest-only request envelope for a qualified runtime adapter.""" + + schema_version: str + request_id: str + execution_profile: dict[str, Any] + execution_profile_digest: str + context_digest: str + evidence_digest: str + input_digest: str + execution_mode: str + required_capabilities: dict[str, Any] + max_output_chars: int + + @classmethod + def from_mapping(cls, value: Mapping[str, Any] | "AdapterRequest") -> "AdapterRequest": + if isinstance(value, cls): + return value + if not isinstance(value, Mapping): + raise RuntimeAdapterError("adapter request must be an object") + _ra_forbidden_keys(value, "request") + unknown = set(value) - _RA_REQUEST_FIELDS + if unknown: + raise RuntimeAdapterError(f"unsupported request fields: {sorted(map(str, unknown))}") + if value.get("schema_version", _RA_REQUEST_SCHEMA) != _RA_REQUEST_SCHEMA: + raise RuntimeAdapterError("unsupported runtime request schema version") + profile = value.get("execution_profile") + if not isinstance(profile, Mapping): + raise RuntimeAdapterError("request requires a resolved execution_profile") + profile_check = verify_execution_profile(profile) + if not profile_check.get("valid"): + raise RuntimeAdapterError("execution_profile commitment is invalid") + requested = value.get("required_capabilities", {}) + if not isinstance(requested, Mapping): + raise RuntimeAdapterError("required_capabilities must be an object") + allowed_required = {"streaming", "tools", "resource_metrics", "min_context_tokens"} + if set(requested) - allowed_required: + raise RuntimeAdapterError("unsupported required capability field") + normalized_required: dict[str, Any] = {} + for field in ("streaming", "tools"): + if field in requested: + if not isinstance(requested[field], bool): + raise RuntimeAdapterError(f"required_capabilities.{field} must be boolean") + normalized_required[field] = requested[field] + if "resource_metrics" in requested: + normalized_required["resource_metrics"] = list(_ra_string_list(requested["resource_metrics"], "resource_metrics")) + if "min_context_tokens" in requested: + normalized_required["min_context_tokens"] = _ra_limit(requested["min_context_tokens"], "min_context_tokens") + mode = _ra_text(value.get("execution_mode", "local"), "execution_mode", max_length=32) + if mode not in _RA_EXECUTION_MODES: + raise RuntimeAdapterError("execution_mode is unsupported") + profile_digest = _ra_digest(value.get("execution_profile_digest", profile["profile_digest"]), "execution_profile_digest") + if profile_digest != str(profile["profile_digest"]).lower().removeprefix("sha256:"): + raise RuntimeAdapterError("execution_profile_digest does not match execution_profile") + return cls( + schema_version=_RA_REQUEST_SCHEMA, + request_id=_ra_id(value["request_id"], "request_id"), + execution_profile=dict(profile), + execution_profile_digest=profile_digest, + context_digest=_ra_digest(value["context_digest"], "context_digest"), + evidence_digest=_ra_digest(value["evidence_digest"], "evidence_digest"), + input_digest=_ra_digest(value["input_digest"], "input_digest"), + execution_mode=mode, + required_capabilities=normalized_required, + max_output_chars=_ra_limit(value.get("max_output_chars", 2048), "max_output_chars", maximum=1_000_000), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "request_id": self.request_id, + "execution_profile": dict(self.execution_profile), + "execution_profile_digest": self.execution_profile_digest, + "context_digest": self.context_digest, + "evidence_digest": self.evidence_digest, + "input_digest": self.input_digest, + "execution_mode": self.execution_mode, + "required_capabilities": dict(self.required_capabilities), + "max_output_chars": self.max_output_chars, + } + + +@dataclass(frozen=True) +class AdapterResult: + """Sanitized runtime result; unavailable paths never become success.""" + + schema_version: str + request_id: str + status: str + output: str | None + usage: dict[str, int] + runtime: dict[str, str] + error_code: str | None + error_message: str | None + external_fallback_allowed: bool = False + + @classmethod + def from_mapping(cls, value: Mapping[str, Any] | "AdapterResult") -> "AdapterResult": + if isinstance(value, cls): + return value + if not isinstance(value, Mapping): + raise RuntimeAdapterError("adapter result must be an object") + _ra_forbidden_keys(value, "result") + unknown = set(value) - _RA_RESULT_FIELDS + if unknown: + raise RuntimeAdapterError(f"unsupported result fields: {sorted(map(str, unknown))}") + if value.get("schema_version", _RA_RESULT_SCHEMA) != _RA_RESULT_SCHEMA: + raise RuntimeAdapterError("unsupported runtime result schema version") + status = _ra_text(value.get("status", ""), "status", max_length=32) + if status not in _RA_RESULT_STATUSES: + raise RuntimeAdapterError("unsupported runtime result status") + output = value.get("output") + if output is not None: + output = _ra_text(output, "output", max_length=1_000_000, allow_empty=True) + usage_raw = value.get("usage", {}) + if not isinstance(usage_raw, Mapping) or set(usage_raw) - _RA_USAGE_FIELDS: + raise RuntimeAdapterError("usage contains unsupported fields") + usage: dict[str, int] = {} + for key, raw in usage_raw.items(): + if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0: + raise RuntimeAdapterError(f"usage.{key} must be a non-negative integer") + usage[key] = raw + runtime_raw = value.get("runtime", {}) + if not isinstance(runtime_raw, Mapping) or set(runtime_raw) - _RA_RUNTIME_FIELDS: + raise RuntimeAdapterError("runtime provenance contains unsupported fields") + runtime = {str(key): _ra_id(raw, f"runtime.{key}") for key, raw in runtime_raw.items()} + error_code = value.get("error_code") + if error_code is not None: + error_code = _ra_id(error_code, "error_code") + error_message = value.get("error_message") + if error_message is not None: + error_message = _ra_text(error_message, "error_message", max_length=256) + fallback = value.get("external_fallback_allowed", False) + if fallback is not False: + raise RuntimeAdapterError("external fallback is permanently disabled by the core contract") + if status in {"success", "partial"} and output is None: + raise RuntimeAdapterError("successful result requires bounded output") + if status not in {"success", "partial"} and output is not None: + raise RuntimeAdapterError("non-success result cannot carry output") + return cls( + schema_version=_RA_RESULT_SCHEMA, + request_id=_ra_id(value["request_id"], "request_id"), + status=status, + output=output, + usage=usage, + runtime=runtime, + error_code=error_code, + error_message=error_message, + external_fallback_allowed=False, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "request_id": self.request_id, + "status": self.status, + "output": self.output, + "usage": dict(self.usage), + "runtime": dict(self.runtime), + "error_code": self.error_code, + "error_message": self.error_message, + "external_fallback_allowed": False, + } + + +def negotiate_runtime_capabilities( + requirements: Mapping[str, Any] | None, + offered: Mapping[str, Any] | RuntimeCapabilities, +) -> dict[str, Any]: + """Return a deterministic negotiation result; never selects another backend.""" + requirements = dict(requirements or {}) + _ra_forbidden_keys(requirements, "requirements") + allowed = {"execution_mode", "min_context_tokens", "streaming", "tools", "resource_metrics", "backend_id"} + if set(requirements) - allowed: + raise RuntimeAdapterError("unsupported runtime capability requirement") + capabilities = RuntimeCapabilities.from_mapping(offered) + normalized: dict[str, Any] = {} + if "execution_mode" in requirements: + mode = _ra_text(requirements["execution_mode"], "requirements.execution_mode", max_length=32) + if mode not in _RA_EXECUTION_MODES: + raise RuntimeAdapterError("requirements.execution_mode is unsupported") + normalized["execution_mode"] = mode + if "min_context_tokens" in requirements: + normalized["min_context_tokens"] = _ra_limit(requirements["min_context_tokens"], "requirements.min_context_tokens") + for field in ("streaming", "tools"): + if field in requirements: + if not isinstance(requirements[field], bool): + raise RuntimeAdapterError(f"requirements.{field} must be boolean") + normalized[field] = requirements[field] + if "resource_metrics" in requirements: + normalized["resource_metrics"] = list(_ra_string_list(requirements["resource_metrics"], "requirements.resource_metrics")) + if "backend_id" in requirements: + normalized["backend_id"] = _ra_id(requirements["backend_id"], "requirements.backend_id") + missing: list[dict[str, Any]] = [] + if normalized.get("execution_mode") and normalized["execution_mode"] not in capabilities.execution_modes: + missing.append({"capability": "execution_mode", "required": normalized["execution_mode"], "available": list(capabilities.execution_modes)}) + if normalized.get("min_context_tokens", 0) > capabilities.context_capacity_tokens: + missing.append({"capability": "context_capacity_tokens", "required": normalized["min_context_tokens"], "available": capabilities.context_capacity_tokens}) + for field in ("streaming", "tools"): + if normalized.get(field) is True and not getattr(capabilities, field): + missing.append({"capability": field, "required": True, "available": False}) + if "resource_metrics" in normalized: + absent = sorted(set(normalized["resource_metrics"]) - set(capabilities.resource_metrics)) + if absent: + missing.append({"capability": "resource_metrics", "required": absent, "available": list(capabilities.resource_metrics)}) + if normalized.get("backend_id") and normalized["backend_id"] != capabilities.backend_id: + missing.append({"capability": "backend_id", "required": normalized["backend_id"], "available": capabilities.backend_id}) + return { + "schema_version": _RA_NEGOTIATION_SCHEMA, + "status": "complete" if not missing else "rejected", + "requirements": normalized, + "capabilities": capabilities.to_dict(), + "capabilities_digest": _ra_sha(capabilities.to_dict()), + "missing": missing, + "external_fallback_allowed": False, + } + + +class ReferenceRuntimeAdapter: + """Deterministic offline adapter used for contract and failure-path tests.""" + + def __init__( + self, + *, + capabilities: Mapping[str, Any] | RuntimeCapabilities | None = None, + behavior: str = "success", + output: str = "", + ) -> None: + self.capabilities = RuntimeCapabilities.from_mapping(capabilities or { + "schema_version": _RA_CAPABILITIES_SCHEMA, + "backend_id": "reference-local", + "backend_version": "0.1", + "model_id": "reference-model", + "model_version": "0.1", + "tokenizer_id": "reference-tokenizer", + "context_capacity_tokens": 4096, + "execution_modes": ["offline", "local"], + "streaming": True, + "tools": False, + "hardware_class": "unknown", + "resource_metrics": ["latency_ms"], + "auth_mode": "none", + "provider_ref": "local-reference", + }) + if behavior not in _RA_RESULT_STATUSES: + raise RuntimeAdapterError("unsupported reference adapter behavior") + self.behavior = behavior + self.output = _ra_text(output, "output", max_length=1_000_000, allow_empty=True) + + def invoke(self, request: Mapping[str, Any] | AdapterRequest) -> AdapterResult: + try: + request_obj = AdapterRequest.from_mapping(request) + except (RuntimeAdapterError, ExecutionProfileError) as exc: + raise RuntimeAdapterError(str(exc)) from exc + requirements = dict(request_obj.required_capabilities) + requirements["execution_mode"] = request_obj.execution_mode + negotiation = negotiate_runtime_capabilities(requirements, self.capabilities) + runtime = { + "backend_id": self.capabilities.backend_id, + "backend_version": self.capabilities.backend_version, + "model_id": self.capabilities.model_id, + "model_version": self.capabilities.model_version, + "auth_mode": self.capabilities.auth_mode, + "provider_ref": self.capabilities.provider_ref, + "execution_mode": request_obj.execution_mode, + } + if negotiation["status"] == "rejected": + return AdapterResult( + schema_version=_RA_RESULT_SCHEMA, + request_id=request_obj.request_id, + status="unavailable", + output=None, + usage={}, + runtime=runtime, + error_code="capability_mismatch", + error_message="required runtime capabilities are unavailable", + ) + if self.behavior in {"unavailable", "timeout", "cancelled", "malformed"}: + return AdapterResult( + schema_version=_RA_RESULT_SCHEMA, + request_id=request_obj.request_id, + status=self.behavior, + output=None, + usage={}, + runtime=runtime, + error_code=self.behavior, + error_message=f"reference adapter {self.behavior}", + ) + bounded = self.output[: request_obj.max_output_chars] + usage = {"output_tokens": max(1, (len(bounded) + 3) // 4)} + return AdapterResult( + schema_version=_RA_RESULT_SCHEMA, + request_id=request_obj.request_id, + status=self.behavior, + output=bounded, + usage=usage, + runtime=runtime, + error_code=None, + error_message=None, + ) +"""Deterministic evidence/uncertainty projection for compiled context (#982). + +The projection consumes caller-supplied normalized evidence. It does not retrieve, +store, or adjudicate Vault/Ledger records. It makes coverage and provider +failure states explicit, carries only sanitized references/commitments, and +turns evidence-required uncertainty into an abstention signal. +""" + +import hashlib +import json +import re +from typing import Any, Mapping + +_CE_SCHEMA_VERSION = "perseus-context-evidence/v1" +_CE_STATES = frozenset({ + "evidence_backed", "partial", "conflicted", "stale", "empty", "unavailable", "timeout", +}) +_CE_PROVIDER_STATES = frozenset({"active", "partial", "degraded", "unavailable", "timeout", "not_configured"}) +_CE_UNCERTAINTY_CLASSES = frozenset({"high", "medium", "low", "stale", "inferred", "tie"}) +_CE_DIGEST_RE = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$") +_CE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:/#@+\-]{0,159}$") +_CE_ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$") +_CE_FORBIDDEN_KEYS = frozenset({ + "api_key", "authorization", "body", "content", "credential", "credentials", + "password", "private_body", "prompt", "raw", "raw_payload", "secret", "token", + "tool_args", "tool_arguments", +}) +_CE_STATE_ALIASES = { + "supported": "evidence_backed", + "evidence-backed": "evidence_backed", + "evidence_backed": "evidence_backed", + "complete": "evidence_backed", + "degraded": "partial", + "contradictory": "conflicted", + "conflict": "conflicted", + "no_evidence": "empty", + "no-evidence": "empty", +} + + +class ContextEvidenceError(ValueError): + """Raised when an evidence projection cannot be sanitized or verified.""" + + +def _ce_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False) + + +def _ce_sha(value: Any) -> str: + return hashlib.sha256(_ce_json(value).encode("utf-8")).hexdigest() + + +def _ce_forbidden_keys(value: Any, path: str = "projection") -> None: + if isinstance(value, Mapping): + for key, nested in value.items(): + if str(key).casefold().replace("-", "_") in _CE_FORBIDDEN_KEYS: + raise ContextEvidenceError(f"{path}.{key} is not permitted") + _ce_forbidden_keys(nested, f"{path}.{key}") + elif isinstance(value, list): + for index, nested in enumerate(value): + _ce_forbidden_keys(nested, f"{path}[{index}]") + + +def _ce_text(value: Any, field: str, *, limit: int = 256) -> str: + if not isinstance(value, str): + raise ContextEvidenceError(f"{field} must be text") + text = re.sub(r"[\x00-\x1f\x7f]", " ", value).strip() + if not text: + return "" + if len(text) > limit: + text = text[:limit].rstrip() + markers = ("api_key", "authorization", "password", "private_body", "prompt", "raw_payload", "secret", "token") + if any(marker in text.casefold().replace("-", "_") for marker in markers): + return "" + return text + + +def _ce_id(value: Any, field: str) -> str: + text = str(value or "").strip() + if not text: + raise ContextEvidenceError(f"{field} must not be empty") + if _CE_ID_RE.fullmatch(text): + return text[:160] + return "sha256:" + hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest() + + +def _ce_digest(value: Any, field: str) -> str | None: + if value is None or value == "": + return None + text = str(value).strip().lower() + if not _CE_DIGEST_RE.fullmatch(text): + raise ContextEvidenceError(f"{field} must be a SHA-256 digest") + return text.removeprefix("sha256:") + + +def _ce_timestamp(value: Any) -> str | None: + if not isinstance(value, str): + return None + text = value.strip() + return text if _CE_ISO_RE.fullmatch(text) else None + + +def _ce_sources(record: Mapping[str, Any], candidate_id: str) -> list[str]: + values: list[Any] = [] + for key in ("source_id", "source_ref", "provenance_id"): + if record.get(key): + values.append(record[key]) + for key in ("source_refs", "provenance_refs"): + raw = record.get(key) + if isinstance(raw, (list, tuple)): + values.extend(raw) + for key in ("provenance", "evidence"): + nested = record.get(key) + if isinstance(nested, Mapping): + for nested_key in ("source_id", "source_ref", "provenance_id", "provenance_ref", "receipt_id", "id"): + if nested.get(nested_key): + values.append(nested[nested_key]) + refs = sorted({_ce_id(value, "source_ref") for value in values if str(value or "").strip()}) + return refs + + +def _ce_evidence_digest(record: Mapping[str, Any], candidate_id: str) -> str | None: + for key in ("evidence_digest", "content_sha256", "content_hash", "sha256"): + if record.get(key): + return _ce_digest(record[key], f"{candidate_id}.{key}") + # The body is never emitted; when a caller supplies it, only its commitment + # can cross this boundary. + for key in ("content", "body", "raw", "private_body"): + value = record.get(key) + if isinstance(value, str) and value: + return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest() + return None + + +def _ce_item_state(record: Mapping[str, Any], *, has_digest: bool) -> str: + raw = str(record.get("coverage_state", record.get("evidence_status", record.get("validity_state", record.get("status", ""))))).strip().lower().replace(" ", "_") + state = _CE_STATE_ALIASES.get(raw, raw) + if state in _CE_STATES: + return state + return "evidence_backed" if has_digest else "empty" + + +def _ce_uncertainty(record: Mapping[str, Any], state: str) -> dict[str, Any]: + raw = record.get("uncertainty") + if isinstance(raw, Mapping): + cls = str(raw.get("class", "")).strip().lower() + score = raw.get("score") + if cls in _CE_UNCERTAINTY_CLASSES and isinstance(score, (int, float)) and not isinstance(score, bool) and 0 <= score <= 1: + return {"class": cls, "score": round(float(score), 6)} + if state == "evidence_backed": + return {"class": "high" if record.get("verified") else "medium", "score": 0.9 if record.get("verified") else 0.65} + if state == "stale": + return {"class": "stale", "score": 0.35} + if state == "conflicted": + return {"class": "tie", "score": 0.5} + return {"class": "low", "score": 0.2} + + +def _ce_reason(record: Mapping[str, Any], default: str) -> str: + reason = _ce_text(record.get("selection_reason", ""), "selection_reason") + return reason or default + + +def _ce_provider_states(value: Mapping[str, Any] | None) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise ContextEvidenceError("provider_states must be an object") + result: dict[str, str] = {} + for key, raw in value.items(): + provider = _ce_id(key, "provider") + state = str(raw).strip().lower().replace("-", "_") + if state not in _CE_PROVIDER_STATES: + raise ContextEvidenceError(f"unsupported provider state: {state}") + result[provider] = state + return dict(sorted(result.items())) + + +def _ce_selected_item(record: Mapping[str, Any], index: int) -> tuple[dict[str, Any] | None, dict[str, str]]: + if not isinstance(record, Mapping): + return None, {"candidate_id": f"item-{index + 1}", "reason": "invalid_record"} + candidate_id = _ce_id(record.get("candidate_id") or record.get("id") or record.get("key") or f"item-{index + 1}", "candidate_id") + digest = _ce_evidence_digest(record, candidate_id) + refs = _ce_sources(record, candidate_id) + state = _ce_item_state(record, has_digest=bool(digest)) + if not refs: + return None, {"candidate_id": candidate_id, "reason": "source_reference_missing"} + if not digest: + return None, {"candidate_id": candidate_id, "reason": "evidence_digest_missing"} + item: dict[str, Any] = { + "candidate_id": candidate_id, + "source_refs": refs, + "evidence_digest": digest, + "coverage_state": state, + "uncertainty": _ce_uncertainty(record, state), + "inclusion_reason": _ce_reason(record, "selected_by_caller"), + } + for key in ("valid_at", "transaction_time", "recorded_at", "observed_at"): + timestamp = _ce_timestamp(record.get(key)) + if timestamp: + item[key] = timestamp + return item, {} + + +def _ce_normalized_exclusions(value: Any) -> list[dict[str, str]]: + if value is None: + return [] + if not isinstance(value, (list, tuple)): + raise ContextEvidenceError("excluded must be a list") + result = [] + for index, raw in enumerate(value): + if isinstance(raw, Mapping): + candidate_id = _ce_id(raw.get("candidate_id") or raw.get("id") or f"excluded-{index + 1}", "excluded.candidate_id") + reason = _ce_text(raw.get("reason") or raw.get("selection_reason") or "", "excluded.reason") or "excluded_by_policy" + else: + candidate_id = _ce_id(raw, "excluded.candidate_id") + reason = "excluded_by_policy" + result.append({"candidate_id": candidate_id, "reason": reason}) + return result + + +def project_context_evidence( + entries: Any, + *, + provider_states: Mapping[str, Any] | None = None, + excluded: Any = None, + evidence_required: bool = False, + selected_ids: list[str] | None = None, +) -> dict[str, Any]: + """Compile a sanitized evidence coverage projection without retrieval.""" + if not isinstance(entries, (list, tuple)): + raise ContextEvidenceError("entries must be a list") + if not isinstance(evidence_required, bool): + raise ContextEvidenceError("evidence_required must be boolean") + providers = _ce_provider_states(provider_states) + wanted = {_ce_id(item, "selected_id") for item in selected_ids} if selected_ids is not None else None + selected: list[dict[str, Any]] = [] + exclusions = _ce_normalized_exclusions(excluded) + seen: set[str] = set() + for index, raw in enumerate(entries): + item, omission = _ce_selected_item(raw, index) + if item is None: + exclusions.append(omission) + continue + if item["candidate_id"] in seen: + exclusions.append({"candidate_id": item["candidate_id"], "reason": "duplicate_candidate_id"}) + continue + seen.add(item["candidate_id"]) + if wanted is not None and item["candidate_id"] not in wanted: + exclusions.append({"candidate_id": item["candidate_id"], "reason": "not_selected"}) + continue + selected.append(item) + selected.sort(key=lambda item: item["candidate_id"]) + exclusions = sorted(exclusions, key=lambda item: (item["candidate_id"], item["reason"])) + item_states = [item["coverage_state"] for item in selected] + if any(state == "timeout" for state in providers.values()): + state = "timeout" + elif any(state == "unavailable" for state in providers.values()): + state = "unavailable" + elif "conflicted" in item_states: + state = "conflicted" + elif "stale" in item_states: + state = "stale" + elif any(state in {"partial", "degraded"} for state in providers.values()) or "partial" in item_states: + state = "partial" + elif not selected: + state = "empty" + else: + state = "evidence_backed" + abstention = bool(evidence_required and state != "evidence_backed") + status = "abstention_required" if abstention else { + "evidence_backed": "complete", + "partial": "degraded", + "conflicted": "review", + "stale": "degraded", + "empty": "empty", + "unavailable": "unavailable", + "timeout": "unavailable", + }[state] + reason = { + "evidence_backed": "evidence is linked to sanitized source references and a digest", + "partial": "provider or selected evidence is partial", + "conflicted": "evidence contains unresolved conflict", + "stale": "selected evidence is stale", + "empty": "no evidence-backed item was selected", + "unavailable": "provider evidence is unavailable", + "timeout": "provider evidence timed out", + }[state] + coverage = { + "state": state, + "reason": reason, + "provider_states": providers, + "evidence_required": evidence_required, + "abstention_required": abstention, + } + unsigned: dict[str, Any] = { + "schema_version": _CE_SCHEMA_VERSION, + "status": status, + "coverage": coverage, + "selected": selected, + "excluded": exclusions, + "diagnostics": { + "relevance_is_not_truth_gate": True, + "selected_count": len(selected), + "excluded_count": len(exclusions), + }, + } + unsigned["projection_digest"] = _ce_sha(unsigned) + return unsigned + + +def verify_context_evidence(projection: Mapping[str, Any]) -> dict[str, Any]: + if not isinstance(projection, Mapping) or projection.get("schema_version") != _CE_SCHEMA_VERSION: + return {"valid": False, "error": "unsupported evidence projection"} + try: + _ce_forbidden_keys(projection) + supplied = projection.get("projection_digest") + if not isinstance(supplied, str): + return {"valid": False, "error": "missing projection digest"} + unsigned = dict(projection) + unsigned.pop("projection_digest", None) + expected = _ce_sha(unsigned) + except (ContextEvidenceError, TypeError, ValueError): + return {"valid": False, "error": "invalid evidence projection"} + return {"valid": expected == supplied, "projection_digest": supplied, "expected_digest": expected} + + +def render_context_evidence(projection: Mapping[str, Any]) -> str: + check = verify_context_evidence(projection) + if not check["valid"]: + raise ContextEvidenceError("refusing to render invalid evidence projection") + coverage = projection["coverage"] + lines = [ + "# Context evidence projection", + "", + f"- coverage: **{coverage['state']}** — {coverage['reason']}", + f"- status: **{projection['status']}**", + f"- evidence required: `{str(coverage['evidence_required']).lower()}`", + f"- abstention required: `{str(coverage['abstention_required']).lower()}`", + f"- projection digest: `{projection['projection_digest']}`", + "", + "## Selected evidence", + "", + ] + if projection["selected"]: + for item in projection["selected"]: + lines.append( + f"- `{item['candidate_id']}` — sources: {', '.join(item['source_refs'])}; " + f"evidence: `{item['evidence_digest']}`; reason: {item['inclusion_reason']}" + ) + else: + lines.append("- none") + lines.extend(["", "## Excluded", ""]) + if projection["excluded"]: + lines.extend(f"- `{item['candidate_id']}` — {item['reason']}" for item in projection["excluded"]) + else: + lines.append("- none") + return "\n".join(lines) + "\n" """Runtime-owned evidence claim verification (#895). Models may propose structured claims, but the host owns the identity, scope, @@ -36761,6 +38069,14 @@ def context_rank( "context_decision": decision, "budget": {"max_items": max_items, "max_chars": max_chars, "returned_items": len(output)}, } + selected_ids = {item["candidate_id"] for item in output} + selected_records = [record for record in prepared if record.get("_contract_id") in selected_ids] + result["evidence_projection"] = project_context_evidence( + selected_records, + provider_states=states, + excluded=[{"candidate_id": item, "reason": "excluded_by_contract"} for item in excluded], + evidence_required=bool(policy_map.get("evidence_required", False)), + ) return result except (TypeError, ValueError) as exc: message = str(exc) @@ -38296,7 +39612,10 @@ def compile_context_dag(*, task_id: str, verdict_hint: str = "sufficient", confidence: Optional[float] = None, created_by: str = "", - meta: Optional[dict] = None) -> dict: + meta: Optional[dict] = None, + execution_profile: Optional[dict] = None, + profile_requirements: Optional[dict] = None, + profile_retrieval_status: str = "complete") -> dict: """Build, expand, and seal an auditable context-compilation DAG. Expansion is layer-wise and selective: only uncertain, contradictory, or @@ -38306,8 +39625,32 @@ def compile_context_dag(*, task_id: str, the advisory inputs + policy so verification is a faithful replay. """ policy = policy or CompilationPolicy() - budget = budget or CompilationBudget() + resolved_profile = None + if execution_profile is not None: + try: + resolved_profile = resolve_execution_profile( + execution_profile, + requirements=profile_requirements, + retrieval_status=profile_retrieval_status, + ) + except ExecutionProfileError as exc: + raise ContextDagError(f"execution profile rejected: {exc}") from exc + profile_budget = resolved_profile["compilation_budget"] + if budget is None: + budget = CompilationBudget(**profile_budget) + else: + # A caller-supplied DAG budget may tighten a profile, never widen it. + budget = CompilationBudget( + max_nodes=min(int(budget.max_nodes), int(profile_budget["max_nodes"])), + max_depth=min(int(budget.max_depth), int(profile_budget["max_depth"])), + max_fanout=min(int(budget.max_fanout), int(profile_budget["max_fanout"])), + max_tokens=min(int(budget.max_tokens), int(profile_budget["max_tokens"])), + deadline_s=min(float(budget.deadline_s), float(profile_budget["deadline_s"])), + ) + else: + budget = budget or CompilationBudget() ledger = budget.ledger() + profile_degradation_reasons: set[str] = set() graph = ContextDAG(task_id=task_id, created_by=created_by, meta=meta or {}) graph.add_node(root, ledger, depth=0) @@ -38331,7 +39674,30 @@ def compile_context_dag(*, task_id: str, if not expand: continue expanded.add(nid) - for child in list(fetch(node) or []): + children = list(fetch(node) or []) + if resolved_profile is not None: + children.sort(key=lambda child: child.node_id) + if depth >= budget.max_depth and children: + profile_degradation_reasons.add("max_depth") + children = [] + if len(children) > budget.max_fanout: + profile_degradation_reasons.add("max_items") + children = children[: budget.max_fanout] + remaining_nodes = max(0, budget.max_nodes - len(ledger.nodes)) + if len(children) > remaining_nodes: + profile_degradation_reasons.add("max_items") + children = children[:remaining_nodes] + remaining_tokens = max(0, budget.max_tokens - ledger.total_tokens) + kept_children: list[ContextNode] = [] + for child in children: + needed = dag_tokens(child.content) + if needed > remaining_tokens: + profile_degradation_reasons.add("max_context_tokens") + continue + kept_children.append(child) + remaining_tokens -= needed + children = kept_children + for child in children: cid = graph.add_node(child, ledger, depth=depth + 1) kind = "supports" if child.kind != "contradiction" else "contradicts" edge_meta: dict = {} @@ -38362,15 +39728,29 @@ def compile_context_dag(*, task_id: str, if nd is not None: packet.append(nd.to_dict()) advisory = {"verdict_hint": verdict_hint, "confidence": confidence} + profile_manifest = resolved_profile or {} + profile_diagnostics = dict(resolved_profile["diagnostics"]) if resolved_profile else {} + if profile_degradation_reasons: + profile_diagnostics["degraded"] = True + profile_diagnostics["reasons"] = sorted(set(profile_diagnostics.get("reasons", [])) | profile_degradation_reasons) + profile_status = ("degraded" if profile_degradation_reasons else resolved_profile["status"]) if resolved_profile else None + digest_parts = [ + "packet", _dag_json(packet), + "verdict", _dag_json(verdict), + "advisory", _dag_json(advisory), + "policy", _dag_json(policy.to_dict()), + "budget", _dag_json(ledger.digest_input()), + "graph", graph.digest(), + "execution_profile", _dag_json(profile_manifest), + ] + if resolved_profile is not None: + digest_parts.extend([ + "profile_status", profile_status, + "profile_diagnostics", _dag_json(profile_diagnostics), + ]) artifact = { "schema_version": "perseus-context-dag/v1", - "compiled_digest": _dag_sha( - "packet", _dag_json(packet), - "verdict", _dag_json(verdict), - "advisory", _dag_json(advisory), - "policy", _dag_json(policy.to_dict()), - "budget", _dag_json(ledger.digest_input()), - "graph", graph.digest()), + "compiled_digest": _dag_sha(*digest_parts), "graph": graph.to_dict(), "selected_node_ids": selected, "packet": packet, @@ -38381,6 +39761,11 @@ def compile_context_dag(*, task_id: str, "token_accounting": TOKEN_ACCOUNTING_NOTE, "compiled_at_unix_s": round(time.time(), 3), } + if resolved_profile is not None: + artifact["status"] = profile_status + artifact["execution_profile"] = resolved_profile + artifact["execution_profile_digest"] = resolved_profile["profile_digest"] + artifact["profile_diagnostics"] = profile_diagnostics return artifact @@ -38431,15 +39816,32 @@ def verify_compiled_dag(artifact: dict) -> dict: ) if verdict != artifact.get("verdict"): errors.append("verdict does not recompute from graph state") + profile_manifest = artifact.get("execution_profile") or {} + profile_diagnostics = artifact.get("profile_diagnostics") or {} + profile_status = None + if profile_manifest: + profile_check = verify_execution_profile(profile_manifest) + if not profile_check.get("valid"): + errors.append("execution profile digest mismatch") + if artifact.get("execution_profile_digest") != profile_manifest.get("profile_digest"): + errors.append("execution_profile_digest does not match profile manifest") + profile_status = "degraded" if profile_diagnostics.get("degraded") else profile_manifest.get("status") + if artifact.get("status") != profile_status: + errors.append("profile status does not match profile manifest") budget_sealed = dict(artifact.get("budget") or {}) budget_sealed.pop("wall_clock_s", None) - expected = _dag_sha( + digest_parts = [ "packet", _dag_json(packet), "verdict", _dag_json(artifact.get("verdict")), "advisory", _dag_json(artifact.get("advisory")), "policy", _dag_json(policy.to_dict()), "budget", _dag_json(budget_sealed), - "graph", graph.digest()) + "graph", graph.digest(), + "execution_profile", _dag_json(profile_manifest), + ] + if profile_manifest: + digest_parts.extend(["profile_status", profile_status, "profile_diagnostics", _dag_json(profile_diagnostics)]) + expected = _dag_sha(*digest_parts) if expected != artifact.get("compiled_digest"): errors.append("compiled_digest mismatch") return {"valid": not errors, "errors": errors, diff --git a/schemas/context-contract.schema.yaml b/schemas/context-contract.schema.yaml index b0523a1f..9e81a93a 100644 --- a/schemas/context-contract.schema.yaml +++ b/schemas/context-contract.schema.yaml @@ -95,6 +95,18 @@ properties: items: {$ref: "#/$defs/evidence"} projection: {$ref: "#/$defs/projection"} projection_digest: {type: string, pattern: "^[0-9a-f]{64}$"} + evidence_projection: + type: object + additionalProperties: false + required: [schema_version, status, coverage, selected, excluded, diagnostics, projection_digest] + properties: + schema_version: {type: string, const: perseus-context-evidence/v1} + status: {type: string, enum: [complete, degraded, review, empty, unavailable, abstention_required]} + coverage: {type: object} + selected: {type: array, maxItems: 64} + excluded: {type: array, maxItems: 128} + diagnostics: {type: object} + projection_digest: {type: string, pattern: "^[0-9a-f]{64}$"} selection: type: array maxItems: 64 diff --git a/schemas/context-evidence.schema.yaml b/schemas/context-evidence.schema.yaml new file mode 100644 index 00000000..b55ba119 --- /dev/null +++ b/schemas/context-evidence.schema.yaml @@ -0,0 +1,65 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "https://perseus.observer/schemas/context-evidence/v1" +title: Perseus context evidence projection +version: "perseus-context-evidence/v1" +type: object +additionalProperties: false +required: [schema_version, status, coverage, selected, excluded, diagnostics, projection_digest] +properties: + schema_version: {type: string, const: perseus-context-evidence/v1} + status: {type: string, enum: [complete, degraded, review, empty, unavailable, abstention_required]} + coverage: + type: object + additionalProperties: false + required: [state, reason, provider_states, evidence_required, abstention_required] + properties: + state: {type: string, enum: [evidence_backed, partial, conflicted, stale, empty, unavailable, timeout]} + reason: {type: string, minLength: 1, maxLength: 256} + provider_states: + type: object + additionalProperties: {type: string, enum: [active, partial, degraded, unavailable, timeout, not_configured]} + evidence_required: {type: boolean} + abstention_required: {type: boolean} + selected: + type: array + maxItems: 64 + items: + type: object + additionalProperties: false + required: [candidate_id, source_refs, evidence_digest, coverage_state, uncertainty, inclusion_reason] + properties: + candidate_id: {type: string, minLength: 1, maxLength: 160} + source_refs: {type: array, maxItems: 64, items: {type: string, minLength: 1, maxLength: 160}} + evidence_digest: {type: string, pattern: "^[0-9a-f]{64}$"} + coverage_state: {type: string, enum: [evidence_backed, partial, conflicted, stale, empty, unavailable, timeout]} + uncertainty: + type: object + additionalProperties: false + required: [class, score] + properties: + class: {type: string, enum: [high, medium, low, stale, inferred, tie]} + score: {type: number, minimum: 0, maximum: 1} + inclusion_reason: {type: string, maxLength: 256} + valid_at: {type: string, maxLength: 40} + transaction_time: {type: string, maxLength: 40} + recorded_at: {type: string, maxLength: 40} + observed_at: {type: string, maxLength: 40} + excluded: + type: array + maxItems: 128 + items: + type: object + additionalProperties: false + required: [candidate_id, reason] + properties: + candidate_id: {type: string, minLength: 1, maxLength: 160} + reason: {type: string, minLength: 1, maxLength: 256} + diagnostics: + type: object + additionalProperties: false + required: [relevance_is_not_truth_gate, selected_count, excluded_count] + properties: + relevance_is_not_truth_gate: {type: boolean, const: true} + selected_count: {type: integer, minimum: 0} + excluded_count: {type: integer, minimum: 0} + projection_digest: {type: string, pattern: "^[0-9a-f]{64}$"} diff --git a/schemas/execution-profile.schema.yaml b/schemas/execution-profile.schema.yaml new file mode 100644 index 00000000..b4dc3b82 --- /dev/null +++ b/schemas/execution-profile.schema.yaml @@ -0,0 +1,26 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "https://perseus.observer/schemas/execution-profile/v1" +title: Perseus execution profile +version: "perseus-execution-profile/v1" +type: object +additionalProperties: false +required: [schema_version, profile_id, mode, max_context_tokens, max_context_bytes, max_items, max_depth, resource_class, network_mode, runtime_capabilities, degradation_policy, auth_mode] +properties: + schema_version: {type: string, const: perseus-execution-profile/v1} + profile_id: {type: string, minLength: 1, maxLength: 160} + mode: {type: string, enum: [standard-local, constrained-edge, air-gapped]} + max_context_tokens: {type: integer, minimum: 1, maximum: 10000000} + max_context_bytes: {type: integer, minimum: 1, maximum: 10000000} + max_items: {type: integer, minimum: 1, maximum: 4096} + max_depth: {type: integer, minimum: 1, maximum: 128} + latency_target_ms: {type: [integer, 'null'], minimum: 1, maximum: 86400000} + resource_class: {type: string, minLength: 1, maxLength: 64} + network_mode: {type: string, enum: [offline, local, approved_network]} + runtime_capabilities: + type: array + maxItems: 32 + items: {type: string, minLength: 1, maxLength: 160} + degradation_policy: {type: string, enum: [fail_closed, partial, omit_low_priority]} + auth_mode: {type: string, minLength: 1, maxLength: 64} + runtime_ref: {type: string, minLength: 1, maxLength: 160} + model_ref: {type: string, minLength: 1, maxLength: 160} diff --git a/schemas/runtime-adapter.schema.yaml b/schemas/runtime-adapter.schema.yaml new file mode 100644 index 00000000..ffab019c --- /dev/null +++ b/schemas/runtime-adapter.schema.yaml @@ -0,0 +1,80 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "https://perseus.observer/schemas/runtime-adapter/v1" +title: Perseus runtime adapter envelopes +version: "perseus-runtime-adapter/v1" +$defs: + digest: {type: string, pattern: "^[0-9a-f]{64}$"} + capabilities: + type: object + additionalProperties: false + required: [schema_version, backend_id, backend_version, model_id, model_version, tokenizer_id, context_capacity_tokens, execution_modes, streaming, tools, hardware_class, resource_metrics, auth_mode, provider_ref] + properties: + schema_version: {type: string, const: perseus-runtime-capabilities/v1} + backend_id: {type: string, minLength: 1, maxLength: 160} + backend_version: {type: string, minLength: 1, maxLength: 160} + model_id: {type: string, minLength: 1, maxLength: 160} + model_version: {type: string, minLength: 1, maxLength: 160} + tokenizer_id: {type: string, minLength: 1, maxLength: 160} + context_capacity_tokens: {type: integer, minimum: 1} + execution_modes: + type: array + maxItems: 32 + items: {type: string, enum: [offline, local, approved_network]} + streaming: {type: boolean} + tools: {type: boolean} + hardware_class: {type: string, minLength: 1, maxLength: 160} + resource_metrics: + type: array + maxItems: 32 + items: {type: string, minLength: 1, maxLength: 160} + auth_mode: {type: string, minLength: 1, maxLength: 64} + provider_ref: {type: string, minLength: 1, maxLength: 160} + request: + type: object + additionalProperties: false + required: [schema_version, request_id, execution_profile, execution_profile_digest, context_digest, evidence_digest, input_digest, execution_mode, required_capabilities, max_output_chars] + properties: + schema_version: {type: string, const: perseus-runtime-request/v1} + request_id: {type: string, minLength: 1, maxLength: 160} + execution_profile: {type: object} + execution_profile_digest: {$ref: "#/$defs/digest"} + context_digest: {$ref: "#/$defs/digest"} + evidence_digest: {$ref: "#/$defs/digest"} + input_digest: {$ref: "#/$defs/digest"} + execution_mode: {type: string, enum: [offline, local, approved_network]} + required_capabilities: {type: object, additionalProperties: true} + max_output_chars: {type: integer, minimum: 1, maximum: 1000000} + result: + type: object + additionalProperties: false + required: [schema_version, request_id, status, output, usage, runtime, error_code, error_message, external_fallback_allowed] + properties: + schema_version: {type: string, const: perseus-runtime-result/v1} + request_id: {type: string, minLength: 1, maxLength: 160} + status: {type: string, enum: [success, partial, unavailable, timeout, cancelled, malformed]} + output: {type: [string, 'null'], maxLength: 1000000} + usage: + type: object + additionalProperties: false + properties: + input_tokens: {type: integer, minimum: 0} + output_tokens: {type: integer, minimum: 0} + latency_ms: {type: integer, minimum: 0} + runtime: + type: object + additionalProperties: false + properties: + backend_id: {type: string, minLength: 1, maxLength: 160} + backend_version: {type: string, minLength: 1, maxLength: 160} + model_id: {type: string, minLength: 1, maxLength: 160} + model_version: {type: string, minLength: 1, maxLength: 160} + auth_mode: {type: string, minLength: 1, maxLength: 64} + provider_ref: {type: string, minLength: 1, maxLength: 160} + execution_mode: {type: string, enum: [offline, local, approved_network]} + error_code: {type: [string, 'null'], maxLength: 160} + error_message: {type: [string, 'null'], maxLength: 256} + external_fallback_allowed: {type: boolean, const: false} +oneOf: + - {$ref: "#/$defs/capabilities"} + - {$ref: "#/$defs/request"} + - {$ref: "#/$defs/result"} diff --git a/scripts/build.py b/scripts/build.py index 7f11caea..1e8c99f4 100755 --- a/scripts/build.py +++ b/scripts/build.py @@ -90,6 +90,9 @@ "src/perseus/speculate.py", # ← #607: @speculate next-intent speculative prefetch (called at runtime from renderer/query/cli) "src/perseus/serve.py", # ← still contains PRODUCT_PROFILES + trust CLI (not yet decomposed) "src/perseus/context_decision.py", # ← #890: deterministic explainable context routing + "src/perseus/execution_profiles.py", # ← #980: resource-aware execution profiles and budget negotiation + "src/perseus/runtime_adapter.py", # ← #981: portable local/edge runtime adapter contract + "src/perseus/context_evidence.py", # ← #982: deterministic evidence/uncertainty/abstention projection "src/perseus/claims.py", # ← #895: runtime-owned evidence claim verification "src/perseus/frontdoor.py", # ← #896: single front-door routing and degraded modes "src/perseus/context_contract.py", # ← #916/#917: bounded Rank/Ask and sanitized agent projection/release diff --git a/scripts/render_claims.py b/scripts/render_claims.py index 17e1503b..05693cef 100644 --- a/scripts/render_claims.py +++ b/scripts/render_claims.py @@ -3,17 +3,16 @@ render_claims.py -- regenerate machine-readable fields from the claims registry. `claims.json` (repo root) is the single source of truth for Perseus public -figures. This script keeps the *machine* fields of the distribution manifests -(version strings) in lockstep with that registry. +figures and capability evidence. The script keeps distribution manifests and +the generated capability evidence surfaces in lockstep with that registry. Usage: - python scripts/render_claims.py # --check (default): report drift, exit 1 if any - python scripts/render_claims.py --check # same as above - python scripts/render_claims.py --write # rewrite the machine fields in place (idempotent) + python scripts/render_claims.py # --check (default) + python scripts/render_claims.py --check # report drift only + python scripts/render_claims.py --write # rewrite generated fields/files Stdlib only. Prose/marketing copy is intentionally NOT auto-rewritten here -- that stays under human review and is guarded by tests/test_claims_sync.py. -When a benchmark re-runs, edit claims.json, then run this with --write. """ import argparse @@ -21,21 +20,216 @@ import re import sys from pathlib import Path +from typing import Any, Mapping _ROOT = Path(__file__).resolve().parents[1] +_CAPABILITY_STATES = frozenset({ + "implemented", "tested", "operational", "degraded", "omitted", + "historical", "not_demonstrated", +}) +_CAPABILITY_REQUIRED = frozenset({ + "id", "capability", "owner", "lifecycle", "evidence_class", + "evidence_refs", "last_verified", "freshness_rule", "claim_ceiling", + "non_claims", "dependencies", "activation", "proof_surface", +}) +_CAPABILITY_FORBIDDEN_KEYS = frozenset({ + "api_key", "authorization", "body", "content", "credential", + "credentials", "password", "private_body", "prompt", "raw", + "raw_payload", "secret", "token", "tool_args", "tool_arguments", +}) +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_COMMIT_RE = re.compile(r"^[0-9a-f]{7,64}$") +_URL_RE = re.compile(r"^https?://") +_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,79}$") -def _claims() -> dict: - return json.loads((_ROOT / "claims.json").read_text(encoding="utf-8"))["claims"] +def _registry() -> dict[str, Any]: + value = json.loads((_ROOT / "claims.json").read_text(encoding="utf-8")) + if not isinstance(value, dict) or not isinstance(value.get("claims"), dict): + raise ValueError("claims.json must contain a claims object") + return value + + +def _claims() -> dict[str, Any]: + return _registry()["claims"] + + +def _text(value: Any, field: str, *, max_length: int = 1024) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"capability {field} must be a non-empty string") + text = value.strip() + if len(text) > max_length: + raise ValueError(f"capability {field} is too long") + return text + + +def _check_forbidden_keys(value: Any, path: str = "capability") -> None: + if isinstance(value, Mapping): + for key, nested in value.items(): + key_text = str(key).casefold().replace("-", "_") + if key_text in _CAPABILITY_FORBIDDEN_KEYS: + raise ValueError(f"capability contains forbidden field {path}.{key}") + _check_forbidden_keys(nested, f"{path}.{key}") + elif isinstance(value, list): + for index, nested in enumerate(value): + _check_forbidden_keys(nested, f"{path}[{index}]") + + +def _check_reference(ref: Any, *, root: Path, field: str) -> str: + text = _text(ref, field, max_length=512) + if _URL_RE.match(text): + return text + path = root / text + if not path.is_file(): + raise ValueError(f"capability evidence reference does not exist: {text}") + return text + + +def validate_capabilities(capabilities: Any, *, root: Path | None = None) -> list[dict[str, Any]]: + """Validate and return the canonical capability rows. + + Local evidence/proof references are resolved against ``root``. URLs are + accepted as references but all other paths must exist, so a generated public + row cannot silently point at a missing artifact. + """ + if not isinstance(capabilities, list) or not capabilities: + raise ValueError("claims.json capabilities must be a non-empty list") + root = root or _ROOT + rows: list[dict[str, Any]] = [] + seen: set[str] = set() + for index, raw in enumerate(capabilities): + if not isinstance(raw, Mapping): + raise ValueError(f"capability row {index} must be an object") + unknown = set(raw) - _CAPABILITY_REQUIRED + missing = _CAPABILITY_REQUIRED - set(raw) + if missing: + raise ValueError(f"capability row {index} missing fields: {sorted(missing)}") + if unknown: + raise ValueError(f"capability row {index} has unsupported fields: {sorted(unknown)}") + _check_forbidden_keys(raw, f"capabilities[{index}]") + row = json.loads(json.dumps(raw, ensure_ascii=False)) + identifier = _text(row["id"], f"capabilities[{index}].id", max_length=80) + if not _ID_RE.fullmatch(identifier): + raise ValueError(f"capability id is invalid: {identifier}") + if identifier in seen: + raise ValueError(f"duplicate capability id: {identifier}") + seen.add(identifier) + lifecycle = _text(row["lifecycle"], f"{identifier}.lifecycle", max_length=32) + if lifecycle not in _CAPABILITY_STATES: + raise ValueError(f"unsupported lifecycle state for {identifier}: {lifecycle}") + if lifecycle == "operational" and ( + not row.get("evidence_class") or not row.get("evidence_refs") or not row.get("claim_ceiling") + ): + raise ValueError(f"operational capability {identifier} requires evidence and a claim ceiling") + for field in ("capability", "owner", "evidence_class", "freshness_rule", "claim_ceiling", "activation"): + _text(row[field], f"{identifier}.{field}") + for field in ("non_claims", "dependencies"): + values = row[field] + if not isinstance(values, list) or not values or any(not isinstance(item, str) or not item.strip() for item in values): + raise ValueError(f"{identifier}.{field} must be a non-empty string list") + if any(len(item) > 512 for item in values): + raise ValueError(f"{identifier}.{field} contains an overlong value") + refs = row["evidence_refs"] + if not isinstance(refs, list) or not refs: + raise ValueError(f"{identifier} must have at least one evidence reference") + row["evidence_refs"] = [_check_reference(ref, root=root, field=f"{identifier}.evidence_refs") for ref in refs] + verified = row["last_verified"] + if not isinstance(verified, Mapping) or set(verified) != {"commit", "build", "date"}: + raise ValueError(f"{identifier}.last_verified must contain commit, build, and date") + commit = _text(verified["commit"], f"{identifier}.last_verified.commit", max_length=64).lower() + if not _COMMIT_RE.fullmatch(commit): + raise ValueError(f"{identifier}.last_verified.commit is not a git SHA") + date = _text(verified["date"], f"{identifier}.last_verified.date", max_length=10) + if not _DATE_RE.fullmatch(date): + raise ValueError(f"{identifier}.last_verified.date must be YYYY-MM-DD") + _text(verified["build"], f"{identifier}.last_verified.build", max_length=160) + row["last_verified"] = {"commit": commit, "build": verified["build"].strip(), "date": date} + proof = row["proof_surface"] + proof_values = proof if isinstance(proof, list) else [proof] + if not proof_values: + raise ValueError(f"{identifier}.proof_surface must not be empty") + row["proof_surface"] = [_check_reference(ref, root=root, field=f"{identifier}.proof_surface") for ref in proof_values] + # Any row carrying current-state language must have a complete evidence + # envelope and an explicit ceiling. Requiring this for every row keeps + # historical/non-demonstrated rows equally honest and machine-checkable. + if lifecycle == "operational" or any( + marker in json.dumps(row, sort_keys=True).casefold() + for marker in ("live", "production", "current") + ): + if not row["evidence_class"] or not row["evidence_refs"] or not row["claim_ceiling"]: + raise ValueError(f"operational capability {identifier} requires evidence and a claim ceiling") + rows.append(row) + return sorted(rows, key=lambda item: item["id"]) + + +def _markdown_link(ref: str) -> str: + if _URL_RE.match(ref): + return f"[{ref}]({ref})" + return f"[{ref}](../{ref})" + + +def _markdown_cell(value: Any) -> str: + if isinstance(value, list): + return ", ".join(_markdown_cell(item) for item in value) + if isinstance(value, Mapping): + return "; ".join(f"{key}: {value[key]}" for key in value) + return str(value).replace("|", "\\|").replace("\n", " ").strip() + + +def render_capability_matrix(registry: Mapping[str, Any], *, root: Path | None = None) -> tuple[dict[str, Any], str]: + """Return deterministic machine and Markdown capability projections.""" + if not isinstance(registry, Mapping) or not isinstance(registry.get("capabilities"), list): + raise ValueError("registry must contain capabilities") + rows = validate_capabilities(registry["capabilities"], root=root) + machine = { + "schema_version": "perseus-capability-evidence/v1", + "source": "claims.json", + "capabilities": rows, + } + lines = [ + "# Capability evidence matrix", + "", + "Generated from [`claims.json`](../claims.json) by `scripts/render_claims.py`; edit the registry, not this file.", + "", + "Lifecycle states are intentionally distinct: `implemented`, `tested`, `operational`, `degraded`, `omitted`, `historical`, and `not_demonstrated`.", + "", + "| Capability | Owner | Lifecycle | Evidence | Verification | Claim ceiling | Non-claims | Dependencies / activation | Proof surface |", + "|---|---|---|---|---|---|---|---|---|", + ] + for row in rows: + evidence = ", ".join(_markdown_link(ref) for ref in row["evidence_refs"]) + proof = ", ".join(_markdown_link(ref) for ref in row["proof_surface"]) + verification = ( + f"{row['last_verified']['date']} / `{row['last_verified']['commit']}` / " + f"{row['last_verified']['build']}" + ) + lines.append( + "| " + " | ".join([ + _markdown_cell(row["capability"]), + _markdown_cell(row["owner"]), + f"`{row['lifecycle']}`", + evidence, + _markdown_cell(verification), + _markdown_cell(row["claim_ceiling"]), + _markdown_cell(row["non_claims"]), + _markdown_cell(row["dependencies"]) + "; " + _markdown_cell(row["activation"]), + proof, + ]) + " |" + ) + lines.extend([ + "", + "## Freshness and scope", + "", + "Each row carries its own freshness rule and claim ceiling. A lifecycle value is not a certification: it describes the evidence state of this repository at the recorded verification point.", + "", + ]) + return machine, "\n".join(lines) # Each target maps a registry claim onto a machine field in a distribution file. -# The regex must expose three groups: (prefix)(current-value)(suffix). Every -# match of the regex in the file is synced to the claim's value. -# (relative_path, claim_id, regex) +# The regex exposes three groups: prefix, current value, suffix. TARGETS = [ ("manifest.json", "perseus_version", r'("version"\s*:\s*")([^"]*)(")'), - ("server.json", "perseus_version", r'("version"\s*:\s*")([^"]*)(")'), ( ".well-known/mcp/server-card.json", @@ -43,54 +237,63 @@ def _claims() -> dict: r'("name"\s*:\s*"perseus"\s*,\s*"version"\s*:\s*")([^"]*)(")', ), ] +_CAPABILITY_OUTPUTS = ( + "docs/capability-evidence.json", + "docs/CAPABILITY-EVIDENCE.md", +) def _process(write: bool) -> int: - claims = _claims() + registry = _registry() + claims = registry["claims"] + machine, markdown = render_capability_matrix(registry, root=_ROOT) + desired_outputs = { + _CAPABILITY_OUTPUTS[0]: json.dumps(machine, indent=2, ensure_ascii=False) + "\n", + _CAPABILITY_OUTPUTS[1]: markdown, + } drifted: list[str] = [] rewritten: list[str] = [] + for rel_path, content in desired_outputs.items(): + path = _ROOT / rel_path + current = path.read_text(encoding="utf-8") if path.exists() else None + if current != content: + drifted.append(f"{rel_path}: generated capability surface differs from claims.json") + if write: + path.write_text(content, encoding="utf-8") + rewritten.append(f"{rel_path}: regenerated") for rel_path, claim_id, pattern in TARGETS: path = _ROOT / rel_path text = path.read_text(encoding="utf-8") desired = claims[claim_id]["value"] rx = re.compile(pattern) - matches = list(rx.finditer(text)) if not matches: drifted.append(f"{rel_path}: no field matched pattern for claim '{claim_id}'") continue - file_drift = [m for m in matches if m.group(2) != desired] if not file_drift: continue - for m in file_drift: - drifted.append( - f"{rel_path}: '{claim_id}' is {m.group(2)!r}, registry says {desired!r}" - ) - + drifted.append(f"{rel_path}: '{claim_id}' is {m.group(2)!r}, registry says {desired!r}") if write: - text = rx.sub(lambda m: m.group(1) + desired + m.group(3), text) - path.write_text(text, encoding="utf-8") + path.write_text(rx.sub(lambda m: m.group(1) + desired + m.group(3), text), encoding="utf-8") rewritten.append(f"{rel_path}: '{claim_id}' -> {desired!r}") if write: if rewritten: - print("Rewrote machine fields from claims.json:") + print("Rewrote generated fields from claims.json:") for line in rewritten: print(" " + line) else: - print("No changes needed -- machine fields already match claims.json.") + print("No changes needed -- generated fields already match claims.json.") return 0 - - # --check if drifted: print("Claim drift detected (run with --write to fix):", file=sys.stderr) for line in drifted: print(" " + line, file=sys.stderr) return 1 - print("OK -- all machine fields match claims.json.") + print("OK -- all generated fields match claims.json.") return 0 @@ -98,7 +301,7 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) group = parser.add_mutually_exclusive_group() group.add_argument("--check", action="store_true", help="report drift only (default)") - group.add_argument("--write", action="store_true", help="rewrite machine fields from claims.json") + group.add_argument("--write", action="store_true", help="rewrite generated fields from claims.json") args = parser.parse_args() return _process(write=args.write) diff --git a/src/perseus/context_contract.py b/src/perseus/context_contract.py index a1ab60ce..2bf164f2 100644 --- a/src/perseus/context_contract.py +++ b/src/perseus/context_contract.py @@ -17,6 +17,7 @@ from perseus.composite_ranking import composite_score from perseus.context_decision import decide_context_route from perseus.frontdoor import route_front_door +from perseus.context_evidence import project_context_evidence CONTEXT_RANK_SCHEMA_VERSION = "perseus-context-rank/v1" @@ -683,6 +684,14 @@ def context_rank( "context_decision": decision, "budget": {"max_items": max_items, "max_chars": max_chars, "returned_items": len(output)}, } + selected_ids = {item["candidate_id"] for item in output} + selected_records = [record for record in prepared if record.get("_contract_id") in selected_ids] + result["evidence_projection"] = project_context_evidence( + selected_records, + provider_states=states, + excluded=[{"candidate_id": item, "reason": "excluded_by_contract"} for item in excluded], + evidence_required=bool(policy_map.get("evidence_required", False)), + ) return result except (TypeError, ValueError) as exc: message = str(exc) diff --git a/src/perseus/context_dag.py b/src/perseus/context_dag.py index 91e2a99e..352e9dd8 100644 --- a/src/perseus/context_dag.py +++ b/src/perseus/context_dag.py @@ -44,6 +44,11 @@ import time from dataclasses import dataclass, field from typing import Any, Callable, Optional +from perseus.execution_profiles import ( + ExecutionProfileError, + resolve_execution_profile, + verify_execution_profile, +) NODE_KINDS = frozenset({ "requirement", "retrieved_record", "summary", "contradiction", @@ -742,7 +747,10 @@ def compile_context_dag(*, task_id: str, verdict_hint: str = "sufficient", confidence: Optional[float] = None, created_by: str = "", - meta: Optional[dict] = None) -> dict: + meta: Optional[dict] = None, + execution_profile: Optional[dict] = None, + profile_requirements: Optional[dict] = None, + profile_retrieval_status: str = "complete") -> dict: """Build, expand, and seal an auditable context-compilation DAG. Expansion is layer-wise and selective: only uncertain, contradictory, or @@ -752,8 +760,32 @@ def compile_context_dag(*, task_id: str, the advisory inputs + policy so verification is a faithful replay. """ policy = policy or CompilationPolicy() - budget = budget or CompilationBudget() + resolved_profile = None + if execution_profile is not None: + try: + resolved_profile = resolve_execution_profile( + execution_profile, + requirements=profile_requirements, + retrieval_status=profile_retrieval_status, + ) + except ExecutionProfileError as exc: + raise ContextDagError(f"execution profile rejected: {exc}") from exc + profile_budget = resolved_profile["compilation_budget"] + if budget is None: + budget = CompilationBudget(**profile_budget) + else: + # A caller-supplied DAG budget may tighten a profile, never widen it. + budget = CompilationBudget( + max_nodes=min(int(budget.max_nodes), int(profile_budget["max_nodes"])), + max_depth=min(int(budget.max_depth), int(profile_budget["max_depth"])), + max_fanout=min(int(budget.max_fanout), int(profile_budget["max_fanout"])), + max_tokens=min(int(budget.max_tokens), int(profile_budget["max_tokens"])), + deadline_s=min(float(budget.deadline_s), float(profile_budget["deadline_s"])), + ) + else: + budget = budget or CompilationBudget() ledger = budget.ledger() + profile_degradation_reasons: set[str] = set() graph = ContextDAG(task_id=task_id, created_by=created_by, meta=meta or {}) graph.add_node(root, ledger, depth=0) @@ -777,7 +809,30 @@ def compile_context_dag(*, task_id: str, if not expand: continue expanded.add(nid) - for child in list(fetch(node) or []): + children = list(fetch(node) or []) + if resolved_profile is not None: + children.sort(key=lambda child: child.node_id) + if depth >= budget.max_depth and children: + profile_degradation_reasons.add("max_depth") + children = [] + if len(children) > budget.max_fanout: + profile_degradation_reasons.add("max_items") + children = children[: budget.max_fanout] + remaining_nodes = max(0, budget.max_nodes - len(ledger.nodes)) + if len(children) > remaining_nodes: + profile_degradation_reasons.add("max_items") + children = children[:remaining_nodes] + remaining_tokens = max(0, budget.max_tokens - ledger.total_tokens) + kept_children: list[ContextNode] = [] + for child in children: + needed = dag_tokens(child.content) + if needed > remaining_tokens: + profile_degradation_reasons.add("max_context_tokens") + continue + kept_children.append(child) + remaining_tokens -= needed + children = kept_children + for child in children: cid = graph.add_node(child, ledger, depth=depth + 1) kind = "supports" if child.kind != "contradiction" else "contradicts" edge_meta: dict = {} @@ -808,15 +863,29 @@ def compile_context_dag(*, task_id: str, if nd is not None: packet.append(nd.to_dict()) advisory = {"verdict_hint": verdict_hint, "confidence": confidence} + profile_manifest = resolved_profile or {} + profile_diagnostics = dict(resolved_profile["diagnostics"]) if resolved_profile else {} + if profile_degradation_reasons: + profile_diagnostics["degraded"] = True + profile_diagnostics["reasons"] = sorted(set(profile_diagnostics.get("reasons", [])) | profile_degradation_reasons) + profile_status = ("degraded" if profile_degradation_reasons else resolved_profile["status"]) if resolved_profile else None + digest_parts = [ + "packet", _dag_json(packet), + "verdict", _dag_json(verdict), + "advisory", _dag_json(advisory), + "policy", _dag_json(policy.to_dict()), + "budget", _dag_json(ledger.digest_input()), + "graph", graph.digest(), + "execution_profile", _dag_json(profile_manifest), + ] + if resolved_profile is not None: + digest_parts.extend([ + "profile_status", profile_status, + "profile_diagnostics", _dag_json(profile_diagnostics), + ]) artifact = { "schema_version": "perseus-context-dag/v1", - "compiled_digest": _dag_sha( - "packet", _dag_json(packet), - "verdict", _dag_json(verdict), - "advisory", _dag_json(advisory), - "policy", _dag_json(policy.to_dict()), - "budget", _dag_json(ledger.digest_input()), - "graph", graph.digest()), + "compiled_digest": _dag_sha(*digest_parts), "graph": graph.to_dict(), "selected_node_ids": selected, "packet": packet, @@ -827,6 +896,11 @@ def compile_context_dag(*, task_id: str, "token_accounting": TOKEN_ACCOUNTING_NOTE, "compiled_at_unix_s": round(time.time(), 3), } + if resolved_profile is not None: + artifact["status"] = profile_status + artifact["execution_profile"] = resolved_profile + artifact["execution_profile_digest"] = resolved_profile["profile_digest"] + artifact["profile_diagnostics"] = profile_diagnostics return artifact @@ -877,15 +951,32 @@ def verify_compiled_dag(artifact: dict) -> dict: ) if verdict != artifact.get("verdict"): errors.append("verdict does not recompute from graph state") + profile_manifest = artifact.get("execution_profile") or {} + profile_diagnostics = artifact.get("profile_diagnostics") or {} + profile_status = None + if profile_manifest: + profile_check = verify_execution_profile(profile_manifest) + if not profile_check.get("valid"): + errors.append("execution profile digest mismatch") + if artifact.get("execution_profile_digest") != profile_manifest.get("profile_digest"): + errors.append("execution_profile_digest does not match profile manifest") + profile_status = "degraded" if profile_diagnostics.get("degraded") else profile_manifest.get("status") + if artifact.get("status") != profile_status: + errors.append("profile status does not match profile manifest") budget_sealed = dict(artifact.get("budget") or {}) budget_sealed.pop("wall_clock_s", None) - expected = _dag_sha( + digest_parts = [ "packet", _dag_json(packet), "verdict", _dag_json(artifact.get("verdict")), "advisory", _dag_json(artifact.get("advisory")), "policy", _dag_json(policy.to_dict()), "budget", _dag_json(budget_sealed), - "graph", graph.digest()) + "graph", graph.digest(), + "execution_profile", _dag_json(profile_manifest), + ] + if profile_manifest: + digest_parts.extend(["profile_status", profile_status, "profile_diagnostics", _dag_json(profile_diagnostics)]) + expected = _dag_sha(*digest_parts) if expected != artifact.get("compiled_digest"): errors.append("compiled_digest mismatch") return {"valid": not errors, "errors": errors, diff --git a/src/perseus/context_evidence.py b/src/perseus/context_evidence.py new file mode 100644 index 00000000..bc08cc2c --- /dev/null +++ b/src/perseus/context_evidence.py @@ -0,0 +1,359 @@ +"""Deterministic evidence/uncertainty projection for compiled context (#982). + +The projection consumes caller-supplied normalized evidence. It does not retrieve, +store, or adjudicate Vault/Ledger records. It makes coverage and provider +failure states explicit, carries only sanitized references/commitments, and +turns evidence-required uncertainty into an abstention signal. +""" +from __future__ import annotations + +import hashlib +import json +import re +from typing import Any, Mapping + +_CE_SCHEMA_VERSION = "perseus-context-evidence/v1" +_CE_STATES = frozenset({ + "evidence_backed", "partial", "conflicted", "stale", "empty", "unavailable", "timeout", +}) +_CE_PROVIDER_STATES = frozenset({"active", "partial", "degraded", "unavailable", "timeout", "not_configured"}) +_CE_UNCERTAINTY_CLASSES = frozenset({"high", "medium", "low", "stale", "inferred", "tie"}) +_CE_DIGEST_RE = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$") +_CE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:/#@+\-]{0,159}$") +_CE_ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$") +_CE_FORBIDDEN_KEYS = frozenset({ + "api_key", "authorization", "body", "content", "credential", "credentials", + "password", "private_body", "prompt", "raw", "raw_payload", "secret", "token", + "tool_args", "tool_arguments", +}) +_CE_STATE_ALIASES = { + "supported": "evidence_backed", + "evidence-backed": "evidence_backed", + "evidence_backed": "evidence_backed", + "complete": "evidence_backed", + "degraded": "partial", + "contradictory": "conflicted", + "conflict": "conflicted", + "no_evidence": "empty", + "no-evidence": "empty", +} + + +class ContextEvidenceError(ValueError): + """Raised when an evidence projection cannot be sanitized or verified.""" + + +def _ce_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False) + + +def _ce_sha(value: Any) -> str: + return hashlib.sha256(_ce_json(value).encode("utf-8")).hexdigest() + + +def _ce_forbidden_keys(value: Any, path: str = "projection") -> None: + if isinstance(value, Mapping): + for key, nested in value.items(): + if str(key).casefold().replace("-", "_") in _CE_FORBIDDEN_KEYS: + raise ContextEvidenceError(f"{path}.{key} is not permitted") + _ce_forbidden_keys(nested, f"{path}.{key}") + elif isinstance(value, list): + for index, nested in enumerate(value): + _ce_forbidden_keys(nested, f"{path}[{index}]") + + +def _ce_text(value: Any, field: str, *, limit: int = 256) -> str: + if not isinstance(value, str): + raise ContextEvidenceError(f"{field} must be text") + text = re.sub(r"[\x00-\x1f\x7f]", " ", value).strip() + if not text: + return "" + if len(text) > limit: + text = text[:limit].rstrip() + markers = ("api_key", "authorization", "password", "private_body", "prompt", "raw_payload", "secret", "token") + if any(marker in text.casefold().replace("-", "_") for marker in markers): + return "" + return text + + +def _ce_id(value: Any, field: str) -> str: + text = str(value or "").strip() + if not text: + raise ContextEvidenceError(f"{field} must not be empty") + if _CE_ID_RE.fullmatch(text): + return text[:160] + return "sha256:" + hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest() + + +def _ce_digest(value: Any, field: str) -> str | None: + if value is None or value == "": + return None + text = str(value).strip().lower() + if not _CE_DIGEST_RE.fullmatch(text): + raise ContextEvidenceError(f"{field} must be a SHA-256 digest") + return text.removeprefix("sha256:") + + +def _ce_timestamp(value: Any) -> str | None: + if not isinstance(value, str): + return None + text = value.strip() + return text if _CE_ISO_RE.fullmatch(text) else None + + +def _ce_sources(record: Mapping[str, Any], candidate_id: str) -> list[str]: + values: list[Any] = [] + for key in ("source_id", "source_ref", "provenance_id"): + if record.get(key): + values.append(record[key]) + for key in ("source_refs", "provenance_refs"): + raw = record.get(key) + if isinstance(raw, (list, tuple)): + values.extend(raw) + for key in ("provenance", "evidence"): + nested = record.get(key) + if isinstance(nested, Mapping): + for nested_key in ("source_id", "source_ref", "provenance_id", "provenance_ref", "receipt_id", "id"): + if nested.get(nested_key): + values.append(nested[nested_key]) + refs = sorted({_ce_id(value, "source_ref") for value in values if str(value or "").strip()}) + return refs + + +def _ce_evidence_digest(record: Mapping[str, Any], candidate_id: str) -> str | None: + for key in ("evidence_digest", "content_sha256", "content_hash", "sha256"): + if record.get(key): + return _ce_digest(record[key], f"{candidate_id}.{key}") + # The body is never emitted; when a caller supplies it, only its commitment + # can cross this boundary. + for key in ("content", "body", "raw", "private_body"): + value = record.get(key) + if isinstance(value, str) and value: + return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest() + return None + + +def _ce_item_state(record: Mapping[str, Any], *, has_digest: bool) -> str: + raw = str(record.get("coverage_state", record.get("evidence_status", record.get("validity_state", record.get("status", ""))))).strip().lower().replace(" ", "_") + state = _CE_STATE_ALIASES.get(raw, raw) + if state in _CE_STATES: + return state + return "evidence_backed" if has_digest else "empty" + + +def _ce_uncertainty(record: Mapping[str, Any], state: str) -> dict[str, Any]: + raw = record.get("uncertainty") + if isinstance(raw, Mapping): + cls = str(raw.get("class", "")).strip().lower() + score = raw.get("score") + if cls in _CE_UNCERTAINTY_CLASSES and isinstance(score, (int, float)) and not isinstance(score, bool) and 0 <= score <= 1: + return {"class": cls, "score": round(float(score), 6)} + if state == "evidence_backed": + return {"class": "high" if record.get("verified") else "medium", "score": 0.9 if record.get("verified") else 0.65} + if state == "stale": + return {"class": "stale", "score": 0.35} + if state == "conflicted": + return {"class": "tie", "score": 0.5} + return {"class": "low", "score": 0.2} + + +def _ce_reason(record: Mapping[str, Any], default: str) -> str: + reason = _ce_text(record.get("selection_reason", ""), "selection_reason") + return reason or default + + +def _ce_provider_states(value: Mapping[str, Any] | None) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise ContextEvidenceError("provider_states must be an object") + result: dict[str, str] = {} + for key, raw in value.items(): + provider = _ce_id(key, "provider") + state = str(raw).strip().lower().replace("-", "_") + if state not in _CE_PROVIDER_STATES: + raise ContextEvidenceError(f"unsupported provider state: {state}") + result[provider] = state + return dict(sorted(result.items())) + + +def _ce_selected_item(record: Mapping[str, Any], index: int) -> tuple[dict[str, Any] | None, dict[str, str]]: + if not isinstance(record, Mapping): + return None, {"candidate_id": f"item-{index + 1}", "reason": "invalid_record"} + candidate_id = _ce_id(record.get("candidate_id") or record.get("id") or record.get("key") or f"item-{index + 1}", "candidate_id") + digest = _ce_evidence_digest(record, candidate_id) + refs = _ce_sources(record, candidate_id) + state = _ce_item_state(record, has_digest=bool(digest)) + if not refs: + return None, {"candidate_id": candidate_id, "reason": "source_reference_missing"} + if not digest: + return None, {"candidate_id": candidate_id, "reason": "evidence_digest_missing"} + item: dict[str, Any] = { + "candidate_id": candidate_id, + "source_refs": refs, + "evidence_digest": digest, + "coverage_state": state, + "uncertainty": _ce_uncertainty(record, state), + "inclusion_reason": _ce_reason(record, "selected_by_caller"), + } + for key in ("valid_at", "transaction_time", "recorded_at", "observed_at"): + timestamp = _ce_timestamp(record.get(key)) + if timestamp: + item[key] = timestamp + return item, {} + + +def _ce_normalized_exclusions(value: Any) -> list[dict[str, str]]: + if value is None: + return [] + if not isinstance(value, (list, tuple)): + raise ContextEvidenceError("excluded must be a list") + result = [] + for index, raw in enumerate(value): + if isinstance(raw, Mapping): + candidate_id = _ce_id(raw.get("candidate_id") or raw.get("id") or f"excluded-{index + 1}", "excluded.candidate_id") + reason = _ce_text(raw.get("reason") or raw.get("selection_reason") or "", "excluded.reason") or "excluded_by_policy" + else: + candidate_id = _ce_id(raw, "excluded.candidate_id") + reason = "excluded_by_policy" + result.append({"candidate_id": candidate_id, "reason": reason}) + return result + + +def project_context_evidence( + entries: Any, + *, + provider_states: Mapping[str, Any] | None = None, + excluded: Any = None, + evidence_required: bool = False, + selected_ids: list[str] | None = None, +) -> dict[str, Any]: + """Compile a sanitized evidence coverage projection without retrieval.""" + if not isinstance(entries, (list, tuple)): + raise ContextEvidenceError("entries must be a list") + if not isinstance(evidence_required, bool): + raise ContextEvidenceError("evidence_required must be boolean") + providers = _ce_provider_states(provider_states) + wanted = {_ce_id(item, "selected_id") for item in selected_ids} if selected_ids is not None else None + selected: list[dict[str, Any]] = [] + exclusions = _ce_normalized_exclusions(excluded) + seen: set[str] = set() + for index, raw in enumerate(entries): + item, omission = _ce_selected_item(raw, index) + if item is None: + exclusions.append(omission) + continue + if item["candidate_id"] in seen: + exclusions.append({"candidate_id": item["candidate_id"], "reason": "duplicate_candidate_id"}) + continue + seen.add(item["candidate_id"]) + if wanted is not None and item["candidate_id"] not in wanted: + exclusions.append({"candidate_id": item["candidate_id"], "reason": "not_selected"}) + continue + selected.append(item) + selected.sort(key=lambda item: item["candidate_id"]) + exclusions = sorted(exclusions, key=lambda item: (item["candidate_id"], item["reason"])) + item_states = [item["coverage_state"] for item in selected] + if any(state == "timeout" for state in providers.values()): + state = "timeout" + elif any(state == "unavailable" for state in providers.values()): + state = "unavailable" + elif "conflicted" in item_states: + state = "conflicted" + elif "stale" in item_states: + state = "stale" + elif any(state in {"partial", "degraded"} for state in providers.values()) or "partial" in item_states: + state = "partial" + elif not selected: + state = "empty" + else: + state = "evidence_backed" + abstention = bool(evidence_required and state != "evidence_backed") + status = "abstention_required" if abstention else { + "evidence_backed": "complete", + "partial": "degraded", + "conflicted": "review", + "stale": "degraded", + "empty": "empty", + "unavailable": "unavailable", + "timeout": "unavailable", + }[state] + reason = { + "evidence_backed": "evidence is linked to sanitized source references and a digest", + "partial": "provider or selected evidence is partial", + "conflicted": "evidence contains unresolved conflict", + "stale": "selected evidence is stale", + "empty": "no evidence-backed item was selected", + "unavailable": "provider evidence is unavailable", + "timeout": "provider evidence timed out", + }[state] + coverage = { + "state": state, + "reason": reason, + "provider_states": providers, + "evidence_required": evidence_required, + "abstention_required": abstention, + } + unsigned: dict[str, Any] = { + "schema_version": _CE_SCHEMA_VERSION, + "status": status, + "coverage": coverage, + "selected": selected, + "excluded": exclusions, + "diagnostics": { + "relevance_is_not_truth_gate": True, + "selected_count": len(selected), + "excluded_count": len(exclusions), + }, + } + unsigned["projection_digest"] = _ce_sha(unsigned) + return unsigned + + +def verify_context_evidence(projection: Mapping[str, Any]) -> dict[str, Any]: + if not isinstance(projection, Mapping) or projection.get("schema_version") != _CE_SCHEMA_VERSION: + return {"valid": False, "error": "unsupported evidence projection"} + try: + _ce_forbidden_keys(projection) + supplied = projection.get("projection_digest") + if not isinstance(supplied, str): + return {"valid": False, "error": "missing projection digest"} + unsigned = dict(projection) + unsigned.pop("projection_digest", None) + expected = _ce_sha(unsigned) + except (ContextEvidenceError, TypeError, ValueError): + return {"valid": False, "error": "invalid evidence projection"} + return {"valid": expected == supplied, "projection_digest": supplied, "expected_digest": expected} + + +def render_context_evidence(projection: Mapping[str, Any]) -> str: + check = verify_context_evidence(projection) + if not check["valid"]: + raise ContextEvidenceError("refusing to render invalid evidence projection") + coverage = projection["coverage"] + lines = [ + "# Context evidence projection", + "", + f"- coverage: **{coverage['state']}** — {coverage['reason']}", + f"- status: **{projection['status']}**", + f"- evidence required: `{str(coverage['evidence_required']).lower()}`", + f"- abstention required: `{str(coverage['abstention_required']).lower()}`", + f"- projection digest: `{projection['projection_digest']}`", + "", + "## Selected evidence", + "", + ] + if projection["selected"]: + for item in projection["selected"]: + lines.append( + f"- `{item['candidate_id']}` — sources: {', '.join(item['source_refs'])}; " + f"evidence: `{item['evidence_digest']}`; reason: {item['inclusion_reason']}" + ) + else: + lines.append("- none") + lines.extend(["", "## Excluded", ""]) + if projection["excluded"]: + lines.extend(f"- `{item['candidate_id']}` — {item['reason']}" for item in projection["excluded"]) + else: + lines.append("- none") + return "\n".join(lines) + "\n" diff --git a/src/perseus/execution_profiles.py b/src/perseus/execution_profiles.py new file mode 100644 index 00000000..b29fad64 --- /dev/null +++ b/src/perseus/execution_profiles.py @@ -0,0 +1,414 @@ +"""Versioned, resource-aware execution profiles for context compilation (#980). + +This module defines a deterministic planning contract only. It does not select a +model, call a provider, measure hardware, or persist resource telemetry. A +profile describes hard context limits and explicit degradation/network policy; +resolution returns a sanitized manifest that a compiler or runtime adapter can +consume. +""" +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from typing import Any, Mapping + +_EP_SCHEMA_VERSION = "perseus-execution-profile/v1" +_EP_DIAGNOSTIC_SCHEMA_VERSION = "perseus-execution-diagnostics/v1" +_EP_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:/#@+\-]{0,159}$") +_EP_ALLOWED_FIELDS = frozenset({ + "schema_version", "profile_id", "mode", "max_context_tokens", + "max_context_bytes", "max_items", "max_depth", "latency_target_ms", + "resource_class", "network_mode", "runtime_capabilities", + "degradation_policy", "auth_mode", "runtime_ref", "model_ref", +}) +_EP_REQUIREMENT_FIELDS = frozenset({ + "network_mode", "require_offline", "required_capabilities", + "max_context_tokens", "max_context_bytes", "max_items", "max_depth", + "latency_target_ms", +}) +_EP_RESOURCE_FIELDS = frozenset({ + "memory_class", "compute_class", "network_available", + "available_memory_mb", "available_compute_units", "resource_metrics", +}) +_EP_NETWORK_MODES = frozenset({"offline", "local", "approved_network"}) +_EP_DEGRADATION_POLICIES = frozenset({"fail_closed", "partial", "omit_low_priority"}) +_EP_RETRIEVAL_STATES = frozenset({"complete", "partial", "degraded", "unavailable", "timeout"}) +_EP_MODE_DEFAULTS = { + "standard-local": { + "max_context_tokens": 8192, + "max_context_bytes": 32768, + "max_items": 64, + "max_depth": 4, + "latency_target_ms": None, + "resource_class": "unknown", + "network_mode": "local", + "degradation_policy": "partial", + }, + "constrained-edge": { + "max_context_tokens": 2048, + "max_context_bytes": 8192, + "max_items": 16, + "max_depth": 2, + "latency_target_ms": 500, + "resource_class": "edge", + "network_mode": "local", + "degradation_policy": "partial", + }, + "air-gapped": { + "max_context_tokens": 4096, + "max_context_bytes": 16384, + "max_items": 32, + "max_depth": 3, + "latency_target_ms": None, + "resource_class": "isolated", + "network_mode": "offline", + "degradation_policy": "fail_closed", + }, +} +_EP_FORBIDDEN_KEYS = frozenset({ + "api_key", "authorization", "body", "content", "credential", "credentials", + "password", "private_body", "prompt", "raw", "raw_payload", "secret", + "token", "tool_args", "tool_arguments", +}) + + +class ExecutionProfileError(ValueError): + """Raised when a profile or hard capability requirement is invalid.""" + + +def _ep_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False) + + +def _ep_sha(value: Any) -> str: + return hashlib.sha256(_ep_json(value).encode("utf-8")).hexdigest() + + +def _ep_text(value: Any, field: str, *, max_length: int = 160) -> str: + if not isinstance(value, str) or not value.strip(): + raise ExecutionProfileError(f"{field} must be a non-empty string") + text = value.strip() + if len(text) > max_length: + raise ExecutionProfileError(f"{field} is too long") + if any(key in text.casefold().replace("-", "_") for key in _EP_FORBIDDEN_KEYS): + raise ExecutionProfileError(f"{field} contains forbidden credential/raw-data marker") + return text + + +def _ep_id(value: Any, field: str, *, default: str = "") -> str: + text = _ep_text(value if value is not None else default, field) + if not _EP_ID_RE.fullmatch(text): + raise ExecutionProfileError(f"{field} must be a bounded identifier") + return text + + +def _ep_limit(value: Any, field: str, *, maximum: int = 10_000_000) -> int: + if isinstance(value, bool): + raise ExecutionProfileError(f"{field} must be a positive integer") + try: + number = int(value) + except (TypeError, ValueError): + raise ExecutionProfileError(f"{field} must be a positive integer") from None + if number < 1 or number > maximum: + raise ExecutionProfileError(f"{field} must be between 1 and {maximum}") + return number + + +def _ep_optional_limit(value: Any, field: str, *, maximum: int = 10_000_000) -> int | None: + if value is None or value == "": + return None + return _ep_limit(value, field, maximum=maximum) + + +def _ep_forbidden_keys(value: Any, path: str = "profile") -> None: + if isinstance(value, Mapping): + for key, nested in value.items(): + normalized = str(key).casefold().replace("-", "_") + if normalized in _EP_FORBIDDEN_KEYS: + raise ExecutionProfileError(f"{path}.{key} is not permitted") + _ep_forbidden_keys(nested, f"{path}.{key}") + elif isinstance(value, (list, tuple)): + for index, nested in enumerate(value): + _ep_forbidden_keys(nested, f"{path}[{index}]") + + +@dataclass(frozen=True) +class ExecutionProfile: + """A bounded, portable description of the resources a compiler may use.""" + + schema_version: str + profile_id: str + mode: str + max_context_tokens: int + max_context_bytes: int + max_items: int + max_depth: int + latency_target_ms: int | None + resource_class: str + network_mode: str + runtime_capabilities: tuple[str, ...] + degradation_policy: str + auth_mode: str + runtime_ref: str = "" + model_ref: str = "" + + @classmethod + def from_mapping(cls, value: Mapping[str, Any] | "ExecutionProfile" | None) -> "ExecutionProfile": + if isinstance(value, cls): + return value + if value is None: + value = {"mode": "standard-local", "profile_id": "default-local"} + if not isinstance(value, Mapping): + raise ExecutionProfileError("execution profile must be an object") + _ep_forbidden_keys(value) + unknown = set(value) - _EP_ALLOWED_FIELDS + if unknown: + raise ExecutionProfileError(f"unsupported execution profile fields: {sorted(map(str, unknown))}") + mode = str(value.get("mode", "standard-local")).strip() + if mode not in _EP_MODE_DEFAULTS: + raise ExecutionProfileError(f"unsupported execution profile mode: {mode!r}") + defaults = _EP_MODE_DEFAULTS[mode] + schema_version = str(value.get("schema_version", _EP_SCHEMA_VERSION)).strip() + if schema_version != _EP_SCHEMA_VERSION: + raise ExecutionProfileError("unsupported execution profile schema version") + profile_id = _ep_id(value.get("profile_id", mode), "profile_id") + network_mode = str(value.get("network_mode", defaults["network_mode"])).strip() + if network_mode not in _EP_NETWORK_MODES: + raise ExecutionProfileError("network_mode must be offline, local, or approved_network") + if mode == "air-gapped" and network_mode != "offline": + raise ExecutionProfileError("air-gapped mode requires offline network_mode") + degradation_policy = str(value.get("degradation_policy", defaults["degradation_policy"])).strip() + if degradation_policy not in _EP_DEGRADATION_POLICIES: + raise ExecutionProfileError("unsupported degradation_policy") + capabilities = value.get("runtime_capabilities", ()) + if isinstance(capabilities, str): + capabilities = [capabilities] + if not isinstance(capabilities, (list, tuple)) or len(capabilities) > 32: + raise ExecutionProfileError("runtime_capabilities must contain at most 32 identifiers") + capability_values = tuple(sorted({_ep_id(item, "runtime_capability") for item in capabilities})) + auth_mode = _ep_text(value.get("auth_mode", "none"), "auth_mode", max_length=64) + runtime_ref = _ep_id(value.get("runtime_ref", "ref:none"), "runtime_ref") + model_ref = _ep_id(value.get("model_ref", "ref:none"), "model_ref") + return cls( + schema_version=schema_version, + profile_id=profile_id, + mode=mode, + max_context_tokens=_ep_limit(value.get("max_context_tokens", defaults["max_context_tokens"]), "max_context_tokens"), + max_context_bytes=_ep_limit(value.get("max_context_bytes", defaults["max_context_bytes"]), "max_context_bytes"), + max_items=_ep_limit(value.get("max_items", defaults["max_items"]), "max_items", maximum=4096), + max_depth=_ep_limit(value.get("max_depth", defaults["max_depth"]), "max_depth", maximum=128), + latency_target_ms=_ep_optional_limit(value.get("latency_target_ms", defaults["latency_target_ms"]), "latency_target_ms", maximum=86_400_000), + resource_class=_ep_text(value.get("resource_class", defaults["resource_class"]), "resource_class", max_length=64), + network_mode=network_mode, + runtime_capabilities=capability_values, + degradation_policy=degradation_policy, + auth_mode=auth_mode, + runtime_ref=runtime_ref, + model_ref=model_ref, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "profile_id": self.profile_id, + "mode": self.mode, + "max_context_tokens": self.max_context_tokens, + "max_context_bytes": self.max_context_bytes, + "max_items": self.max_items, + "max_depth": self.max_depth, + "latency_target_ms": self.latency_target_ms, + "resource_class": self.resource_class, + "network_mode": self.network_mode, + "runtime_capabilities": list(self.runtime_capabilities), + "degradation_policy": self.degradation_policy, + "auth_mode": self.auth_mode, + "runtime_ref": self.runtime_ref, + "model_ref": self.model_ref, + } + + +def _ep_requirements(value: Mapping[str, Any] | None) -> dict[str, Any]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise ExecutionProfileError("profile requirements must be an object") + _ep_forbidden_keys(value, "requirements") + unknown = set(value) - _EP_REQUIREMENT_FIELDS + if unknown: + raise ExecutionProfileError(f"unsupported profile requirements: {sorted(map(str, unknown))}") + result: dict[str, Any] = {} + for field in ("max_context_tokens", "max_context_bytes", "max_items", "max_depth", "latency_target_ms"): + if field in value and value[field] is not None: + result[field] = _ep_limit(value[field], f"requirements.{field}", maximum=86_400_000 if field == "latency_target_ms" else 10_000_000) + required = value.get("required_capabilities", ()) + if isinstance(required, str): + required = [required] + if not isinstance(required, (list, tuple)) or len(required) > 32: + raise ExecutionProfileError("required_capabilities must contain at most 32 identifiers") + result["required_capabilities"] = sorted({_ep_id(item, "required_capability") for item in required}) + if "network_mode" in value: + network_mode = str(value["network_mode"]).strip() + if network_mode not in _EP_NETWORK_MODES: + raise ExecutionProfileError("requirements.network_mode is unsupported") + result["network_mode"] = network_mode + result["require_offline"] = value.get("require_offline", False) + if not isinstance(result["require_offline"], bool): + raise ExecutionProfileError("requirements.require_offline must be boolean") + return result + + +def _ep_resources(value: Mapping[str, Any] | None) -> tuple[dict[str, Any], str]: + if value is None: + return {}, "unknown" + if not isinstance(value, Mapping): + raise ExecutionProfileError("resources must be an object") + _ep_forbidden_keys(value, "resources") + unknown = set(value) - _EP_RESOURCE_FIELDS + if unknown: + raise ExecutionProfileError(f"unsupported resource fields: {sorted(map(str, unknown))}") + result: dict[str, Any] = {} + for field in ("memory_class", "compute_class"): + if value.get(field) is not None: + result[field] = _ep_text(value[field], f"resources.{field}", max_length=64) + if value.get("network_available") is not None: + if not isinstance(value["network_available"], bool): + raise ExecutionProfileError("resources.network_available must be boolean") + result["network_available"] = value["network_available"] + for field in ("available_memory_mb", "available_compute_units"): + if value.get(field) is not None: + result[field] = _ep_limit(value[field], f"resources.{field}", maximum=10_000_000_000) + metrics = value.get("resource_metrics") + if metrics is not None: + if isinstance(metrics, str): + metrics = [metrics] + if not isinstance(metrics, (list, tuple)) or len(metrics) > 32: + raise ExecutionProfileError("resources.resource_metrics must contain at most 32 names") + result["resource_metrics"] = sorted({_ep_id(item, "resource_metric") for item in metrics}) + return result, "known" if result else "unknown" + + +def execution_profile_compilation_budget(resolved: Mapping[str, Any]) -> dict[str, Any]: + """Project a resolved profile into the existing DAG budget vocabulary.""" + if not isinstance(resolved, Mapping) or not isinstance(resolved.get("effective"), Mapping): + raise ExecutionProfileError("resolved execution profile is malformed") + effective = resolved["effective"] + max_tokens = min(int(effective["max_context_tokens"]), max(1, int(effective["max_context_bytes"]) // 4)) + latency = effective.get("latency_target_ms") + return { + "max_nodes": max(1, int(effective["max_items"])), + "max_depth": max(1, int(effective["max_depth"])), + "max_fanout": max(1, int(effective["max_items"])), + "max_tokens": max_tokens, + "deadline_s": max(0.001, float(latency) / 1000.0) if latency is not None else 30.0, + } + + +def _ep_resolve_execution_profile_impl( + profile: Mapping[str, Any] | ExecutionProfile | None = None, + *, + requirements: Mapping[str, Any] | None = None, + resources: Mapping[str, Any] | None = None, + retrieval_status: str = "complete", +) -> dict[str, Any]: + """Resolve hard limits and capabilities into a digest-sealed manifest.""" + base = ExecutionProfile.from_mapping(profile) + req = _ep_requirements(requirements) + if retrieval_status not in _EP_RETRIEVAL_STATES: + raise ExecutionProfileError("retrieval_status is unsupported") + if req.get("network_mode") and base.network_mode == "offline" and req["network_mode"] != "offline": + raise ExecutionProfileError("offline profile cannot satisfy a network requirement") + if req.get("require_offline") and base.network_mode != "offline": + raise ExecutionProfileError("profile does not satisfy required offline mode") + missing = sorted(set(req.get("required_capabilities", ())) - set(base.runtime_capabilities)) + if missing: + raise ExecutionProfileError(f"required capabilities are unsupported: {', '.join(missing)}") + safe_resources, resource_state = _ep_resources(resources) + effective = base.to_dict() + for field in ("max_context_tokens", "max_context_bytes", "max_items", "max_depth", "latency_target_ms"): + if field in req and req[field] is not None: + effective[field] = min(int(effective[field]), int(req[field])) + if effective["max_context_tokens"] < 1 or effective["max_context_bytes"] < 1 or effective["max_items"] < 1 or effective["max_depth"] < 1: + raise ExecutionProfileError("requirements leave no usable context budget") + reasons: list[str] = [] + status = "complete" + abstention_required = False + if retrieval_status in {"partial", "degraded"}: + status = "degraded" + reasons.append("retrieval_partial") + elif retrieval_status in {"unavailable", "timeout"}: + status = "degraded" + reasons.append(f"retrieval_{retrieval_status}") + abstention_required = True + diagnostics = { + "schema_version": _EP_DIAGNOSTIC_SCHEMA_VERSION, + "degraded": bool(reasons), + "reasons": reasons, + "resource_state": resource_state, + "abstention_required": abstention_required, + "degradation_policy": base.degradation_policy, + } + resolved: dict[str, Any] = { + "schema_version": _EP_SCHEMA_VERSION, + "profile": base.to_dict(), + "effective": effective, + "requirements": req, + "resources": safe_resources, + "resource_state": resource_state, + "status": status, + "diagnostics": diagnostics, + } + resolved["compilation_budget"] = execution_profile_compilation_budget(resolved) + resolved["profile_digest"] = _ep_sha(resolved) + return resolved + + +class _EPExecutionProfileResolver: + """Callable API object kept outside the directive resolver registry.""" + + def __call__(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + return _ep_resolve_execution_profile_impl(*args, **kwargs) + + +resolve_execution_profile = _EPExecutionProfileResolver() + + +def negotiate_context_budget( + profile: Mapping[str, Any] | ExecutionProfile | None = None, + *, + requested_tokens: int | None = None, + requested_bytes: int | None = None, + requested_items: int | None = None, + requested_depth: int | None = None, + retrieval_status: str = "complete", + resources: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + requirements: dict[str, Any] = {} + for name, value in ( + ("max_context_tokens", requested_tokens), + ("max_context_bytes", requested_bytes), + ("max_items", requested_items), + ("max_depth", requested_depth), + ): + if value is not None: + requirements[name] = value + return resolve_execution_profile( + profile, + requirements=requirements, + resources=resources, + retrieval_status=retrieval_status, + ) + + +def verify_execution_profile(resolved: Mapping[str, Any]) -> dict[str, Any]: + """Verify the commitment over a resolved, sanitized profile manifest.""" + if not isinstance(resolved, Mapping) or not isinstance(resolved.get("profile_digest"), str): + return {"valid": False, "error": "missing profile digest"} + unsigned = dict(resolved) + supplied = unsigned.pop("profile_digest") + try: + expected = _ep_sha(unsigned) + except (TypeError, ValueError): + return {"valid": False, "error": "profile manifest is not canonical JSON"} + return {"valid": expected == supplied, "profile_digest": supplied, "expected_digest": expected} diff --git a/src/perseus/mcp.py b/src/perseus/mcp.py index 83911e9d..8627a9c3 100644 --- a/src/perseus/mcp.py +++ b/src/perseus/mcp.py @@ -272,6 +272,46 @@ def obj(properties: dict, required: tuple[str, ...] = ()) -> dict: "revocation_epoch": {"type": "integer", "minimum": 0}, }, required=("schema_version", "receipt_id", "projection_digest", "release_decision", "status")) + evidence_coverage = obj({ + "state": {"type": "string", "enum": ["evidence_backed", "partial", "conflicted", "stale", "empty", "unavailable", "timeout"]}, + "reason": {"type": "string", "maxLength": 256}, + "provider_states": obj({ + "vault": {"type": "string", "enum": ["active", "partial", "degraded", "unavailable", "timeout", "not_configured"]}, + "ledger": {"type": "string", "enum": ["active", "partial", "degraded", "unavailable", "timeout", "not_configured"]}, + }), + "evidence_required": {"type": "boolean"}, + "abstention_required": {"type": "boolean"}, + }, required=("state", "reason", "provider_states", "evidence_required", "abstention_required")) + evidence_item = obj({ + "candidate_id": string_id, + "source_refs": {"type": "array", "maxItems": 64, "items": string_id}, + "evidence_digest": sha256, + "coverage_state": {"type": "string", "enum": ["evidence_backed", "partial", "conflicted", "stale", "empty", "unavailable", "timeout"]}, + "uncertainty": uncertainty, + "inclusion_reason": {"type": "string", "maxLength": 256}, + "valid_at": {"type": "string", "maxLength": 64}, + "transaction_time": {"type": "string", "maxLength": 64}, + "recorded_at": {"type": "string", "maxLength": 64}, + "observed_at": {"type": "string", "maxLength": 64}, + }, required=("candidate_id", "source_refs", "evidence_digest", "coverage_state", "uncertainty", "inclusion_reason")) + evidence_excluded = obj({ + "candidate_id": string_id, + "reason": {"type": "string", "maxLength": 256}, + }, required=("candidate_id", "reason")) + evidence_projection = obj({ + "schema_version": {"type": "string", "const": "perseus-context-evidence/v1"}, + "status": {"type": "string", "enum": ["complete", "degraded", "review", "empty", "unavailable", "abstention_required"]}, + "coverage": evidence_coverage, + "selected": {"type": "array", "maxItems": 64, "items": evidence_item}, + "excluded": {"type": "array", "maxItems": 128, "items": evidence_excluded}, + "diagnostics": obj({ + "relevance_is_not_truth_gate": {"type": "boolean", "const": True}, + "selected_count": {"type": "integer", "minimum": 0}, + "excluded_count": {"type": "integer", "minimum": 0}, + }, required=("relevance_is_not_truth_gate", "selected_count", "excluded_count")), + "projection_digest": sha256, + }, required=("schema_version", "status", "coverage", "selected", "excluded", "diagnostics", "projection_digest")) + return obj({ "schema_version": {"type": "string", "enum": [ "perseus-context-rank/v1", "perseus-context-ask/v1", @@ -321,6 +361,7 @@ def obj(properties: dict, required: tuple[str, ...] = ()) -> dict: "evidence": {"type": "array", "maxItems": 64, "items": evidence}, "projection": projection, "projection_digest": sha256, + "evidence_projection": evidence_projection, "selection": {"type": "array", "maxItems": 64, "items": selection}, "provenance": {"type": "array", "maxItems": 64, "items": {"type": "array", "maxItems": 64, "items": evidence}}, "release_decision": {"type": "string", "enum": [ diff --git a/src/perseus/runtime_adapter.py b/src/perseus/runtime_adapter.py new file mode 100644 index 00000000..1217623e --- /dev/null +++ b/src/perseus/runtime_adapter.py @@ -0,0 +1,498 @@ +"""Portable local/edge runtime adapter envelopes (#981). + +The adapter seam is an integration contract, not an inference engine. Requests +carry only bounded context/profile commitments; results carry explicit status, +sanitary output, usage, and runtime provenance. The bundled reference adapter +never opens a network connection and exists only to exercise the contract. +""" +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from typing import Any, Mapping + +from perseus.execution_profiles import ExecutionProfileError, verify_execution_profile + +_RA_CAPABILITIES_SCHEMA = "perseus-runtime-capabilities/v1" +_RA_REQUEST_SCHEMA = "perseus-runtime-request/v1" +_RA_RESULT_SCHEMA = "perseus-runtime-result/v1" +_RA_NEGOTIATION_SCHEMA = "perseus-runtime-negotiation/v1" +_RA_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:/#@+\-]{0,159}$") +_RA_DIGEST_RE = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$") +_RA_EXECUTION_MODES = frozenset({"offline", "local", "approved_network"}) +_RA_RESULT_STATUSES = frozenset({"success", "partial", "unavailable", "timeout", "cancelled", "malformed"}) +_RA_CAPABILITY_FIELDS = frozenset({ + "schema_version", "backend_id", "backend_version", "model_id", "model_version", + "tokenizer_id", "context_capacity_tokens", "execution_modes", "streaming", "tools", + "hardware_class", "resource_metrics", "auth_mode", "provider_ref", +}) +_RA_REQUEST_FIELDS = frozenset({ + "schema_version", "request_id", "execution_profile", "execution_profile_digest", "context_digest", + "evidence_digest", "input_digest", "execution_mode", "required_capabilities", + "max_output_chars", +}) +_RA_RESULT_FIELDS = frozenset({ + "schema_version", "request_id", "status", "output", "usage", "runtime", + "error_code", "error_message", "external_fallback_allowed", +}) +_RA_FORBIDDEN_KEYS = frozenset({ + "api_key", "authorization", "body", "content", "credential", "credentials", + "password", "private_body", "prompt", "raw", "raw_payload", "secret", "token", + "tool_args", "tool_arguments", +}) +_RA_RUNTIME_FIELDS = frozenset({ + "backend_id", "backend_version", "model_id", "model_version", "auth_mode", + "provider_ref", "execution_mode", +}) +_RA_USAGE_FIELDS = frozenset({"input_tokens", "output_tokens", "latency_ms"}) + + +class RuntimeAdapterError(ValueError): + """Raised when an adapter envelope cannot be admitted safely.""" + + +def _ra_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False) + + +def _ra_sha(value: Any) -> str: + return hashlib.sha256(_ra_json(value).encode("utf-8")).hexdigest() + + +def _ra_text(value: Any, field: str, *, max_length: int = 160, allow_empty: bool = False) -> str: + if not isinstance(value, str): + raise RuntimeAdapterError(f"{field} must be text") + text = value.strip() + if not text and not allow_empty: + raise RuntimeAdapterError(f"{field} must not be empty") + if len(text) > max_length: + raise RuntimeAdapterError(f"{field} is too long") + return text + + +def _ra_id(value: Any, field: str, *, allow_empty: bool = False) -> str: + text = _ra_text(value, field, allow_empty=allow_empty) + if text and not _RA_ID_RE.fullmatch(text): + raise RuntimeAdapterError(f"{field} must be a bounded identifier") + return text + + +def _ra_digest(value: Any, field: str) -> str: + text = _ra_text(value, field, max_length=71).lower() + if not _RA_DIGEST_RE.fullmatch(text): + raise RuntimeAdapterError(f"{field} must be a SHA-256 digest") + return text.removeprefix("sha256:") + + +def _ra_limit(value: Any, field: str, *, maximum: int = 10_000_000) -> int: + if isinstance(value, bool): + raise RuntimeAdapterError(f"{field} must be a positive integer") + try: + number = int(value) + except (TypeError, ValueError): + raise RuntimeAdapterError(f"{field} must be a positive integer") from None + if number < 1 or number > maximum: + raise RuntimeAdapterError(f"{field} must be between 1 and {maximum}") + return number + + +def _ra_forbidden_keys(value: Any, path: str = "envelope") -> None: + if isinstance(value, Mapping): + for key, nested in value.items(): + normalized = str(key).casefold().replace("-", "_") + if normalized in _RA_FORBIDDEN_KEYS: + raise RuntimeAdapterError(f"{path}.{key} is not permitted") + _ra_forbidden_keys(nested, f"{path}.{key}") + elif isinstance(value, (list, tuple)): + for index, nested in enumerate(value): + _ra_forbidden_keys(nested, f"{path}[{index}]") + + +def _ra_string_list(value: Any, field: str, *, maximum: int = 32) -> tuple[str, ...]: + if isinstance(value, str): + value = [value] + if not isinstance(value, (list, tuple)) or len(value) > maximum: + raise RuntimeAdapterError(f"{field} must contain at most {maximum} identifiers") + return tuple(sorted({_ra_id(item, field) for item in value})) + + +@dataclass(frozen=True) +class RuntimeCapabilities: + """Sanitized capabilities advertised by one runtime/backend.""" + + schema_version: str + backend_id: str + backend_version: str + model_id: str + model_version: str + tokenizer_id: str + context_capacity_tokens: int + execution_modes: tuple[str, ...] + streaming: bool + tools: bool + hardware_class: str + resource_metrics: tuple[str, ...] + auth_mode: str + provider_ref: str + + @classmethod + def from_mapping(cls, value: Mapping[str, Any] | "RuntimeCapabilities") -> "RuntimeCapabilities": + if isinstance(value, cls): + return value + if not isinstance(value, Mapping): + raise RuntimeAdapterError("runtime capabilities must be an object") + _ra_forbidden_keys(value, "capabilities") + unknown = set(value) - _RA_CAPABILITY_FIELDS + if unknown: + raise RuntimeAdapterError(f"unsupported capability fields: {sorted(map(str, unknown))}") + if value.get("schema_version", _RA_CAPABILITIES_SCHEMA) != _RA_CAPABILITIES_SCHEMA: + raise RuntimeAdapterError("unsupported runtime capabilities schema version") + modes = _ra_string_list(value.get("execution_modes", ()), "execution_modes") + if not modes or not set(modes).issubset(_RA_EXECUTION_MODES): + raise RuntimeAdapterError("execution_modes must contain offline, local, or approved_network") + metrics = _ra_string_list(value.get("resource_metrics", ()), "resource_metrics") + for field in ("backend_id", "backend_version", "model_id", "tokenizer_id", "auth_mode", "provider_ref"): + _ra_id(value.get(field, ""), field) + model_version = _ra_id(value.get("model_version", "unknown"), "model_version") + hardware_class = _ra_id(value.get("hardware_class", "unknown"), "hardware_class") + if not isinstance(value.get("streaming", False), bool) or not isinstance(value.get("tools", False), bool): + raise RuntimeAdapterError("streaming and tools must be booleans") + return cls( + schema_version=_RA_CAPABILITIES_SCHEMA, + backend_id=_ra_id(value["backend_id"], "backend_id"), + backend_version=_ra_id(value["backend_version"], "backend_version"), + model_id=_ra_id(value["model_id"], "model_id"), + model_version=model_version, + tokenizer_id=_ra_id(value["tokenizer_id"], "tokenizer_id"), + context_capacity_tokens=_ra_limit(value["context_capacity_tokens"], "context_capacity_tokens"), + execution_modes=modes, + streaming=value.get("streaming", False), + tools=value.get("tools", False), + hardware_class=hardware_class, + resource_metrics=metrics, + auth_mode=_ra_id(value["auth_mode"], "auth_mode"), + provider_ref=_ra_id(value["provider_ref"], "provider_ref"), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "backend_id": self.backend_id, + "backend_version": self.backend_version, + "model_id": self.model_id, + "model_version": self.model_version, + "tokenizer_id": self.tokenizer_id, + "context_capacity_tokens": self.context_capacity_tokens, + "execution_modes": list(self.execution_modes), + "streaming": self.streaming, + "tools": self.tools, + "hardware_class": self.hardware_class, + "resource_metrics": list(self.resource_metrics), + "auth_mode": self.auth_mode, + "provider_ref": self.provider_ref, + } + + +@dataclass(frozen=True) +class AdapterRequest: + """A digest-only request envelope for a qualified runtime adapter.""" + + schema_version: str + request_id: str + execution_profile: dict[str, Any] + execution_profile_digest: str + context_digest: str + evidence_digest: str + input_digest: str + execution_mode: str + required_capabilities: dict[str, Any] + max_output_chars: int + + @classmethod + def from_mapping(cls, value: Mapping[str, Any] | "AdapterRequest") -> "AdapterRequest": + if isinstance(value, cls): + return value + if not isinstance(value, Mapping): + raise RuntimeAdapterError("adapter request must be an object") + _ra_forbidden_keys(value, "request") + unknown = set(value) - _RA_REQUEST_FIELDS + if unknown: + raise RuntimeAdapterError(f"unsupported request fields: {sorted(map(str, unknown))}") + if value.get("schema_version", _RA_REQUEST_SCHEMA) != _RA_REQUEST_SCHEMA: + raise RuntimeAdapterError("unsupported runtime request schema version") + profile = value.get("execution_profile") + if not isinstance(profile, Mapping): + raise RuntimeAdapterError("request requires a resolved execution_profile") + profile_check = verify_execution_profile(profile) + if not profile_check.get("valid"): + raise RuntimeAdapterError("execution_profile commitment is invalid") + requested = value.get("required_capabilities", {}) + if not isinstance(requested, Mapping): + raise RuntimeAdapterError("required_capabilities must be an object") + allowed_required = {"streaming", "tools", "resource_metrics", "min_context_tokens"} + if set(requested) - allowed_required: + raise RuntimeAdapterError("unsupported required capability field") + normalized_required: dict[str, Any] = {} + for field in ("streaming", "tools"): + if field in requested: + if not isinstance(requested[field], bool): + raise RuntimeAdapterError(f"required_capabilities.{field} must be boolean") + normalized_required[field] = requested[field] + if "resource_metrics" in requested: + normalized_required["resource_metrics"] = list(_ra_string_list(requested["resource_metrics"], "resource_metrics")) + if "min_context_tokens" in requested: + normalized_required["min_context_tokens"] = _ra_limit(requested["min_context_tokens"], "min_context_tokens") + mode = _ra_text(value.get("execution_mode", "local"), "execution_mode", max_length=32) + if mode not in _RA_EXECUTION_MODES: + raise RuntimeAdapterError("execution_mode is unsupported") + profile_digest = _ra_digest(value.get("execution_profile_digest", profile["profile_digest"]), "execution_profile_digest") + if profile_digest != str(profile["profile_digest"]).lower().removeprefix("sha256:"): + raise RuntimeAdapterError("execution_profile_digest does not match execution_profile") + return cls( + schema_version=_RA_REQUEST_SCHEMA, + request_id=_ra_id(value["request_id"], "request_id"), + execution_profile=dict(profile), + execution_profile_digest=profile_digest, + context_digest=_ra_digest(value["context_digest"], "context_digest"), + evidence_digest=_ra_digest(value["evidence_digest"], "evidence_digest"), + input_digest=_ra_digest(value["input_digest"], "input_digest"), + execution_mode=mode, + required_capabilities=normalized_required, + max_output_chars=_ra_limit(value.get("max_output_chars", 2048), "max_output_chars", maximum=1_000_000), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "request_id": self.request_id, + "execution_profile": dict(self.execution_profile), + "execution_profile_digest": self.execution_profile_digest, + "context_digest": self.context_digest, + "evidence_digest": self.evidence_digest, + "input_digest": self.input_digest, + "execution_mode": self.execution_mode, + "required_capabilities": dict(self.required_capabilities), + "max_output_chars": self.max_output_chars, + } + + +@dataclass(frozen=True) +class AdapterResult: + """Sanitized runtime result; unavailable paths never become success.""" + + schema_version: str + request_id: str + status: str + output: str | None + usage: dict[str, int] + runtime: dict[str, str] + error_code: str | None + error_message: str | None + external_fallback_allowed: bool = False + + @classmethod + def from_mapping(cls, value: Mapping[str, Any] | "AdapterResult") -> "AdapterResult": + if isinstance(value, cls): + return value + if not isinstance(value, Mapping): + raise RuntimeAdapterError("adapter result must be an object") + _ra_forbidden_keys(value, "result") + unknown = set(value) - _RA_RESULT_FIELDS + if unknown: + raise RuntimeAdapterError(f"unsupported result fields: {sorted(map(str, unknown))}") + if value.get("schema_version", _RA_RESULT_SCHEMA) != _RA_RESULT_SCHEMA: + raise RuntimeAdapterError("unsupported runtime result schema version") + status = _ra_text(value.get("status", ""), "status", max_length=32) + if status not in _RA_RESULT_STATUSES: + raise RuntimeAdapterError("unsupported runtime result status") + output = value.get("output") + if output is not None: + output = _ra_text(output, "output", max_length=1_000_000, allow_empty=True) + usage_raw = value.get("usage", {}) + if not isinstance(usage_raw, Mapping) or set(usage_raw) - _RA_USAGE_FIELDS: + raise RuntimeAdapterError("usage contains unsupported fields") + usage: dict[str, int] = {} + for key, raw in usage_raw.items(): + if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0: + raise RuntimeAdapterError(f"usage.{key} must be a non-negative integer") + usage[key] = raw + runtime_raw = value.get("runtime", {}) + if not isinstance(runtime_raw, Mapping) or set(runtime_raw) - _RA_RUNTIME_FIELDS: + raise RuntimeAdapterError("runtime provenance contains unsupported fields") + runtime = {str(key): _ra_id(raw, f"runtime.{key}") for key, raw in runtime_raw.items()} + error_code = value.get("error_code") + if error_code is not None: + error_code = _ra_id(error_code, "error_code") + error_message = value.get("error_message") + if error_message is not None: + error_message = _ra_text(error_message, "error_message", max_length=256) + fallback = value.get("external_fallback_allowed", False) + if fallback is not False: + raise RuntimeAdapterError("external fallback is permanently disabled by the core contract") + if status in {"success", "partial"} and output is None: + raise RuntimeAdapterError("successful result requires bounded output") + if status not in {"success", "partial"} and output is not None: + raise RuntimeAdapterError("non-success result cannot carry output") + return cls( + schema_version=_RA_RESULT_SCHEMA, + request_id=_ra_id(value["request_id"], "request_id"), + status=status, + output=output, + usage=usage, + runtime=runtime, + error_code=error_code, + error_message=error_message, + external_fallback_allowed=False, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "request_id": self.request_id, + "status": self.status, + "output": self.output, + "usage": dict(self.usage), + "runtime": dict(self.runtime), + "error_code": self.error_code, + "error_message": self.error_message, + "external_fallback_allowed": False, + } + + +def negotiate_runtime_capabilities( + requirements: Mapping[str, Any] | None, + offered: Mapping[str, Any] | RuntimeCapabilities, +) -> dict[str, Any]: + """Return a deterministic negotiation result; never selects another backend.""" + requirements = dict(requirements or {}) + _ra_forbidden_keys(requirements, "requirements") + allowed = {"execution_mode", "min_context_tokens", "streaming", "tools", "resource_metrics", "backend_id"} + if set(requirements) - allowed: + raise RuntimeAdapterError("unsupported runtime capability requirement") + capabilities = RuntimeCapabilities.from_mapping(offered) + normalized: dict[str, Any] = {} + if "execution_mode" in requirements: + mode = _ra_text(requirements["execution_mode"], "requirements.execution_mode", max_length=32) + if mode not in _RA_EXECUTION_MODES: + raise RuntimeAdapterError("requirements.execution_mode is unsupported") + normalized["execution_mode"] = mode + if "min_context_tokens" in requirements: + normalized["min_context_tokens"] = _ra_limit(requirements["min_context_tokens"], "requirements.min_context_tokens") + for field in ("streaming", "tools"): + if field in requirements: + if not isinstance(requirements[field], bool): + raise RuntimeAdapterError(f"requirements.{field} must be boolean") + normalized[field] = requirements[field] + if "resource_metrics" in requirements: + normalized["resource_metrics"] = list(_ra_string_list(requirements["resource_metrics"], "requirements.resource_metrics")) + if "backend_id" in requirements: + normalized["backend_id"] = _ra_id(requirements["backend_id"], "requirements.backend_id") + missing: list[dict[str, Any]] = [] + if normalized.get("execution_mode") and normalized["execution_mode"] not in capabilities.execution_modes: + missing.append({"capability": "execution_mode", "required": normalized["execution_mode"], "available": list(capabilities.execution_modes)}) + if normalized.get("min_context_tokens", 0) > capabilities.context_capacity_tokens: + missing.append({"capability": "context_capacity_tokens", "required": normalized["min_context_tokens"], "available": capabilities.context_capacity_tokens}) + for field in ("streaming", "tools"): + if normalized.get(field) is True and not getattr(capabilities, field): + missing.append({"capability": field, "required": True, "available": False}) + if "resource_metrics" in normalized: + absent = sorted(set(normalized["resource_metrics"]) - set(capabilities.resource_metrics)) + if absent: + missing.append({"capability": "resource_metrics", "required": absent, "available": list(capabilities.resource_metrics)}) + if normalized.get("backend_id") and normalized["backend_id"] != capabilities.backend_id: + missing.append({"capability": "backend_id", "required": normalized["backend_id"], "available": capabilities.backend_id}) + return { + "schema_version": _RA_NEGOTIATION_SCHEMA, + "status": "complete" if not missing else "rejected", + "requirements": normalized, + "capabilities": capabilities.to_dict(), + "capabilities_digest": _ra_sha(capabilities.to_dict()), + "missing": missing, + "external_fallback_allowed": False, + } + + +class ReferenceRuntimeAdapter: + """Deterministic offline adapter used for contract and failure-path tests.""" + + def __init__( + self, + *, + capabilities: Mapping[str, Any] | RuntimeCapabilities | None = None, + behavior: str = "success", + output: str = "", + ) -> None: + self.capabilities = RuntimeCapabilities.from_mapping(capabilities or { + "schema_version": _RA_CAPABILITIES_SCHEMA, + "backend_id": "reference-local", + "backend_version": "0.1", + "model_id": "reference-model", + "model_version": "0.1", + "tokenizer_id": "reference-tokenizer", + "context_capacity_tokens": 4096, + "execution_modes": ["offline", "local"], + "streaming": True, + "tools": False, + "hardware_class": "unknown", + "resource_metrics": ["latency_ms"], + "auth_mode": "none", + "provider_ref": "local-reference", + }) + if behavior not in _RA_RESULT_STATUSES: + raise RuntimeAdapterError("unsupported reference adapter behavior") + self.behavior = behavior + self.output = _ra_text(output, "output", max_length=1_000_000, allow_empty=True) + + def invoke(self, request: Mapping[str, Any] | AdapterRequest) -> AdapterResult: + try: + request_obj = AdapterRequest.from_mapping(request) + except (RuntimeAdapterError, ExecutionProfileError) as exc: + raise RuntimeAdapterError(str(exc)) from exc + requirements = dict(request_obj.required_capabilities) + requirements["execution_mode"] = request_obj.execution_mode + negotiation = negotiate_runtime_capabilities(requirements, self.capabilities) + runtime = { + "backend_id": self.capabilities.backend_id, + "backend_version": self.capabilities.backend_version, + "model_id": self.capabilities.model_id, + "model_version": self.capabilities.model_version, + "auth_mode": self.capabilities.auth_mode, + "provider_ref": self.capabilities.provider_ref, + "execution_mode": request_obj.execution_mode, + } + if negotiation["status"] == "rejected": + return AdapterResult( + schema_version=_RA_RESULT_SCHEMA, + request_id=request_obj.request_id, + status="unavailable", + output=None, + usage={}, + runtime=runtime, + error_code="capability_mismatch", + error_message="required runtime capabilities are unavailable", + ) + if self.behavior in {"unavailable", "timeout", "cancelled", "malformed"}: + return AdapterResult( + schema_version=_RA_RESULT_SCHEMA, + request_id=request_obj.request_id, + status=self.behavior, + output=None, + usage={}, + runtime=runtime, + error_code=self.behavior, + error_message=f"reference adapter {self.behavior}", + ) + bounded = self.output[: request_obj.max_output_chars] + usage = {"output_tokens": max(1, (len(bounded) + 3) // 4)} + return AdapterResult( + schema_version=_RA_RESULT_SCHEMA, + request_id=request_obj.request_id, + status=self.behavior, + output=bounded, + usage=usage, + runtime=runtime, + error_code=None, + error_message=None, + ) diff --git a/tests/test_capability_evidence.py b/tests/test_capability_evidence.py new file mode 100644 index 00000000..d6a08418 --- /dev/null +++ b/tests/test_capability_evidence.py @@ -0,0 +1,89 @@ +"""Contract tests for the #979 capability evidence matrix.""" +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + + +_ROOT = Path(__file__).parents[1] +_SPEC = importlib.util.spec_from_file_location("render_claims", _ROOT / "scripts" / "render_claims.py") +assert _SPEC and _SPEC.loader +_RENDER_CLAIMS = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_RENDER_CLAIMS) + + +def _registry() -> dict: + return json.loads((_ROOT / "claims.json").read_text(encoding="utf-8")) + + +def test_capability_matrix_has_distinct_lifecycle_states_and_required_fields(): + registry = _registry() + rows = registry["capabilities"] + assert {row["lifecycle"] for row in rows} == { + "implemented", "tested", "operational", "degraded", "omitted", "historical", "not_demonstrated", + } + _RENDER_CLAIMS.validate_capabilities(rows, root=_ROOT) + for row in rows: + assert row["capability"] + assert row["owner"] + assert row["evidence_class"] + assert row["evidence_refs"] + assert row["last_verified"]["date"] + assert row["claim_ceiling"] + assert row["non_claims"] + assert row["dependencies"] + assert row["activation"] + assert row["proof_surface"] + + +def test_operational_capability_requires_current_evidence_and_ceiling(tmp_path): + row = { + "id": "bad", + "capability": "Bad", + "owner": "component", + "lifecycle": "operational", + "evidence_class": "", + "evidence_refs": [], + "last_verified": {"commit": "", "build": "", "date": ""}, + "freshness_rule": "", + "claim_ceiling": "", + "non_claims": ["none"], + "dependencies": ["none"], + "activation": "never", + "proof_surface": "docs/nope.md", + } + with pytest.raises(ValueError, match="operational"): + _RENDER_CLAIMS.validate_capabilities([row], root=tmp_path) + + +def test_missing_local_evidence_reference_is_rejected(tmp_path): + row = dict(_registry()["capabilities"][0]) + row["evidence_refs"] = ["missing/evidence.json"] + with pytest.raises(ValueError, match="evidence reference"): + _RENDER_CLAIMS.validate_capabilities([row], root=tmp_path) + + +def test_capability_outputs_are_deterministic_and_redacted(): + registry = _registry() + machine_a, markdown_a = _RENDER_CLAIMS.render_capability_matrix(registry, root=_ROOT) + machine_b, markdown_b = _RENDER_CLAIMS.render_capability_matrix(registry, root=_ROOT) + assert machine_a == machine_b + assert markdown_a == markdown_b + encoded = json.dumps(machine_a, sort_keys=True).lower() + for forbidden in ("api_key", "authorization", "password", "secret", "token", "prompt", "body", "raw"): + assert forbidden not in encoded + assert "operational" in markdown_a + assert "historical" in markdown_a + assert "not_demonstrated" in markdown_a + + +def test_generated_capability_files_are_current(): + registry = _registry() + machine, markdown = _RENDER_CLAIMS.render_capability_matrix(registry, root=_ROOT) + actual_machine = json.loads((_ROOT / "docs" / "capability-evidence.json").read_text(encoding="utf-8")) + actual_markdown = (_ROOT / "docs" / "CAPABILITY-EVIDENCE.md").read_text(encoding="utf-8") + assert actual_machine == machine + assert actual_markdown == markdown diff --git a/tests/test_context_evidence.py b/tests/test_context_evidence.py new file mode 100644 index 00000000..326dccc5 --- /dev/null +++ b/tests/test_context_evidence.py @@ -0,0 +1,118 @@ +"""Tests for the #982 context evidence/abstention projection.""" +from __future__ import annotations + +import json + +import pytest + +from conftest import perseus + + +def _entry(identifier="item-1", **extra): + value = { + "candidate_id": identifier, + "agent_text": "The bounded evidence summary.", + "source_id": "vault:entity-1", + "provenance_id": "ledger:receipt-1", + "evidence_digest": "a" * 64, + "valid_at": "2026-08-17T12:00:00Z", + "transaction_time": "2026-08-17T12:01:00Z", + "validity_state": "observed", + "selection_reason": "source matched the task", + "relevance": 0.99, + } + value.update(extra) + return value + + +def test_success_projection_is_deterministic_sanitized_and_traceable(): + projection = perseus.project_context_evidence( + [_entry()], + provider_states={"vault": "active", "ledger": "active"}, + evidence_required=True, + ) + assert projection["schema_version"] == "perseus-context-evidence/v1" + assert projection["coverage"]["state"] == "evidence_backed" + assert projection["coverage"]["abstention_required"] is False + item = projection["selected"][0] + assert item["source_refs"] == ["ledger:receipt-1", "vault:entity-1"] + assert item["evidence_digest"] == "a" * 64 + assert item["transaction_time"] == "2026-08-17T12:01:00Z" + serialized = json.dumps(projection, sort_keys=True).lower() + for forbidden in ("prompt", "private_body", "api_key", "authorization", "password"): + assert forbidden not in serialized + assert perseus.verify_context_evidence(projection)["valid"] is True + assert perseus.render_context_evidence(projection) == perseus.render_context_evidence(projection) + + +def test_states_remain_distinct_and_evidence_required_abstains(): + cases = { + "partial": {"coverage_state": "partial"}, + "conflicted": {"coverage_state": "conflicted"}, + "stale": {"validity_state": "stale"}, + "empty": [], + "unavailable": {"provider_states": {"vault": "unavailable"}}, + "timeout": {"provider_states": {"vault": "timeout"}}, + } + for expected, case in cases.items(): + if isinstance(case, list): + entries = case + providers = {"vault": "active"} + else: + entry_values = {key: value for key, value in case.items() if key != "provider_states"} + entries = [_entry(**entry_values)] + providers = case.get("provider_states", {"vault": "active"}) + projection = perseus.project_context_evidence( + entries, provider_states=providers, evidence_required=True + ) + assert projection["coverage"]["state"] == expected + assert projection["coverage"]["abstention_required"] is True + assert projection["status"] == "abstention_required" + + +def test_score_does_not_become_a_truth_gate_and_exclusions_are_bounded(): + projection = perseus.project_context_evidence( + [_entry("low-score", relevance=0.0)], + excluded=[{"candidate_id": "missing", "reason": "scope mismatch"}], + evidence_required=True, + ) + assert projection["coverage"]["state"] == "evidence_backed" + assert projection["diagnostics"]["relevance_is_not_truth_gate"] is True + assert projection["excluded"] == [{"candidate_id": "missing", "reason": "scope mismatch"}] + + +def test_raw_material_is_never_emitted_even_when_used_for_digest(): + projection = perseus.project_context_evidence( + [_entry(content="password=top-secret", body="private body")], + evidence_required=True, + ) + serialized = json.dumps(projection, sort_keys=True).lower() + assert "top-secret" not in serialized + assert "private body" not in serialized + assert projection["selected"][0]["evidence_digest"] + + +def test_item_without_source_reference_is_excluded_even_with_a_digest(): + projection = perseus.project_context_evidence([ + {"candidate_id": "no-source", "evidence_digest": "e" * 64} + ]) + assert projection["coverage"]["state"] == "empty" + assert projection["excluded"] == [{"candidate_id": "no-source", "reason": "source_reference_missing"}] + + +def test_context_rank_composes_evidence_projection_and_abstains_on_stale_required_evidence(): + result = perseus.context_rank( + [_entry(validity_state="stale", verified=False)], + task="bounded evidence summary", + policy={"evidence_required": True}, + integrations={"vault": "active", "ledger": "active"}, + ) + assert result["evidence_projection"]["coverage"]["state"] == "stale" + assert result["evidence_projection"]["status"] == "abstention_required" + + +def test_tampering_projection_digest_fails_closed(): + projection = perseus.project_context_evidence([_entry()]) + tampered = json.loads(json.dumps(projection)) + tampered["selected"][0]["source_refs"] = ["forged"] + assert perseus.verify_context_evidence(tampered)["valid"] is False diff --git a/tests/test_contract_schemas.py b/tests/test_contract_schemas.py new file mode 100644 index 00000000..71f81f5b --- /dev/null +++ b/tests/test_contract_schemas.py @@ -0,0 +1,68 @@ +"""Schema validation for the #979/#980/#981/#982 contracts.""" +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from jsonschema import Draft202012Validator + +from conftest import perseus + + +_ROOT = Path(__file__).parents[1] + + +def _schema(name: str) -> dict: + value = yaml.safe_load((_ROOT / "schemas" / name).read_text(encoding="utf-8")) + Draft202012Validator.check_schema(value) + return value + + +def test_new_contract_schemas_are_valid_and_accept_reference_envelopes(): + capability_schema = _schema("runtime-adapter.schema.yaml") + profile_schema = _schema("execution-profile.schema.yaml") + evidence_schema = _schema("context-evidence.schema.yaml") + + profile = perseus.resolve_execution_profile(None) + Draft202012Validator(profile_schema).validate(profile["profile"]) + capabilities = perseus.RuntimeCapabilities.from_mapping({ + "schema_version": "perseus-runtime-capabilities/v1", + "backend_id": "reference-local", + "backend_version": "0.1", + "model_id": "reference-model", + "model_version": "0.1", + "tokenizer_id": "reference-tokenizer", + "context_capacity_tokens": 4096, + "execution_modes": ["offline", "local"], + "streaming": True, + "tools": False, + "hardware_class": "unknown", + "resource_metrics": ["latency_ms"], + "auth_mode": "none", + "provider_ref": "local-reference", + }) + Draft202012Validator(capability_schema).validate(capabilities.to_dict()) + request = perseus.AdapterRequest.from_mapping({ + "schema_version": "perseus-runtime-request/v1", + "request_id": "schema-request", + "execution_profile": profile, + "context_digest": "a" * 64, + "evidence_digest": "b" * 64, + "input_digest": "c" * 64, + "execution_mode": "local", + "required_capabilities": {}, + "max_output_chars": 128, + }) + Draft202012Validator(capability_schema).validate(request.to_dict()) + result = perseus.ReferenceRuntimeAdapter(capabilities=capabilities, output="ok").invoke(request) + Draft202012Validator(capability_schema).validate(result.to_dict()) + + projection = perseus.project_context_evidence([{ + "candidate_id": "schema-item", + "agent_text": "bounded", + "source_id": "vault:item", + "evidence_digest": "d" * 64, + "validity_state": "observed", + }]) + Draft202012Validator(evidence_schema).validate(projection) diff --git a/tests/test_execution_profile_dag.py b/tests/test_execution_profile_dag.py new file mode 100644 index 00000000..043ef502 --- /dev/null +++ b/tests/test_execution_profile_dag.py @@ -0,0 +1,88 @@ +"""Composition tests for #980 profiles and the existing context DAG.""" +from __future__ import annotations + +from conftest import perseus + + +def _profile(): + return { + "schema_version": "perseus-execution-profile/v1", + "profile_id": "dag-profile", + "mode": "constrained-edge", + "max_context_tokens": 2048, + "max_context_bytes": 8192, + "max_items": 4, + "max_depth": 2, + "latency_target_ms": 1000, + "resource_class": "edge", + "network_mode": "offline", + "runtime_capabilities": [], + "degradation_policy": "partial", + "auth_mode": "none", + } + + +def test_context_dag_manifest_carries_resolved_profile_and_digest(): + root = perseus.ContextNode( + kind="requirement", + content="compile the bounded context", + evidence={"validity": "observed", "verified": True, "source_ids": ["task"]}, + ) + artifact = perseus.compile_context_dag( + task_id="profiled-dag", + root=root, + execution_profile=_profile(), + profile_requirements={"max_context_tokens": 1000}, + ) + assert artifact["status"] == "complete" + assert artifact["execution_profile"]["effective"]["max_context_tokens"] == 1000 + assert artifact["execution_profile_digest"] == artifact["execution_profile"]["profile_digest"] + assert perseus.verify_compiled_dag(artifact)["valid"] is True + + +def test_context_dag_profile_reports_partial_retrieval_without_claiming_complete(): + root = perseus.ContextNode( + kind="requirement", + content="compile partial context", + evidence={"validity": "observed", "verified": True, "source_ids": ["task"]}, + ) + artifact = perseus.compile_context_dag( + task_id="partial-profiled-dag", + root=root, + execution_profile=_profile(), + profile_requirements={"max_context_tokens": 1000}, + profile_retrieval_status="partial", + ) + assert artifact["status"] == "degraded" + assert artifact["profile_diagnostics"]["degraded"] is True + assert "retrieval_partial" in artifact["profile_diagnostics"]["reasons"] + assert perseus.verify_compiled_dag(artifact)["valid"] is True + + +def test_context_dag_profile_trims_item_overflow_and_reports_degradation(): + root = perseus.ContextNode( + kind="requirement", + content="compile overflow context", + evidence={"validity": "observed", "verified": True, "source_ids": ["task"]}, + ) + + def fetch(_node): + return [ + perseus.ContextNode( + kind="retrieved_record", + content=f"record {index}", + evidence={"validity": "observed", "verified": True, "source_ids": [f"source-{index}"]}, + ) + for index in range(10) + ] + + artifact = perseus.compile_context_dag( + task_id="overflow-profiled-dag", + root=root, + fetch=fetch, + execution_profile=_profile(), + ) + assert artifact["status"] == "degraded" + assert "max_items" in artifact["profile_diagnostics"]["reasons"] + assert artifact["budget"]["nodes"] <= 4 + assert perseus.verify_compiled_dag(artifact)["valid"] is True diff --git a/tests/test_execution_profiles.py b/tests/test_execution_profiles.py new file mode 100644 index 00000000..cb549f09 --- /dev/null +++ b/tests/test_execution_profiles.py @@ -0,0 +1,107 @@ +"""Tests for the #980 resource-aware execution profile contract.""" +from __future__ import annotations + +import hashlib +import json + +import pytest + +from conftest import perseus + + +def _profile(mode="constrained-edge"): + return { + "schema_version": "perseus-execution-profile/v1", + "profile_id": "test-profile", + "mode": mode, + "max_context_tokens": 2048, + "max_context_bytes": 8192, + "max_items": 8, + "max_depth": 2, + "latency_target_ms": 250, + "resource_class": "edge", + "network_mode": "offline" if mode == "air-gapped" else "local", + "runtime_capabilities": ["streaming"], + "degradation_policy": "partial", + "auth_mode": "none", + } + + +def test_profile_normalization_is_versioned_bounded_and_secret_free(): + profile = perseus.ExecutionProfile.from_mapping(_profile()) + data = profile.to_dict() + assert data["schema_version"] == "perseus-execution-profile/v1" + assert data["max_context_tokens"] == 2048 + assert data["runtime_capabilities"] == ["streaming"] + assert "api_key" not in json.dumps(data).lower() + with pytest.raises(perseus.ExecutionProfileError): + perseus.ExecutionProfile.from_mapping(dict(_profile(), api_key="must-reject")) + + +def test_resolution_intersects_hard_context_item_and_depth_limits(): + resolved = perseus.resolve_execution_profile( + _profile(), + requirements={ + "max_context_tokens": 1000, + "max_context_bytes": 3000, + "max_items": 3, + "max_depth": 1, + "required_capabilities": ["streaming"], + }, + resources={"memory_class": "small", "compute_class": "low"}, + ) + assert resolved["status"] == "complete" + assert resolved["effective"]["max_context_tokens"] == 1000 + assert resolved["effective"]["max_context_bytes"] == 3000 + assert resolved["effective"]["max_items"] == 3 + assert resolved["effective"]["max_depth"] == 1 + assert resolved["resource_state"] == "known" + assert len(resolved["profile_digest"]) == 64 + + +def test_unavailable_resources_are_explicit_not_fabricated(): + resolved = perseus.resolve_execution_profile(_profile(), resources=None) + assert resolved["resource_state"] == "unknown" + assert resolved["resources"] == {} + assert "available_memory_mb" not in resolved["resources"] + + +def test_degraded_retrieval_is_reported_without_overflowing_the_profile(): + resolved = perseus.negotiate_context_budget( + _profile(), retrieval_status="partial", requested_tokens=1800 + ) + assert resolved["status"] == "degraded" + assert resolved["diagnostics"]["degraded"] is True + assert "retrieval_partial" in resolved["diagnostics"]["reasons"] + assert resolved["effective"]["max_context_tokens"] == 1800 + + +def test_air_gapped_profile_rejects_network_requirement(): + with pytest.raises(perseus.ExecutionProfileError, match="offline"): + perseus.resolve_execution_profile( + _profile("air-gapped"), + requirements={"network_mode": "approved_network"}, + ) + + +def test_unsupported_capability_fails_closed_and_digest_replays(): + with pytest.raises(perseus.ExecutionProfileError, match="capabil"): + perseus.resolve_execution_profile( + _profile(), requirements={"required_capabilities": ["tools"]} + ) + resolved_a = perseus.resolve_execution_profile(_profile()) + resolved_b = perseus.resolve_execution_profile(_profile()) + assert resolved_a == resolved_b + verification = perseus.verify_execution_profile(resolved_a) + assert verification["valid"] is True + tampered = json.loads(json.dumps(resolved_a)) + tampered["effective"]["max_items"] = 1 + assert perseus.verify_execution_profile(tampered)["valid"] is False + + +def test_profile_projects_to_dag_budget_and_manifest(): + resolved = perseus.resolve_execution_profile(_profile()) + budget = perseus.execution_profile_compilation_budget(resolved) + assert budget["max_tokens"] == 2048 + assert budget["max_nodes"] == 8 + assert budget["max_depth"] == 2 diff --git a/tests/test_runtime_adapter.py b/tests/test_runtime_adapter.py new file mode 100644 index 00000000..6b1d3f4f --- /dev/null +++ b/tests/test_runtime_adapter.py @@ -0,0 +1,139 @@ +"""Tests for the #981 portable local/edge runtime adapter seam.""" +from __future__ import annotations + +import json + +import pytest + +from conftest import perseus + + +def _profile(): + return perseus.resolve_execution_profile( + { + "schema_version": "perseus-execution-profile/v1", + "profile_id": "adapter-profile", + "mode": "constrained-edge", + "max_context_tokens": 1024, + "max_context_bytes": 4096, + "max_items": 4, + "max_depth": 2, + "latency_target_ms": 500, + "resource_class": "edge", + "network_mode": "offline", + "runtime_capabilities": ["streaming"], + "degradation_policy": "partial", + "auth_mode": "none", + } + ) + + +def _capabilities(): + return perseus.RuntimeCapabilities.from_mapping({ + "schema_version": "perseus-runtime-capabilities/v1", + "backend_id": "reference-local", + "backend_version": "0.1", + "model_id": "fake-model", + "model_version": "1", + "tokenizer_id": "fake-tokenizer", + "context_capacity_tokens": 2048, + "execution_modes": ["offline", "local"], + "streaming": True, + "tools": False, + "hardware_class": "cpu", + "resource_metrics": ["latency_ms"], + "auth_mode": "none", + "provider_ref": "local-reference", + }) + + +def _request(**extra): + data = { + "schema_version": "perseus-runtime-request/v1", + "request_id": "request-1", + "execution_profile": _profile(), + "context_digest": "a" * 64, + "evidence_digest": "b" * 64, + "input_digest": "c" * 64, + "execution_mode": "offline", + "required_capabilities": {"streaming": True}, + "max_output_chars": 128, + } + data.update(extra) + return perseus.AdapterRequest.from_mapping(data) + + +def test_capabilities_and_request_are_versioned_and_digest_only(): + caps = _capabilities() + request = _request() + assert caps.to_dict()["schema_version"] == "perseus-runtime-capabilities/v1" + assert request.to_dict()["execution_profile_digest"] == _profile()["profile_digest"] + serialized = json.dumps({"capabilities": caps.to_dict(), "request": request.to_dict()}, sort_keys=True).lower() + for forbidden in ("api_key", "authorization", "password", "prompt", "content", "private_body"): + assert forbidden not in serialized + with pytest.raises(perseus.RuntimeAdapterError): + perseus.AdapterRequest.from_mapping(dict(request.to_dict(), prompt="do not persist")) + + +def test_adapter_request_round_trips_its_profile_digest(): + request = _request() + restored = perseus.AdapterRequest.from_mapping(request.to_dict()) + assert restored.to_dict() == request.to_dict() + + +def test_capability_negotiation_succeeds_without_network_or_fallback(): + negotiated = perseus.negotiate_runtime_capabilities( + {"execution_mode": "offline", "min_context_tokens": 1000, "streaming": True}, + _capabilities(), + ) + assert negotiated["status"] == "complete" + assert negotiated["external_fallback_allowed"] is False + assert negotiated["missing"] == [] + assert len(negotiated["capabilities_digest"]) == 64 + + +def test_capability_negotiation_rejects_unsupported_requirements(): + rejected = perseus.negotiate_runtime_capabilities( + {"execution_mode": "approved_network", "min_context_tokens": 9000, "tools": True}, + _capabilities(), + ) + assert rejected["status"] == "rejected" + assert rejected["external_fallback_allowed"] is False + assert {item["capability"] for item in rejected["missing"]} == { + "execution_mode", "context_capacity_tokens", "tools" + } + + +@pytest.mark.parametrize("status", ["success", "partial", "unavailable", "timeout", "cancelled", "malformed"]) +def test_reference_adapter_exercises_every_explicit_status_without_network(status): + adapter = perseus.ReferenceRuntimeAdapter( + capabilities=_capabilities(), behavior=status, output="bounded output" + ) + result = adapter.invoke(_request()) + assert result.status == status + assert result.request_id == "request-1" + assert result.external_fallback_allowed is False + if status in {"success", "partial"}: + assert result.output == "bounded output" + else: + assert result.output is None + + +def test_malformed_result_and_usage_are_rejected_at_the_boundary(): + with pytest.raises(perseus.RuntimeAdapterError): + perseus.AdapterResult.from_mapping({ + "schema_version": "perseus-runtime-result/v1", + "request_id": "request-1", + "status": "success", + "output": "x", + "usage": {"api_key": "forbidden"}, + "runtime": {}, + }) + + +def test_adapter_fails_closed_on_profile_or_capability_mismatch(): + request = _request(required_capabilities={"tools": True}) + result = perseus.ReferenceRuntimeAdapter(capabilities=_capabilities()).invoke(request) + assert result.status == "unavailable" + assert result.error_code == "capability_mismatch" + assert result.external_fallback_allowed is False