diff --git a/crates/origin-chain-fees/src/fee_schedule.rs b/crates/origin-chain-fees/src/fee_schedule.rs index 5ba861f..043c947 100644 --- a/crates/origin-chain-fees/src/fee_schedule.rs +++ b/crates/origin-chain-fees/src/fee_schedule.rs @@ -87,6 +87,11 @@ pub const fn subsidy_basis_points(hw: HardwareTier, action: ChainAction) -> u32 (EmbeddedArm, Write) => 9000, (EmbeddedArm, Compute) => 10000, // educational compute free (EmbeddedArm, Transfer) => 9000, + // EmbeddedAI (Jetson Orin Nano): MISMO piso EmbeddedArm — Inmutable #16, + // el estudiante en Jetson no paga más que en Pi 5. Ergon bbac1b2. + (EmbeddedAI, Write) => 9000, + (EmbeddedAI, Compute) => 10000, // educational compute free + (EmbeddedAI, Transfer) => 9000, // Portable: subsidy media (Portable, Write) => 7500, (Portable, Compute) => 5000, @@ -108,6 +113,10 @@ pub const fn subsidy_basis_points(hw: HardwareTier, action: ChainAction) -> u32 (EmbeddedArm, DeployContract) => 9000, // Pi 5 dev contracts canonical (EmbeddedArm, CallContract) => 10000, // educational call free (EmbeddedArm, UploadContractCode) => 9000, // Pi 5 storage subsidy + // EmbeddedAI (Jetson): mismo piso EmbeddedArm en contract actions (#16). + (EmbeddedAI, DeployContract) => 9000, // Jetson dev contracts = piso Pi 5 + (EmbeddedAI, CallContract) => 10000, // educational call free + (EmbeddedAI, UploadContractCode) => 9000, // Jetson storage subsidy = piso (Portable, DeployContract) => 7500, (Portable, CallContract) => 5000, (Portable, UploadContractCode) => 7500, @@ -247,11 +256,12 @@ impl OitFeeSchedule for OitFeeScheduleMock { } fn subsidy_eligible(&self, _user_id: &str, action: ChainAction) -> bool { - // OCAFB-3 override: educational_world → 100% subsidy EmbeddedArm + Portable + // OCAFB-3 override: educational_world → 100% subsidy en los tiers floor + // EmbeddedArm + EmbeddedAI + Portable (Jetson = device de estudiante, #16). if self.educational_world && matches!( self.default_hardware, - HardwareTier::EmbeddedArm | HardwareTier::Portable + HardwareTier::EmbeddedArm | HardwareTier::EmbeddedAI | HardwareTier::Portable ) { return true; @@ -260,11 +270,11 @@ impl OitFeeSchedule for OitFeeScheduleMock { } fn tier_multiplier(&self, _user_id: &str, action: ChainAction) -> u64 { - // Educational override → multiplier 0 (free) + // Educational override → multiplier 0 (free) en los tiers floor. if self.educational_world && matches!( self.default_hardware, - HardwareTier::EmbeddedArm | HardwareTier::Portable + HardwareTier::EmbeddedArm | HardwareTier::EmbeddedAI | HardwareTier::Portable ) { return 0; @@ -295,6 +305,7 @@ mod tests { // Inmutable #37 baseline: Read NEVER charged per HardwareTier for hw in [ HardwareTier::EmbeddedArm, + HardwareTier::EmbeddedAI, HardwareTier::Portable, HardwareTier::Desktop, HardwareTier::Ultra, @@ -303,6 +314,27 @@ mod tests { } } + #[test] + fn embedded_ai_subsidy_mirrors_embedded_arm_floor_inmutable_16() { + // El estudiante en Jetson (EmbeddedAI) no paga MÁS que en Pi 5 + // (EmbeddedArm): piso idéntico en TODA acción. Ergon bbac1b2, #16. + for action in [ + ChainAction::Read, + ChainAction::Write, + ChainAction::Compute, + ChainAction::Transfer, + ChainAction::DeployContract, + ChainAction::CallContract, + ChainAction::UploadContractCode, + ] { + assert_eq!( + subsidy_basis_points(HardwareTier::EmbeddedAI, action), + subsidy_basis_points(HardwareTier::EmbeddedArm, action), + "EmbeddedAI debe espejar el piso EmbeddedArm en {action:?}" + ); + } + } + #[test] fn ocafb_3_ultra_pays_full_fee_canonical() { // Ultra: no subsidy except Read diff --git a/crates/origin-chain-fees/src/unified.rs b/crates/origin-chain-fees/src/unified.rs index 166f088..2979919 100644 --- a/crates/origin-chain-fees/src/unified.rs +++ b/crates/origin-chain-fees/src/unified.rs @@ -396,8 +396,9 @@ mod tests { use super::*; use crate::fee_schedule::compute_fee as vendor_compute_fee; - const ALL_HW: [HardwareTier; 4] = [ + const ALL_HW: [HardwareTier; 5] = [ HardwareTier::EmbeddedArm, + HardwareTier::EmbeddedAI, HardwareTier::Portable, HardwareTier::Desktop, HardwareTier::Ultra, diff --git a/crates/origin-chain-types/src/fees.rs b/crates/origin-chain-types/src/fees.rs index 28a226e..2008f04 100644 --- a/crates/origin-chain-types/src/fees.rs +++ b/crates/origin-chain-types/src/fees.rs @@ -105,6 +105,24 @@ mod tests { assert_eq!(fee.as_base_units(), Ogc::from_whole(1).as_base_units()); } + #[test] + fn embedded_ai_pays_same_floor_as_embedded_arm_inmutable_16() { + // El estudiante en Jetson (EmbeddedAI) paga el MISMO piso 0.1× que en + // Pi 5 (EmbeddedArm): mismo base_multiplier (10) + Hero gated (0). #16. + let ai = compute_fee( + Ogc::from_whole(10), + HardwareTier::EmbeddedAI, + AssetQualityTier::Standard, + ); + let arm = compute_fee( + Ogc::from_whole(10), + HardwareTier::EmbeddedArm, + AssetQualityTier::Standard, + ); + assert_eq!(ai.as_base_units(), arm.as_base_units()); + assert_eq!(ai.as_base_units(), Ogc::from_whole(1).as_base_units()); + } + #[test] fn portable_pays_three_tenths() { let fee = compute_fee( diff --git a/crates/origin-chain-types/src/tiers.rs b/crates/origin-chain-types/src/tiers.rs index 351fc9b..265eb72 100644 --- a/crates/origin-chain-types/src/tiers.rs +++ b/crates/origin-chain-types/src/tiers.rs @@ -14,9 +14,14 @@ /// Per Inmutable #16, fees scale inversely with tier so global access is preserved: /// a Pi 5 user in Sub-Saharan Africa pays 0.1x what a Vision Pro user pays for the /// same operation. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize, -)] +/// +/// **`PartialOrd`/`Ord` se implementan MANUALMENTE** (vía [`HardwareTier::rank`]), +/// NO derivados — espejo byte-fiel de `arkhe-core::HardwareTier`. Con el APPEND de +/// `EmbeddedAI` (discriminante 4), un `Ord` derivado ordenaría por posición de +/// declaración y pondría `EmbeddedAI > Ultra` — semánticamente FALSO (es un tier +/// floor con-IA). El orden de capacidad vive en `rank()`; el discriminante de wire +/// vive en `ordinal()`. Confundirlos = perder el tier-floor (#16). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[derive(parity_scale_codec::Encode, parity_scale_codec::Decode)] #[derive(parity_scale_codec::DecodeWithMemTracking)] #[derive(parity_scale_codec::MaxEncodedLen, scale_info::TypeInfo)] @@ -29,23 +34,106 @@ pub enum HardwareTier { Desktop, /// Vision Pro, high-end workstations — multiplier 0.7× (discount). Hero +0.3×. Ultra, + /// Jetson Orin Nano class — embedded ARM **con aceleración IA** (Ampere + + /// Tensor Cores) y memoria UNIFICADA. Floor con-IA que complementa + /// `EmbeddedArm` (Pi 5): mismo piso de fee (0.1×, Inmutable #16 — el + /// estudiante en Jetson no paga más que en Pi 5). Hero gated out. + /// + /// **APPEND ordinal 4** (serde por-nombre + SCALE wire-safe: ningún ordinal + /// existente cambia, attestations `runtime_tier: u8` viejas válidas). Su + /// **capacidad NO es la del discriminante**: ver [`HardwareTier::rank`] (=1, + /// entre `EmbeddedArm`=0 y `Portable`=2). ADR-0072 / DC `2ba235e1`. Espejo de + /// `arkhe-core` commit `bbac1b2` (Ergon Fase 1 EmbeddedAI). + EmbeddedAI, +} + +impl PartialOrd for HardwareTier { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for HardwareTier { + /// Orden por **capacidad** ([`HardwareTier::rank`]), no por discriminante de + /// declaración. Espejo de `arkhe-core::HardwareTier`. + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.rank().cmp(&other.rank()) + } } impl HardwareTier { + /// Rango de **capacidad** (desacoplado del discriminante de declaración). + /// `EmbeddedAI` (Jetson) se inserta entre `EmbeddedArm` y `Portable`: tiene IA + /// pero su techo de memoria unificada lo limita. Úsese para TODO gating de + /// capacidad (Hero, presupuestos); NUNCA `ordinal()`. Espejo de `arkhe-core`. + #[inline] + pub const fn rank(self) -> u8 { + match self { + HardwareTier::EmbeddedArm => 0, + HardwareTier::EmbeddedAI => 1, + HardwareTier::Portable => 2, + HardwareTier::Desktop => 3, + HardwareTier::Ultra => 4, + } + } + + /// Discriminante de declaración (serde por-nombre + SCALE wire-format). + /// **NO es capacidad** — para gating usar [`HardwareTier::rank`]. `EmbeddedAI` + /// = 4 por APPEND (wire-safe: ordinales existentes intactos). Coincide con el + /// índice SCALE del enum (declaración) por construcción. Espejo de `arkhe-core`. + #[inline] + pub const fn ordinal(self) -> u8 { + match self { + HardwareTier::EmbeddedArm => 0, + HardwareTier::Portable => 1, + HardwareTier::Desktop => 2, + HardwareTier::Ultra => 3, + HardwareTier::EmbeddedAI => 4, + } + } + + /// Inverso de [`HardwareTier::ordinal`] — la **fuente única** de + /// `u8 ordinal → HardwareTier`. `None` para un ordinal desconocido + /// (forward-compat con futuros APPEND). Todo consumidor que interprete un + /// tier `u8` (p.ej. `runtime_tier` de una attestation) DEBE delegar aquí en + /// vez de duplicar la tabla: una tabla reverse duplicada fue cómo un variant + /// nuevo (`EmbeddedAI`=4) se mapeaba a `None` y perdía el tier-floor al + /// deserializar (Inmutable #16). Sellado por `ordinal_round_trips_all_variants`. + #[inline] + pub const fn from_ordinal(ordinal: u8) -> Option { + match ordinal { + 0 => Some(HardwareTier::EmbeddedArm), + 1 => Some(HardwareTier::Portable), + 2 => Some(HardwareTier::Desktop), + 3 => Some(HardwareTier::Ultra), + 4 => Some(HardwareTier::EmbeddedAI), + _ => None, + } + } + /// Base fee multiplier scaled by 100 (avoids float). /// `EmbeddedArm` = 10 means 0.10× the base fee, `Desktop` = 100 means 1.00×. #[inline] pub const fn base_multiplier_x100(self) -> u32 { match self { HardwareTier::EmbeddedArm => 10, + // EmbeddedAI (Jetson): piso EmbeddedArm (0.10×) — Inmutable #16: el + // estudiante en Jetson no paga más que en Pi 5. Ergon `bbac1b2`. + HardwareTier::EmbeddedAI => 10, HardwareTier::Portable => 30, HardwareTier::Desktop => 100, HardwareTier::Ultra => 70, } } - /// True if this tier can transact `AssetQualityTier::Hero` assets. EmbeddedArm - /// and Portable are gated out (Hero requires more VRAM than these tiers ship). + /// True if this tier can transact `AssetQualityTier::Hero` assets. EmbeddedArm, + /// EmbeddedAI and Portable are gated out (Hero requires more VRAM/unified + /// memory than these tiers ship). El gating es por **capacidad** ([`rank`]): + /// `EmbeddedAI` (rank 1) NO pasa Hero aunque su discriminante sea 4 — espejo + /// de `arkhe-core::HardwareTier::asset_quality_tier` (Hero gated por memoria + /// unificada, no por GPU). + /// + /// [`rank`]: HardwareTier::rank #[inline] pub const fn supports_hero(self) -> bool { matches!(self, HardwareTier::Desktop | HardwareTier::Ultra) @@ -75,9 +163,11 @@ impl AssetQualityTier { (AssetQualityTier::Standard, _) => 0, (AssetQualityTier::Hero, HardwareTier::Desktop) => 50, (AssetQualityTier::Hero, HardwareTier::Ultra) => 30, - // Hero gated out for EmbeddedArm + Portable — surcharge 0 because the - // operation should be rejected upstream by the runtime, not charged. + // Hero gated out for EmbeddedArm + EmbeddedAI + Portable (rank < Desktop) + // — surcharge 0 because the operation should be rejected upstream by the + // runtime, not charged. (AssetQualityTier::Hero, HardwareTier::EmbeddedArm) => 0, + (AssetQualityTier::Hero, HardwareTier::EmbeddedAI) => 0, (AssetQualityTier::Hero, HardwareTier::Portable) => 0, } } @@ -130,6 +220,8 @@ mod tests { #[test] fn multipliers_match_chn_8_canonical() { assert_eq!(HardwareTier::EmbeddedArm.base_multiplier_x100(), 10); + // EmbeddedAI = piso EmbeddedArm (0.10×, Inmutable #16). Ergon bbac1b2. + assert_eq!(HardwareTier::EmbeddedAI.base_multiplier_x100(), 10); assert_eq!(HardwareTier::Portable.base_multiplier_x100(), 30); assert_eq!(HardwareTier::Desktop.base_multiplier_x100(), 100); assert_eq!(HardwareTier::Ultra.base_multiplier_x100(), 70); @@ -138,11 +230,85 @@ mod tests { #[test] fn hero_gating_canonical() { assert!(!HardwareTier::EmbeddedArm.supports_hero()); + // EmbeddedAI (rank 1 < Desktop 3) NO pasa Hero, pese a discriminante 4. + assert!(!HardwareTier::EmbeddedAI.supports_hero()); assert!(!HardwareTier::Portable.supports_hero()); assert!(HardwareTier::Desktop.supports_hero()); assert!(HardwareTier::Ultra.supports_hero()); } + #[test] + fn tier_ordering_by_capability_rank() { + // Ord MANUAL vía rank() — EmbeddedAI se inserta entre EmbeddedArm y + // Portable, NUNCA por encima de Ultra (lo que haría el derive(Ord) roto). + assert!(HardwareTier::EmbeddedArm < HardwareTier::EmbeddedAI); + assert!(HardwareTier::EmbeddedAI < HardwareTier::Portable); + assert!(HardwareTier::Portable < HardwareTier::Desktop); + assert!(HardwareTier::Desktop < HardwareTier::Ultra); + // El anti-test de la trampa: EmbeddedAI (discriminante 4) NO es > Ultra. + assert!(HardwareTier::EmbeddedAI < HardwareTier::Ultra); + // rank explícito. + assert_eq!(HardwareTier::EmbeddedArm.rank(), 0); + assert_eq!(HardwareTier::EmbeddedAI.rank(), 1); + assert_eq!(HardwareTier::Portable.rank(), 2); + assert_eq!(HardwareTier::Desktop.rank(), 3); + assert_eq!(HardwareTier::Ultra.rank(), 4); + } + + #[test] + fn ordinal_round_trips_all_variants() { + // SSOT del mapeo u8→tier: from_ordinal(ordinal()) == Some(self) para TODOS + // los variants. Caza el bug #16: un APPEND que olvide from_ordinal mapearía + // el tier nuevo a None y perdería el floor al deserializar. + for t in [ + HardwareTier::EmbeddedArm, + HardwareTier::Portable, + HardwareTier::Desktop, + HardwareTier::Ultra, + HardwareTier::EmbeddedAI, + ] { + assert_eq!(HardwareTier::from_ordinal(t.ordinal()), Some(t)); + } + // Ordinales existentes intactos (attestations viejas runtime_tier: u8). + assert_eq!(HardwareTier::EmbeddedArm.ordinal(), 0); + assert_eq!(HardwareTier::Portable.ordinal(), 1); + assert_eq!(HardwareTier::Desktop.ordinal(), 2); + assert_eq!(HardwareTier::Ultra.ordinal(), 3); + assert_eq!(HardwareTier::EmbeddedAI.ordinal(), 4, "APPEND wire-safe"); + // Forward-compat: ordinal desconocido → None. + assert_eq!(HardwareTier::from_ordinal(5), None); + assert_eq!(HardwareTier::from_ordinal(255), None); + } + + #[test] + fn embedded_ai_scale_index_matches_ordinal() { + use parity_scale_codec::{Decode, Encode}; + // SCALE codifica por índice de declaración: EmbeddedAI declarado último = + // índice 4 = ordinal(). Garantiza que el wire on-chain coincide con el u8. + for t in [ + HardwareTier::EmbeddedArm, + HardwareTier::Portable, + HardwareTier::Desktop, + HardwareTier::Ultra, + HardwareTier::EmbeddedAI, + ] { + let enc = t.encode(); + assert_eq!(enc.len(), 1, "fieldless enum = 1 byte {t:?}"); + assert_eq!(enc[0], t.ordinal(), "SCALE index debe = ordinal {t:?}"); + let back = HardwareTier::decode(&mut &enc[..]).unwrap(); + assert_eq!(back, t); + } + } + + #[test] + fn embedded_ai_serde_name_stable() { + // serde por-nombre — wire de payloads guardados estable cross-version. + let s = serde_json::to_string(&HardwareTier::EmbeddedAI).unwrap(); + assert_eq!(s, "\"EmbeddedAI\""); + let back: HardwareTier = serde_json::from_str(&s).unwrap(); + assert_eq!(back, HardwareTier::EmbeddedAI); + } + #[test] fn hero_surcharge_canonical_chn_8() { assert_eq!( @@ -159,6 +325,7 @@ mod tests { fn standard_surcharge_always_zero() { for hw in [ HardwareTier::EmbeddedArm, + HardwareTier::EmbeddedAI, HardwareTier::Portable, HardwareTier::Desktop, HardwareTier::Ultra, @@ -169,11 +336,16 @@ mod tests { #[test] fn hero_surcharge_zero_for_gated_tiers() { - // Hero on EmbeddedArm/Portable returns 0 because rejection is upstream. + // Hero on EmbeddedArm/EmbeddedAI/Portable returns 0 because rejection is + // upstream (rank < Desktop). assert_eq!( AssetQualityTier::Hero.surcharge_x100(HardwareTier::EmbeddedArm), 0 ); + assert_eq!( + AssetQualityTier::Hero.surcharge_x100(HardwareTier::EmbeddedAI), + 0 + ); assert_eq!( AssetQualityTier::Hero.surcharge_x100(HardwareTier::Portable), 0