lodestone-types 0.0.16

Shared semantic types for Lodestone components and public Rust APIs
Documentation
use std::error::Error;
use std::fmt::{self, Display, Formatter};

use serde::{Deserialize, Serialize};

use crate::{
    DurabilityClass, ExternalKeyResolverCapabilityId, IndexMode, LifecyclePolicyId, ResourceRevision, StorageClass,
};

pub const INDEX_RESOURCE_SPEC_FORMAT_VERSION: u16 = 3;
pub const MAX_RESOURCE_INITIAL_SHARDS: u16 = 4_096;
pub const MAX_RESOURCE_BYTES: u64 = 1 << 50;
pub const MAX_RESOURCE_ADMISSION_WEIGHT: u32 = 1_000_000;
pub const MAX_RESOURCE_REFRESH_MILLIS: u32 = 7 * 24 * 60 * 60 * 1_000;
pub const MAX_RESOURCE_PARALLELISM: u16 = 4_096;
pub const MIN_RESOURCE_MEMTABLE_BYTES: u64 = 15_000_000;

/// Public vector quality intent. Physical partition, graph, and quantizer
/// parameters remain server-owned implementation details.
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum VectorQualityObjectiveV1 {
    #[default]
    Balanced,
    HighRecall,
    MemoryEfficient,
}

/// Public indexing trade-off for reconstructible vector acceleration.
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum VectorIndexingObjectiveV1 {
    FastBuild,
    #[default]
    Balanced,
    Compact,
}

/// Optional reconstructible residency intent. This is performance policy,
/// never evidence that bytes are currently resident or query authority.
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum VectorResidencyIntentV1 {
    Automatic,
    PreferResident,
    RequireResident,
}

/// Immutable, versioned server-owned calibration profile selected for a
/// reconstructible vector rebuild.
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum VectorStrategyProfileV1 {
    BalancedV1,
    HighRecallV1,
    MemoryEfficientV1,
}

#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct VectorCalibrationBindingV1 {
    pub profile: VectorStrategyProfileV1,
    pub evidence_digest: [u8; 32],
}

#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct VectorResourcePolicyV1 {
    pub quality: VectorQualityObjectiveV1,
    pub indexing: VectorIndexingObjectiveV1,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub residency: Option<VectorResidencyIntentV1>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub calibration: Option<VectorCalibrationBindingV1>,
}

/// Complete provider-neutral operational input stored for one index.
///
/// This type is a public semantic projection, not a second catalog authority.
/// The paged ResourceSpec record stores its facts in the exact route, policy,
/// maintenance and operational fields consumed by the runtime.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct IndexResourceSpecV1 {
    pub format_version: u16,
    pub revision: ResourceRevision,
    pub initial_shards: u16,
    pub target_shard_bytes: u64,
    pub durability: DurabilityClass,
    pub storage_class: StorageClass,
    pub ram_admission_weight: u32,
    pub ram_hard_max_bytes: u64,
    pub nvme_admission_weight: u32,
    pub nvme_hard_max_bytes: u64,
    pub refresh_objective_millis: u32,
    pub memtable_hard_max_bytes: u64,
    pub seal_parallelism: u16,
    pub merge_parallelism: u16,
    pub vector: VectorResourcePolicyV1,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lifecycle_binding: Option<LifecyclePolicyId>,
    pub encryption_required: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resolver_capability: Option<ExternalKeyResolverCapabilityId>,
}

impl IndexResourceSpecV1 {
    pub fn validate(self) -> Result<(), IndexResourceSpecError> {
        if self.format_version != INDEX_RESOURCE_SPEC_FORMAT_VERSION {
            return Err(IndexResourceSpecError::UnsupportedVersion {
                observed: self.format_version,
                supported: INDEX_RESOURCE_SPEC_FORMAT_VERSION,
            });
        }
        if self.revision.get() == 0 {
            return Err(IndexResourceSpecError::Invalid("revision must be nonzero"));
        }
        if self.initial_shards == 0 || self.initial_shards > MAX_RESOURCE_INITIAL_SHARDS {
            return Err(IndexResourceSpecError::Invalid(
                "initial_shards is outside the supported bound",
            ));
        }
        if self.target_shard_bytes == 0 || self.target_shard_bytes > MAX_RESOURCE_BYTES {
            return Err(IndexResourceSpecError::Invalid(
                "target_shard_bytes is outside the supported bound",
            ));
        }
        if self.ram_admission_weight == 0
            || self.ram_admission_weight > MAX_RESOURCE_ADMISSION_WEIGHT
            || self.nvme_admission_weight == 0
            || self.nvme_admission_weight > MAX_RESOURCE_ADMISSION_WEIGHT
        {
            return Err(IndexResourceSpecError::Invalid(
                "RAM and NVMe admission weights are outside the supported bound",
            ));
        }
        if self.ram_hard_max_bytes == 0
            || self.ram_hard_max_bytes > MAX_RESOURCE_BYTES
            || self.nvme_hard_max_bytes == 0
            || self.nvme_hard_max_bytes > MAX_RESOURCE_BYTES
        {
            return Err(IndexResourceSpecError::Invalid(
                "RAM and NVMe hard maxima are outside the supported bound",
            ));
        }
        if self.refresh_objective_millis == 0 || self.refresh_objective_millis > MAX_RESOURCE_REFRESH_MILLIS {
            return Err(IndexResourceSpecError::Invalid(
                "refresh_objective_millis is outside the supported bound",
            ));
        }
        if self.memtable_hard_max_bytes < MIN_RESOURCE_MEMTABLE_BYTES
            || self.memtable_hard_max_bytes > MAX_RESOURCE_BYTES
        {
            return Err(IndexResourceSpecError::Invalid(
                "memtable_hard_max_bytes is outside the supported bound",
            ));
        }
        if self.seal_parallelism == 0
            || self.seal_parallelism > MAX_RESOURCE_PARALLELISM
            || self.merge_parallelism == 0
            || self.merge_parallelism > MAX_RESOURCE_PARALLELISM
        {
            return Err(IndexResourceSpecError::Invalid(
                "seal and merge parallelism are outside the supported bound",
            ));
        }
        if self
            .vector
            .calibration
            .is_some_and(|binding| binding.evidence_digest == [0; 32])
        {
            return Err(IndexResourceSpecError::Invalid(
                "vector calibration evidence digest cannot be zero",
            ));
        }
        if self.lifecycle_binding.is_some_and(|binding| binding.get() == 0) {
            return Err(IndexResourceSpecError::Invalid("lifecycle binding must be nonzero"));
        }
        if self.encryption_required != self.resolver_capability.is_some()
            || self.resolver_capability.is_some_and(|capability| capability.get() == 0)
        {
            return Err(IndexResourceSpecError::Invalid(
                "encryption requires one nonzero resolver capability and an unencrypted index carries none",
            ));
        }
        Ok(())
    }

