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#[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#[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#[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#[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#[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}