1use serde::{Deserialize, Serialize};
2
3use crate::{digest::json_digest, rotation::ROTATION_ALGORITHM_VERSION, FibQuantError, Result};
4
5pub const PROFILE_SCHEMA: &str = "fib_quant_profile_v1";
6pub const MAX_AMBIENT_DIM: usize = 16_384;
8pub const MAX_BLOCK_DIM: usize = 256;
10pub const MAX_CODEBOOK_SIZE: usize = 1 << 20;
12pub const MAX_TRAINING_SAMPLES: u32 = 10_000_000;
14pub const MAX_ROTATION_MATRIX_VALUES: usize = 16_777_216;
16pub const MAX_CODEBOOK_VALUES: usize = 67_108_864;
18pub const MAX_PACKED_INDEX_BITS: usize = 1 << 34;
20
21const RATE_TOLERANCE: f64 = 1.0e-12;
22const MAX_LLOYD_RESTARTS: u32 = 1_024;
23const MAX_LLOYD_ITERATIONS: u32 = 100_000;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[non_exhaustive]
28#[serde(rename_all = "snake_case")]
29pub enum NormFormat {
30 Fp16Paper,
32 #[doc(hidden)]
34 F32Reference,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[non_exhaustive]
40#[serde(rename_all = "snake_case")]
41pub enum SourceMode {
42 CanonicalSphericalBeta,
44 #[doc(hidden)]
46 ReferenceGaussianProjection,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[non_exhaustive]
52#[serde(rename_all = "snake_case")]
53pub enum RadiusMethod {
54 BetaQuantile,
56 K2ClosedForm,
58 #[doc(hidden)]
60 LargeDSingleShellExplicit,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[non_exhaustive]
66#[serde(rename_all = "snake_case")]
67pub enum DirectionMethod {
68 FibonacciSpiral,
70 FibonacciSphere,
72 RobertsKronecker,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[non_exhaustive]
79#[serde(rename_all = "snake_case")]
80pub enum EmptyCellPolicy {
81 SplitHighestDistortion,
83 FailClosed,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[non_exhaustive]
90#[serde(rename_all = "snake_case")]
91pub enum LloydMode {
92 Refine,
94 Skip,
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct FibQuantProfileV1 {
103 pub schema_version: String,
105 pub ambient_dim: u32,
107 pub block_dim: u32,
109 pub codebook_size: u32,
111 pub paper_rate_bits_per_coord: f64,
113 pub wire_index_bits: u8,
115 pub wire_bits_per_coord: f64,
117 pub norm_format: NormFormat,
119 pub rotation_seed: u64,
121 pub rotation_algorithm_version: String,
123 pub codebook_seed: u64,
125 pub codebook_version: String,
127 pub source_mode: SourceMode,
129 pub radius_method: RadiusMethod,
131 pub direction_method: DirectionMethod,
133 pub lloyd_restarts: u32,
135 pub lloyd_iterations: u32,
137 pub training_samples: u32,
139 pub empty_cell_policy: EmptyCellPolicy,
141 pub lloyd_mode: LloydMode,
143}
144
145impl FibQuantProfileV1 {
146 pub fn paper_default(
148 ambient_dim: usize,
149 block_dim: usize,
150 codebook_size: usize,
151 seed: u64,
152 ) -> Result<Self> {
153 validate_profile_parts(ambient_dim, block_dim, codebook_size)?;
154 let direction_method = match block_dim {
155 2 => DirectionMethod::FibonacciSpiral,
156 3 => DirectionMethod::FibonacciSphere,
157 _ => DirectionMethod::RobertsKronecker,
158 };
159 let radius_method = if block_dim == 2 {
160 RadiusMethod::K2ClosedForm
161 } else {
162 RadiusMethod::BetaQuantile
163 };
164 let wire_index_bits = wire_index_bits(codebook_size)?;
165 let profile = Self {
166 schema_version: PROFILE_SCHEMA.into(),
167 ambient_dim: ambient_dim as u32,
168 block_dim: block_dim as u32,
169 codebook_size: codebook_size as u32,
170 paper_rate_bits_per_coord: (codebook_size as f64).log2() / block_dim as f64,
171 wire_index_bits,
172 wire_bits_per_coord: f64::from(wire_index_bits) / block_dim as f64,
173 norm_format: NormFormat::Fp16Paper,
174 rotation_seed: seed,
175 rotation_algorithm_version: ROTATION_ALGORITHM_VERSION.into(),
176 codebook_seed: seed.wrapping_add(0x9e37_79b9_7f4a_7c15),
177 codebook_version: "fib-quant:paper-core-v1".into(),
178 source_mode: SourceMode::CanonicalSphericalBeta,
179 radius_method,
180 direction_method,
181 lloyd_restarts: 4,
182 lloyd_iterations: 25,
183 training_samples: default_training_samples(codebook_size)?,
184 empty_cell_policy: EmptyCellPolicy::SplitHighestDistortion,
185 lloyd_mode: LloydMode::Refine,
186 };
187 profile.validate()?;
188 Ok(profile)
189 }
190
191 pub fn validate(&self) -> Result<()> {
193 if self.schema_version != PROFILE_SCHEMA {
194 return Err(FibQuantError::CorruptPayload(format!(
195 "profile schema_version {}, expected {PROFILE_SCHEMA}",
196 self.schema_version
197 )));
198 }
199 validate_profile_parts(
200 self.ambient_dim as usize,
201 self.block_dim as usize,
202 self.codebook_size as usize,
203 )?;
204 validate_resource_bounds(
205 self.ambient_dim as usize,
206 self.block_dim as usize,
207 self.codebook_size as usize,
208 self.training_samples,
209 self.wire_index_bits,
210 )?;
211 if self.norm_format != NormFormat::Fp16Paper {
212 return Err(FibQuantError::CorruptPayload(
213 "paper profile requires fp16 norm side header".into(),
214 ));
215 }
216 if self.source_mode != SourceMode::CanonicalSphericalBeta {
217 return Err(FibQuantError::CorruptPayload(
218 "paper profile requires canonical spherical-Beta source mode".into(),
219 ));
220 }
221 if self.rotation_algorithm_version != ROTATION_ALGORITHM_VERSION {
222 return Err(FibQuantError::CorruptPayload(format!(
223 "rotation_algorithm_version {}, expected {ROTATION_ALGORITHM_VERSION}",
224 self.rotation_algorithm_version
225 )));
226 }
227 let expected_bits = wire_index_bits(self.codebook_size as usize)?;
228 if self.wire_index_bits != expected_bits {
229 return Err(FibQuantError::CorruptPayload(format!(
230 "wire_index_bits {} does not match ceil(log2(N)) {expected_bits}",
231 self.wire_index_bits
232 )));
233 }
234 let k = self.block_dim as usize;
235 let expected_paper_rate = (self.codebook_size as f64).log2() / k as f64;
236 validate_rate(
237 "paper_rate_bits_per_coord",
238 self.paper_rate_bits_per_coord,
239 expected_paper_rate,
240 )?;
241 let expected_wire_rate = f64::from(self.wire_index_bits) / k as f64;
242 validate_rate(
243 "wire_bits_per_coord",
244 self.wire_bits_per_coord,
245 expected_wire_rate,
246 )?;
247 validate_method_pair(k, &self.radius_method, &self.direction_method)?;
248 if self.lloyd_restarts == 0 || self.lloyd_restarts > MAX_LLOYD_RESTARTS {
249 return Err(FibQuantError::CorruptPayload(format!(
250 "lloyd_restarts {} outside supported range 1..={MAX_LLOYD_RESTARTS}",
251 self.lloyd_restarts
252 )));
253 }
254 if self.lloyd_iterations == 0 || self.lloyd_iterations > MAX_LLOYD_ITERATIONS {
255 return Err(FibQuantError::CorruptPayload(format!(
256 "lloyd_iterations {} outside supported range 1..={MAX_LLOYD_ITERATIONS}",
257 self.lloyd_iterations
258 )));
259 }
260 if self.training_samples < self.codebook_size
261 || self.training_samples > MAX_TRAINING_SAMPLES
262 {
263 return Err(FibQuantError::CorruptPayload(format!(
264 "training_samples {} outside supported range {}..={MAX_TRAINING_SAMPLES}",
265 self.training_samples, self.codebook_size
266 )));
267 }
268 Ok(())
269 }
270
271 pub fn digest(&self) -> Result<String> {
273 self.validate()?;
274 json_digest(PROFILE_SCHEMA, self)
275 }
276
277 pub fn block_count(&self) -> u32 {
279 self.ambient_dim / self.block_dim
280 }
281}
282
283pub fn wire_index_bits(codebook_size: usize) -> Result<u8> {
285 if codebook_size < 2 {
286 return Err(FibQuantError::InvalidCodebookSize(codebook_size));
287 }
288 let bits = usize::BITS - (codebook_size - 1).leading_zeros();
289 u8::try_from(bits).map_err(|_| FibQuantError::InvalidCodebookSize(codebook_size))
290}
291
292fn validate_profile_parts(
293 ambient_dim: usize,
294 block_dim: usize,
295 codebook_size: usize,
296) -> Result<()> {
297 if ambient_dim == 0 {
298 return Err(FibQuantError::ZeroDimension);
299 }
300 if block_dim == 0 || block_dim > ambient_dim {
301 return Err(FibQuantError::InvalidBlockDim {
302 ambient_dim,
303 block_dim,
304 });
305 }
306 if ambient_dim == block_dim {
307 return Err(FibQuantError::InvalidBlockDim {
308 ambient_dim,
309 block_dim,
310 });
311 }
312 if ambient_dim % block_dim != 0 {
313 return Err(FibQuantError::DimensionNotDivisible {
314 ambient_dim,
315 block_dim,
316 });
317 }
318 if ambient_dim > MAX_AMBIENT_DIM {
319 return Err(FibQuantError::ResourceLimitExceeded(format!(
320 "ambient_dim {ambient_dim} exceeds MAX_AMBIENT_DIM {MAX_AMBIENT_DIM}"
321 )));
322 }
323 if block_dim > MAX_BLOCK_DIM {
324 return Err(FibQuantError::ResourceLimitExceeded(format!(
325 "block_dim {block_dim} exceeds MAX_BLOCK_DIM {MAX_BLOCK_DIM}"
326 )));
327 }
328 if !(2..=MAX_CODEBOOK_SIZE).contains(&codebook_size) {
329 return Err(FibQuantError::InvalidCodebookSize(codebook_size));
330 }
331 Ok(())
332}
333
334fn default_training_samples(codebook_size: usize) -> Result<u32> {
335 let samples = 30usize
336 .checked_mul(codebook_size)
337 .ok_or_else(|| FibQuantError::ResourceLimitExceeded("30 * codebook_size overflow".into()))?
338 .max(256)
339 .min(MAX_TRAINING_SAMPLES as usize);
340 u32::try_from(samples)
341 .map_err(|_| FibQuantError::ResourceLimitExceeded("training sample count overflow".into()))
342}
343
344fn checked_profile_mul(lhs: usize, rhs: usize, label: &str) -> Result<usize> {
345 lhs.checked_mul(rhs)
346 .ok_or_else(|| FibQuantError::ResourceLimitExceeded(format!("{label} overflow")))
347}
348
349fn validate_resource_bounds(
350 ambient_dim: usize,
351 block_dim: usize,
352 codebook_size: usize,
353 training_samples: u32,
354 wire_index_bits: u8,
355) -> Result<()> {
356 let rotation_values =
357 checked_profile_mul(ambient_dim, ambient_dim, "ambient_dim * ambient_dim")?;
358 if rotation_values > MAX_ROTATION_MATRIX_VALUES {
359 return Err(FibQuantError::ResourceLimitExceeded(format!(
360 "rotation matrix values {rotation_values} exceed MAX_ROTATION_MATRIX_VALUES {MAX_ROTATION_MATRIX_VALUES}"
361 )));
362 }
363
364 let codebook_values =
365 checked_profile_mul(codebook_size, block_dim, "codebook_size * block_dim")?;
366 if codebook_values > MAX_CODEBOOK_VALUES {
367 return Err(FibQuantError::ResourceLimitExceeded(format!(
368 "codebook values {codebook_values} exceed MAX_CODEBOOK_VALUES {MAX_CODEBOOK_VALUES}"
369 )));
370 }
371
372 checked_profile_mul(
373 training_samples as usize,
374 block_dim,
375 "training_samples * block_dim",
376 )?;
377
378 let block_count = ambient_dim / block_dim;
379 let packed_bits = checked_profile_mul(
380 block_count,
381 wire_index_bits as usize,
382 "block_count * wire_index_bits",
383 )?;
384 if packed_bits > MAX_PACKED_INDEX_BITS {
385 return Err(FibQuantError::ResourceLimitExceeded(format!(
386 "packed index bits {packed_bits} exceed MAX_PACKED_INDEX_BITS {MAX_PACKED_INDEX_BITS}"
387 )));
388 }
389 Ok(())
390}
391
392fn validate_rate(name: &str, actual: f64, expected: f64) -> Result<()> {
393 if !actual.is_finite() || !expected.is_finite() || (actual - expected).abs() > RATE_TOLERANCE {
394 return Err(FibQuantError::CorruptPayload(format!(
395 "{name} {actual} does not match expected {expected}"
396 )));
397 }
398 Ok(())
399}
400
401fn validate_method_pair(
402 block_dim: usize,
403 radius: &RadiusMethod,
404 direction: &DirectionMethod,
405) -> Result<()> {
406 let valid = match block_dim {
407 2 => {
408 radius == &RadiusMethod::K2ClosedForm && direction == &DirectionMethod::FibonacciSpiral
409 }
410 3 => {
411 radius == &RadiusMethod::BetaQuantile && direction == &DirectionMethod::FibonacciSphere
412 }
413 _ => {
414 radius == &RadiusMethod::BetaQuantile && direction == &DirectionMethod::RobertsKronecker
415 }
416 };
417 if valid {
418 Ok(())
419 } else {
420 Err(FibQuantError::CorruptPayload(format!(
421 "unsupported radius/direction pair for k={block_dim}: {radius:?}/{direction:?}"
422 )))
423 }
424}