Skip to main content

lodestone_types/
resource_spec.rs

1use std::error::Error;
2use std::fmt::{self, Display, Formatter};
3
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    DurabilityClass, ExternalKeyResolverCapabilityId, IndexMode, LifecyclePolicyId, ResourceRevision, StorageClass,
8};
9
10pub const INDEX_RESOURCE_SPEC_FORMAT_VERSION: u16 = 3;
11pub const MAX_RESOURCE_INITIAL_SHARDS: u16 = 4_096;
12pub const MAX_RESOURCE_BYTES: u64 = 1 << 50;
13pub const MAX_RESOURCE_ADMISSION_WEIGHT: u32 = 1_000_000;
14pub const MAX_RESOURCE_REFRESH_MILLIS: u32 = 7 * 24 * 60 * 60 * 1_000;
15pub const MAX_RESOURCE_PARALLELISM: u16 = 4_096;
16pub const MIN_RESOURCE_MEMTABLE_BYTES: u64 = 15_000_000;
17
18/// Public vector quality intent. Physical partition, graph, and quantizer
19/// parameters remain server-owned implementation details.
20#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum VectorQualityObjectiveV1 {
23    #[default]
24    Balanced,
25    HighRecall,
26    MemoryEfficient,
27}
28
29/// Public indexing trade-off for reconstructible vector acceleration.
30#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum VectorIndexingObjectiveV1 {
33    FastBuild,
34    #[default]
35    Balanced,
36    Compact,
37}
38
39/// Optional reconstructible residency intent. This is performance policy,
40/// never evidence that bytes are currently resident or query authority.
41#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
42#[serde(rename_all = "snake_case")]
43pub enum VectorResidencyIntentV1 {
44    Automatic,
45    PreferResident,
46    RequireResident,
47}
48
49/// Immutable, versioned server-owned calibration profile selected for a
50/// reconstructible vector rebuild.
51#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
52#[serde(rename_all = "snake_case")]
53pub enum VectorStrategyProfileV1 {
54    BalancedV1,
55    HighRecallV1,
56    MemoryEfficientV1,
57}
58
59#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
60#[serde(deny_unknown_fields)]
61pub struct VectorCalibrationBindingV1 {
62    pub profile: VectorStrategyProfileV1,
63    pub evidence_digest: [u8; 32],
64}
65
66#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
67#[serde(deny_unknown_fields)]
68pub struct VectorResourcePolicyV1 {
69    pub quality: VectorQualityObjectiveV1,
70    pub indexing: VectorIndexingObjectiveV1,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub residency: Option<VectorResidencyIntentV1>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub calibration: Option<VectorCalibrationBindingV1>,
75}
76
77/// Complete provider-neutral operational input stored for one index.
78///
79/// This type is a public semantic projection, not a second catalog authority.
80/// The paged ResourceSpec record stores its facts in the exact route, policy,
81/// maintenance and operational fields consumed by the runtime.
82#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
83#[serde(deny_unknown_fields)]
84pub struct IndexResourceSpecV1 {
85    pub format_version: u16,
86    pub revision: ResourceRevision,
87    pub initial_shards: u16,
88    pub target_shard_bytes: u64,
89    pub durability: DurabilityClass,
90    pub storage_class: StorageClass,
91    pub ram_admission_weight: u32,
92    pub ram_hard_max_bytes: u64,
93    pub nvme_admission_weight: u32,
94    pub nvme_hard_max_bytes: u64,
95    pub refresh_objective_millis: u32,
96    pub memtable_hard_max_bytes: u64,
97    pub seal_parallelism: u16,
98    pub merge_parallelism: u16,
99    pub vector: VectorResourcePolicyV1,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub lifecycle_binding: Option<LifecyclePolicyId>,
102    pub encryption_required: bool,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub resolver_capability: Option<ExternalKeyResolverCapabilityId>,
105}
106
107impl IndexResourceSpecV1 {
108    pub fn validate(self) -> Result<(), IndexResourceSpecError> {
109        if self.format_version != INDEX_RESOURCE_SPEC_FORMAT_VERSION {
110            return Err(IndexResourceSpecError::UnsupportedVersion {
111                observed: self.format_version,
112                supported: INDEX_RESOURCE_SPEC_FORMAT_VERSION,
113            });
114        }
115        if self.revision.get() == 0 {
116            return Err(IndexResourceSpecError::Invalid("revision must be nonzero"));
117        }
118        if self.initial_shards == 0 || self.initial_shards > MAX_RESOURCE_INITIAL_SHARDS {
119            return Err(IndexResourceSpecError::Invalid(
120                "initial_shards is outside the supported bound",
121            ));
122        }
123        if self.target_shard_bytes == 0 || self.target_shard_bytes > MAX_RESOURCE_BYTES {
124            return Err(IndexResourceSpecError::Invalid(
125                "target_shard_bytes is outside the supported bound",
126            ));
127        }
128        if self.ram_admission_weight == 0
129            || self.ram_admission_weight > MAX_RESOURCE_ADMISSION_WEIGHT
130            || self.nvme_admission_weight == 0
131            || self.nvme_admission_weight > MAX_RESOURCE_ADMISSION_WEIGHT
132        {
133            return Err(IndexResourceSpecError::Invalid(
134                "RAM and NVMe admission weights are outside the supported bound",
135            ));
136        }
137        if self.ram_hard_max_bytes == 0
138            || self.ram_hard_max_bytes > MAX_RESOURCE_BYTES
139            || self.nvme_hard_max_bytes == 0
140            || self.nvme_hard_max_bytes > MAX_RESOURCE_BYTES
141        {
142            return Err(IndexResourceSpecError::Invalid(
143                "RAM and NVMe hard maxima are outside the supported bound",
144            ));
145        }
146        if self.refresh_objective_millis == 0 || self.refresh_objective_millis > MAX_RESOURCE_REFRESH_MILLIS {
147            return Err(IndexResourceSpecError::Invalid(
148                "refresh_objective_millis is outside the supported bound",
149            ));
150        }
151        if self.memtable_hard_max_bytes < MIN_RESOURCE_MEMTABLE_BYTES
152            || self.memtable_hard_max_bytes > MAX_RESOURCE_BYTES
153        {
154            return Err(IndexResourceSpecError::Invalid(
155                "memtable_hard_max_bytes is outside the supported bound",
156            ));
157        }
158        if self.seal_parallelism == 0
159            || self.seal_parallelism > MAX_RESOURCE_PARALLELISM
160            || self.merge_parallelism == 0
161            || self.merge_parallelism > MAX_RESOURCE_PARALLELISM
162        {
163            return Err(IndexResourceSpecError::Invalid(
164                "seal and merge parallelism are outside the supported bound",
165            ));
166        }
167        if self
168            .vector
169            .calibration
170            .is_some_and(|binding| binding.evidence_digest == [0; 32])
171        {
172            return Err(IndexResourceSpecError::Invalid(
173                "vector calibration evidence digest cannot be zero",
174            ));
175        }
176        if self.lifecycle_binding.is_some_and(|binding| binding.get() == 0) {
177            return Err(IndexResourceSpecError::Invalid("lifecycle binding must be nonzero"));
178        }
179        if self.encryption_required != self.resolver_capability.is_some()
180            || self.resolver_capability.is_some_and(|capability| capability.get() == 0)
181        {
182            return Err(IndexResourceSpecError::Invalid(
183                "encryption requires one nonzero resolver capability and an unencrypted index carries none",
184            ));
185        }
186        Ok(())
187    }
188
189    pub fn validate_current_execution(self, mode: IndexMode) -> Result<(), IndexResourceSpecError> {
190        self.validate()?;
191        let durability = if mode == IndexMode::State {
192            self.durability.require_state_available()
193        } else {
194            self.durability.require_available()
195        };
196        durability.map_err(|_| IndexResourceSpecError::Unavailable("durability class"))?;
197        if self.storage_class != StorageClass::Standard {
198            return Err(IndexResourceSpecError::Unavailable("storage class"));
199        }
200        Ok(())
201    }
202}
203
204#[derive(Clone, Copy, Debug, Eq, PartialEq)]
205pub enum IndexResourceSpecError {
206    UnsupportedVersion { observed: u16, supported: u16 },
207    Invalid(&'static str),
208    Unavailable(&'static str),
209}
210
211impl Display for IndexResourceSpecError {
212    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
213        match self {
214            Self::UnsupportedVersion { observed, supported } => {
215                write!(
216                    formatter,
217                    "unsupported resource-spec version {observed}; supported version is {supported}"
218                )
219            }
220            Self::Invalid(reason) => write!(formatter, "invalid index resource specification: {reason}"),
221            Self::Unavailable(axis) => write!(formatter, "index resource specification requests an unavailable {axis}"),
222        }
223    }
224}
225
226impl Error for IndexResourceSpecError {}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn valid() -> IndexResourceSpecV1 {
233        IndexResourceSpecV1 {
234            format_version: INDEX_RESOURCE_SPEC_FORMAT_VERSION,
235            revision: ResourceRevision::new(1),
236            initial_shards: 2,
237            target_shard_bytes: 64 << 20,
238            durability: DurabilityClass::RegionalObject,
239            storage_class: StorageClass::Standard,
240            ram_admission_weight: 3,
241            ram_hard_max_bytes: 128 << 20,
242            nvme_admission_weight: 2,
243            nvme_hard_max_bytes: 512 << 20,
244            refresh_objective_millis: 1_000,
245            memtable_hard_max_bytes: 32 << 20,
246            seal_parallelism: 2,
247            merge_parallelism: 1,
248            vector: VectorResourcePolicyV1::default(),
249            lifecycle_binding: None,
250            encryption_required: false,
251            resolver_capability: None,
252        }
253    }
254
255    #[test]
256    fn validates_current_operational_values_without_product_tiers() {
257        assert_eq!(valid().validate_current_execution(IndexMode::Mutable), Ok(()));
258        let encoded = serde_json::to_string(&valid());
259        assert!(encoded.is_ok());
260        let encoded = encoded.unwrap_or_default();
261        for forbidden in ["free", "growth", "enterprise", "price", "tenant"] {
262            assert!(!encoded.contains(forbidden));
263        }
264    }
265
266    #[test]
267    fn vector_residency_is_an_optional_performance_intent() {
268        let mut spec = valid();
269        spec.vector.residency = Some(VectorResidencyIntentV1::RequireResident);
270        assert_eq!(spec.validate_current_execution(IndexMode::Mutable), Ok(()));
271        let encoded = serde_json::to_string(&spec).unwrap_or_default();
272        assert!(encoded.contains("\"residency\":\"require_resident\""));
273        assert!(!encoded.contains("warm"));
274    }
275
276    #[test]
277    fn refuses_newer_versions_and_unbound_encryption() {
278        let mut newer = valid();
279        newer.format_version = INDEX_RESOURCE_SPEC_FORMAT_VERSION + 1;
280        assert!(matches!(
281            newer.validate(),
282            Err(IndexResourceSpecError::UnsupportedVersion { .. })
283        ));
284        let mut unbound = valid();
285        unbound.encryption_required = true;
286        assert!(matches!(unbound.validate(), Err(IndexResourceSpecError::Invalid(_))));
287
288        let mut zero_evidence = valid();
289        zero_evidence.vector.calibration = Some(VectorCalibrationBindingV1 {
290            profile: VectorStrategyProfileV1::BalancedV1,
291            evidence_digest: [0; 32],
292        });
293        assert!(matches!(
294            zero_evidence.validate(),
295            Err(IndexResourceSpecError::Invalid(_))
296        ));
297    }
298}