    pub fn validate_current_execution(self, mode: IndexMode) -> Result<(), IndexResourceSpecError> {
        self.validate()?;
        let durability = if mode == IndexMode::State {
            self.durability.require_state_available()
        } else {
            self.durability.require_available()
        };
        durability.map_err(|_| IndexResourceSpecError::Unavailable("durability class"))?;
        if self.storage_class != StorageClass::Standard {
            return Err(IndexResourceSpecError::Unavailable("storage class"));
        }
        Ok(())
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum IndexResourceSpecError {
    UnsupportedVersion { observed: u16, supported: u16 },
    Invalid(&'static str),
    Unavailable(&'static str),
}

impl Display for IndexResourceSpecError {
    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
        match self {
            Self::UnsupportedVersion { observed, supported } => {
                write!(
                    formatter,
                    "unsupported resource-spec version {observed}; supported version is {supported}"
                )
            }
            Self::Invalid(reason) => write!(formatter, "invalid index resource specification: {reason}"),
            Self::Unavailable(axis) => write!(formatter, "index resource specification requests an unavailable {axis}"),
        }
    }
}

impl Error for IndexResourceSpecError {}

#[cfg(test)]
mod tests {
    use super::*;

    fn valid() -> IndexResourceSpecV1 {
        IndexResourceSpecV1 {
            format_version: INDEX_RESOURCE_SPEC_FORMAT_VERSION,
            revision: ResourceRevision::new(1),
            initial_shards: 2,
            target_shard_bytes: 64 << 20,
            durability: DurabilityClass::RegionalObject,
            storage_class: StorageClass::Standard,
            ram_admission_weight: 3,
            ram_hard_max_bytes: 128 << 20,
            nvme_admission_weight: 2,
            nvme_hard_max_bytes: 512 << 20,
            refresh_objective_millis: 1_000,
            memtable_hard_max_bytes: 32 << 20,
            seal_parallelism: 2,
            merge_parallelism: 1,
            vector: VectorResourcePolicyV1::default(),
            lifecycle_binding: None,
            encryption_required: false,
            resolver_capability: None,
        }
    }

    #[test]
    fn validates_current_operational_values_without_product_tiers() {
        assert_eq!(valid().validate_current_execution(IndexMode::Mutable), Ok(()));
        let encoded = serde_json::to_string(&valid());
        assert!(encoded.is_ok());
        let encoded = encoded.unwrap_or_default();
        for forbidden in ["free", "growth", "enterprise", "price", "tenant"] {
            assert!(!encoded.contains(forbidden));
        }
    }

    #[test]
    fn vector_residency_is_an_optional_performance_intent() {
        let mut spec = valid();
        spec.vector.residency = Some(VectorResidencyIntentV1::RequireResident);
        assert_eq!(spec.validate_current_execution(IndexMode::Mutable), Ok(()));
        let encoded = serde_json::to_string(&spec).unwrap_or_default();
        assert!(encoded.contains("\"residency\":\"require_resident\""));
        assert!(!encoded.contains("warm"));
    }

    #[test]
    fn refuses_newer_versions_and_unbound_encryption() {
        let mut newer = valid();
        newer.format_version = INDEX_RESOURCE_SPEC_FORMAT_VERSION + 1;
        assert!(matches!(
            newer.validate(),
            Err(IndexResourceSpecError::UnsupportedVersion { .. })
        ));
        let mut unbound = valid();
        unbound.encryption_required = true;
        assert!(matches!(unbound.validate(), Err(IndexResourceSpecError::Invalid(_))));

        let mut zero_evidence = valid();
        zero_evidence.vector.calibration = Some(VectorCalibrationBindingV1 {
            profile: VectorStrategyProfileV1::BalancedV1,
            evidence_digest: [0; 32],
        });
        assert!(matches!(
            zero_evidence.validate(),
            Err(IndexResourceSpecError::Invalid(_))
        ));
    }
}