diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 9575a04..f70c942 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -131,10 +131,11 @@ vector, the scalar, and the certificate state. `reconciliation_required` and `failed_platform` are unscored. They carry no scalar and the contract cannot map them to zero. `certified` is true only at -oracle tier 2 or 3 with a current certificate, a calibration corpus digest, -and a stated `calibration_scope`. A self-signed certificate may carry only -`synthetic` scope, and today that is the only scope anyone can compute. Tier -0 and 1 receipts are `development_only`. +oracle tier 2 or 3, with a current certificate that names this contract and +clears its `certificate_policy`. `calibration_scope` accepts `synthetic` and +`issuer` accepts `self_signed`, because nothing in this package can check a +production calibration or an issuer identity. Tier 0 and 1 receipts are +`development_only`. The reward receipt is not an Execute Seal. It has its own schema id and none of the Seal's fields. It says OpenAdapt verified one episode's terminal diff --git a/docs/REWARD.md b/docs/REWARD.md index 2fe3422..4ab41ff 100644 --- a/docs/REWARD.md +++ b/docs/REWARD.md @@ -25,9 +25,10 @@ in a different order get the same digest. `RewardCertificateV1` is the bound: `epsilon`, `delta`, `threshold`, the calibration corpus digest, the calibration scope, the checker configuration digest, the issuer, the policy update it was issued at, and its expiry in -policy updates. It is signed. Expiry counts updates, not hours, because -on-policy training breaks the exchangeability the bound assumes. -`is_current(policy_update)` answers whether a trainer may still use it. +policy updates. It carries a signature. Expiry counts updates, not hours, +because on-policy training breaks the exchangeability the bound assumes. +`is_current(policy_update)` answers whether a trainer may still use it, and +`unmet(policy)` lists every way it falls short of a contract's demands. `RewardEvidenceReceiptV1` binds the contract digest, the policy checkpoint and update number, the episode, the oracle tier, the evidence digest, the @@ -35,20 +36,54 @@ component vector, the scalar, the certificate reference and its state, the calibration corpus digest and scope, and two booleans a trainer must read: `certified` and `development_only`. -## Synthetic scope is the only scope today - -Today the only certificate anyone can compute is against the synthetic -MockMed/ExtraDup corpus, so its `calibration_scope` is `synthetic`. A -`production` scope needs the Phase-1 calibration, which is not published. -Until that changes, the word "certified" in any public text about a reward -must sit next to the word "synthetic". - -The types hold that line. A certificate with `issuer: self_signed` may carry -only `synthetic` scope; `self_signed` plus `production` does not validate. A -receipt is `certified` only when the oracle tier is 2 or 3, the certificate is -current, the calibration corpus digest is present, and the scope is stated. -`production_certified` is true only when that scope is `production`, which no -self-signed certificate can reach. +## Synthetic scope is the only scope this version can express + +The only certificate anyone can compute is against the synthetic +MockMed/ExtraDup corpus, so `calibration_scope` accepts `synthetic` and +nothing else. A `production` scope needs the Phase-1 calibration, which is not +published, so the value does not exist in the enum. It is unrepresentable +rather than merely unissued, and there is no `production_certified` property +to read. Any public text that calls a reward certified puts the word +"synthetic" next to it. + +`issuer` is narrowed the same way, and for the same reason. It accepts +`self_signed` only. An `organization` issuer would assert an identity, and +this package holds no issuer key registry, so `issuer_key_id` resolves to +nothing here. Both enums keep their shape, so a later registry can add a +member without changing the field. + +Two more things this version does not do, said plainly so no reader assumes +otherwise. Nothing verifies the signature: `RewardCertificateV1` checks that +`signature` is 64 base64-encoded bytes and stops there, and a consumer that +wants issuer identity verifies it against a key it already holds. And there is +no revocation list. Expiry in policy updates is the only way a certificate +stops being current. + +A receipt is `certified` only when the oracle tier is 2 or 3, the certificate +is current, the calibration corpus digest is present, and the scope is stated. +The certificate also has to clear the contract that scored the episode, which +is what the next section covers. + +## The contract's own certificate policy is the bar + +Every `RewardContractV1` carries a `certificate_policy`: the `epsilon` and +`delta` it demands, the `threshold` the bound was calibrated at, the corpus it +was calibrated on, and the longest expiry it accepts. `score()` requires the +contract, compares the certificate against that policy, and refuses to certify +an episode when the certificate is weaker. A certificate measured at epsilon +0.248885 against a contract demanding 0.05 scores its scalar and reports +`certified` false. + +`score()` also refuses a certificate whose `reward_contract_digest` names some +other contract, so a strong certificate cannot be carried across to a task it +never covered. Each refusal comes back as a sentence in +`certification_refusals`, which is empty when `certified` is true. + +The receipt stores digests rather than the contract and the certificate +themselves, so it cannot check its own flag while pydantic validates it. A +reader who holds both calls +`receipt.certification_refusals(contract, certificate)` and gets the same +answer `score()` gave. ## Outcome to scalar @@ -74,8 +109,8 @@ failure this contract exists to stop. | --- | --- | --- | | 0 (visual, OCR) | yes | never | | 1 (second session) | yes | never | -| 2 (API, DB, file, ack) | no | with a current certificate, corpus digest, and stated scope | -| 3 (counterparty) | no | with a current certificate, corpus digest, and stated scope | +| 2 (API, DB, file, ack) | no | with a current certificate that clears the contract's policy | +| 3 (counterparty) | no | with a current certificate that clears the contract's policy | The tier comes from the oracle channel, as it does for every Seal. `refuse_development_certification` raises `RewardCertificationRefused` for @@ -88,18 +123,25 @@ does not validate. ```python from openadapt_types import RewardOutcomeV1, score -scalar, certified, development_only = score( +scalar, certified, development_only, refusals = score( RewardOutcomeV1.VERIFIED, tier=2, certificate=certificate, policy_update=120, + contract=contract, ) ``` +The contract is a required keyword. It supplies the scoring policy that turns +the outcome into `scalar`, and the certificate policy that decides `certified`, +so there is no way to score an episode without naming what it was scored +against. + Per episode, a signed `RewardEvidenceReceiptV1`: ids, digests, the tier, the outcome, the component vector, the scalar or its absence, and the certificate state. The trainer checks `certified` before it counts the episode toward a -certified arm and drops the episode when `scalar_reward` is `None`. +certified arm and drops the episode when `scalar_reward` is `None`. When +`certified` is false, `refusals` says why. ## What stays on the organization node diff --git a/openadapt_types/reward.py b/openadapt_types/reward.py index 4246471..04be71d 100644 --- a/openadapt_types/reward.py +++ b/openadapt_types/reward.py @@ -1,9 +1,9 @@ """Versioned reward contracts for training against verified terminal effects. -A reward receipt reuses the evidence, signature, admission, and revocation -mechanisms of the Execute contracts. It states one thing: OpenAdapt verified -the terminal effect of one episode against one reward contract. It does not -state that Flow governed the policy's actions. It is not an Execute Seal. +A reward receipt reuses the evidence and signature mechanisms of the Execute +contracts. It states one thing: OpenAdapt verified the terminal effect of one +episode against one reward contract. It does not state that Flow governed the +policy's actions. It is not an Execute Seal. An arbitrary model rollout never receives ``ExecuteEvidenceReceiptV1``. A production Flow result requires a qualified deterministic program with zero @@ -17,6 +17,21 @@ calibrated on a corpus that is referenced by digest only, with an expiry denominated in policy updates. The corpus contents, tuned adversary parameters, and deployment thresholds stay private. + +What this version does NOT do, stated here so no reader infers it: + +* There is no issuer key registry, so nothing here can decide whether an + ``issuer_key_id`` names a key anyone trusts. ``signature`` is checked for + its encoding and length only. A consumer that wants issuer identity must + verify the signature itself, against a key it already holds. +* There is no revocation list and no revocation check. The only way to + withdraw a certificate in this version is to let its policy-update expiry + run out, or to stop distributing it. + +Because neither exists, the contracts refuse the claims that would depend on +them. ``calibration_scope`` accepts ``synthetic`` and nothing else, and +``issuer`` accepts ``self_signed`` and nothing else. A production-scope +certificate is unrepresentable in this version, not merely unissued. """ from __future__ import annotations @@ -150,25 +165,27 @@ class RewardUncertaintyStateV1(str, Enum): class RewardCalibrationScopeV1(str, Enum): """What corpus the certificate was calibrated against. - ``synthetic`` is the only scope anyone can compute today. ``production`` - requires the Phase-1 calibration, which is not published. A consumer - must show the scope beside the word certified. + ``synthetic`` is the only member. A production scope would assert that + the bound came from the Phase-1 calibration on a real corpus, and nothing + in this package can check that assertion, so the value does not exist + here. Widening the enum later is backward compatible; a consumer must + show the scope beside the word certified either way. """ SYNTHETIC = "synthetic" - PRODUCTION = "production" class RewardCertificateIssuerV1(str, Enum): """Who signed the certificate. - ``self_signed`` is a certificate the trainer computed for itself. It may - carry only ``synthetic`` scope. ``organization`` is an organization node - that holds the calibration corpus and the signing key. + ``self_signed`` is the only member: a certificate the holder computed for + itself. An organization issuer would assert an identity, and there is no + issuer key registry to resolve ``issuer_key_id`` against, so that value + does not exist here. The enum keeps its shape so a registry can add a + member without changing the field. """ SELF_SIGNED = "self_signed" - ORGANIZATION = "organization" class RewardCertificationRefused(ValueError): @@ -341,12 +358,19 @@ def digest(self) -> str: class RewardCertificateV1(_StrictContract): - """A signed, expiring bound on one reward contract's false-accept rate. + """An expiring bound on one reward contract's false-accept rate. Expiry counts policy updates, not wall-clock time. A certificate issued at update ``i`` with expiry ``n`` is current for updates ``i`` through - ``i + n - 1``. Revocation is a separate list keyed by ``certificate_id`` - and is checked by the issuer, as it is for every other admission. + ``i + n - 1``. Expiry is the only withdrawal mechanism in this version. + There is no revocation list and nothing here checks one. + + ``signature`` is validated for base64 encoding and ed25519 length only. + This package holds no issuer key registry, so it cannot say whether + ``issuer_key_id`` names a key anyone trusts. A consumer that needs issuer + identity verifies the signature itself against a key it already holds. + Until such a registry exists, ``issuer`` and ``calibration_scope`` each + carry exactly one admissible value. """ schema_version: Literal["openadapt.reward-certificate/v1"] = ( @@ -376,13 +400,6 @@ def _signature(cls, value: str) -> str: @model_validator(mode="after") def _issue_window(self) -> RewardCertificateV1: _parse_timestamp(self.issued_at, "issued_at") - if ( - self.issuer is RewardCertificateIssuerV1.SELF_SIGNED - and self.calibration_scope is not RewardCalibrationScopeV1.SYNTHETIC - ): - raise ValueError( - "a self-signed reward certificate may only carry synthetic scope" - ) if self.issued_at_policy_update + self.expiry_policy_updates > _MAX_POLICY_UPDATES: raise ValueError("reward certificate expiry overflows the update counter") return self @@ -405,16 +422,44 @@ def is_current(self, policy_update: int) -> bool: return self.state_at(policy_update) is RewardCertificateStateV1.CURRENT + def unmet(self, policy: RewardCertificatePolicyV1) -> tuple[str, ...]: + """Every way this certificate falls short of ``policy``, in order. + + An empty tuple means the certificate is at least as strong as the + contract asks. ``score`` reports these strings so a caller learns + why a reward was not certified instead of reading a bare ``False``. + """ + + reasons: list[str] = [] + if self.epsilon > policy.epsilon: + reasons.append( + f"certificate epsilon {self.epsilon} exceeds the contract's " + f"{policy.epsilon}" + ) + if self.delta > policy.delta: + reasons.append( + f"certificate delta {self.delta} exceeds the contract's {policy.delta}" + ) + if self.threshold != policy.threshold: + reasons.append( + f"certificate threshold {self.threshold} is not the contract's " + f"{policy.threshold}" + ) + if self.calibration_corpus_digest != policy.calibration_corpus_digest: + reasons.append( + "certificate names a calibration corpus the contract does not" + ) + if self.expiry_policy_updates > policy.expiry_policy_updates: + reasons.append( + f"certificate expiry {self.expiry_policy_updates} policy updates " + f"exceeds the contract's {policy.expiry_policy_updates}" + ) + return tuple(reasons) + def satisfies(self, policy: RewardCertificatePolicyV1) -> bool: """True when this certificate is at least as strong as the policy asks.""" - return ( - self.epsilon <= policy.epsilon - and self.delta <= policy.delta - and self.threshold == policy.threshold - and self.calibration_corpus_digest == policy.calibration_corpus_digest - and self.expiry_policy_updates <= policy.expiry_policy_updates - ) + return not self.unmet(policy) def unsigned_payload(self) -> dict[str, Any]: return self.model_dump( @@ -441,6 +486,7 @@ class RewardScoreV1(NamedTuple): scalar: float | None certified: bool development_only: bool + certification_refusals: tuple[str, ...] = () def score( @@ -449,34 +495,50 @@ def score( certificate: RewardCertificateV1 | None, policy_update: int, *, - scoring: RewardScoringPolicyV1 = DEFAULT_REWARD_SCORING, + contract: RewardContractV1, ) -> RewardScoreV1: """Score one episode. Pure. Never turns an unscored outcome into 0.0. + The contract is required, and it supplies both halves of the answer: its + ``scoring`` maps the outcome to a scalar, and its ``certificate_policy`` + is the bar the certificate has to clear. There is no way to score an + episode without naming the contract it was scored against, so a caller + cannot certify a reward against a policy nobody read. + * ``scalar`` is ``None`` for ``RECONCILIATION_REQUIRED`` and ``FAILED_PLATFORM``. A trainer must drop or hold those episodes. - * ``certified`` is true only at tier 2 or 3 with a certificate that is - current at ``policy_update``, names its calibration corpus by digest, - and states its calibration scope. A self-signed certificate can state - only ``synthetic`` scope, so a self-signed certificate alone never - yields a production-scope certification. + * ``certified`` is true only at tier 2 or 3 with a certificate that names + this contract by digest, is current at ``policy_update``, and satisfies + ``contract.certificate_policy``. Its scope is ``synthetic``, which is + the only scope this version can represent. * ``development_only`` is true at tier 0 or 1. A tier-0 reward can train a local experiment. It can never be certified. + * ``certification_refusals`` lists every reason ``certified`` is false. + It is empty when ``certified`` is true. """ if policy_update < 0: raise ValueError("policy_update must be non-negative") development_only = int(tier) < REWARD_CERTIFIED_MINIMUM_TIER state = certificate_state(certificate, policy_update) - certified = ( - not development_only - and certificate is not None - and state is RewardCertificateStateV1.CURRENT - and bool(certificate.calibration_corpus_digest) - and certificate.calibration_scope in RewardCalibrationScopeV1 - ) - scalar = scoring.scalar_for(RewardOutcomeV1(outcome)) - return RewardScoreV1(scalar, certified, development_only) + refusals: list[str] = [] + if development_only: + refusals.append( + f"oracle tier {int(tier)} is development only; " + f"certification needs tier {REWARD_CERTIFIED_MINIMUM_TIER} or above" + ) + if certificate is None: + refusals.append("no reward certificate was presented") + else: + if state is not RewardCertificateStateV1.CURRENT: + refusals.append( + f"the certificate is {state.value} at policy update {policy_update}" + ) + if certificate.reward_contract_digest != contract.digest: + refusals.append("the certificate names a different reward contract") + refusals.extend(certificate.unmet(contract.certificate_policy)) + scalar = contract.scoring.scalar_for(RewardOutcomeV1(outcome)) + return RewardScoreV1(scalar, not refusals, development_only, tuple(refusals)) class RewardEvidenceReceiptV1(_StrictContract): @@ -611,14 +673,38 @@ def _scoring_contract(self) -> RewardEvidenceReceiptV1: def scoring_class(self) -> RewardScoringClassV1: return REWARD_SCORING_CLASS[self.reward_outcome] - @property - def production_certified(self) -> bool: - """True only for a certified receipt whose scope is ``production``.""" - - return ( - self.certified - and self.calibration_scope is RewardCalibrationScopeV1.PRODUCTION + def certification_refusals( + self, + contract: RewardContractV1, + certificate: RewardCertificateV1 | None, + ) -> tuple[str, ...]: + """Recheck this receipt's ``certified`` flag against its own sources. + + The receipt carries digests, not the contract and certificate + themselves, so it cannot check its own ``certified`` flag while it is + being validated. A reader who holds both re-runs the decision here + and gets the same refusal strings ``score`` returns, plus any + disagreement between the receipt and the pair it was handed. An + empty tuple means the flag is supported by what the reader has. + """ + + refusals: list[str] = [] + if self.reward_contract_digest != contract.digest: + refusals.append("the receipt names a different reward contract") + if certificate is None: + if self.certificate_id is not None: + refusals.append("the receipt references a certificate that was not given") + elif self.certificate_digest != certificate.digest: + refusals.append("the receipt references a different certificate") + scored = score( + self.reward_outcome, + self.oracle_tier, + certificate, + self.policy_update, + contract=contract, ) + refusals.extend(scored.certification_refusals) + return tuple(dict.fromkeys(refusals)) def unsigned_payload(self) -> dict[str, Any]: return self.model_dump( diff --git a/openadapt_types/schemas/reward-certificate-v1.json b/openadapt_types/schemas/reward-certificate-v1.json index 594bd0c..8ac4250 100644 --- a/openadapt_types/schemas/reward-certificate-v1.json +++ b/openadapt_types/schemas/reward-certificate-v1.json @@ -1,26 +1,24 @@ { "$defs": { "RewardCalibrationScopeV1": { - "description": "What corpus the certificate was calibrated against.\n\n``synthetic`` is the only scope anyone can compute today. ``production``\nrequires the Phase-1 calibration, which is not published. A consumer\nmust show the scope beside the word certified.", + "description": "What corpus the certificate was calibrated against.\n\n``synthetic`` is the only member. A production scope would assert that\nthe bound came from the Phase-1 calibration on a real corpus, and nothing\nin this package can check that assertion, so the value does not exist\nhere. Widening the enum later is backward compatible; a consumer must\nshow the scope beside the word certified either way.", "enum": [ - "synthetic", - "production" + "synthetic" ], "title": "RewardCalibrationScopeV1", "type": "string" }, "RewardCertificateIssuerV1": { - "description": "Who signed the certificate.\n\n``self_signed`` is a certificate the trainer computed for itself. It may\ncarry only ``synthetic`` scope. ``organization`` is an organization node\nthat holds the calibration corpus and the signing key.", + "description": "Who signed the certificate.\n\n``self_signed`` is the only member: a certificate the holder computed for\nitself. An organization issuer would assert an identity, and there is no\nissuer key registry to resolve ``issuer_key_id`` against, so that value\ndoes not exist here. The enum keeps its shape so a registry can add a\nmember without changing the field.", "enum": [ - "self_signed", - "organization" + "self_signed" ], "title": "RewardCertificateIssuerV1", "type": "string" } }, "additionalProperties": false, - "description": "A signed, expiring bound on one reward contract's false-accept rate.\n\nExpiry counts policy updates, not wall-clock time. A certificate issued\nat update ``i`` with expiry ``n`` is current for updates ``i`` through\n``i + n - 1``. Revocation is a separate list keyed by ``certificate_id``\nand is checked by the issuer, as it is for every other admission.", + "description": "An expiring bound on one reward contract's false-accept rate.\n\nExpiry counts policy updates, not wall-clock time. A certificate issued\nat update ``i`` with expiry ``n`` is current for updates ``i`` through\n``i + n - 1``. Expiry is the only withdrawal mechanism in this version.\nThere is no revocation list and nothing here checks one.\n\n``signature`` is validated for base64 encoding and ed25519 length only.\nThis package holds no issuer key registry, so it cannot say whether\n``issuer_key_id`` names a key anyone trusts. A consumer that needs issuer\nidentity verifies the signature itself against a key it already holds.\nUntil such a registry exists, ``issuer`` and ``calibration_scope`` each\ncarry exactly one admissible value.", "properties": { "calibration_corpus_digest": { "pattern": "^sha256:[a-f0-9]{64}$", diff --git a/openadapt_types/schemas/reward-evidence-receipt-v1.json b/openadapt_types/schemas/reward-evidence-receipt-v1.json index d1a4054..36ec397 100644 --- a/openadapt_types/schemas/reward-evidence-receipt-v1.json +++ b/openadapt_types/schemas/reward-evidence-receipt-v1.json @@ -1,10 +1,9 @@ { "$defs": { "RewardCalibrationScopeV1": { - "description": "What corpus the certificate was calibrated against.\n\n``synthetic`` is the only scope anyone can compute today. ``production``\nrequires the Phase-1 calibration, which is not published. A consumer\nmust show the scope beside the word certified.", + "description": "What corpus the certificate was calibrated against.\n\n``synthetic`` is the only member. A production scope would assert that\nthe bound came from the Phase-1 calibration on a real corpus, and nothing\nin this package can check that assertion, so the value does not exist\nhere. Widening the enum later is backward compatible; a consumer must\nshow the scope beside the word certified either way.", "enum": [ - "synthetic", - "production" + "synthetic" ], "title": "RewardCalibrationScopeV1", "type": "string" diff --git a/tests/test_reward.py b/tests/test_reward.py index 48ba213..7eb8b1c 100644 --- a/tests/test_reward.py +++ b/tests/test_reward.py @@ -278,7 +278,9 @@ def test_every_outcome_has_exactly_one_scoring_class() -> None: @pytest.mark.parametrize("outcome", sorted(UNSCORED_REWARD_OUTCOMES, key=str)) def test_unscored_outcomes_never_become_zero(outcome: RewardOutcomeV1) -> None: - scalar, certified, development_only = score(outcome, 2, _certificate(), 120) + scalar, certified, development_only, refusals = score( + outcome, 2, _certificate(), 120, contract=_contract() + ) assert scalar is None assert certified is True assert development_only is False @@ -286,30 +288,44 @@ def test_unscored_outcomes_never_become_zero(outcome: RewardOutcomeV1) -> None: def test_verified_yields_the_admitted_positive_reward() -> None: - scalar, certified, development_only = score( - RewardOutcomeV1.VERIFIED, 3, _certificate(), 120 + scalar, certified, development_only, refusals = score( + RewardOutcomeV1.VERIFIED, 3, _certificate(), 120, contract=_contract() ) assert scalar == 1.0 assert certified and not development_only + assert refusals == () - custom = RewardScoringPolicyV1(verified_reward=2.5, wrong_effect_reward=-3.0) - assert score(RewardOutcomeV1.VERIFIED, 2, None, 0, scoring=custom).scalar == 2.5 - assert score(RewardOutcomeV1.WRONG_EFFECT, 2, None, 0, scoring=custom).scalar == -3.0 + custom = _contract_payload() + custom["scoring"] = {"verified_reward": 2.5, "wrong_effect_reward": -3.0} + contract = RewardContractV1.model_validate(custom) + assert score(RewardOutcomeV1.VERIFIED, 2, None, 0, contract=contract).scalar == 2.5 + assert ( + score(RewardOutcomeV1.WRONG_EFFECT, 2, None, 0, contract=contract).scalar == -3.0 + ) def test_halt_and_rejection_yield_zero_or_declared_penalty() -> None: - assert score(RewardOutcomeV1.HALTED_BEFORE_EFFECT, 2, None, 0).scalar == 0.0 - assert score(RewardOutcomeV1.REJECTED_POLICY, 2, None, 0).scalar == 0.0 - assert score(RewardOutcomeV1.REFUSED, 2, None, 0).scalar == 0.0 - penalised = RewardScoringPolicyV1( - halted_before_effect_reward=-0.1, rejected_policy_reward=-0.5 + plain = _contract() + assert ( + score(RewardOutcomeV1.HALTED_BEFORE_EFFECT, 2, None, 0, contract=plain).scalar + == 0.0 ) + assert score(RewardOutcomeV1.REJECTED_POLICY, 2, None, 0, contract=plain).scalar == 0.0 + assert score(RewardOutcomeV1.REFUSED, 2, None, 0, contract=plain).scalar == 0.0 + payload = _contract_payload() + payload["scoring"] = { + "halted_before_effect_reward": -0.1, + "rejected_policy_reward": -0.5, + } + penalised = RewardContractV1.model_validate(payload) assert ( - score(RewardOutcomeV1.HALTED_BEFORE_EFFECT, 2, None, 0, scoring=penalised).scalar + score( + RewardOutcomeV1.HALTED_BEFORE_EFFECT, 2, None, 0, contract=penalised + ).scalar == -0.1 ) assert ( - score(RewardOutcomeV1.REJECTED_POLICY, 2, None, 0, scoring=penalised).scalar + score(RewardOutcomeV1.REJECTED_POLICY, 2, None, 0, contract=penalised).scalar == -0.5 ) @@ -317,8 +333,8 @@ def test_halt_and_rejection_yield_zero_or_declared_penalty() -> None: def test_tier_zero_and_one_are_development_only_and_never_certified() -> None: certificate = _certificate() for tier in (0, 1): - scalar, certified, development_only = score( - RewardOutcomeV1.VERIFIED, tier, certificate, 120 + scalar, certified, development_only, _ = score( + RewardOutcomeV1.VERIFIED, tier, certificate, 120, contract=_contract() ) assert scalar == 1.0 assert certified is False @@ -330,11 +346,18 @@ def test_tier_zero_and_one_are_development_only_and_never_certified() -> None: def test_expired_or_absent_certificate_is_not_certified() -> None: certificate = _certificate() - assert score(RewardOutcomeV1.VERIFIED, 2, certificate, 150).certified is False - assert score(RewardOutcomeV1.VERIFIED, 2, certificate, 99).certified is False - assert score(RewardOutcomeV1.VERIFIED, 2, None, 120).certified is False + contract = _contract() + expired = score(RewardOutcomeV1.VERIFIED, 2, certificate, 150, contract=contract) + assert expired.certified is False + assert "expired" in expired.certification_refusals[0] + early = score(RewardOutcomeV1.VERIFIED, 2, certificate, 99, contract=contract) + assert early.certified is False + assert "not_yet_valid" in early.certification_refusals[0] + absent = score(RewardOutcomeV1.VERIFIED, 2, None, 120, contract=contract) + assert absent.certified is False + assert absent.certification_refusals == ("no reward certificate was presented",) with pytest.raises(ValueError, match="non-negative"): - score(RewardOutcomeV1.VERIFIED, 2, certificate, -1) + score(RewardOutcomeV1.VERIFIED, 2, certificate, -1, contract=contract) # --- receipt ---------------------------------------------------------------- @@ -456,14 +479,29 @@ def test_receipt_uncertainty_states_are_closed() -> None: # --- calibration scope ------------------------------------------------------ -def test_self_signed_certificate_refuses_production_scope() -> None: - with pytest.raises(ValidationError, match="self-signed.*synthetic scope"): - _certificate(issuer="self_signed", calibration_scope="production") +def test_production_scope_is_unrepresentable() -> None: + """The reviewer's reproduction: `issuer=organization` bought production scope.""" + + for issuer in ("self_signed", "organization"): + with pytest.raises(ValidationError, match="calibration_scope"): + _certificate(issuer=issuer, calibration_scope="production") + assert {item.value for item in RewardCalibrationScopeV1} == {"synthetic"} + synthetic = _certificate(issuer="self_signed", calibration_scope="synthetic") assert synthetic.issuer is RewardCertificateIssuerV1.SELF_SIGNED assert synthetic.calibration_scope is RewardCalibrationScopeV1.SYNTHETIC - organization = _certificate(issuer="organization", calibration_scope="production") - assert organization.calibration_scope is RewardCalibrationScopeV1.PRODUCTION + + +def test_an_unverifiable_issuer_identity_is_unrepresentable() -> None: + with pytest.raises(ValidationError, match="issuer"): + _certificate(issuer="organization") + assert {item.value for item in RewardCertificateIssuerV1} == {"self_signed"} + + +def test_no_receipt_claims_a_production_scope() -> None: + with pytest.raises(ValidationError, match="calibration_scope"): + _receipt(calibration_scope="production") + assert not hasattr(RewardEvidenceReceiptV1, "production_certified") def test_certified_requires_corpus_digest_and_stated_scope() -> None: @@ -477,15 +515,98 @@ def test_certified_requires_corpus_digest_and_stated_scope() -> None: receipt = _receipt() assert receipt.certified is True assert receipt.calibration_scope is RewardCalibrationScopeV1.SYNTHETIC - assert receipt.production_certified is False - production = _receipt(calibration_scope="production") - assert production.production_certified is True - scored = score(RewardOutcomeV1.VERIFIED, 2, _certificate(), 120) + scored = score(RewardOutcomeV1.VERIFIED, 2, _certificate(), 120, contract=_contract()) assert scored.certified is True assert _certificate().calibration_scope is RewardCalibrationScopeV1.SYNTHETIC +# --- the contract's own certificate policy is enforced ---------------------- + + +def test_a_certificate_weaker_than_the_contract_is_not_certified() -> None: + """The reviewer's reproduction: epsilon 0.248885 against a contract of 0.05.""" + + contract = _contract() + weak = _certificate(epsilon=0.248885) + assert weak.satisfies(contract.certificate_policy) is False + scored = score(RewardOutcomeV1.VERIFIED, 2, weak, 120, contract=contract) + assert scored.certified is False + assert scored.scalar == 1.0 + assert scored.certification_refusals == ( + "certificate epsilon 0.248885 exceeds the contract's 0.0114", + ) + + +def test_every_shortfall_against_the_contract_policy_is_named() -> None: + contract = _contract() + assert _certificate().unmet(contract.certificate_policy) == () + cases = { + "delta": ({"delta": 0.5}, "certificate delta 0.5 exceeds"), + "threshold": ({"threshold": 0.9}, "is not the contract's 0.5"), + "corpus": ( + {"calibration_corpus_digest": _OTHER_DIGEST}, + "names a calibration corpus the contract does not", + ), + "expiry": ({"expiry_policy_updates": 51}, "certificate expiry 51 policy updates"), + } + for updates, fragment in cases.values(): + certificate = _certificate(**updates) + reasons = certificate.unmet(contract.certificate_policy) + assert any(fragment in reason for reason in reasons), reasons + assert certificate.satisfies(contract.certificate_policy) is False + assert ( + score( + RewardOutcomeV1.VERIFIED, 2, certificate, 120, contract=contract + ).certified + is False + ) + + +def test_a_certificate_for_another_contract_is_not_certified() -> None: + payload = _contract_payload() + payload["task_id"] = "task.reference.0002" + other = RewardContractV1.model_validate(payload) + scored = score(RewardOutcomeV1.VERIFIED, 2, _certificate(), 120, contract=other) + assert scored.certified is False + assert "different reward contract" in scored.certification_refusals[0] + + +def test_a_receipt_rechecks_its_own_certified_flag() -> None: + contract = _contract() + assert _receipt().certification_refusals(contract, _certificate()) == () + + weak = _certificate(epsilon=0.248885) + hand_built = _receipt(certificate_digest=weak.digest) + refusals = hand_built.certification_refusals(contract, weak) + assert refusals == ("certificate epsilon 0.248885 exceeds the contract's 0.0114",) + + payload = _contract_payload() + payload["task_id"] = "task.reference.0002" + other = RewardContractV1.model_validate(payload) + assert _receipt().certification_refusals(other, _certificate()) == ( + "the receipt names a different reward contract", + "the certificate names a different reward contract", + ) + assert _receipt().certification_refusals(contract, None) == ( + "the receipt references a certificate that was not given", + "no reward certificate was presented", + ) + + +def test_the_package_offers_no_revocation_check() -> None: + """The docstrings described revocation as existing. Nothing implements it.""" + + import openadapt_types.reward as module + + assert not [name for name in dir(module) if "revo" in name.lower()] + assert not [ + name + for name in RewardCertificateV1.model_fields + if "revo" in name.lower() + ] + + # --- not an Execute Seal ----------------------------------------------------