rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

reconciliation_reducer.rs (27225B)


      1 //! Pure deterministic reduction of one sealed reconciliation manifest.
      2 
      3 use core::fmt;
      4 use std::{collections::BTreeSet, error::Error};
      5 
      6 use radroots_event::{
      7     id::{MutationId, TradeId},
      8     trade::trade_mutation_from_canonical_content,
      9 };
     10 use radroots_trade::{
     11     evidence::{
     12         RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceOutcomeV1,
     13         RadrootsTradeEvidenceStateV1, RadrootsTradeMutationRecordV1,
     14     },
     15     model::{RadrootsTradeAgreementStateV1, RadrootsTradeProjectionV1},
     16     reducer::{
     17         RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION,
     18         RadrootsTradeReductionInputV1, reduce_trade_records,
     19     },
     20 };
     21 use sha2::{Digest, Sha256};
     22 
     23 use crate::{
     24     RhiReconciliationManifest,
     25     reconciliation_manifest::{
     26         RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES, RHI_REDUCER_MAXIMUM_MUTATIONS,
     27         RhiReducerMutationMaterial,
     28     },
     29 };
     30 
     31 /// Exact version of the RHI reconciliation-reducer binding.
     32 pub const RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION: u32 = 1;
     33 /// Exact version of the RHI reconciliation coverage/outcome binding.
     34 pub const RHI_RECONCILIATION_OUTCOME_CONTRACT_VERSION: u32 = 1;
     35 
     36 /// Exact shared four-state reconciliation coverage vocabulary.
     37 pub type RhiReconciliationCoverage = RadrootsTradeEvidenceCoverageV1;
     38 /// Exact shared three-state reconciliation outcome vocabulary.
     39 pub type RhiReconciliationOutcome = RadrootsTradeEvidenceOutcomeV1;
     40 
     41 const PROJECTION_DIGEST_DOMAIN: &[u8] = b"radroots.rhi.reconciliation_projection.v1\0";
     42 
     43 /// Stable source-free reconciliation-reducer failure class.
     44 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     45 pub enum RhiReconciliationReducerErrorKind {
     46     InvalidManifest,
     47     ProjectionUnavailable,
     48 }
     49 
     50 impl RhiReconciliationReducerErrorKind {
     51     /// Returns the stable machine-readable failure code.
     52     #[must_use]
     53     pub const fn code(self) -> &'static str {
     54         match self {
     55             Self::InvalidManifest => "reconciliation_reducer_manifest_invalid",
     56             Self::ProjectionUnavailable => "reconciliation_reducer_projection_unavailable",
     57         }
     58     }
     59 }
     60 
     61 /// Redacted source-free reconciliation-reducer failure.
     62 #[derive(Clone, Copy, PartialEq, Eq)]
     63 pub struct RhiReconciliationReducerError {
     64     kind: RhiReconciliationReducerErrorKind,
     65 }
     66 
     67 impl RhiReconciliationReducerError {
     68     /// Returns the stable failure class.
     69     #[must_use]
     70     pub const fn kind(self) -> RhiReconciliationReducerErrorKind {
     71         self.kind
     72     }
     73 
     74     /// Returns the stable machine-readable failure code.
     75     #[must_use]
     76     pub const fn code(self) -> &'static str {
     77         self.kind.code()
     78     }
     79 }
     80 
     81 impl fmt::Display for RhiReconciliationReducerError {
     82     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     83         formatter.write_str(match self.kind {
     84             RhiReconciliationReducerErrorKind::InvalidManifest => {
     85                 "RHI reconciliation manifest cannot be reduced"
     86             }
     87             RhiReconciliationReducerErrorKind::ProjectionUnavailable => {
     88                 "RHI reconciliation projection is unavailable"
     89             }
     90         })
     91     }
     92 }
     93 
     94 impl fmt::Debug for RhiReconciliationReducerError {
     95     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     96         formatter
     97             .debug_struct("RhiReconciliationReducerError")
     98             .field("kind", &self.kind)
     99             .finish()
    100     }
    101 }
    102 
    103 impl Error for RhiReconciliationReducerError {}
    104 
    105 /// Sealed deterministic projection retaining its exact manifest capability.
    106 ///
    107 /// Callers cannot construct or relabel a projection.
    108 ///
    109 /// ```compile_fail
    110 /// use rhi::RhiReconciliationProjection;
    111 ///
    112 /// let _forged = RhiReconciliationProjection {};
    113 /// ```
    114 pub struct RhiReconciliationProjection {
    115     manifest: RhiReconciliationManifest,
    116     shared: RadrootsTradeProjectionV1,
    117     shared_projection_digest: Option<[u8; 32]>,
    118     digest: Option<[u8; 32]>,
    119 }
    120 
    121 impl RhiReconciliationProjection {
    122     /// Returns the exact RHI reducer-binding contract version.
    123     #[must_use]
    124     pub const fn contract_version(&self) -> u32 {
    125         RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION
    126     }
    127 
    128     /// Returns the exact shared reducer contract ID.
    129     #[must_use]
    130     pub const fn shared_reducer_contract_id(&self) -> &'static str {
    131         RADROOTS_TRADE_REDUCER_CONTRACT_ID
    132     }
    133 
    134     /// Returns the exact shared reducer contract version.
    135     #[must_use]
    136     pub const fn shared_reducer_contract_version(&self) -> u16 {
    137         RADROOTS_TRADE_REDUCER_VERSION
    138     }
    139 
    140     /// Returns the sealed manifest consumed by this projection.
    141     #[must_use]
    142     pub const fn manifest(&self) -> &RhiReconciliationManifest {
    143         &self.manifest
    144     }
    145 
    146     /// Returns the exact trade selected by the immutable manifest.
    147     #[must_use]
    148     pub const fn trade_id(&self) -> &TradeId {
    149         self.manifest.trade_id()
    150     }
    151 
    152     /// Returns the exact shared projection digest decoded from lowercase hex.
    153     #[must_use]
    154     pub const fn shared_projection_digest(&self) -> Option<[u8; 32]> {
    155         self.shared_projection_digest
    156     }
    157 
    158     /// Returns the domain-separated RHI projection digest.
    159     #[must_use]
    160     pub const fn digest(&self) -> Option<[u8; 32]> {
    161         self.digest
    162     }
    163 
    164     /// Returns the number of canonical shared reducer issues.
    165     #[must_use]
    166     pub fn issue_count(&self) -> usize {
    167         self.shared.issues().len()
    168     }
    169 
    170     /// Returns the selected root mutation when the manifest establishes one.
    171     #[must_use]
    172     pub const fn root_mutation_id(&self) -> Option<&MutationId> {
    173         self.shared.root_mutation_id()
    174     }
    175 }
    176 
    177 /// Stable closed reason for one claim-specific reconciliation outcome.
    178 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
    179 pub enum RhiReconciliationReasonCode {
    180     RequiredEvidenceMissing,
    181     RequiredSourceIncomplete,
    182     RequiredSourceUnsupported,
    183     ProjectionDigestUnavailable,
    184     GoverningSchemaUnsupported,
    185     ReducerIssueUnresolved,
    186     AgreementClaimMissing,
    187     AgreementClaimUnresolved,
    188     ScopeSatisfied,
    189     AgreementClaimCancelled,
    190 }
    191 
    192 impl RhiReconciliationReasonCode {
    193     /// Returns the exact stable lowercase report reason code.
    194     #[must_use]
    195     pub const fn code(self) -> &'static str {
    196         match self {
    197             Self::RequiredEvidenceMissing => "required_evidence_missing",
    198             Self::RequiredSourceIncomplete => "required_source_incomplete",
    199             Self::RequiredSourceUnsupported => "required_source_unsupported",
    200             Self::ProjectionDigestUnavailable => "projection_digest_unavailable",
    201             Self::GoverningSchemaUnsupported => "governing_schema_unsupported",
    202             Self::ReducerIssueUnresolved => "reducer_issue_unresolved",
    203             Self::AgreementClaimMissing => "agreement_claim_missing",
    204             Self::AgreementClaimUnresolved => "agreement_claim_unresolved",
    205             Self::ScopeSatisfied => "scope_satisfied",
    206             Self::AgreementClaimCancelled => "agreement_claim_cancelled",
    207         }
    208     }
    209 }
    210 
    211 /// Sealed claim-specific coverage and outcome derived from one projection.
    212 ///
    213 /// Callers cannot construct or relabel an evaluation.
    214 ///
    215 /// ```compile_fail
    216 /// use rhi::RhiReconciliationEvaluation;
    217 ///
    218 /// let _forged = RhiReconciliationEvaluation {};
    219 /// ```
    220 pub struct RhiReconciliationEvaluation {
    221     projection: RhiReconciliationProjection,
    222     claim_mutation_id: MutationId,
    223     coverage: RhiReconciliationCoverage,
    224     outcome: RhiReconciliationOutcome,
    225     reason_codes: [RhiReconciliationReasonCode; 1],
    226 }
    227 
    228 impl RhiReconciliationEvaluation {
    229     /// Returns the exact coverage/outcome contract version.
    230     #[must_use]
    231     pub const fn contract_version(&self) -> u32 {
    232         RHI_RECONCILIATION_OUTCOME_CONTRACT_VERSION
    233     }
    234 
    235     /// Returns the sealed projection evaluated for this claim.
    236     #[must_use]
    237     pub const fn projection(&self) -> &RhiReconciliationProjection {
    238         &self.projection
    239     }
    240 
    241     /// Returns the exact typed claim selected by the evaluation.
    242     #[must_use]
    243     pub const fn claim_mutation_id(&self) -> &MutationId {
    244         &self.claim_mutation_id
    245     }
    246 
    247     /// Returns the exact four-state evidence coverage.
    248     #[must_use]
    249     pub const fn coverage(&self) -> RhiReconciliationCoverage {
    250         self.coverage
    251     }
    252 
    253     /// Returns the exact three-state claim outcome.
    254     #[must_use]
    255     pub const fn outcome(&self) -> RhiReconciliationOutcome {
    256         self.outcome
    257     }
    258 
    259     /// Returns the exact bounded stable reason-code inventory.
    260     #[must_use]
    261     pub const fn reason_codes(&self) -> &[RhiReconciliationReasonCode] {
    262         &self.reason_codes
    263     }
    264 }
    265 
    266 impl fmt::Debug for RhiReconciliationEvaluation {
    267     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    268         formatter
    269             .debug_struct("RhiReconciliationEvaluation")
    270             .field("coverage", &self.coverage)
    271             .field("outcome", &self.outcome)
    272             .field("reason_codes", &self.reason_codes)
    273             .finish_non_exhaustive()
    274     }
    275 }
    276 
    277 impl fmt::Debug for RhiReconciliationProjection {
    278     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    279         formatter
    280             .debug_struct("RhiReconciliationProjection")
    281             .field("source_count", &self.manifest.source_count())
    282             .field("observation_count", &self.manifest.observation_count())
    283             .field("issue_count", &self.issue_count())
    284             .finish_non_exhaustive()
    285     }
    286 }
    287 
    288 /// Reduces one sealed immutable reconciliation manifest without external I/O.
    289 pub fn reduce_rhi_reconciliation_manifest(
    290     manifest: RhiReconciliationManifest,
    291 ) -> Result<RhiReconciliationProjection, RhiReconciliationReducerError> {
    292     let inner = manifest.inner();
    293     if !mutation_material_within_bounds(manifest.reducer_mutations()) {
    294         return Err(failure(RhiReconciliationReducerErrorKind::InvalidManifest));
    295     }
    296     let observation_inventory = inner
    297         .observations()
    298         .iter()
    299         .map(|observation| (*observation.mutation_id(), *observation.event_id()))
    300         .collect::<BTreeSet<_>>();
    301     if inner.observations().iter().any(|observation| {
    302         manifest
    303             .reducer_mutations()
    304             .binary_search_by_key(observation.mutation_id(), |material| material.mutation_id)
    305             .is_err()
    306     }) {
    307         return Err(failure(RhiReconciliationReducerErrorKind::InvalidManifest));
    308     }
    309 
    310     let mut mutations = Vec::with_capacity(manifest.reducer_mutations().len());
    311     for material in manifest.reducer_mutations() {
    312         if !observation_inventory.contains(&(material.mutation_id, material.event_id)) {
    313             return Err(failure(RhiReconciliationReducerErrorKind::InvalidManifest));
    314         }
    315         let content = core::str::from_utf8(&material.canonical_content)
    316             .map_err(|_| failure(RhiReconciliationReducerErrorKind::InvalidManifest))?;
    317         let mutation = trade_mutation_from_canonical_content(content)
    318             .map_err(|_| failure(RhiReconciliationReducerErrorKind::InvalidManifest))?;
    319         if mutation.mutation_id != Some(material.mutation_id)
    320             || mutation.trade_id != *inner.trade_id()
    321         {
    322             return Err(failure(RhiReconciliationReducerErrorKind::InvalidManifest));
    323         }
    324         mutations.push(RadrootsTradeMutationRecordV1::new(
    325             Some(material.event_id),
    326             mutation,
    327         ));
    328     }
    329 
    330     let input = RadrootsTradeReductionInputV1::new(*inner.trade_id())
    331         .with_mutations(mutations)
    332         .with_evidence_state(evidence_state(inner.coverage()))
    333         .with_observed_at_unix_s(Some(inner.observed_at_unix_s()));
    334     let shared = reduce_trade_records(input);
    335     if shared.reducer_contract_id() != RADROOTS_TRADE_REDUCER_CONTRACT_ID
    336         || shared.reducer_version() != RADROOTS_TRADE_REDUCER_VERSION
    337         || shared.trade_id() != inner.trade_id()
    338     {
    339         return Err(failure(
    340             RhiReconciliationReducerErrorKind::ProjectionUnavailable,
    341         ));
    342     }
    343     let shared_projection_digest = decode_lower_hex_32(shared.projection_digest());
    344     let digest = shared_projection_digest.and_then(|shared_projection_digest| {
    345         projection_digest(&manifest, shared_projection_digest)
    346     });
    347     Ok(RhiReconciliationProjection {
    348         manifest,
    349         shared,
    350         shared_projection_digest,
    351         digest,
    352     })
    353 }
    354 
    355 /// Evaluates one exact typed agreement claim against a sealed projection.
    356 pub fn evaluate_rhi_reconciliation_claim(
    357     projection: RhiReconciliationProjection,
    358     claim_mutation_id: MutationId,
    359 ) -> RhiReconciliationEvaluation {
    360     let shared = &projection.shared;
    361     let facts = EvaluationFacts {
    362         coverage: projection.manifest.inner().coverage(),
    363         shared_evidence: shared.evidence_state(),
    364         projection_digest_available: projection.digest.is_some(),
    365         reducer_issue_present: !shared.issues().is_empty(),
    366         claim_present: shared
    367             .agreement_claims()
    368             .iter()
    369             .any(|claim| claim.claim_mutation_id() == &claim_mutation_id),
    370         claim_active: shared
    371             .active_agreement_claim_ids()
    372             .contains(&claim_mutation_id),
    373         claim_contested: shared.contested_claim_ids().contains(&claim_mutation_id),
    374         claim_cancelled: shared.cancelled_claim_ids().contains(&claim_mutation_id),
    375         agreement_agreed: shared.agreement_state() == RadrootsTradeAgreementStateV1::Agreed,
    376     };
    377     let (outcome, reason) = classify_evaluation(facts);
    378     RhiReconciliationEvaluation {
    379         projection,
    380         claim_mutation_id,
    381         coverage: facts.coverage,
    382         outcome,
    383         reason_codes: [reason],
    384     }
    385 }
    386 
    387 #[derive(Clone, Copy)]
    388 struct EvaluationFacts {
    389     coverage: RhiReconciliationCoverage,
    390     shared_evidence: RadrootsTradeEvidenceStateV1,
    391     projection_digest_available: bool,
    392     reducer_issue_present: bool,
    393     claim_present: bool,
    394     claim_active: bool,
    395     claim_contested: bool,
    396     claim_cancelled: bool,
    397     agreement_agreed: bool,
    398 }
    399 
    400 fn classify_evaluation(
    401     facts: EvaluationFacts,
    402 ) -> (RhiReconciliationOutcome, RhiReconciliationReasonCode) {
    403     use RadrootsTradeEvidenceOutcomeV1::{Indeterminate, Invalid, Valid};
    404     use RhiReconciliationReasonCode::{
    405         AgreementClaimCancelled, AgreementClaimMissing, AgreementClaimUnresolved,
    406         GoverningSchemaUnsupported, ProjectionDigestUnavailable, ReducerIssueUnresolved,
    407         RequiredEvidenceMissing, RequiredSourceIncomplete, RequiredSourceUnsupported,
    408         ScopeSatisfied,
    409     };
    410 
    411     match facts.coverage {
    412         RhiReconciliationCoverage::Missing => return (Indeterminate, RequiredEvidenceMissing),
    413         RhiReconciliationCoverage::Partial => return (Indeterminate, RequiredSourceIncomplete),
    414         RhiReconciliationCoverage::Unsupported => {
    415             return (Indeterminate, RequiredSourceUnsupported);
    416         }
    417         RhiReconciliationCoverage::ScopeSatisfied => {}
    418     }
    419     if !facts.projection_digest_available {
    420         return (Indeterminate, ProjectionDigestUnavailable);
    421     }
    422     match facts.shared_evidence {
    423         RadrootsTradeEvidenceStateV1::Missing => {
    424             return (Indeterminate, RequiredEvidenceMissing);
    425         }
    426         RadrootsTradeEvidenceStateV1::QueryPartial => {
    427             return (Indeterminate, RequiredSourceIncomplete);
    428         }
    429         RadrootsTradeEvidenceStateV1::UnsupportedVersion => {
    430             return (Indeterminate, GoverningSchemaUnsupported);
    431         }
    432         RadrootsTradeEvidenceStateV1::Complete => {}
    433     }
    434     if facts.reducer_issue_present
    435         || facts.claim_contested
    436         || (facts.claim_active && facts.claim_cancelled)
    437     {
    438         return (Indeterminate, ReducerIssueUnresolved);
    439     }
    440     if !facts.claim_present {
    441         return (Indeterminate, AgreementClaimMissing);
    442     }
    443     if facts.claim_active && facts.agreement_agreed {
    444         return (Valid, ScopeSatisfied);
    445     }
    446     if facts.claim_cancelled && !facts.claim_active {
    447         return (Invalid, AgreementClaimCancelled);
    448     }
    449     (Indeterminate, AgreementClaimUnresolved)
    450 }
    451 
    452 fn mutation_material_within_bounds(materials: &[RhiReducerMutationMaterial]) -> bool {
    453     material_lengths_within_bounds(
    454         materials.len(),
    455         materials
    456             .iter()
    457             .map(|material| material.canonical_content.len()),
    458     )
    459 }
    460 
    461 fn material_lengths_within_bounds<I>(count: usize, lengths: I) -> bool
    462 where
    463     I: IntoIterator<Item = usize>,
    464 {
    465     count <= RHI_REDUCER_MAXIMUM_MUTATIONS
    466         && lengths
    467             .into_iter()
    468             .try_fold(0_usize, usize::checked_add)
    469             .is_some_and(|total| total <= RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES)
    470 }
    471 
    472 fn evidence_state(coverage: RadrootsTradeEvidenceCoverageV1) -> RadrootsTradeEvidenceStateV1 {
    473     match coverage {
    474         RadrootsTradeEvidenceCoverageV1::Missing => RadrootsTradeEvidenceStateV1::Missing,
    475         RadrootsTradeEvidenceCoverageV1::Partial => RadrootsTradeEvidenceStateV1::QueryPartial,
    476         RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => RadrootsTradeEvidenceStateV1::Complete,
    477         RadrootsTradeEvidenceCoverageV1::Unsupported => {
    478             RadrootsTradeEvidenceStateV1::UnsupportedVersion
    479         }
    480     }
    481 }
    482 
    483 fn projection_digest(manifest: &RhiReconciliationManifest, shared: [u8; 32]) -> Option<[u8; 32]> {
    484     let inner = manifest.inner();
    485     let mut digest = Sha256::new();
    486     digest.update(PROJECTION_DIGEST_DOMAIN);
    487     digest.update(RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION.to_be_bytes());
    488     digest.update(
    489         u64::try_from(RADROOTS_TRADE_REDUCER_CONTRACT_ID.len())
    490             .ok()?
    491             .to_be_bytes(),
    492     );
    493     digest.update(RADROOTS_TRADE_REDUCER_CONTRACT_ID.as_bytes());
    494     digest.update(RADROOTS_TRADE_REDUCER_VERSION.to_be_bytes());
    495     digest.update(manifest.digest());
    496     digest.update(inner.evidence_policy_digest().as_bytes());
    497     digest.update(shared);
    498     Some(digest.finalize().into())
    499 }
    500 
    501 fn decode_lower_hex_32(value: &str) -> Option<[u8; 32]> {
    502     if value.len() != 64 {
    503         return None;
    504     }
    505     let mut output = [0_u8; 32];
    506     for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
    507         output[index] = decode_lower_hex(pair[0])?
    508             .checked_mul(16)?
    509             .checked_add(decode_lower_hex(pair[1])?)?;
    510     }
    511     Some(output)
    512 }
    513 
    514 const fn decode_lower_hex(value: u8) -> Option<u8> {
    515     match value {
    516         b'0'..=b'9' => Some(value - b'0'),
    517         b'a'..=b'f' => Some(value - b'a' + 10),
    518         _ => None,
    519     }
    520 }
    521 
    522 const fn failure(kind: RhiReconciliationReducerErrorKind) -> RhiReconciliationReducerError {
    523     RhiReconciliationReducerError { kind }
    524 }
    525 
    526 #[cfg(test)]
    527 mod tests {
    528     use super::*;
    529 
    530     fn scope_satisfied_facts() -> EvaluationFacts {
    531         EvaluationFacts {
    532             coverage: RhiReconciliationCoverage::ScopeSatisfied,
    533             shared_evidence: RadrootsTradeEvidenceStateV1::Complete,
    534             projection_digest_available: true,
    535             reducer_issue_present: false,
    536             claim_present: true,
    537             claim_active: true,
    538             claim_contested: false,
    539             claim_cancelled: false,
    540             agreement_agreed: true,
    541         }
    542     }
    543 
    544     #[test]
    545     fn diagnostics_and_digest_decoder_are_closed() {
    546         for kind in [
    547             RhiReconciliationReducerErrorKind::InvalidManifest,
    548             RhiReconciliationReducerErrorKind::ProjectionUnavailable,
    549         ] {
    550             let error = failure(kind);
    551             assert_eq!(error.kind(), kind);
    552             assert!(Error::source(&error).is_none());
    553             assert!(!format!("{error} {error:?}").contains("trade-primary"));
    554         }
    555         assert_eq!(decode_lower_hex_32(&"ab".repeat(32)), Some([0xab; 32]));
    556         assert_eq!(decode_lower_hex_32(&"AB".repeat(32)), None);
    557         assert_eq!(decode_lower_hex_32("00"), None);
    558     }
    559 
    560     #[test]
    561     fn mutation_material_length_and_count_bounds_are_exact() {
    562         assert!(material_lengths_within_bounds(
    563             RHI_REDUCER_MAXIMUM_MUTATIONS,
    564             [RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES]
    565         ));
    566         assert!(!material_lengths_within_bounds(
    567             RHI_REDUCER_MAXIMUM_MUTATIONS,
    568             [RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES, 1]
    569         ));
    570         assert!(!material_lengths_within_bounds(
    571             RHI_REDUCER_MAXIMUM_MUTATIONS + 1,
    572             core::iter::empty()
    573         ));
    574         assert!(!material_lengths_within_bounds(1, [usize::MAX, 1]));
    575     }
    576 
    577     #[test]
    578     fn coverage_and_claim_state_matrix_is_total_and_fail_closed() {
    579         use RadrootsTradeEvidenceCoverageV1::{Missing, Partial, ScopeSatisfied, Unsupported};
    580         use RadrootsTradeEvidenceOutcomeV1::{Indeterminate, Invalid, Valid};
    581         use RhiReconciliationReasonCode::{
    582             AgreementClaimCancelled, AgreementClaimMissing, RequiredEvidenceMissing,
    583             RequiredSourceIncomplete, RequiredSourceUnsupported, ScopeSatisfied as ScopeReason,
    584         };
    585 
    586         for (coverage, incomplete_reason) in [
    587             (Missing, RequiredEvidenceMissing),
    588             (Partial, RequiredSourceIncomplete),
    589             (Unsupported, RequiredSourceUnsupported),
    590         ] {
    591             for (present, active, cancelled) in [
    592                 (false, false, false),
    593                 (true, true, false),
    594                 (true, false, true),
    595             ] {
    596                 let actual = classify_evaluation(EvaluationFacts {
    597                     coverage,
    598                     claim_present: present,
    599                     claim_active: active,
    600                     claim_cancelled: cancelled,
    601                     ..scope_satisfied_facts()
    602                 });
    603                 assert_eq!(actual, (Indeterminate, incomplete_reason));
    604                 assert!(coverage.permits(actual.0));
    605             }
    606         }
    607 
    608         for (present, active, cancelled, expected) in [
    609             (false, false, false, (Indeterminate, AgreementClaimMissing)),
    610             (true, true, false, (Valid, ScopeReason)),
    611             (true, false, true, (Invalid, AgreementClaimCancelled)),
    612         ] {
    613             let actual = classify_evaluation(EvaluationFacts {
    614                 coverage: ScopeSatisfied,
    615                 claim_present: present,
    616                 claim_active: active,
    617                 claim_cancelled: cancelled,
    618                 ..scope_satisfied_facts()
    619             });
    620             assert_eq!(actual, expected);
    621             assert!(ScopeSatisfied.permits(actual.0));
    622         }
    623     }
    624 
    625     #[test]
    626     fn fail_closed_precedence_covers_every_unavailable_or_ambiguous_fact() {
    627         use RadrootsTradeEvidenceOutcomeV1::Indeterminate;
    628         use RhiReconciliationReasonCode::{
    629             AgreementClaimUnresolved, GoverningSchemaUnsupported, ProjectionDigestUnavailable,
    630             ReducerIssueUnresolved, RequiredEvidenceMissing, RequiredSourceIncomplete,
    631         };
    632 
    633         let cases = [
    634             (
    635                 EvaluationFacts {
    636                     projection_digest_available: false,
    637                     ..scope_satisfied_facts()
    638                 },
    639                 ProjectionDigestUnavailable,
    640             ),
    641             (
    642                 EvaluationFacts {
    643                     shared_evidence: RadrootsTradeEvidenceStateV1::Missing,
    644                     ..scope_satisfied_facts()
    645                 },
    646                 RequiredEvidenceMissing,
    647             ),
    648             (
    649                 EvaluationFacts {
    650                     shared_evidence: RadrootsTradeEvidenceStateV1::QueryPartial,
    651                     ..scope_satisfied_facts()
    652                 },
    653                 RequiredSourceIncomplete,
    654             ),
    655             (
    656                 EvaluationFacts {
    657                     shared_evidence: RadrootsTradeEvidenceStateV1::UnsupportedVersion,
    658                     ..scope_satisfied_facts()
    659                 },
    660                 GoverningSchemaUnsupported,
    661             ),
    662             (
    663                 EvaluationFacts {
    664                     reducer_issue_present: true,
    665                     ..scope_satisfied_facts()
    666                 },
    667                 ReducerIssueUnresolved,
    668             ),
    669             (
    670                 EvaluationFacts {
    671                     claim_contested: true,
    672                     ..scope_satisfied_facts()
    673                 },
    674                 ReducerIssueUnresolved,
    675             ),
    676             (
    677                 EvaluationFacts {
    678                     claim_cancelled: true,
    679                     ..scope_satisfied_facts()
    680                 },
    681                 ReducerIssueUnresolved,
    682             ),
    683             (
    684                 EvaluationFacts {
    685                     agreement_agreed: false,
    686                     ..scope_satisfied_facts()
    687                 },
    688                 AgreementClaimUnresolved,
    689             ),
    690             (
    691                 EvaluationFacts {
    692                     claim_active: false,
    693                     claim_cancelled: false,
    694                     ..scope_satisfied_facts()
    695                 },
    696                 AgreementClaimUnresolved,
    697             ),
    698         ];
    699         for (facts, reason) in cases {
    700             assert_eq!(classify_evaluation(facts), (Indeterminate, reason));
    701         }
    702 
    703         let precedence = classify_evaluation(EvaluationFacts {
    704             coverage: RhiReconciliationCoverage::Missing,
    705             projection_digest_available: false,
    706             shared_evidence: RadrootsTradeEvidenceStateV1::UnsupportedVersion,
    707             reducer_issue_present: true,
    708             claim_present: false,
    709             ..scope_satisfied_facts()
    710         });
    711         assert_eq!(precedence, (Indeterminate, RequiredEvidenceMissing));
    712     }
    713 
    714     #[test]
    715     fn reason_codes_are_closed_stable_and_source_free() {
    716         let inventory = [
    717             (
    718                 RhiReconciliationReasonCode::RequiredEvidenceMissing,
    719                 "required_evidence_missing",
    720             ),
    721             (
    722                 RhiReconciliationReasonCode::RequiredSourceIncomplete,
    723                 "required_source_incomplete",
    724             ),
    725             (
    726                 RhiReconciliationReasonCode::RequiredSourceUnsupported,
    727                 "required_source_unsupported",
    728             ),
    729             (
    730                 RhiReconciliationReasonCode::ProjectionDigestUnavailable,
    731                 "projection_digest_unavailable",
    732             ),
    733             (
    734                 RhiReconciliationReasonCode::GoverningSchemaUnsupported,
    735                 "governing_schema_unsupported",
    736             ),
    737             (
    738                 RhiReconciliationReasonCode::ReducerIssueUnresolved,
    739                 "reducer_issue_unresolved",
    740             ),
    741             (
    742                 RhiReconciliationReasonCode::AgreementClaimMissing,
    743                 "agreement_claim_missing",
    744             ),
    745             (
    746                 RhiReconciliationReasonCode::AgreementClaimUnresolved,
    747                 "agreement_claim_unresolved",
    748             ),
    749             (
    750                 RhiReconciliationReasonCode::ScopeSatisfied,
    751                 "scope_satisfied",
    752             ),
    753             (
    754                 RhiReconciliationReasonCode::AgreementClaimCancelled,
    755                 "agreement_claim_cancelled",
    756             ),
    757         ];
    758         assert_eq!(inventory.len(), 10);
    759         for (reason, code) in inventory {
    760             assert_eq!(reason.code(), code);
    761             assert!(!code.contains("source://"));
    762         }
    763     }
    764 }