From 10f5cfc63c10e530f087015529d4bbe0a5ea683e Mon Sep 17 00:00:00 2001 From: Foundups Agent Date: Wed, 13 May 2026 10:12:33 +0900 Subject: [PATCH] feat(cabr): implement deterministic CABR runtime scoring engine First sovereign consensus scoring seam per WSP 29 and PR #574 audit: - CABRScoreDecision/CABRScoreReason enums for deterministic decisions - score_cabr_receipt() with quorum evaluation (min_validators=3) - Duplicate verifier rejection (anti-Sybil per WSP 29 Section 6) - Consumes ProofOfComputeReceipt and PAVSVerificationResult - WSP 97 truth fields preserved (verification_complete=False, etc.) 42 tests covering evidence, quorum, pAVS, truth boundaries. No payouts, no token issuance, no network calls. Slice: CABR_RUNTIME_SCORING_ENGINE_PHASE1 Worker: W1 Co-Authored-By: Claude Opus 4.5 --- .../CABR_RUNTIME_SCORING_ENGINE_PHASE1.md | 333 +++++ .../communication/moltbot_bridge/ModLog.md | 65 + .../moltbot_bridge/src/cabr_scoring_engine.py | 1082 +++++++++++++++++ .../moltbot_bridge/tests/TestModLog.md | 33 + .../tests/test_cabr_scoring_engine.py | 837 +++++++++++++ 5 files changed, 2350 insertions(+) create mode 100644 docs/audits/consensus/CABR_RUNTIME_SCORING_ENGINE_PHASE1.md create mode 100644 modules/communication/moltbot_bridge/src/cabr_scoring_engine.py create mode 100644 modules/communication/moltbot_bridge/tests/test_cabr_scoring_engine.py diff --git a/docs/audits/consensus/CABR_RUNTIME_SCORING_ENGINE_PHASE1.md b/docs/audits/consensus/CABR_RUNTIME_SCORING_ENGINE_PHASE1.md new file mode 100644 index 00000000..abb7ed47 --- /dev/null +++ b/docs/audits/consensus/CABR_RUNTIME_SCORING_ENGINE_PHASE1.md @@ -0,0 +1,333 @@ +# CABR Runtime Scoring Engine Phase 1 + +**Implementation Date**: 2026-05-13 +**Slice**: `CABR_RUNTIME_SCORING_ENGINE_PHASE1` +**Worker**: W1 +**WSP Lock**: WSP 00 -> WSP 50 -> WSP 97 +**Base Commit**: 255bf3fc0dc78bec465e7dc24c4e7848e2501e82 + +--- + +## 1. Mission Summary + +Implement the first deterministic CABR runtime scoring seam for internal sovereign consensus per PR #574 gap analysis. + +**Key Constraint**: Deterministic scoring only. No payouts, DAO activation, external attestation, network calls, secrets, or token issuance. + +--- + +## 2. Implementation + +### 2.1 Files Created + +| File | Lines | Purpose | +|------|-------|---------| +| `modules/communication/moltbot_bridge/src/cabr_scoring_engine.py` | ~750 | Core CABR scoring engine | +| `modules/communication/moltbot_bridge/tests/test_cabr_scoring_engine.py` | ~560 | Test coverage (42 tests) | +| `docs/audits/consensus/CABR_RUNTIME_SCORING_ENGINE_PHASE1.md` | This document | Audit trail | + +### 2.2 API Surface + +```python +# Enums +class CABRScoreDecision(str, Enum): + NOT_EVALUATED + ACCEPTED_FOR_REVIEW + ACCEPTED_FOR_REVIEW_PENDING_QUORUM + REJECTED_INSUFFICIENT_EVIDENCE + REJECTED_TRUTH_BOUNDARY + REJECTED_QUORUM_NOT_MET + REJECTED_DUPLICATE_VERIFIERS + REJECTED_PAVS_FAILED + REJECTED_MISSING_IDENTITY + +class CABRScoreReason(str, Enum): + OK_EVIDENCE_PRESENT_QUORUM_MET + OK_EVIDENCE_PRESENT_DRY_RUN + OK_EVIDENCE_PRESENT_PENDING_QUORUM + REJECTED_NO_EVIDENCE + REJECTED_EMPTY_EVIDENCE + REJECTED_VERIFICATION_COMPLETE_CLAIMED + REJECTED_CABR_READY_CLAIMED + REJECTED_PAYOUT_READY_CLAIMED + REJECTED_BELOW_MIN_VALIDATORS + REJECTED_DUPLICATE_VERIFIER_IDS + REJECTED_PAVS_BLOCKED_MISSING_EVIDENCE + REJECTED_PAVS_BLOCKED_UPSTREAM + REJECTED_PAVS_FAILED_INPUT + REJECTED_PAVS_REJECTED_IDENTITY + REJECTED_PAVS_REJECTED_STATUS + REJECTED_NO_RECEIPT_ID + REJECTED_NO_JOB_ID + REJECTED_NO_TENANT_ID + NOT_EVALUATED + +# Dataclasses +@dataclass +class CABRScoreInput: + receipt_id: str + job_id: str + tenant_id: str + evidence_refs: List[str] + verifier_ids: List[str] + pavs_decision: Optional[str] + is_dry_run: bool + is_simulated: bool + verification_complete: bool # WSP 97 input + cabr_ready: bool # WSP 97 input + payout_ready: bool # WSP 97 input + foundup_id: Optional[str] + intent_id: Optional[str] + source_type: str + +@dataclass +class CABRScoreResult: + score_id: str + receipt_id: str + job_id: str + tenant_id: str + decision: CABRScoreDecision + reason_code: CABRScoreReason + reason_human: str + verifier_count: int + unique_verifier_count: int + min_validators: int # WSP 29 default: 3 + quorum_met: bool + duplicate_verifiers_detected: bool + evidence_count: int + evidence_present: bool + is_dry_run: bool + is_simulated: bool + verification_complete: bool # WSP 97: Always False + cabr_ready: bool # WSP 97: Always False + payout_ready: bool # WSP 97: Always False + pavs_decision: Optional[str] + pavs_passed: bool + scored_at: datetime + scorer_version: str + +# Core Functions +def score_cabr_receipt( + score_input: CABRScoreInput, + min_validators: int = 3, + include_input_snapshot: bool = False, +) -> CABRScoreResult + +def score_cabr_batch( + inputs: List[CABRScoreInput], + min_validators: int = 3, +) -> List[CABRScoreResult] + +# Convenience +def score_from_receipt(receipt, verifier_ids, min_validators) -> CABRScoreResult +def score_from_pavs_result(result, verifier_ids, min_validators) -> CABRScoreResult +def build_score_input_from_receipt(receipt, verifier_ids) -> CABRScoreInput +def build_score_input_from_pavs_result(result, verifier_ids) -> CABRScoreInput +``` + +--- + +## 3. Scoring Decisions + +### 3.1 Decision Tree + +``` +1. Validate identity (receipt_id, job_id, tenant_id) + -> Missing: REJECTED_MISSING_IDENTITY + +2. Validate WSP 97 truth boundaries + -> verification_complete=True: REJECTED_TRUTH_BOUNDARY + -> cabr_ready=True: REJECTED_TRUTH_BOUNDARY + -> payout_ready=True: REJECTED_TRUTH_BOUNDARY + +3. Validate evidence presence + -> No evidence: REJECTED_INSUFFICIENT_EVIDENCE + +4. Validate pAVS decision (if present) + -> blocked_*: REJECTED_PAVS_FAILED + -> failed_input: REJECTED_PAVS_FAILED + +5. Evaluate verifier quorum + -> Duplicate IDs: REJECTED_DUPLICATE_VERIFIERS + -> Below min_validators: ACCEPTED_FOR_REVIEW_PENDING_QUORUM + -> Met quorum: ACCEPTED_FOR_REVIEW + +6. Check execution mode + -> dry_run/simulated: ACCEPTED_FOR_REVIEW (reason=DRY_RUN) + -> real execution + quorum: ACCEPTED_FOR_REVIEW (reason=QUORUM_MET) +``` + +### 3.2 Quorum Behavior + +| Verifiers | Unique | Quorum (min=3) | Decision | +|-----------|--------|----------------|----------| +| 0 | 0 | No | ACCEPTED_FOR_REVIEW_PENDING_QUORUM | +| 2 | 2 | No | ACCEPTED_FOR_REVIEW_PENDING_QUORUM | +| 3 | 3 | Yes | ACCEPTED_FOR_REVIEW | +| 5 | 5 | Yes | ACCEPTED_FOR_REVIEW | +| 4 | 2 (duplicates) | N/A | REJECTED_DUPLICATE_VERIFIERS | + +--- + +## 4. WSP 97 Compliance + +### 4.1 Truth Fields (Always False in Phase 1) + +| Field | Value | Rationale | +|-------|-------|-----------| +| `verification_complete` | `False` | No cryptographic verification performed | +| `cabr_ready` | `False` | No CABR consensus exists | +| `payout_ready` | `False` | No payout engine exists | + +### 4.2 Input Rejection + +If ANY of these input fields are `True`, the scoring is rejected with `REJECTED_TRUTH_BOUNDARY`: +- `verification_complete` +- `cabr_ready` +- `payout_ready` + +This enforces fail-closed behavior: Phase 1 cannot accept inputs claiming completion. + +--- + +## 5. Test Coverage + +### 5.1 Test Results + +``` +pytest modules/communication/moltbot_bridge/tests/test_cabr_scoring_engine.py -q +42 passed in 3.70s +``` + +### 5.2 Coverage Matrix + +| Category | Tests | Status | +|----------|-------|--------| +| Missing evidence rejects | 2 | PASS | +| Valid dry-run receipt accepted for review only | 3 | PASS | +| verification_complete=False never produces final consensus | 2 | PASS | +| cabr_ready=False preserved | 1 | PASS | +| payout_ready=False preserved | 1 | PASS | +| verifier_count below 3 fails quorum | 3 | PASS | +| 3 unique verifiers passes quorum eligibility | 2 | PASS | +| Duplicate verifiers do not count | 2 | PASS | +| Failed pAVS result rejects | 4 | PASS | +| Truth-boundary violation rejects | 3 | PASS | +| Batch scoring deterministic | 2 | PASS | +| No network calls | 1 | PASS | +| No token issuance | 1 | PASS | +| WSP 97 truth fields remain False | 1 | PASS | +| Missing identity rejects | 4 | PASS | +| Score ID generation | 2 | PASS | +| Serialization roundtrip | 2 | PASS | +| Convenience functions | 2 | PASS | +| min_validators configuration | 2 | PASS | +| Input builders | 2 | PASS | + +### 5.3 Related Test Suites + +All related test suites pass: +- `test_pavs_verification_seam.py`: 24 passed +- `test_proof_of_compute_receipt.py`: 26 passed +- `test_hermes_job_executor.py`: 94 passed + +--- + +## 6. Architecture Integration + +### 6.1 Position in Pipeline + +``` +ProofOfComputeReceipt (W6) + | + v +PAVSVerificationResult (W7) + | + v +CABRScoreResult (W1) <-- THIS SLICE + | + v +[Future: CABR Consensus Engine] + | + v +[Future: Payout Engine] +``` + +### 6.2 Dependencies + +**Consumes**: +- `ProofOfComputeReceipt` (proof_of_compute_receipt.py) +- `PAVSVerificationResult` (pavs_verification_seam.py) + +**Produces**: +- `CABRScoreResult` for future consensus/payout engines + +**Does NOT Depend On**: +- Network services +- External attestation +- Token systems +- Wallet services +- FAM state mutation + +--- + +## 7. WSP Compliance + +| WSP | Requirement | Status | +|-----|-------------|--------| +| WSP 11 | Interface contract (explicit, typed) | COMPLIANT | +| WSP 29 | CABR Engine Framework (min_validators=3) | COMPLIANT | +| WSP 50 | Pre-Action Verification | COMPLIANT | +| WSP 91 | Observability (timestamps, audit fields) | COMPLIANT | +| WSP 97 | System Execution Prompting (truth boundaries) | COMPLIANT | + +--- + +## 8. WSP 97 Verdict + +| Claim | Status | Evidence | +|-------|--------|----------| +| Scoring is deterministic | TRUE | Pure function, no side effects | +| No network calls | TRUE | No imports of http/socket/requests | +| No token issuance | TRUE | No token-related fields in output | +| verification_complete=False always | TRUE | Hardcoded in all code paths | +| cabr_ready=False always | TRUE | Hardcoded in all code paths | +| payout_ready=False always | TRUE | Hardcoded in all code paths | +| No FAM/pAVS mutation | TRUE | Read-only input consumption | +| Quorum enforcement per WSP 29 | TRUE | min_validators=3 default | +| Duplicate verifier rejection | TRUE | Test coverage confirms | + +**Verdict**: PHASE 1 COMPLIANT - Deterministic scoring seam operational. + +--- + +## 9. Recommended Next Slice + +``` +QUORUM_VERIFICATION_ENFORCEMENT_PHASE1 + +Mission: +- Implement verifier attestation recording +- Connect CABR scoring to pAVS verification result storage +- Enforce quorum thresholds before state transition +- WSP 97: Do not claim verification_complete until quorum met + +Deliverables: +- Verifier attestation model +- Quorum threshold enforcement logic +- pAVS -> CABR integration seam +``` + +--- + +## 10. Audit Trail + +- **Worker**: W1 +- **Slice**: CABR_RUNTIME_SCORING_ENGINE_PHASE1 +- **Tests**: 42 passed +- **Related Tests**: 144 passed (24 + 26 + 94) +- **Files Created**: 3 (engine, tests, audit doc) + +--- + +*Audit performed by Worker W1 under WSP 00/50/97 truth boundaries.* diff --git a/modules/communication/moltbot_bridge/ModLog.md b/modules/communication/moltbot_bridge/ModLog.md index 069f570b..86725476 100644 --- a/modules/communication/moltbot_bridge/ModLog.md +++ b/modules/communication/moltbot_bridge/ModLog.md @@ -1,5 +1,70 @@ # ModLog - moltbot_bridge +## 2026-05-13: CABR Runtime Scoring Engine Phase 1 (WSP 29/97) + +**Author**: 0102 (Worker W1) +**WSP**: 29 (CABR Engine Framework), 97 (System Execution Prompting) +**Slice**: `CABR_RUNTIME_SCORING_ENGINE_PHASE1` + +### Summary + +Implemented the first deterministic CABR runtime scoring seam for internal sovereign consensus. This addresses the critical gap identified in PR #574 (WSP_CONSENSUS_INFRASTRUCTURE_AUDIT): "No runtime CABR scoring engine exists." + +### Scope Constraints + +- Deterministic scoring only +- No payouts, DAO activation, external attestation, network calls, secrets, or token issuance +- WSP 97 truth boundaries enforced: verification_complete=False, cabr_ready=False, payout_ready=False + +### Files Created + +| File | Lines | Purpose | +|------|-------|---------| +| `src/cabr_scoring_engine.py` | ~750 | Core CABR scoring engine | +| `tests/test_cabr_scoring_engine.py` | ~560 | Test coverage (42 tests) | +| `docs/audits/consensus/CABR_RUNTIME_SCORING_ENGINE_PHASE1.md` | ~350 | Audit documentation | + +### API Surface + +```python +# Enums +CABRScoreDecision: NOT_EVALUATED, ACCEPTED_FOR_REVIEW, ACCEPTED_FOR_REVIEW_PENDING_QUORUM, + REJECTED_INSUFFICIENT_EVIDENCE, REJECTED_TRUTH_BOUNDARY, + REJECTED_QUORUM_NOT_MET, REJECTED_DUPLICATE_VERIFIERS, + REJECTED_PAVS_FAILED, REJECTED_MISSING_IDENTITY + +CABRScoreReason: OK_EVIDENCE_PRESENT_QUORUM_MET, OK_EVIDENCE_PRESENT_DRY_RUN, + OK_EVIDENCE_PRESENT_PENDING_QUORUM, REJECTED_* codes + +# Core Functions +score_cabr_receipt(score_input, min_validators=3) -> CABRScoreResult +score_cabr_batch(inputs, min_validators=3) -> List[CABRScoreResult] +score_from_receipt(receipt, verifier_ids) -> CABRScoreResult +score_from_pavs_result(result, verifier_ids) -> CABRScoreResult +``` + +### Quorum Behavior + +| Verifiers | Unique | Decision | +|-----------|--------|----------| +| 0 | 0 | ACCEPTED_FOR_REVIEW_PENDING_QUORUM | +| 2 | 2 | ACCEPTED_FOR_REVIEW_PENDING_QUORUM | +| 3+ | 3+ | ACCEPTED_FOR_REVIEW (quorum_met=True) | +| N | ProofOfComputeReceipt + W7 (pAVS) -> PAVSVerificationResult + W1 (this) -> CABRScoreResult (deterministic scoring decision) + +WSP Compliance: + WSP 11 : Interface contract (explicit, typed) + WSP 29 : CABR Engine Framework (min_validators=3, quorum logic) + WSP 97 : System Execution Prompting (truth boundaries) + WSP 91 : Observability (timestamps, audit fields) + +Slice: CABR_RUNTIME_SCORING_ENGINE_PHASE1 +Worker: W1 +""" + +from __future__ import annotations + +import logging +import secrets +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Dict, List, Optional, Union + +logger = logging.getLogger("cabr_scoring_engine") + + +def _utc_now() -> datetime: + """Return current UTC timestamp.""" + return datetime.now(timezone.utc) + + +def _utc_iso(dt: Optional[datetime]) -> Optional[str]: + """Convert datetime to ISO string or None.""" + return dt.isoformat() if dt else None + + +# --------------------------------------------------------------------------- +# WSP 29 Configuration +# --------------------------------------------------------------------------- + +# WSP 29 Section 1.2: min_validators default +MIN_VALIDATORS_DEFAULT: int = 3 + +# Consensus threshold from WSP 29 Section 1.2 +CONSENSUS_THRESHOLD: float = 0.382 + + +# --------------------------------------------------------------------------- +# Scoring Decision Enum +# --------------------------------------------------------------------------- + + +class CABRScoreDecision(str, Enum): + """ + CABR scoring decision — deterministic outcome of scoring evaluation. + + WSP 97: These decisions describe WHAT WE SCORED, not claims of completion. + """ + + NOT_EVALUATED = "not_evaluated" + """Receipt/result has not been evaluated by CABR scoring.""" + + ACCEPTED_FOR_REVIEW = "accepted_for_review" + """Evidence present and valid, accepted for human/consensus review. NOT final.""" + + ACCEPTED_FOR_REVIEW_PENDING_QUORUM = "accepted_for_review_pending_quorum" + """Evidence valid but verifier count below quorum. Awaiting additional verifiers.""" + + REJECTED_INSUFFICIENT_EVIDENCE = "rejected_insufficient_evidence" + """Rejected: No evidence or evidence_refs empty. Cannot score without evidence.""" + + REJECTED_TRUTH_BOUNDARY = "rejected_truth_boundary" + """Rejected: Input claims verification_complete/cabr_ready/payout_ready = True. + Phase 1 does not accept already-completed claims.""" + + REJECTED_QUORUM_NOT_MET = "rejected_quorum_not_met" + """Rejected: Verifier count below min_validators and no dry_run exemption.""" + + REJECTED_DUPLICATE_VERIFIERS = "rejected_duplicate_verifiers" + """Rejected: Duplicate verifier IDs detected. Unique verifiers required.""" + + REJECTED_PAVS_FAILED = "rejected_pavs_failed" + """Rejected: pAVS verification result indicates failure/blocked state.""" + + REJECTED_MISSING_IDENTITY = "rejected_missing_identity" + """Rejected: Required identity fields missing (receipt_id, job_id, tenant_id).""" + + +class CABRScoreReason(str, Enum): + """Machine-readable reason codes for CABR scoring decisions.""" + + # Acceptance reasons + OK_EVIDENCE_PRESENT_QUORUM_MET = "ok_evidence_present_quorum_met" + """Evidence present and verifier quorum met. Accepted for review.""" + + OK_EVIDENCE_PRESENT_DRY_RUN = "ok_evidence_present_dry_run" + """Evidence present, dry-run mode. Accepted for review only (not consensus).""" + + OK_EVIDENCE_PRESENT_PENDING_QUORUM = "ok_evidence_present_pending_quorum" + """Evidence present but quorum not yet met. Accepted for review pending verifiers.""" + + # Rejection reasons + REJECTED_NO_EVIDENCE = "rejected_no_evidence" + """No evidence_refs provided. Cannot score.""" + + REJECTED_EMPTY_EVIDENCE = "rejected_empty_evidence" + """evidence_refs is empty list. Cannot score.""" + + REJECTED_VERIFICATION_COMPLETE_CLAIMED = "rejected_verification_complete_claimed" + """Input claims verification_complete=True. Phase 1 does not accept.""" + + REJECTED_CABR_READY_CLAIMED = "rejected_cabr_ready_claimed" + """Input claims cabr_ready=True. Phase 1 does not accept.""" + + REJECTED_PAYOUT_READY_CLAIMED = "rejected_payout_ready_claimed" + """Input claims payout_ready=True. Phase 1 does not accept.""" + + REJECTED_BELOW_MIN_VALIDATORS = "rejected_below_min_validators" + """Verifier count below min_validators threshold.""" + + REJECTED_DUPLICATE_VERIFIER_IDS = "rejected_duplicate_verifier_ids" + """Duplicate verifier IDs detected.""" + + REJECTED_PAVS_BLOCKED_MISSING_EVIDENCE = "rejected_pavs_blocked_missing_evidence" + """pAVS returned BLOCKED_MISSING_EVIDENCE.""" + + REJECTED_PAVS_BLOCKED_UPSTREAM = "rejected_pavs_blocked_upstream" + """pAVS returned BLOCKED_UPSTREAM.""" + + REJECTED_PAVS_FAILED_INPUT = "rejected_pavs_failed_input" + """pAVS returned FAILED_INPUT.""" + + REJECTED_PAVS_REJECTED_IDENTITY = "rejected_pavs_rejected_identity" + """pAVS returned REJECTED_MISSING_IDENTITY.""" + + REJECTED_PAVS_REJECTED_STATUS = "rejected_pavs_rejected_status" + """pAVS returned REJECTED_INVALID_STATUS.""" + + REJECTED_NO_RECEIPT_ID = "rejected_no_receipt_id" + """Missing receipt_id in input.""" + + REJECTED_NO_JOB_ID = "rejected_no_job_id" + """Missing job_id in input.""" + + REJECTED_NO_TENANT_ID = "rejected_no_tenant_id" + """Missing tenant_id in input.""" + + NOT_EVALUATED = "not_evaluated" + """Scoring not performed (default state).""" + + +# --------------------------------------------------------------------------- +# Score Input / Result Dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class CABRScoreInput: + """ + Input for CABR scoring evaluation. + + Can be populated from ProofOfComputeReceipt, PAVSVerificationResult, or dict. + """ + + # === Identity Fields === + receipt_id: str + """Receipt identifier from ProofOfComputeReceipt.""" + + job_id: str + """Source job identifier.""" + + tenant_id: str + """Actor scope / owner.""" + + # === Evidence Fields === + evidence_refs: List[str] = field(default_factory=list) + """Evidence references from receipt/verification.""" + + evidence_count: int = 0 + """Convenience: len(evidence_refs).""" + + # === Verifier Fields === + verifier_ids: List[str] = field(default_factory=list) + """List of verifier IDs who have attested to this receipt.""" + + # === pAVS Decision (if available) === + pavs_decision: Optional[str] = None + """pAVS decision value (e.g., 'accepted_for_review', 'blocked_missing_evidence').""" + + # === Execution Mode === + is_dry_run: bool = False + """True if the source execution was dry-run/simulated.""" + + is_simulated: bool = False + """True if the execution was fully simulated (no real work).""" + + # === WSP 97 Truth Fields (from source) === + verification_complete: bool = False + """From source: If True, indicates completed verification (Phase 1 rejects).""" + + cabr_ready: bool = False + """From source: If True, indicates CABR is ready (Phase 1 rejects).""" + + payout_ready: bool = False + """From source: If True, indicates payout is ready (Phase 1 rejects).""" + + # === Metadata === + foundup_id: Optional[str] = None + """Target FoundUp ID if scoped.""" + + intent_id: Optional[str] = None + """Request correlation ID.""" + + source_type: str = "unknown" + """Source type: 'receipt', 'pavs_result', 'dict'.""" + + def to_dict(self) -> Dict[str, Any]: + """Serialize to dict.""" + return { + "receipt_id": self.receipt_id, + "job_id": self.job_id, + "tenant_id": self.tenant_id, + "evidence_refs": self.evidence_refs, + "evidence_count": self.evidence_count, + "verifier_ids": self.verifier_ids, + "pavs_decision": self.pavs_decision, + "is_dry_run": self.is_dry_run, + "is_simulated": self.is_simulated, + "verification_complete": self.verification_complete, + "cabr_ready": self.cabr_ready, + "payout_ready": self.payout_ready, + "foundup_id": self.foundup_id, + "intent_id": self.intent_id, + "source_type": self.source_type, + } + + +@dataclass +class CABRScoreResult: + """ + Result from CABR scoring evaluation. + + Contains deterministic scoring decision with WSP 97 truth boundaries. + """ + + # === Score Identity === + score_id: str + """Unique score identifier. Format: cabr_{receipt_suffix}_{timestamp}_{random}.""" + + receipt_id: str + """Source receipt identifier.""" + + job_id: str + """Source job identifier.""" + + tenant_id: str + """Actor scope / owner.""" + + # === Decision Fields === + decision: CABRScoreDecision + """Scoring decision made.""" + + reason_code: CABRScoreReason + """Machine-readable reason for decision.""" + + reason_human: str + """Operator-readable explanation.""" + + # === Quorum Fields === + verifier_count: int = 0 + """Number of unique verifiers.""" + + unique_verifier_count: int = 0 + """Number of unique verifier IDs (after dedup).""" + + min_validators: int = MIN_VALIDATORS_DEFAULT + """Minimum validators threshold (WSP 29 default: 3).""" + + quorum_met: bool = False + """True if unique_verifier_count >= min_validators.""" + + duplicate_verifiers_detected: bool = False + """True if duplicate verifier IDs were detected.""" + + # === Evidence Fields === + evidence_count: int = 0 + """Number of evidence references.""" + + evidence_present: bool = False + """True if evidence_refs is non-empty.""" + + # === Execution Mode === + is_dry_run: bool = False + """True if source was dry-run execution.""" + + is_simulated: bool = False + """True if source was fully simulated.""" + + # === WSP 97 Truth Fields (OUTPUT - always False in Phase 1) === + verification_complete: bool = False + """Always False in Phase 1. No full verification performed.""" + + cabr_ready: bool = False + """Always False in Phase 1. No CABR consensus exists.""" + + payout_ready: bool = False + """Always False in Phase 1. No payout engine exists.""" + + # === pAVS Integration === + pavs_decision: Optional[str] = None + """pAVS decision if available.""" + + pavs_passed: bool = False + """True if pAVS decision indicates acceptance.""" + + # === Timestamps === + scored_at: datetime = field(default_factory=_utc_now) + """When this score was calculated.""" + + # === Audit Fields === + scorer_version: str = "0.1.0" + """CABR scorer version.""" + + input_snapshot: Optional[Dict[str, Any]] = None + """Optional: Input snapshot for audit trail.""" + + def to_dict(self) -> Dict[str, Any]: + """Serialize to dict.""" + return { + "score_id": self.score_id, + "receipt_id": self.receipt_id, + "job_id": self.job_id, + "tenant_id": self.tenant_id, + "decision": self.decision.value, + "reason_code": self.reason_code.value, + "reason_human": self.reason_human, + "verifier_count": self.verifier_count, + "unique_verifier_count": self.unique_verifier_count, + "min_validators": self.min_validators, + "quorum_met": self.quorum_met, + "duplicate_verifiers_detected": self.duplicate_verifiers_detected, + "evidence_count": self.evidence_count, + "evidence_present": self.evidence_present, + "is_dry_run": self.is_dry_run, + "is_simulated": self.is_simulated, + "verification_complete": self.verification_complete, + "cabr_ready": self.cabr_ready, + "payout_ready": self.payout_ready, + "pavs_decision": self.pavs_decision, + "pavs_passed": self.pavs_passed, + "scored_at": _utc_iso(self.scored_at), + "scorer_version": self.scorer_version, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "CABRScoreResult": + """Deserialize from dict.""" + result = cls( + score_id=data["score_id"], + receipt_id=data["receipt_id"], + job_id=data["job_id"], + tenant_id=data["tenant_id"], + decision=CABRScoreDecision(data["decision"]), + reason_code=CABRScoreReason(data["reason_code"]), + reason_human=data.get("reason_human", ""), + verifier_count=data.get("verifier_count", 0), + unique_verifier_count=data.get("unique_verifier_count", 0), + min_validators=data.get("min_validators", MIN_VALIDATORS_DEFAULT), + quorum_met=data.get("quorum_met", False), + duplicate_verifiers_detected=data.get("duplicate_verifiers_detected", False), + evidence_count=data.get("evidence_count", 0), + evidence_present=data.get("evidence_present", False), + is_dry_run=data.get("is_dry_run", False), + is_simulated=data.get("is_simulated", False), + verification_complete=data.get("verification_complete", False), + cabr_ready=data.get("cabr_ready", False), + payout_ready=data.get("payout_ready", False), + pavs_decision=data.get("pavs_decision"), + pavs_passed=data.get("pavs_passed", False), + scorer_version=data.get("scorer_version", "0.1.0"), + ) + + scored_at = data.get("scored_at") + if scored_at: + result.scored_at = datetime.fromisoformat(scored_at) + + return result + + +# --------------------------------------------------------------------------- +# Score ID Generation +# --------------------------------------------------------------------------- + + +def generate_score_id(receipt_id: str) -> str: + """ + Generate unique score ID from receipt ID. + + Format: cabr_{receipt_suffix}_{timestamp_hex}_{random_hex} + Example: cabr_extract_18a3b2c1_66d1a2b3_abc123 + """ + parts = receipt_id.split("_") + if len(parts) >= 2: + suffix = f"{parts[1]}"[:12] + else: + suffix = receipt_id[:12] + + timestamp_hex = hex(int(_utc_now().timestamp()))[2:][:8] + random_hex = secrets.token_hex(3) + return f"cabr_{suffix}_{timestamp_hex}_{random_hex}" + + +# --------------------------------------------------------------------------- +# Input Builders +# --------------------------------------------------------------------------- + + +def build_score_input_from_receipt( + receipt: Union["ProofOfComputeReceipt", Dict[str, Any]], + verifier_ids: Optional[List[str]] = None, +) -> CABRScoreInput: + """ + Build CABRScoreInput from ProofOfComputeReceipt. + + Args: + receipt: ProofOfComputeReceipt object or dict + verifier_ids: Optional list of verifier IDs for quorum evaluation + + Returns: + CABRScoreInput ready for scoring + """ + if isinstance(receipt, dict): + return CABRScoreInput( + receipt_id=receipt.get("receipt_id", ""), + job_id=receipt.get("job_id", ""), + tenant_id=receipt.get("tenant_id", ""), + evidence_refs=receipt.get("evidence_refs", []), + evidence_count=len(receipt.get("evidence_refs", [])), + verifier_ids=verifier_ids or [], + pavs_decision=None, + is_dry_run=receipt.get("verification_status") == "not_required", + is_simulated=False, + verification_complete=False, # Receipt never claims completion + cabr_ready=False, # Receipt always NOT_SUBMITTED + payout_ready=False, # Receipt always NOT_EVALUATED + foundup_id=receipt.get("foundup_id"), + intent_id=receipt.get("intent_id"), + source_type="receipt", + ) + else: + # Assume ProofOfComputeReceipt object + from .proof_of_compute_receipt import VerificationStatus + + is_dry_run = receipt.verification_status == VerificationStatus.NOT_REQUIRED + return CABRScoreInput( + receipt_id=receipt.receipt_id, + job_id=receipt.job_id, + tenant_id=receipt.tenant_id, + evidence_refs=list(receipt.evidence_refs), + evidence_count=len(receipt.evidence_refs), + verifier_ids=verifier_ids or [], + pavs_decision=None, + is_dry_run=is_dry_run, + is_simulated=False, + verification_complete=False, + cabr_ready=False, + payout_ready=False, + foundup_id=receipt.foundup_id, + intent_id=receipt.intent_id, + source_type="receipt", + ) + + +def build_score_input_from_pavs_result( + result: Union["PAVSVerificationResult", Dict[str, Any]], + verifier_ids: Optional[List[str]] = None, +) -> CABRScoreInput: + """ + Build CABRScoreInput from PAVSVerificationResult. + + Args: + result: PAVSVerificationResult object or dict + verifier_ids: Optional list of verifier IDs for quorum evaluation + + Returns: + CABRScoreInput ready for scoring + """ + if isinstance(result, dict): + return CABRScoreInput( + receipt_id=result.get("receipt_id", ""), + job_id=result.get("job_id", ""), + tenant_id=result.get("tenant_id", ""), + evidence_refs=result.get("evidence_refs", []), + evidence_count=result.get("evidence_count", 0), + verifier_ids=verifier_ids or [], + pavs_decision=result.get("decision"), + is_dry_run=result.get("decision") == "not_required", + is_simulated=False, + verification_complete=result.get("verification_complete", False), + cabr_ready=result.get("cabr_ready", False), + payout_ready=result.get("payout_ready", False), + foundup_id=None, # Not in PAVS result + intent_id=None, + source_type="pavs_result", + ) + else: + # Assume PAVSVerificationResult object + from .pavs_verification_seam import PAVSDecision + + is_dry_run = result.decision == PAVSDecision.NOT_REQUIRED + return CABRScoreInput( + receipt_id=result.receipt_id, + job_id=result.job_id, + tenant_id=result.tenant_id, + evidence_refs=list(result.evidence_refs), + evidence_count=result.evidence_count, + verifier_ids=verifier_ids or [], + pavs_decision=result.decision.value, + is_dry_run=is_dry_run, + is_simulated=False, + verification_complete=result.verification_complete, + cabr_ready=result.cabr_ready, + payout_ready=result.payout_ready, + foundup_id=None, + intent_id=None, + source_type="pavs_result", + ) + + +# --------------------------------------------------------------------------- +# Core Scoring Logic +# --------------------------------------------------------------------------- + + +def _validate_identity( + score_input: CABRScoreInput, +) -> Optional[CABRScoreResult]: + """ + Validate required identity fields. + + Returns rejection result if invalid, None if valid. + """ + if not score_input.receipt_id or not score_input.receipt_id.strip(): + return CABRScoreResult( + score_id=f"cabr_rejected_{secrets.token_hex(4)}", + receipt_id="", + job_id=score_input.job_id, + tenant_id=score_input.tenant_id, + decision=CABRScoreDecision.REJECTED_MISSING_IDENTITY, + reason_code=CABRScoreReason.REJECTED_NO_RECEIPT_ID, + reason_human="Missing receipt_id. Cannot score without receipt identity.", + ) + + if not score_input.job_id or not score_input.job_id.strip(): + return CABRScoreResult( + score_id=generate_score_id(score_input.receipt_id), + receipt_id=score_input.receipt_id, + job_id="", + tenant_id=score_input.tenant_id, + decision=CABRScoreDecision.REJECTED_MISSING_IDENTITY, + reason_code=CABRScoreReason.REJECTED_NO_JOB_ID, + reason_human="Missing job_id. Cannot correlate score to source job.", + ) + + if not score_input.tenant_id or not score_input.tenant_id.strip(): + return CABRScoreResult( + score_id=generate_score_id(score_input.receipt_id), + receipt_id=score_input.receipt_id, + job_id=score_input.job_id, + tenant_id="", + decision=CABRScoreDecision.REJECTED_MISSING_IDENTITY, + reason_code=CABRScoreReason.REJECTED_NO_TENANT_ID, + reason_human="Missing tenant_id. Cannot scope score to actor.", + ) + + return None + + +def _validate_truth_boundaries( + score_input: CABRScoreInput, +) -> Optional[CABRScoreResult]: + """ + Validate WSP 97 truth boundaries. + + Phase 1 rejects inputs claiming verification/CABR/payout completion. + + Returns rejection result if boundaries violated, None if valid. + """ + if score_input.verification_complete: + return CABRScoreResult( + score_id=generate_score_id(score_input.receipt_id), + receipt_id=score_input.receipt_id, + job_id=score_input.job_id, + tenant_id=score_input.tenant_id, + decision=CABRScoreDecision.REJECTED_TRUTH_BOUNDARY, + reason_code=CABRScoreReason.REJECTED_VERIFICATION_COMPLETE_CLAIMED, + reason_human=( + "Input claims verification_complete=True. " + "Phase 1 CABR scoring does not accept already-completed verification. " + "WSP 97 requires truthful state transitions." + ), + ) + + if score_input.cabr_ready: + return CABRScoreResult( + score_id=generate_score_id(score_input.receipt_id), + receipt_id=score_input.receipt_id, + job_id=score_input.job_id, + tenant_id=score_input.tenant_id, + decision=CABRScoreDecision.REJECTED_TRUTH_BOUNDARY, + reason_code=CABRScoreReason.REJECTED_CABR_READY_CLAIMED, + reason_human=( + "Input claims cabr_ready=True. " + "Phase 1 CABR scoring does not accept already-ready CABR state. " + "WSP 97 requires truthful state transitions." + ), + ) + + if score_input.payout_ready: + return CABRScoreResult( + score_id=generate_score_id(score_input.receipt_id), + receipt_id=score_input.receipt_id, + job_id=score_input.job_id, + tenant_id=score_input.tenant_id, + decision=CABRScoreDecision.REJECTED_TRUTH_BOUNDARY, + reason_code=CABRScoreReason.REJECTED_PAYOUT_READY_CLAIMED, + reason_human=( + "Input claims payout_ready=True. " + "Phase 1 CABR scoring does not accept payout-ready state. " + "WSP 97 requires truthful state transitions." + ), + ) + + return None + + +def _validate_evidence( + score_input: CABRScoreInput, +) -> Optional[CABRScoreResult]: + """ + Validate evidence presence. + + Returns rejection result if no evidence, None if valid. + """ + if score_input.evidence_refs is None: + return CABRScoreResult( + score_id=generate_score_id(score_input.receipt_id), + receipt_id=score_input.receipt_id, + job_id=score_input.job_id, + tenant_id=score_input.tenant_id, + decision=CABRScoreDecision.REJECTED_INSUFFICIENT_EVIDENCE, + reason_code=CABRScoreReason.REJECTED_NO_EVIDENCE, + reason_human="No evidence_refs provided. Cannot score without execution evidence.", + ) + + if len(score_input.evidence_refs) == 0: + return CABRScoreResult( + score_id=generate_score_id(score_input.receipt_id), + receipt_id=score_input.receipt_id, + job_id=score_input.job_id, + tenant_id=score_input.tenant_id, + decision=CABRScoreDecision.REJECTED_INSUFFICIENT_EVIDENCE, + reason_code=CABRScoreReason.REJECTED_EMPTY_EVIDENCE, + reason_human=( + "evidence_refs is empty. Cannot score without execution evidence. " + "Provide at least one evidence reference." + ), + ) + + return None + + +def _validate_pavs_decision( + score_input: CABRScoreInput, +) -> Optional[CABRScoreResult]: + """ + Validate pAVS decision if present. + + Returns rejection result if pAVS indicates failure, None if valid/absent. + """ + if not score_input.pavs_decision: + return None # No pAVS decision to validate + + pavs = score_input.pavs_decision.lower() + + # Map pAVS failure states to CABR rejection + rejection_mapping = { + "blocked_missing_evidence": ( + CABRScoreReason.REJECTED_PAVS_BLOCKED_MISSING_EVIDENCE, + "pAVS returned BLOCKED_MISSING_EVIDENCE. Cannot score without evidence.", + ), + "blocked_upstream": ( + CABRScoreReason.REJECTED_PAVS_BLOCKED_UPSTREAM, + "pAVS returned BLOCKED_UPSTREAM. Upstream job was blocked.", + ), + "failed_input": ( + CABRScoreReason.REJECTED_PAVS_FAILED_INPUT, + "pAVS returned FAILED_INPUT. Upstream job failed due to input/validation.", + ), + "rejected_missing_identity": ( + CABRScoreReason.REJECTED_PAVS_REJECTED_IDENTITY, + "pAVS returned REJECTED_MISSING_IDENTITY. Receipt identity incomplete.", + ), + "rejected_invalid_status": ( + CABRScoreReason.REJECTED_PAVS_REJECTED_STATUS, + "pAVS returned REJECTED_INVALID_STATUS. Receipt has invalid verification status.", + ), + } + + if pavs in rejection_mapping: + reason_code, reason_human = rejection_mapping[pavs] + return CABRScoreResult( + score_id=generate_score_id(score_input.receipt_id), + receipt_id=score_input.receipt_id, + job_id=score_input.job_id, + tenant_id=score_input.tenant_id, + decision=CABRScoreDecision.REJECTED_PAVS_FAILED, + reason_code=reason_code, + reason_human=reason_human, + pavs_decision=score_input.pavs_decision, + pavs_passed=False, + ) + + return None + + +def _evaluate_quorum( + verifier_ids: List[str], + min_validators: int, +) -> tuple[int, int, bool, bool]: + """ + Evaluate verifier quorum. + + Args: + verifier_ids: List of verifier IDs + min_validators: Minimum validators threshold + + Returns: + Tuple of (verifier_count, unique_count, duplicates_detected, quorum_met) + """ + verifier_count = len(verifier_ids) + unique_ids = set(verifier_ids) + unique_count = len(unique_ids) + duplicates_detected = unique_count < verifier_count + quorum_met = unique_count >= min_validators + + return verifier_count, unique_count, duplicates_detected, quorum_met + + +# --------------------------------------------------------------------------- +# Public API: Score CABR Receipt +# --------------------------------------------------------------------------- + + +def score_cabr_receipt( + score_input: CABRScoreInput, + min_validators: int = MIN_VALIDATORS_DEFAULT, + include_input_snapshot: bool = False, +) -> CABRScoreResult: + """ + Score a CABR receipt/verification result. + + Evaluates the input against CABR criteria and returns a deterministic + scoring decision. Does NOT claim verification/CABR/payout completion. + + Decision tree: + 1. Validate identity fields + 2. Validate WSP 97 truth boundaries (reject if already completed) + 3. Validate evidence presence + 4. Validate pAVS decision (if present) + 5. Evaluate verifier quorum + 6. Determine acceptance decision + + Args: + score_input: CABRScoreInput to score + min_validators: Minimum validators threshold (WSP 29 default: 3) + include_input_snapshot: If True, include input dict in result + + Returns: + CABRScoreResult with deterministic decision and WSP 97 truth fields + + WSP 97 Truth Fields (always False in Phase 1): + verification_complete = False + cabr_ready = False + payout_ready = False + """ + # Step 1: Validate identity + identity_error = _validate_identity(score_input) + if identity_error: + logger.warning( + "[CABR-SCORE] Identity validation failed: %s", + identity_error.reason_code.value, + ) + return identity_error + + # Step 2: Validate truth boundaries + truth_error = _validate_truth_boundaries(score_input) + if truth_error: + logger.warning( + "[CABR-SCORE] Truth boundary violation: %s", + truth_error.reason_code.value, + ) + return truth_error + + # Step 3: Validate evidence + evidence_error = _validate_evidence(score_input) + if evidence_error: + logger.warning( + "[CABR-SCORE] Evidence validation failed: %s", + evidence_error.reason_code.value, + ) + return evidence_error + + # Step 4: Validate pAVS decision + pavs_error = _validate_pavs_decision(score_input) + if pavs_error: + logger.warning( + "[CABR-SCORE] pAVS validation failed: %s", + pavs_error.reason_code.value, + ) + return pavs_error + + # Step 5: Evaluate quorum + verifier_count, unique_count, duplicates_detected, quorum_met = _evaluate_quorum( + score_input.verifier_ids, + min_validators, + ) + + # Step 5a: Check for duplicate verifiers (strict rejection) + if duplicates_detected and len(score_input.verifier_ids) > 0: + # Only reject if there were verifiers and duplicates were found + # Empty verifier list is handled by quorum check + logger.warning( + "[CABR-SCORE] Duplicate verifiers detected: %d total, %d unique", + verifier_count, + unique_count, + ) + return CABRScoreResult( + score_id=generate_score_id(score_input.receipt_id), + receipt_id=score_input.receipt_id, + job_id=score_input.job_id, + tenant_id=score_input.tenant_id, + decision=CABRScoreDecision.REJECTED_DUPLICATE_VERIFIERS, + reason_code=CABRScoreReason.REJECTED_DUPLICATE_VERIFIER_IDS, + reason_human=( + f"Duplicate verifier IDs detected. " + f"Provided {verifier_count} verifier IDs but only {unique_count} are unique. " + "Each verifier may only attest once." + ), + verifier_count=verifier_count, + unique_verifier_count=unique_count, + min_validators=min_validators, + quorum_met=False, + duplicate_verifiers_detected=True, + evidence_count=len(score_input.evidence_refs), + evidence_present=True, + is_dry_run=score_input.is_dry_run, + is_simulated=score_input.is_simulated, + # WSP 97: Always False in Phase 1 + verification_complete=False, + cabr_ready=False, + payout_ready=False, + pavs_decision=score_input.pavs_decision, + pavs_passed=score_input.pavs_decision in ("accepted_for_review", "pending_verification", "not_required"), + ) + + # Step 6: Determine acceptance decision + evidence_present = len(score_input.evidence_refs) > 0 + evidence_count = len(score_input.evidence_refs) + + # Determine pAVS pass state + pavs_passed = score_input.pavs_decision in ( + "accepted_for_review", + "pending_verification", + "not_required", + None, # No pAVS decision means receipt-only scoring + ) + + # Build base result fields + base_fields = { + "score_id": generate_score_id(score_input.receipt_id), + "receipt_id": score_input.receipt_id, + "job_id": score_input.job_id, + "tenant_id": score_input.tenant_id, + "verifier_count": verifier_count, + "unique_verifier_count": unique_count, + "min_validators": min_validators, + "quorum_met": quorum_met, + "duplicate_verifiers_detected": duplicates_detected, + "evidence_count": evidence_count, + "evidence_present": evidence_present, + "is_dry_run": score_input.is_dry_run, + "is_simulated": score_input.is_simulated, + # WSP 97: Always False in Phase 1 + "verification_complete": False, + "cabr_ready": False, + "payout_ready": False, + "pavs_decision": score_input.pavs_decision, + "pavs_passed": pavs_passed, + } + + # Decision logic + if score_input.is_dry_run or score_input.is_simulated: + # Dry-run/simulated: Accept for review only, never consensus + result = CABRScoreResult( + **base_fields, + decision=CABRScoreDecision.ACCEPTED_FOR_REVIEW, + reason_code=CABRScoreReason.OK_EVIDENCE_PRESENT_DRY_RUN, + reason_human=( + f"Dry-run/simulated execution accepted for review. " + f"{evidence_count} evidence ref(s) present. " + "Dry-run receipts cannot achieve final consensus but are recorded for audit." + ), + ) + elif quorum_met: + # Quorum met: Accept for review with eligibility noted + result = CABRScoreResult( + **base_fields, + decision=CABRScoreDecision.ACCEPTED_FOR_REVIEW, + reason_code=CABRScoreReason.OK_EVIDENCE_PRESENT_QUORUM_MET, + reason_human=( + f"Evidence present and verifier quorum met. " + f"{evidence_count} evidence ref(s), {unique_count} unique verifier(s) " + f"(min_validators={min_validators}). " + "Accepted for review. WSP 97: cabr_ready=False, payout_ready=False." + ), + ) + elif verifier_count == 0: + # No verifiers: Accept for review pending quorum + result = CABRScoreResult( + **base_fields, + decision=CABRScoreDecision.ACCEPTED_FOR_REVIEW_PENDING_QUORUM, + reason_code=CABRScoreReason.OK_EVIDENCE_PRESENT_PENDING_QUORUM, + reason_human=( + f"Evidence present but no verifiers yet. " + f"{evidence_count} evidence ref(s), 0 verifiers " + f"(need {min_validators} for quorum). " + "Accepted for review pending verifier attestations." + ), + ) + else: + # Some verifiers but below quorum: Accept for review pending + result = CABRScoreResult( + **base_fields, + decision=CABRScoreDecision.ACCEPTED_FOR_REVIEW_PENDING_QUORUM, + reason_code=CABRScoreReason.OK_EVIDENCE_PRESENT_PENDING_QUORUM, + reason_human=( + f"Evidence present but verifier quorum not met. " + f"{evidence_count} evidence ref(s), {unique_count} verifier(s) " + f"(need {min_validators} for quorum). " + "Accepted for review pending additional verifiers." + ), + ) + + if include_input_snapshot: + result.input_snapshot = score_input.to_dict() + + logger.info( + "[CABR-SCORE] Scored receipt %s -> decision=%s, reason=%s, quorum=%s", + score_input.receipt_id, + result.decision.value, + result.reason_code.value, + quorum_met, + ) + + return result + + +# --------------------------------------------------------------------------- +# Public API: Batch Scoring +# --------------------------------------------------------------------------- + + +def score_cabr_batch( + inputs: List[CABRScoreInput], + min_validators: int = MIN_VALIDATORS_DEFAULT, +) -> List[CABRScoreResult]: + """ + Score multiple CABR inputs in batch. + + Deterministic: Results are in same order as inputs. + No network calls, no state mutation. + + Args: + inputs: List of CABRScoreInput to score + min_validators: Minimum validators threshold + + Returns: + List of CABRScoreResult in same order as inputs + """ + return [ + score_cabr_receipt(inp, min_validators=min_validators) + for inp in inputs + ] + + +# --------------------------------------------------------------------------- +# Convenience: Direct Scoring Functions +# --------------------------------------------------------------------------- + + +def score_from_receipt( + receipt: Union["ProofOfComputeReceipt", Dict[str, Any]], + verifier_ids: Optional[List[str]] = None, + min_validators: int = MIN_VALIDATORS_DEFAULT, +) -> CABRScoreResult: + """ + Score a ProofOfComputeReceipt directly. + + Convenience wrapper that builds CABRScoreInput and scores. + + Args: + receipt: ProofOfComputeReceipt or dict + verifier_ids: Optional verifier IDs for quorum + min_validators: Minimum validators threshold + + Returns: + CABRScoreResult + """ + score_input = build_score_input_from_receipt(receipt, verifier_ids) + return score_cabr_receipt(score_input, min_validators=min_validators) + + +def score_from_pavs_result( + result: Union["PAVSVerificationResult", Dict[str, Any]], + verifier_ids: Optional[List[str]] = None, + min_validators: int = MIN_VALIDATORS_DEFAULT, +) -> CABRScoreResult: + """ + Score a PAVSVerificationResult directly. + + Convenience wrapper that builds CABRScoreInput and scores. + + Args: + result: PAVSVerificationResult or dict + verifier_ids: Optional verifier IDs for quorum + min_validators: Minimum validators threshold + + Returns: + CABRScoreResult + """ + score_input = build_score_input_from_pavs_result(result, verifier_ids) + return score_cabr_receipt(score_input, min_validators=min_validators) diff --git a/modules/communication/moltbot_bridge/tests/TestModLog.md b/modules/communication/moltbot_bridge/tests/TestModLog.md index 3c95c255..bfb40fe2 100644 --- a/modules/communication/moltbot_bridge/tests/TestModLog.md +++ b/modules/communication/moltbot_bridge/tests/TestModLog.md @@ -1,3 +1,36 @@ +## 2026-05-13: CABR Runtime Scoring Engine Tests (WSP 29/97) + +**File**: `test_cabr_scoring_engine.py` (NEW - 42 tests) + +**Test Classes**: +- `TestMissingEvidenceRejects`: Empty/None evidence_refs rejection +- `TestDryRunAcceptedForReviewOnly`: Dry-run/simulated execution scoring +- `TestVerificationCompleteNeverTrue`: WSP 97 truth field enforcement +- `TestCABRReadyAlwaysFalse`: cabr_ready=False preservation +- `TestPayoutReadyAlwaysFalse`: payout_ready=False preservation +- `TestQuorumBelowThreeFails`: Verifier count below min_validators +- `TestThreeVerifiersQuorumEligible`: Quorum met with 3+ verifiers +- `TestDuplicateVerifiersDoNotCount`: Duplicate verifier ID rejection +- `TestFailedPAVSResultRejects`: pAVS failure state propagation +- `TestTruthBoundaryViolationRejects`: Input claiming completion rejected +- `TestBatchScoringDeterministic`: Batch ordering preservation +- `TestNoNetworkCalls`: Pure local computation +- `TestNoTokenIssuance`: No token-related output fields +- `TestWSP97TruthFieldsRemainFalse`: All acceptance states have False truth fields +- `TestMissingIdentityRejects`: Identity field validation +- `TestScoreIdGeneration`: Score ID format/uniqueness +- `TestResultSerialization`: to_dict/from_dict roundtrip +- `TestConvenienceFunctions`: score_from_receipt, score_from_pavs_result +- `TestMinValidatorsConfiguration`: Custom quorum threshold +- `TestInputBuilders`: build_score_input_from_receipt/pavs_result + +**Run**: +- `python -m pytest modules/communication/moltbot_bridge/tests/test_cabr_scoring_engine.py -q` + +**Result**: 42 passed + +--- + ## 2026-05-12: HXA24 Capability Token PolicyFlags Tests (WSP 97) **File**: `test_foundup_job_contract.py` (extended - 8 new tests) diff --git a/modules/communication/moltbot_bridge/tests/test_cabr_scoring_engine.py b/modules/communication/moltbot_bridge/tests/test_cabr_scoring_engine.py new file mode 100644 index 00000000..8dbc2f96 --- /dev/null +++ b/modules/communication/moltbot_bridge/tests/test_cabr_scoring_engine.py @@ -0,0 +1,837 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Tests for CABR Scoring Engine Phase 1. + +Validates deterministic CABR scoring per WSP 29 and WSP 97 truth boundaries. + +Required coverage: + - Missing evidence rejects + - Valid dry-run receipt accepted for review only + - verification_complete=False never produces final consensus + - cabr_ready=False preserved + - payout_ready=False preserved + - verifier_count below 3 fails quorum + - 3 unique verifiers passes quorum eligibility but still no payout + - Duplicate verifiers do not count + - Failed pAVS result rejects + - Truth-boundary violation rejects + - Batch scoring deterministic + - No network calls + - No token issuance + - WSP 97 truth fields remain False + +Slice: CABR_RUNTIME_SCORING_ENGINE_PHASE1 +Worker: W1 +""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent.parent.parent.parent +if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + +from modules.communication.moltbot_bridge.src.cabr_scoring_engine import ( + CABRScoreDecision, + CABRScoreInput, + CABRScoreReason, + CABRScoreResult, + MIN_VALIDATORS_DEFAULT, + build_score_input_from_pavs_result, + build_score_input_from_receipt, + generate_score_id, + score_cabr_batch, + score_cabr_receipt, + score_from_pavs_result, + score_from_receipt, +) + + +class TestMissingEvidenceRejects(unittest.TestCase): + """Missing evidence results in rejection.""" + + def test_empty_evidence_refs_rejects(self): + """Empty evidence_refs list results in REJECTED_INSUFFICIENT_EVIDENCE.""" + score_input = CABRScoreInput( + receipt_id="rcpt_test_001", + job_id="j_test_001", + tenant_id="t_test", + evidence_refs=[], + evidence_count=0, + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_INSUFFICIENT_EVIDENCE) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_EMPTY_EVIDENCE) + self.assertFalse(result.evidence_present) + + def test_none_evidence_refs_rejects(self): + """None evidence_refs results in REJECTED_INSUFFICIENT_EVIDENCE.""" + score_input = CABRScoreInput( + receipt_id="rcpt_test_002", + job_id="j_test_002", + tenant_id="t_test", + evidence_refs=None, # type: ignore + evidence_count=0, + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_INSUFFICIENT_EVIDENCE) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_NO_EVIDENCE) + + +class TestDryRunAcceptedForReviewOnly(unittest.TestCase): + """Dry-run receipts are accepted for review only, not final consensus.""" + + def test_dry_run_with_evidence_accepted(self): + """Dry-run receipt with evidence is ACCEPTED_FOR_REVIEW.""" + score_input = CABRScoreInput( + receipt_id="rcpt_dry_001", + job_id="j_dry_001", + tenant_id="t_test", + evidence_refs=["logs/run.txt", "outputs/result.json"], + evidence_count=2, + is_dry_run=True, + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW) + self.assertEqual(result.reason_code, CABRScoreReason.OK_EVIDENCE_PRESENT_DRY_RUN) + self.assertTrue(result.is_dry_run) + self.assertTrue(result.evidence_present) + + def test_dry_run_never_cabr_ready(self): + """Dry-run receipt never sets cabr_ready=True.""" + score_input = CABRScoreInput( + receipt_id="rcpt_dry_002", + job_id="j_dry_002", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + is_dry_run=True, + verifier_ids=["v1", "v2", "v3"], # Even with quorum + ) + + result = score_cabr_receipt(score_input) + + self.assertFalse(result.cabr_ready) + self.assertFalse(result.payout_ready) + self.assertFalse(result.verification_complete) + + def test_simulated_accepted_for_review(self): + """Simulated execution is also accepted for review only.""" + score_input = CABRScoreInput( + receipt_id="rcpt_sim_001", + job_id="j_sim_001", + tenant_id="t_test", + evidence_refs=["sim_output.json"], + is_simulated=True, + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW) + self.assertTrue(result.is_simulated) + + +class TestVerificationCompleteNeverTrue(unittest.TestCase): + """verification_complete=False in all Phase 1 outputs.""" + + def test_accepted_review_verification_false(self): + """ACCEPTED_FOR_REVIEW result has verification_complete=False.""" + score_input = CABRScoreInput( + receipt_id="rcpt_ver_001", + job_id="j_ver_001", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verifier_ids=["v1", "v2", "v3"], + ) + + result = score_cabr_receipt(score_input) + + self.assertFalse(result.verification_complete) + + def test_rejected_verification_false(self): + """Rejected results also have verification_complete=False.""" + score_input = CABRScoreInput( + receipt_id="rcpt_ver_002", + job_id="j_ver_002", + tenant_id="t_test", + evidence_refs=[], + ) + + result = score_cabr_receipt(score_input) + + self.assertFalse(result.verification_complete) + + +class TestCABRReadyAlwaysFalse(unittest.TestCase): + """cabr_ready=False in all Phase 1 outputs.""" + + def test_accepted_with_quorum_cabr_false(self): + """Even with quorum met, cabr_ready=False.""" + score_input = CABRScoreInput( + receipt_id="rcpt_cabr_001", + job_id="j_cabr_001", + tenant_id="t_test", + evidence_refs=["e1.txt", "e2.txt"], + verifier_ids=["v1", "v2", "v3", "v4", "v5"], # Well above quorum + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW) + self.assertTrue(result.quorum_met) + self.assertFalse(result.cabr_ready) + + +class TestPayoutReadyAlwaysFalse(unittest.TestCase): + """payout_ready=False in all Phase 1 outputs.""" + + def test_accepted_payout_false(self): + """Accepted results have payout_ready=False.""" + score_input = CABRScoreInput( + receipt_id="rcpt_pay_001", + job_id="j_pay_001", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verifier_ids=["v1", "v2", "v3"], + ) + + result = score_cabr_receipt(score_input) + + self.assertFalse(result.payout_ready) + + +class TestQuorumBelowThreeFails(unittest.TestCase): + """Verifier count below 3 (min_validators) does not meet quorum.""" + + def test_two_verifiers_pending_quorum(self): + """2 verifiers results in ACCEPTED_FOR_REVIEW_PENDING_QUORUM.""" + score_input = CABRScoreInput( + receipt_id="rcpt_q_001", + job_id="j_q_001", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verifier_ids=["v1", "v2"], + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW_PENDING_QUORUM) + self.assertEqual(result.reason_code, CABRScoreReason.OK_EVIDENCE_PRESENT_PENDING_QUORUM) + self.assertEqual(result.unique_verifier_count, 2) + self.assertFalse(result.quorum_met) + + def test_one_verifier_pending_quorum(self): + """1 verifier results in ACCEPTED_FOR_REVIEW_PENDING_QUORUM.""" + score_input = CABRScoreInput( + receipt_id="rcpt_q_002", + job_id="j_q_002", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verifier_ids=["v1"], + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW_PENDING_QUORUM) + self.assertEqual(result.unique_verifier_count, 1) + self.assertFalse(result.quorum_met) + + def test_zero_verifiers_pending_quorum(self): + """0 verifiers results in ACCEPTED_FOR_REVIEW_PENDING_QUORUM.""" + score_input = CABRScoreInput( + receipt_id="rcpt_q_003", + job_id="j_q_003", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verifier_ids=[], + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW_PENDING_QUORUM) + self.assertEqual(result.unique_verifier_count, 0) + self.assertFalse(result.quorum_met) + + +class TestThreeVerifiersQuorumEligible(unittest.TestCase): + """3 unique verifiers passes quorum eligibility but still no payout.""" + + def test_three_verifiers_accepted_with_quorum(self): + """3 verifiers results in ACCEPTED_FOR_REVIEW with quorum_met=True.""" + score_input = CABRScoreInput( + receipt_id="rcpt_3v_001", + job_id="j_3v_001", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verifier_ids=["v1", "v2", "v3"], + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW) + self.assertEqual(result.reason_code, CABRScoreReason.OK_EVIDENCE_PRESENT_QUORUM_MET) + self.assertEqual(result.unique_verifier_count, 3) + self.assertTrue(result.quorum_met) + # Still no payout + self.assertFalse(result.payout_ready) + self.assertFalse(result.cabr_ready) + + def test_four_verifiers_accepted_with_quorum(self): + """More than 3 verifiers also passes quorum.""" + score_input = CABRScoreInput( + receipt_id="rcpt_4v_001", + job_id="j_4v_001", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verifier_ids=["v1", "v2", "v3", "v4"], + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW) + self.assertEqual(result.unique_verifier_count, 4) + self.assertTrue(result.quorum_met) + + +class TestDuplicateVerifiersDoNotCount(unittest.TestCase): + """Duplicate verifier IDs do not count toward quorum.""" + + def test_duplicate_verifiers_rejected(self): + """Duplicate verifier IDs result in REJECTED_DUPLICATE_VERIFIERS.""" + score_input = CABRScoreInput( + receipt_id="rcpt_dup_001", + job_id="j_dup_001", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verifier_ids=["v1", "v1", "v2"], # v1 duplicated + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_DUPLICATE_VERIFIERS) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_DUPLICATE_VERIFIER_IDS) + self.assertTrue(result.duplicate_verifiers_detected) + self.assertEqual(result.verifier_count, 3) + self.assertEqual(result.unique_verifier_count, 2) + + def test_all_duplicates_rejected(self): + """All-duplicate verifier list is rejected.""" + score_input = CABRScoreInput( + receipt_id="rcpt_dup_002", + job_id="j_dup_002", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verifier_ids=["v1", "v1", "v1"], # All same + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_DUPLICATE_VERIFIERS) + self.assertEqual(result.verifier_count, 3) + self.assertEqual(result.unique_verifier_count, 1) + + +class TestFailedPAVSResultRejects(unittest.TestCase): + """Failed pAVS results cause CABR rejection.""" + + def test_pavs_blocked_missing_evidence_rejects(self): + """BLOCKED_MISSING_EVIDENCE pAVS decision rejects.""" + score_input = CABRScoreInput( + receipt_id="rcpt_pavs_001", + job_id="j_pavs_001", + tenant_id="t_test", + evidence_refs=["evidence.txt"], # Has evidence but pAVS says no + pavs_decision="blocked_missing_evidence", + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_PAVS_FAILED) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_PAVS_BLOCKED_MISSING_EVIDENCE) + self.assertFalse(result.pavs_passed) + + def test_pavs_blocked_upstream_rejects(self): + """BLOCKED_UPSTREAM pAVS decision rejects.""" + score_input = CABRScoreInput( + receipt_id="rcpt_pavs_002", + job_id="j_pavs_002", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + pavs_decision="blocked_upstream", + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_PAVS_FAILED) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_PAVS_BLOCKED_UPSTREAM) + + def test_pavs_failed_input_rejects(self): + """FAILED_INPUT pAVS decision rejects.""" + score_input = CABRScoreInput( + receipt_id="rcpt_pavs_003", + job_id="j_pavs_003", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + pavs_decision="failed_input", + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_PAVS_FAILED) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_PAVS_FAILED_INPUT) + + def test_pavs_accepted_passes(self): + """ACCEPTED_FOR_REVIEW pAVS decision allows scoring.""" + score_input = CABRScoreInput( + receipt_id="rcpt_pavs_004", + job_id="j_pavs_004", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + pavs_decision="accepted_for_review", + verifier_ids=["v1", "v2", "v3"], + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW) + self.assertTrue(result.pavs_passed) + + +class TestTruthBoundaryViolationRejects(unittest.TestCase): + """Inputs claiming completion/ready states are rejected.""" + + def test_verification_complete_true_rejects(self): + """verification_complete=True in input causes rejection.""" + score_input = CABRScoreInput( + receipt_id="rcpt_truth_001", + job_id="j_truth_001", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verification_complete=True, # Violates WSP 97 + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_TRUTH_BOUNDARY) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_VERIFICATION_COMPLETE_CLAIMED) + + def test_cabr_ready_true_rejects(self): + """cabr_ready=True in input causes rejection.""" + score_input = CABRScoreInput( + receipt_id="rcpt_truth_002", + job_id="j_truth_002", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + cabr_ready=True, # Violates WSP 97 + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_TRUTH_BOUNDARY) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_CABR_READY_CLAIMED) + + def test_payout_ready_true_rejects(self): + """payout_ready=True in input causes rejection.""" + score_input = CABRScoreInput( + receipt_id="rcpt_truth_003", + job_id="j_truth_003", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + payout_ready=True, # Violates WSP 97 + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_TRUTH_BOUNDARY) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_PAYOUT_READY_CLAIMED) + + +class TestBatchScoringDeterministic(unittest.TestCase): + """Batch scoring returns deterministic results in order.""" + + def test_batch_order_preserved(self): + """Batch results are in same order as inputs.""" + inputs = [ + CABRScoreInput( + receipt_id="rcpt_batch_1", + job_id="j_1", + tenant_id="t_1", + evidence_refs=["e1.txt"], + verifier_ids=["v1", "v2", "v3"], + ), + CABRScoreInput( + receipt_id="rcpt_batch_2", + job_id="j_2", + tenant_id="t_2", + evidence_refs=[], # Will reject + ), + CABRScoreInput( + receipt_id="rcpt_batch_3", + job_id="j_3", + tenant_id="t_3", + evidence_refs=["e3.txt"], + is_dry_run=True, + ), + ] + + results = score_cabr_batch(inputs) + + self.assertEqual(len(results), 3) + self.assertEqual(results[0].receipt_id, "rcpt_batch_1") + self.assertEqual(results[0].decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW) + self.assertEqual(results[1].receipt_id, "rcpt_batch_2") + self.assertEqual(results[1].decision, CABRScoreDecision.REJECTED_INSUFFICIENT_EVIDENCE) + self.assertEqual(results[2].receipt_id, "rcpt_batch_3") + self.assertEqual(results[2].decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW) + self.assertTrue(results[2].is_dry_run) + + def test_batch_empty_input(self): + """Empty batch returns empty results.""" + results = score_cabr_batch([]) + self.assertEqual(len(results), 0) + + +class TestNoNetworkCalls(unittest.TestCase): + """CABR scoring makes no network calls.""" + + def test_score_is_purely_local(self): + """Scoring is a pure local computation.""" + # This test verifies the function runs without network + # by successfully completing - if it tried network calls + # and they failed, this would raise + score_input = CABRScoreInput( + receipt_id="rcpt_local_001", + job_id="j_local_001", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verifier_ids=["v1", "v2", "v3"], + ) + + result = score_cabr_receipt(score_input) + + # Successfully computed + self.assertIsNotNone(result) + self.assertIsNotNone(result.score_id) + + +class TestNoTokenIssuance(unittest.TestCase): + """CABR scoring does not issue tokens.""" + + def test_no_token_fields_in_result(self): + """Result does not contain token/UPS/payout amounts.""" + score_input = CABRScoreInput( + receipt_id="rcpt_token_001", + job_id="j_token_001", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verifier_ids=["v1", "v2", "v3"], + ) + + result = score_cabr_receipt(score_input) + result_dict = result.to_dict() + + # Verify no token-related fields + self.assertNotIn("tokens_issued", result_dict) + self.assertNotIn("ups_allocated", result_dict) + self.assertNotIn("payout_amount", result_dict) + self.assertNotIn("reward_amount", result_dict) + + +class TestWSP97TruthFieldsRemainFalse(unittest.TestCase): + """All WSP 97 truth fields remain False in Phase 1 output.""" + + def test_all_acceptance_states_have_false_truth_fields(self): + """Every acceptance state has truth fields False.""" + # Test various acceptance scenarios + test_cases = [ + CABRScoreInput( + receipt_id="rcpt_wsp_1", + job_id="j_1", + tenant_id="t", + evidence_refs=["e.txt"], + verifier_ids=["v1", "v2", "v3"], + ), + CABRScoreInput( + receipt_id="rcpt_wsp_2", + job_id="j_2", + tenant_id="t", + evidence_refs=["e.txt"], + is_dry_run=True, + ), + CABRScoreInput( + receipt_id="rcpt_wsp_3", + job_id="j_3", + tenant_id="t", + evidence_refs=["e.txt"], + verifier_ids=["v1"], # Pending quorum + ), + ] + + for score_input in test_cases: + result = score_cabr_receipt(score_input) + self.assertFalse( + result.verification_complete, + f"verification_complete should be False for {score_input.receipt_id}", + ) + self.assertFalse( + result.cabr_ready, + f"cabr_ready should be False for {score_input.receipt_id}", + ) + self.assertFalse( + result.payout_ready, + f"payout_ready should be False for {score_input.receipt_id}", + ) + + +class TestMissingIdentityRejects(unittest.TestCase): + """Missing identity fields cause rejection.""" + + def test_missing_receipt_id_rejects(self): + """Empty receipt_id causes rejection.""" + score_input = CABRScoreInput( + receipt_id="", + job_id="j_id_001", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_MISSING_IDENTITY) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_NO_RECEIPT_ID) + + def test_whitespace_receipt_id_rejects(self): + """Whitespace-only receipt_id causes rejection.""" + score_input = CABRScoreInput( + receipt_id=" ", + job_id="j_id_002", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_MISSING_IDENTITY) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_NO_RECEIPT_ID) + + def test_missing_job_id_rejects(self): + """Empty job_id causes rejection.""" + score_input = CABRScoreInput( + receipt_id="rcpt_id_001", + job_id="", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_MISSING_IDENTITY) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_NO_JOB_ID) + + def test_missing_tenant_id_rejects(self): + """Empty tenant_id causes rejection.""" + score_input = CABRScoreInput( + receipt_id="rcpt_id_001", + job_id="j_id_001", + tenant_id="", + evidence_refs=["evidence.txt"], + ) + + result = score_cabr_receipt(score_input) + + self.assertEqual(result.decision, CABRScoreDecision.REJECTED_MISSING_IDENTITY) + self.assertEqual(result.reason_code, CABRScoreReason.REJECTED_NO_TENANT_ID) + + +class TestScoreIdGeneration(unittest.TestCase): + """Score ID generation.""" + + def test_score_id_format(self): + """Score ID follows cabr_{suffix}_{timestamp}_{random} format.""" + score_id = generate_score_id("rcpt_test_abc123_def456") + + self.assertTrue(score_id.startswith("cabr_")) + parts = score_id.split("_") + self.assertGreaterEqual(len(parts), 4) + + def test_score_ids_unique(self): + """Consecutive scores have unique IDs.""" + ids = [generate_score_id("rcpt_test_123") for _ in range(10)] + self.assertEqual(len(ids), len(set(ids))) + + +class TestResultSerialization(unittest.TestCase): + """CABRScoreResult serialization round-trips.""" + + def test_to_dict_contains_required_fields(self): + """to_dict includes all required fields.""" + score_input = CABRScoreInput( + receipt_id="rcpt_serial_001", + job_id="j_serial_001", + tenant_id="t_test", + evidence_refs=["e.txt"], + verifier_ids=["v1", "v2", "v3"], + ) + + result = score_cabr_receipt(score_input) + d = result.to_dict() + + self.assertIn("score_id", d) + self.assertIn("receipt_id", d) + self.assertIn("job_id", d) + self.assertIn("tenant_id", d) + self.assertIn("decision", d) + self.assertIn("reason_code", d) + self.assertIn("quorum_met", d) + self.assertIn("verification_complete", d) + self.assertIn("cabr_ready", d) + self.assertIn("payout_ready", d) + + def test_from_dict_roundtrip(self): + """from_dict restores result from to_dict.""" + score_input = CABRScoreInput( + receipt_id="rcpt_round_001", + job_id="j_round_001", + tenant_id="t_roundtrip", + evidence_refs=["r1.txt", "r2.txt"], + verifier_ids=["v1", "v2", "v3"], + ) + + result = score_cabr_receipt(score_input) + d = result.to_dict() + restored = CABRScoreResult.from_dict(d) + + self.assertEqual(restored.score_id, result.score_id) + self.assertEqual(restored.receipt_id, result.receipt_id) + self.assertEqual(restored.decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW) + self.assertTrue(restored.quorum_met) + self.assertFalse(restored.cabr_ready) + self.assertFalse(restored.payout_ready) + + +class TestConvenienceFunctions(unittest.TestCase): + """Convenience functions for direct scoring.""" + + def test_score_from_receipt_dict(self): + """score_from_receipt works with dict input.""" + receipt_dict = { + "receipt_id": "rcpt_conv_001", + "job_id": "j_conv_001", + "tenant_id": "t_test", + "evidence_refs": ["evidence.txt"], + "verification_status": "pending_pavs", + } + + result = score_from_receipt(receipt_dict, verifier_ids=["v1", "v2", "v3"]) + + self.assertEqual(result.decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW) + self.assertEqual(result.receipt_id, "rcpt_conv_001") + + def test_score_from_pavs_result_dict(self): + """score_from_pavs_result works with dict input.""" + pavs_dict = { + "receipt_id": "rcpt_pavs_conv_001", + "job_id": "j_pavs_conv_001", + "tenant_id": "t_test", + "evidence_refs": ["evidence.txt"], + "evidence_count": 1, + "decision": "accepted_for_review", + "verification_complete": False, + "cabr_ready": False, + "payout_ready": False, + } + + result = score_from_pavs_result(pavs_dict, verifier_ids=["v1", "v2", "v3"]) + + self.assertEqual(result.decision, CABRScoreDecision.ACCEPTED_FOR_REVIEW) + self.assertTrue(result.pavs_passed) + + +class TestMinValidatorsConfiguration(unittest.TestCase): + """min_validators configuration.""" + + def test_default_min_validators_is_three(self): + """Default min_validators is 3 per WSP 29.""" + self.assertEqual(MIN_VALIDATORS_DEFAULT, 3) + + def test_custom_min_validators(self): + """Custom min_validators threshold works.""" + score_input = CABRScoreInput( + receipt_id="rcpt_minval_001", + job_id="j_minval_001", + tenant_id="t_test", + evidence_refs=["evidence.txt"], + verifier_ids=["v1", "v2", "v3", "v4", "v5"], # 5 verifiers + ) + + # With min_validators=5, quorum should be met + result_5 = score_cabr_receipt(score_input, min_validators=5) + self.assertTrue(result_5.quorum_met) + self.assertEqual(result_5.min_validators, 5) + + # With min_validators=6, quorum should NOT be met + result_6 = score_cabr_receipt(score_input, min_validators=6) + self.assertFalse(result_6.quorum_met) + self.assertEqual(result_6.min_validators, 6) + + +class TestInputBuilders(unittest.TestCase): + """Input builder functions.""" + + def test_build_from_receipt_dict(self): + """build_score_input_from_receipt works with dict.""" + receipt_dict = { + "receipt_id": "rcpt_build_001", + "job_id": "j_build_001", + "tenant_id": "t_test", + "evidence_refs": ["e1.txt", "e2.txt"], + "foundup_id": "f_test", + "intent_id": "i_test", + "verification_status": "pending_pavs", + } + + score_input = build_score_input_from_receipt( + receipt_dict, + verifier_ids=["v1", "v2"], + ) + + self.assertEqual(score_input.receipt_id, "rcpt_build_001") + self.assertEqual(score_input.evidence_count, 2) + self.assertEqual(score_input.foundup_id, "f_test") + self.assertEqual(len(score_input.verifier_ids), 2) + self.assertEqual(score_input.source_type, "receipt") + + def test_build_from_pavs_dict(self): + """build_score_input_from_pavs_result works with dict.""" + pavs_dict = { + "receipt_id": "rcpt_pavs_build_001", + "job_id": "j_pavs_build_001", + "tenant_id": "t_test", + "evidence_refs": ["e.txt"], + "evidence_count": 1, + "decision": "not_required", + "verification_complete": False, + "cabr_ready": False, + "payout_ready": False, + } + + score_input = build_score_input_from_pavs_result(pavs_dict) + + self.assertEqual(score_input.receipt_id, "rcpt_pavs_build_001") + self.assertEqual(score_input.pavs_decision, "not_required") + self.assertTrue(score_input.is_dry_run) + self.assertEqual(score_input.source_type, "pavs_result") + + +if __name__ == "__main__": + unittest.main(verbosity=2)