Skip to main content

ftts_artifacts/
converter.rs

1//! Shared quantization primitives for runtime loading and offline conversion.
2//!
3//! The offline `.fttsq` converter must not own a second numerical recipe. Both paths call the
4//! row primitive in this module, so their Q8 bytes and scales are identical by construction.
5
6use std::collections::BTreeSet;
7use std::fmt;
8
9use serde_json::Value;
10
11use crate::census::{CensusReport, WeightsManifest};
12use crate::fttsq::{
13    AccessClass, FttsqError, FttsqStreamPlan, FttsqStreamingWriter, StoredDtype,
14    TensorEntry as ArtifactTensorEntry,
15};
16use crate::safetensors::{Dtype, SafetensorsIndex, TensorView, WeightsError};
17use crate::sha256::Sha256;
18
19/// Largest input width accepted by the bounded Q8 matrix-row adapter.
20///
21/// The pinned tensor inventory's largest trailing shape product is 12,288. This leaves more than
22/// five times that headroom while bounding the adapter's `f32` + Q8 scratch space to 320 KiB. A
23/// new checkpoint or a malformed external input with a wider row must be given an explicit tiling
24/// policy rather than turning one "row" into an unbounded allocation.
25pub const MAX_Q8_OUTPUT_CHANNEL_WIDTH: usize = 65_536;
26
27/// Largest number of Q8 output channels whose scales one conversion section may retain.
28///
29/// The pinned checkpoint's widest matrix is the 151,936-row text embedding, whose scale tail is
30/// 607,744 bytes. This cap leaves room for a future similarly sized tensor but fixes the tail at
31/// one MiB: a converter cannot quietly turn a malicious outer dimension into an unbounded scale
32/// allocation while it waits to append that tail after the Q8 payload.
33pub const MAX_Q8_OUTPUT_CHANNELS: usize = 262_144;
34
35/// Group width for [`TensorStoragePolicy::Q8PerGroup64`], fixed rather than configurable.
36///
37/// 64 is where the cold-embedding SQNR sweep flattened (per-row 23.8 dB worst → 35.0 dB at
38/// group 64, +1.2 dB more at group 32 for double the scale bytes, frankentts-6ea1), and one
39/// fixed width keeps every grouped artifact readable by every grouped-aware loader — a
40/// per-artifact knob would be a compatibility surface with no measured benefit.
41pub const Q8_GROUP_WIDTH: usize = 64;
42
43/// Largest number of per-group scales one grouped conversion section may retain.
44///
45/// The grouped scale tail is necessarily larger than the per-row tail: the 151,936×2048 text
46/// embedding at group 64 carries 4,861,952 scales (18.5 MiB), which the sink holds until the
47/// payload finishes streaming. This cap admits that tensor with headroom while still refusing
48/// to let a malicious shape turn the tail into an unbounded allocation.
49pub const MAX_Q8_GROUP_SCALES: usize = 8_388_608;
50
51/// The storage recipe for one source tensor in a portable `.fttsq` artifact.
52///
53/// The conversion plan must state this policy for every tensor in its source manifest. That makes
54/// protected high-precision tensors an explicit, auditable choice rather than an accidental
55/// fallback, and it prevents a new checkpoint tensor from being silently omitted.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum TensorStoragePolicy {
58    /// Preserve the source BF16 or F32 bytes exactly.
59    Verbatim,
60    /// Quantize a rank-two-or-greater weight matrix with canonical per-output-channel Q8 scales.
61    Q8PerOutputChannel,
62    /// Quantize with one canonical Q8 scale per [`Q8_GROUP_WIDTH`]-element group of each row.
63    ///
64    /// For rows whose energy is uneven across the row (the cold text embedding's common-token
65    /// rows), a single row scale quantizes the quiet stretches at the loud stretch's step size;
66    /// per-group scales recover ~11 dB on the worst measured rows for ~3% payload overhead in
67    /// scales. The quantization primitive is the same [`quantize_output_channel_q8`], applied
68    /// per group, so grouped bytes remain bit-consistent with the canonical recipe.
69    Q8PerGroup64,
70}
71
72/// One source tensor's explicit artifact location and storage recipe.
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub struct TensorConversion {
75    source_name: String,
76    artifact_name: String,
77    access_class: AccessClass,
78    storage: TensorStoragePolicy,
79}
80
81impl TensorConversion {
82    /// Declares a source tensor that remains at its source precision.
83    #[must_use]
84    pub fn verbatim(
85        source_name: impl Into<String>,
86        artifact_name: impl Into<String>,
87        access_class: AccessClass,
88    ) -> Self {
89        Self {
90            source_name: source_name.into(),
91            artifact_name: artifact_name.into(),
92            access_class,
93            storage: TensorStoragePolicy::Verbatim,
94        }
95    }
96
97    /// Declares a source matrix that uses the shared canonical Q8 quantization primitive.
98    #[must_use]
99    pub fn q8_per_output_channel(
100        source_name: impl Into<String>,
101        artifact_name: impl Into<String>,
102        access_class: AccessClass,
103    ) -> Self {
104        Self {
105            source_name: source_name.into(),
106            artifact_name: artifact_name.into(),
107            access_class,
108            storage: TensorStoragePolicy::Q8PerOutputChannel,
109        }
110    }
111
112    /// Declares a source matrix quantized with one Q8 scale per [`Q8_GROUP_WIDTH`]-element group.
113    #[must_use]
114    pub fn q8_per_group_64(
115        source_name: impl Into<String>,
116        artifact_name: impl Into<String>,
117        access_class: AccessClass,
118    ) -> Self {
119        Self {
120            source_name: source_name.into(),
121            artifact_name: artifact_name.into(),
122            access_class,
123            storage: TensorStoragePolicy::Q8PerGroup64,
124        }
125    }
126
127    /// The access-class section this tensor's bytes land in.
128    ///
129    /// One section per [`AccessClass`], never per tensor: the format caps sections at
130    /// [`crate::fttsq::MAX_SECTIONS`] because a section IS an access class (the page-in policy
131    /// unit), while tensors locate themselves inside it by offset. Emitting per-tensor sections
132    /// overflowed that cap at 478 on the real checkpoint (frankentts-zm5).
133    fn section_name(&self) -> &'static str {
134        self.access_class.as_str()
135    }
136
137    fn scales_name(&self) -> String {
138        format!("{}.scales", self.artifact_name)
139    }
140}
141
142/// Metadata, pinned source digest, and per-tensor policy for one bounded conversion.
143///
144/// A real model recipe supplies one [`TensorConversion`] for **every** tensor named by its
145/// [`WeightsManifest`]. The plan contains no source payload and no machine-specific packing; it
146/// can therefore be reviewed before a multi-gigabyte conversion opens an output file.
147#[derive(Clone, Debug)]
148pub struct StreamingConversionPlan {
149    model_family: String,
150    source_sha256: String,
151    license_notice: String,
152    model_config: Value,
153    quantization_manifest: Value,
154    tensors: Vec<TensorConversion>,
155}
156
157impl StreamingConversionPlan {
158    /// Starts a portable conversion plan tied to the expected source SHA-256.
159    #[must_use]
160    pub fn new(model_family: impl Into<String>, source_sha256: impl Into<String>) -> Self {
161        Self {
162            model_family: model_family.into(),
163            source_sha256: source_sha256.into(),
164            license_notice: String::new(),
165            model_config: Value::Null,
166            quantization_manifest: Value::Null,
167            tensors: Vec::new(),
168        }
169    }
170
171    /// Sets the required Apache-2.0 attribution and change notice.
172    #[must_use]
173    pub fn license_notice(mut self, notice: impl Into<String>) -> Self {
174        self.license_notice = notice.into();
175        self
176    }
177
178    /// Records the frozen source model configuration in the artifact directory.
179    #[must_use]
180    pub fn model_config(mut self, config: Value) -> Self {
181        self.model_config = config;
182        self
183    }
184
185    /// Records the reviewed per-tensor quantization recipe in the artifact directory.
186    #[must_use]
187    pub fn quantization_manifest(mut self, manifest: Value) -> Self {
188        self.quantization_manifest = manifest;
189        self
190    }
191
192    /// Adds one explicit source-to-artifact tensor conversion.
193    #[must_use]
194    pub fn tensor(mut self, tensor: TensorConversion) -> Self {
195        self.tensors.push(tensor);
196        self
197    }
198}
199
200/// A plan validation failure detected before a destination stream is opened.
201#[derive(Clone, Debug, PartialEq, Eq)]
202pub enum ConversionPlanError {
203    /// The plan did not declare a conversion recipe for any source tensor.
204    NoTensorPolicies,
205    /// One source tensor was declared twice with conflicting or duplicate policies.
206    DuplicateSourcePolicy {
207        /// Source tensor name.
208        name: String,
209    },
210    /// A policy named a tensor absent from the validated source checkpoint.
211    SourceTensorMissing {
212        /// Source tensor name.
213        name: String,
214    },
215    /// A source tensor had no policy, so emitting an artifact would silently omit it.
216    SourceTensorUnplanned {
217        /// Source tensor name.
218        name: String,
219    },
220    /// Two policies would create the same artifact tensor name.
221    DuplicateArtifactTensor {
222        /// Artifact tensor name.
223        name: String,
224    },
225    /// Artifact names cannot be empty because the container uses them as stable keys.
226    EmptyArtifactTensorName {
227        /// Source tensor whose artifact name was empty.
228        source_name: String,
229    },
230    /// A Q8 policy was assigned to a non-matrix tensor.
231    Q8RequiresMatrix {
232        /// Source tensor name.
233        name: String,
234        /// Source rank.
235        rank: usize,
236    },
237    /// A Q8 matrix had no values in an output channel.
238    Q8EmptyOutputChannel {
239        /// Source tensor name.
240        name: String,
241    },
242    /// A Q8 matrix would exceed the converter's fixed row scratch bound.
243    Q8OutputChannelTooWide {
244        /// Source tensor name.
245        name: String,
246        /// Values per output channel.
247        width: usize,
248        /// Fixed adapter limit.
249        limit: usize,
250    },
251    /// A Q8 scale tail would exceed the converter's fixed memory bound.
252    Q8OutputChannelCountTooLarge {
253        /// Source tensor name.
254        name: String,
255        /// Output-channel count.
256        rows: usize,
257        /// Fixed scale-tail limit.
258        limit: usize,
259    },
260    /// The source shape cannot be represented by the portable u64 container directory.
261    ShapeOutOfRange {
262        /// Source tensor name.
263        name: String,
264    },
265    /// The derived section length could not fit in the portable container format.
266    SectionLengthOverflow {
267        /// Source tensor name.
268        name: String,
269    },
270}
271
272impl fmt::Display for ConversionPlanError {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        match self {
275            Self::NoTensorPolicies => f.write_str("conversion plan has no tensor policies"),
276            Self::DuplicateSourcePolicy { name } => {
277                write!(
278                    f,
279                    "conversion plan names source tensor `{name}` more than once"
280                )
281            }
282            Self::SourceTensorMissing { name } => {
283                write!(
284                    f,
285                    "conversion plan names source tensor `{name}`, which is absent"
286                )
287            }
288            Self::SourceTensorUnplanned { name } => {
289                write!(
290                    f,
291                    "source tensor `{name}` has no explicit conversion policy"
292                )
293            }
294            Self::DuplicateArtifactTensor { name } => {
295                write!(
296                    f,
297                    "conversion plan would emit artifact tensor `{name}` more than once"
298                )
299            }
300            Self::EmptyArtifactTensorName { source_name } => write!(
301                f,
302                "conversion plan gives source tensor `{source_name}` an empty artifact name"
303            ),
304            Self::Q8RequiresMatrix { name, rank } => write!(
305                f,
306                "Q8 conversion for `{name}` requires rank 2 or greater, got rank {rank}"
307            ),
308            Self::Q8EmptyOutputChannel { name } => {
309                write!(f, "Q8 conversion for `{name}` has an empty output channel")
310            }
311            Self::Q8OutputChannelTooWide { name, width, limit } => write!(
312                f,
313                "Q8 conversion for `{name}` has output-channel width {width}, exceeding {limit}"
314            ),
315            Self::Q8OutputChannelCountTooLarge { name, rows, limit } => write!(
316                f,
317                "Q8 conversion for `{name}` has {rows} output channels, exceeding {limit}"
318            ),
319            Self::ShapeOutOfRange { name } => {
320                write!(
321                    f,
322                    "source tensor `{name}` has a shape outside the artifact range"
323                )
324            }
325            Self::SectionLengthOverflow { name } => {
326                write!(
327                    f,
328                    "source tensor `{name}` overflows its planned artifact section length"
329                )
330            }
331        }
332    }
333}
334
335impl std::error::Error for ConversionPlanError {}
336
337/// Failure while converting a manifest-validated safetensors checkpoint.
338#[derive(Debug)]
339pub enum StreamingConversionError {
340    /// The source bytes were not a valid supported safetensors file.
341    Source(WeightsError),
342    /// The source file did not match its complete pinned manifest.
343    SourceCensus(Box<CensusReport>),
344    /// The source bytes did not match the plan's pinned SHA-256.
345    SourceDigestMismatch {
346        /// Digest the plan requires.
347        expected: String,
348        /// Digest calculated over the exact source bytes.
349        actual: String,
350    },
351    /// The conversion plan was incomplete or structurally inconsistent.
352    Plan(ConversionPlanError),
353    /// The container refused planned metadata or could not write the destination stream.
354    Artifact(FttsqError),
355    /// The shared Q8 primitive refused a source matrix or destination section.
356    Quantization(MatrixQuantizationError<Q8SectionSinkError>),
357}
358
359impl fmt::Display for StreamingConversionError {
360    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361        match self {
362            Self::Source(error) => write!(f, "cannot parse source checkpoint: {error}"),
363            Self::SourceCensus(report) => f.write_str(&report.render()),
364            Self::SourceDigestMismatch { expected, actual } => write!(
365                f,
366                "source checkpoint SHA-256 mismatch: expected {expected}, got {actual}"
367            ),
368            Self::Plan(error) => write!(f, "invalid conversion plan: {error}"),
369            Self::Artifact(error) => write!(f, "cannot write .fttsq artifact: {error}"),
370            Self::Quantization(error) => write!(f, "cannot quantize artifact matrix: {error}"),
371        }
372    }
373}
374
375impl std::error::Error for StreamingConversionError {
376    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
377        match self {
378            Self::Source(error) => Some(error),
379            Self::SourceCensus(report) => Some(report),
380            Self::Plan(error) => Some(error),
381            Self::Artifact(error) => Some(error),
382            Self::Quantization(error) => Some(error),
383            Self::SourceDigestMismatch { .. } => None,
384        }
385    }
386}
387
388/// Failure while quantizing one output channel.
389#[derive(Clone, Debug, PartialEq)]
390pub enum QuantizationError {
391    /// The caller did not provide one output byte for every input value.
392    OutputLength {
393        /// Number of source values in the row.
394        values: usize,
395        /// Number of output slots supplied by the caller.
396        output: usize,
397    },
398    /// A checkpoint value cannot participate in a deterministic finite Q8 recipe.
399    NonFiniteValue {
400        /// Index within the output channel.
401        index: usize,
402        /// The rejected value.
403        value: f32,
404    },
405}
406
407impl fmt::Display for QuantizationError {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        match self {
410            Self::OutputLength { values, output } => write!(
411                f,
412                "Q8 output length {output} does not match input row length {values}"
413            ),
414            Self::NonFiniteValue { index, value } => {
415                write!(
416                    f,
417                    "Q8 input row has non-finite value {value} at index {index}"
418                )
419            }
420        }
421    }
422}
423
424impl std::error::Error for QuantizationError {}
425
426/// Destination for one bounded Q8 matrix row.
427///
428/// The converter owns only a single input row and its Q8 counterpart while invoking this sink.
429/// An offline artifact writer can append `values` and record `scale` immediately; a runtime loader
430/// can route the exact same bytes directly into its packed-weight allocation. Neither caller needs
431/// to materialize a whole widened tensor.
432pub trait Q8RowSink {
433    /// Failure produced while accepting a quantized row.
434    type Error;
435
436    /// Accepts one output channel of a matrix.
437    ///
438    /// `row` is the outermost matrix index, `scale` is the canonical symmetric Q8 scale, and
439    /// `values` has one signed byte for each source element in that row.
440    fn write_q8_row(&mut self, row: usize, scale: f32, values: &[i8]) -> Result<(), Self::Error>;
441}
442
443/// Failure while streaming a matrix through the canonical Q8 quantizer.
444#[derive(Clone, Debug, PartialEq)]
445pub enum MatrixQuantizationError<E> {
446    /// Q8 weights are defined here only for matrices, never by accidentally flattening vectors.
447    ExpectedMatrix {
448        /// The source rank presented to the quantizer.
449        rank: usize,
450    },
451    /// An empty trailing dimension cannot represent an output channel for a GEMM weight matrix.
452    EmptyOutputChannel {
453        /// Source matrix shape.
454        shape: Vec<usize>,
455    },
456    /// One output channel would exceed the adapter's bounded scratch-space contract.
457    OutputChannelTooWide {
458        /// Number of source values in one output channel.
459        width: usize,
460        /// Maximum number of values the bounded adapter accepts.
461        limit: usize,
462    },
463    /// A validated view could not provide a complete source row.
464    ///
465    /// This is defensive: a [`TensorView`] created by [`crate::safetensors::SafetensorsIndex`]
466    /// should make it unreachable, but conversion must refuse rather than emit a partial row.
467    SourceRowUnavailable {
468        /// Source output-channel index.
469        row: usize,
470    },
471    /// A source value cannot be represented by the deterministic Q8 recipe.
472    Quantization {
473        /// Source output-channel index.
474        row: usize,
475        /// The underlying numerical refusal.
476        source: QuantizationError,
477    },
478    /// The caller's streaming destination rejected a complete quantized row.
479    Sink {
480        /// Source output-channel index.
481        row: usize,
482        /// The destination-specific error.
483        source: E,
484    },
485}
486
487impl<E: fmt::Display> fmt::Display for MatrixQuantizationError<E> {
488    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
489        match self {
490            Self::ExpectedMatrix { rank } => {
491                write!(
492                    f,
493                    "Q8 matrix quantization requires rank 2 or greater, got rank {rank}"
494                )
495            }
496            Self::EmptyOutputChannel { shape } => write!(
497                f,
498                "Q8 matrix quantization refuses empty output channels for shape {shape:?}"
499            ),
500            Self::OutputChannelTooWide { width, limit } => write!(
501                f,
502                "Q8 output-channel width {width} exceeds the bounded adapter limit {limit}"
503            ),
504            Self::SourceRowUnavailable { row } => {
505                write!(f, "Q8 source row {row} is unavailable or incomplete")
506            }
507            Self::Quantization { row, source } => {
508                write!(f, "Q8 source row {row} cannot be quantized: {source}")
509            }
510            Self::Sink { row, source } => {
511                write!(f, "Q8 destination rejected row {row}: {source}")
512            }
513        }
514    }
515}
516
517impl<E> std::error::Error for MatrixQuantizationError<E>
518where
519    E: std::error::Error + 'static,
520{
521    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
522        match self {
523            Self::Quantization { source, .. } => Some(source),
524            Self::Sink { source, .. } => Some(source),
525            Self::ExpectedMatrix { .. }
526            | Self::EmptyOutputChannel { .. }
527            | Self::OutputChannelTooWide { .. }
528            | Self::SourceRowUnavailable { .. } => None,
529        }
530    }
531}
532
533/// Failure while writing canonical Q8 values and their scale tail into one `.fttsq` section.
534#[derive(Clone, Debug, PartialEq, Eq)]
535pub enum Q8SectionSinkError {
536    /// The conversion plan would require a scale tail larger than the bounded contract permits.
537    OutputChannelCountTooLarge {
538        /// Number of matrix output channels.
539        rows: usize,
540        /// Maximum number of rows whose scales this sink can retain.
541        limit: usize,
542    },
543    /// A caller bypassed the matrix adapter and supplied an unbounded Q8 row directly.
544    OutputChannelTooWide {
545        /// Number of values in the attempted row.
546        width: usize,
547        /// Maximum row width accepted by the shared adapter.
548        limit: usize,
549    },
550    /// The shared matrix adapter did not present rows in the source's physical order.
551    RowOutOfOrder {
552        /// Row index the sink expected next.
553        expected: usize,
554        /// Row index the adapter supplied.
555        actual: usize,
556    },
557    /// The caller tried to finalize before every planned row supplied a scale.
558    Incomplete {
559        /// Rows the section metadata declared.
560        expected: usize,
561        /// Rows actually received.
562        written: usize,
563    },
564    /// Writing the values or scale tail into the artifact stream failed.
565    Artifact(FttsqError),
566}
567
568impl fmt::Display for Q8SectionSinkError {
569    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
570        match self {
571            Self::OutputChannelCountTooLarge { rows, limit } => write!(
572                f,
573                "Q8 matrix has {rows} output channels, exceeding the bounded scale-tail limit {limit}"
574            ),
575            Self::OutputChannelTooWide { width, limit } => write!(
576                f,
577                "Q8 section row width {width} exceeds the bounded row limit {limit}"
578            ),
579            Self::RowOutOfOrder { expected, actual } => write!(
580                f,
581                "Q8 section expected source row {expected}, received row {actual}"
582            ),
583            Self::Incomplete { expected, written } => write!(
584                f,
585                "Q8 section needs {expected} scales but received {written}"
586            ),
587            Self::Artifact(error) => write!(f, "cannot write Q8 section: {error}"),
588        }
589    }
590}
591
592impl std::error::Error for Q8SectionSinkError {
593    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
594        match self {
595            Self::Artifact(error) => Some(error),
596            Self::OutputChannelCountTooLarge { .. }
597            | Self::OutputChannelTooWide { .. }
598            | Self::RowOutOfOrder { .. }
599            | Self::Incomplete { .. } => None,
600        }
601    }
602}
603
604/// A bounded bridge from canonical Q8 matrix rows into one streaming `.fttsq` section.
605///
606/// `.fttsq` keeps the Q8 tensor contiguous, followed by the contiguous F32 scale tensor that its
607/// directory names. The sink therefore streams every Q8 row immediately, retaining only the scale
608/// tail (at most one MiB for the pinned inventory) until the values have completed. It also keeps
609/// one byte-per-row scratch buffer for the signed-to-wire byte conversion, so the total working
610/// set remains bounded by the row adapter's 320 KiB plus at most 64 KiB of value bytes and one MiB
611/// of scales — never by the full matrix size.
612pub struct Q8SectionSink<'a, W> {
613    writer: &'a mut FttsqStreamingWriter<W>,
614    section: String,
615    expected_rows: usize,
616    next_row: usize,
617    value_bytes: Vec<u8>,
618    scale_bytes: Vec<u8>,
619}
620
621impl<'a, W: std::io::Write + std::io::Seek> Q8SectionSink<'a, W> {
622    /// Starts writing one Q8 matrix section with a fixed number of output channels.
623    ///
624    /// # Errors
625    ///
626    /// Returns [`Q8SectionSinkError::OutputChannelCountTooLarge`] before allocating when the
627    /// planned F32 scale tail exceeds this adapter's one-MiB memory ceiling.
628    pub fn new(
629        writer: &'a mut FttsqStreamingWriter<W>,
630        section: impl Into<String>,
631        expected_rows: usize,
632    ) -> Result<Self, Q8SectionSinkError> {
633        if expected_rows > MAX_Q8_OUTPUT_CHANNELS {
634            return Err(Q8SectionSinkError::OutputChannelCountTooLarge {
635                rows: expected_rows,
636                limit: MAX_Q8_OUTPUT_CHANNELS,
637            });
638        }
639        Ok(Self::unbounded(writer, section, expected_rows))
640    }
641
642    /// Starts writing one grouped-Q8 matrix section: one scale per group, not per row.
643    ///
644    /// Split from [`Q8SectionSink::new`] because the two paths have honestly different tail
645    /// bounds — see [`MAX_Q8_GROUP_SCALES`] — and sharing the larger bound would quietly weaken
646    /// the per-row path's one-MiB refusal.
647    ///
648    /// # Errors
649    ///
650    /// Returns [`Q8SectionSinkError::OutputChannelCountTooLarge`] before allocating when the
651    /// planned scale tail exceeds [`MAX_Q8_GROUP_SCALES`].
652    pub fn new_grouped(
653        writer: &'a mut FttsqStreamingWriter<W>,
654        section: impl Into<String>,
655        expected_groups: usize,
656    ) -> Result<Self, Q8SectionSinkError> {
657        if expected_groups > MAX_Q8_GROUP_SCALES {
658            return Err(Q8SectionSinkError::OutputChannelCountTooLarge {
659                rows: expected_groups,
660                limit: MAX_Q8_GROUP_SCALES,
661            });
662        }
663        Ok(Self::unbounded(writer, section, expected_groups))
664    }
665
666    fn unbounded(
667        writer: &'a mut FttsqStreamingWriter<W>,
668        section: impl Into<String>,
669        expected_rows: usize,
670    ) -> Self {
671        Self {
672            writer,
673            section: section.into(),
674            expected_rows,
675            next_row: 0,
676            value_bytes: Vec::new(),
677            scale_bytes: Vec::with_capacity(expected_rows * std::mem::size_of::<f32>()),
678        }
679    }
680
681    /// Appends the scale tail after all Q8 values have been streamed.
682    ///
683    /// # Errors
684    ///
685    /// Returns a named refusal when a prior row failed or a stream write cannot complete.
686    pub fn finish(self) -> Result<(), Q8SectionSinkError> {
687        if self.next_row != self.expected_rows {
688            return Err(Q8SectionSinkError::Incomplete {
689                expected: self.expected_rows,
690                written: self.next_row,
691            });
692        }
693        self.writer
694            .write_section(&self.section, &self.scale_bytes)
695            .map_err(Q8SectionSinkError::Artifact)
696    }
697}
698
699impl<W: std::io::Write + std::io::Seek> Q8RowSink for Q8SectionSink<'_, W> {
700    type Error = Q8SectionSinkError;
701
702    fn write_q8_row(&mut self, row: usize, scale: f32, values: &[i8]) -> Result<(), Self::Error> {
703        if row != self.next_row {
704            return Err(Q8SectionSinkError::RowOutOfOrder {
705                expected: self.next_row,
706                actual: row,
707            });
708        }
709        if values.len() > MAX_Q8_OUTPUT_CHANNEL_WIDTH {
710            return Err(Q8SectionSinkError::OutputChannelTooWide {
711                width: values.len(),
712                limit: MAX_Q8_OUTPUT_CHANNEL_WIDTH,
713            });
714        }
715        self.value_bytes.clear();
716        self.value_bytes.extend(
717            values
718                .iter()
719                .map(|&value| u8::from_ne_bytes(value.to_ne_bytes())),
720        );
721        self.writer
722            .write_section(&self.section, &self.value_bytes)
723            .map_err(Q8SectionSinkError::Artifact)?;
724        self.scale_bytes.extend_from_slice(&scale.to_le_bytes());
725        self.next_row += 1;
726        Ok(())
727    }
728}
729
730/// Converts one safetensors matrix directly into its declared Q8 `.fttsq` section.
731///
732/// The section must have been declared with exactly `matrix.len() + rows * 4` bytes, with a Q8
733/// tensor at relative offset zero followed by its F32 scale tensor. This function calls the shared
734/// [`quantize_output_channel_q8`] path through [`quantize_matrix_q8_rows`], so offline artifact
735/// bytes and runtime quantization are produced by the same numerical primitive.
736///
737/// # Errors
738///
739/// Returns a precise source-shape, quantization, bounded-scale-tail, or artifact-write failure.
740pub fn stream_matrix_q8_section<W: std::io::Write + std::io::Seek>(
741    matrix: &TensorView<'_>,
742    writer: &mut FttsqStreamingWriter<W>,
743    section: &str,
744) -> Result<(), MatrixQuantizationError<Q8SectionSinkError>> {
745    let shape = matrix.shape();
746    if shape.len() < 2 {
747        return Err(MatrixQuantizationError::ExpectedMatrix { rank: shape.len() });
748    }
749    let Some(&row_count) = shape.first() else {
750        return Err(MatrixQuantizationError::ExpectedMatrix { rank: 0 });
751    };
752    let mut sink = Q8SectionSink::new(writer, section, row_count)
753        .map_err(|source| MatrixQuantizationError::Sink { row: 0, source })?;
754    quantize_matrix_q8_rows(matrix, &mut sink)?;
755    sink.finish()
756        .map_err(|source| MatrixQuantizationError::Sink {
757            row: row_count,
758            source,
759        })
760}
761
762/// Converts one safetensors matrix into a grouped-Q8 `.fttsq` section (one scale per
763/// [`Q8_GROUP_WIDTH`]-element group of every row).
764///
765/// Payload layout is byte-identical to the per-row form — groups of a row are contiguous, rows
766/// follow each other — so a reader walks the same row-major i8 bytes and only the scale lookup
767/// changes. Each group runs through the same [`quantize_output_channel_q8`] primitive the
768/// per-row path uses (a group is a row of width [`Q8_GROUP_WIDTH`] to the primitive), keeping
769/// offline artifact bytes and runtime quantization numerically identical by construction.
770///
771/// # Errors
772///
773/// Returns a precise source-shape, quantization, bounded-scale-tail, or artifact-write failure;
774/// a row width not divisible by [`Q8_GROUP_WIDTH`] is refused before any byte is written.
775pub fn stream_matrix_q8_group64_section<W: std::io::Write + std::io::Seek>(
776    matrix: &TensorView<'_>,
777    writer: &mut FttsqStreamingWriter<W>,
778    section: &str,
779) -> Result<(), MatrixQuantizationError<Q8SectionSinkError>> {
780    let shape = matrix.shape();
781    if shape.len() < 2 {
782        return Err(MatrixQuantizationError::ExpectedMatrix { rank: shape.len() });
783    }
784    let Some(&row_count) = shape.first() else {
785        return Err(MatrixQuantizationError::ExpectedMatrix { rank: 0 });
786    };
787    let row_width = matrix.row_len();
788    if row_width == 0 || !row_width.is_multiple_of(Q8_GROUP_WIDTH) {
789        return Err(MatrixQuantizationError::EmptyOutputChannel {
790            shape: shape.to_vec(),
791        });
792    }
793    if row_width > MAX_Q8_OUTPUT_CHANNEL_WIDTH {
794        return Err(MatrixQuantizationError::OutputChannelTooWide {
795            width: row_width,
796            limit: MAX_Q8_OUTPUT_CHANNEL_WIDTH,
797        });
798    }
799    let groups_per_row = row_width / Q8_GROUP_WIDTH;
800    let total_groups = row_count.checked_mul(groups_per_row).ok_or(
801        MatrixQuantizationError::OutputChannelTooWide {
802            width: row_width,
803            limit: MAX_Q8_OUTPUT_CHANNEL_WIDTH,
804        },
805    )?;
806    let mut sink = Q8SectionSink::new_grouped(writer, section, total_groups)
807        .map_err(|source| MatrixQuantizationError::Sink { row: 0, source })?;
808
809    let mut source_row = vec![0.0_f32; row_width];
810    let mut quantized_group = [0_i8; Q8_GROUP_WIDTH];
811    for row in 0..row_count {
812        if !matrix.copy_row_f32(row, &mut source_row) {
813            return Err(MatrixQuantizationError::SourceRowUnavailable { row });
814        }
815        for (group_index, group) in source_row
816            .as_chunks::<Q8_GROUP_WIDTH>()
817            .0
818            .iter()
819            .enumerate()
820        {
821            let scale = quantize_output_channel_q8(group, &mut quantized_group)
822                .map_err(|source| MatrixQuantizationError::Quantization { row, source })?;
823            sink.write_q8_row(row * groups_per_row + group_index, scale, &quantized_group)
824                .map_err(|source| MatrixQuantizationError::Sink { row, source })?;
825        }
826    }
827    sink.finish()
828        .map_err(|source| MatrixQuantizationError::Sink {
829            row: row_count,
830            source,
831        })
832}
833
834/// Converts a manifest-validated safetensors checkpoint into a portable `.fttsq` stream.
835///
836/// The source is borrowed so a caller may provide a memory map rather than a copied checkpoint.
837/// Before the output stream is opened, this function parses the safetensors directory, verifies
838/// the complete [`WeightsManifest`], verifies the plan's source SHA-256, and checks that every
839/// source tensor has exactly one explicit policy. It then writes one complete section per source
840/// tensor in plan order: high-precision payloads are copied verbatim and Q8 matrices use
841/// [`quantize_output_channel_q8`] through [`stream_matrix_q8_section`].
842///
843/// The destination is caller-owned deliberately. Pass a same-filesystem temporary file, sync it,
844/// and rename it only after this returns successfully; a failed conversion must never publish a
845/// partial artifact. The stream itself never retains a source tensor, Q8 payload, or section
846/// payload after it has been written.
847///
848/// # Errors
849///
850/// Refuses invalid safetensors bytes, a stale or wrong source manifest, digest mismatches,
851/// incomplete/ambiguous policy coverage, non-finite Q8 values, or container I/O/metadata errors.
852pub fn convert_safetensors_streaming<W: std::io::Write + std::io::Seek>(
853    source: &[u8],
854    manifest: &WeightsManifest,
855    plan: &StreamingConversionPlan,
856    destination: W,
857) -> Result<W, StreamingConversionError> {
858    let index = SafetensorsIndex::parse(source).map_err(StreamingConversionError::Source)?;
859    manifest
860        .verify(&index)
861        .map_err(StreamingConversionError::SourceCensus)?;
862
863    let actual_digest = sha256_hex(source);
864    if actual_digest != plan.source_sha256 {
865        return Err(StreamingConversionError::SourceDigestMismatch {
866            expected: plan.source_sha256.clone(),
867            actual: actual_digest,
868        });
869    }
870
871    let artifact_plan =
872        build_artifact_plan(&index, plan).map_err(StreamingConversionError::Plan)?;
873    let mut writer = artifact_plan
874        .begin(destination)
875        .map_err(StreamingConversionError::Artifact)?;
876
877    for tensor in tensors_in_write_order(plan) {
878        let matrix_or_values = index.view(&tensor.source_name, source).ok_or_else(|| {
879            StreamingConversionError::Plan(ConversionPlanError::SourceTensorMissing {
880                name: tensor.source_name.clone(),
881            })
882        })?;
883        let section = tensor.section_name();
884        match tensor.storage {
885            TensorStoragePolicy::Verbatim => writer
886                .write_section(section, matrix_or_values.as_bytes())
887                .map_err(StreamingConversionError::Artifact)?,
888            TensorStoragePolicy::Q8PerOutputChannel => {
889                stream_matrix_q8_section(&matrix_or_values, &mut writer, section)
890                    .map_err(StreamingConversionError::Quantization)?;
891            }
892            TensorStoragePolicy::Q8PerGroup64 => {
893                stream_matrix_q8_group64_section(&matrix_or_values, &mut writer, section)
894                    .map_err(StreamingConversionError::Quantization)?;
895            }
896        }
897    }
898
899    writer.finish().map_err(StreamingConversionError::Artifact)
900}
901
902fn build_artifact_plan(
903    index: &SafetensorsIndex,
904    plan: &StreamingConversionPlan,
905) -> Result<FttsqStreamPlan, ConversionPlanError> {
906    if plan.tensors.is_empty() {
907        return Err(ConversionPlanError::NoTensorPolicies);
908    }
909
910    let mut seen_sources = BTreeSet::<String>::new();
911    let mut seen_artifacts = BTreeSet::<String>::new();
912    for tensor in &plan.tensors {
913        if !seen_sources.insert(tensor.source_name.clone()) {
914            return Err(ConversionPlanError::DuplicateSourcePolicy {
915                name: tensor.source_name.clone(),
916            });
917        }
918        if index.entry(&tensor.source_name).is_none() {
919            return Err(ConversionPlanError::SourceTensorMissing {
920                name: tensor.source_name.clone(),
921            });
922        }
923        if tensor.artifact_name.is_empty() {
924            return Err(ConversionPlanError::EmptyArtifactTensorName {
925                source_name: tensor.source_name.clone(),
926            });
927        }
928        if !seen_artifacts.insert(tensor.artifact_name.clone()) {
929            return Err(ConversionPlanError::DuplicateArtifactTensor {
930                name: tensor.artifact_name.clone(),
931            });
932        }
933        if matches!(
934            tensor.storage,
935            TensorStoragePolicy::Q8PerOutputChannel | TensorStoragePolicy::Q8PerGroup64
936        ) {
937            let entry = index.entry(&tensor.source_name).ok_or_else(|| {
938                ConversionPlanError::SourceTensorMissing {
939                    name: tensor.source_name.clone(),
940                }
941            })?;
942            if entry.shape.len() < 2 {
943                return Err(ConversionPlanError::Q8RequiresMatrix {
944                    name: tensor.source_name.clone(),
945                    rank: entry.shape.len(),
946                });
947            }
948            let Some((&rows, trailing_shape)) = entry.shape.split_first() else {
949                return Err(ConversionPlanError::Q8RequiresMatrix {
950                    name: tensor.source_name.clone(),
951                    rank: 0,
952                });
953            };
954            let row_width = trailing_shape
955                .iter()
956                .try_fold(1_usize, |product, &dimension| {
957                    product.checked_mul(dimension)
958                })
959                .ok_or_else(|| ConversionPlanError::ShapeOutOfRange {
960                    name: tensor.source_name.clone(),
961                })?;
962            if row_width == 0 {
963                return Err(ConversionPlanError::Q8EmptyOutputChannel {
964                    name: tensor.source_name.clone(),
965                });
966            }
967            if row_width > MAX_Q8_OUTPUT_CHANNEL_WIDTH {
968                return Err(ConversionPlanError::Q8OutputChannelTooWide {
969                    name: tensor.source_name.clone(),
970                    width: row_width,
971                    limit: MAX_Q8_OUTPUT_CHANNEL_WIDTH,
972                });
973            }
974            if rows > MAX_Q8_OUTPUT_CHANNELS {
975                return Err(ConversionPlanError::Q8OutputChannelCountTooLarge {
976                    name: tensor.source_name.clone(),
977                    rows,
978                    limit: MAX_Q8_OUTPUT_CHANNELS,
979                });
980            }
981            if tensor.storage == TensorStoragePolicy::Q8PerGroup64 {
982                // The grouped stream refuses this too, but the plan is reviewed before a
983                // multi-gigabyte conversion opens an output file — refuse it here first.
984                if !row_width.is_multiple_of(Q8_GROUP_WIDTH) {
985                    return Err(ConversionPlanError::Q8EmptyOutputChannel {
986                        name: tensor.source_name.clone(),
987                    });
988                }
989                let groups = rows * (row_width / Q8_GROUP_WIDTH);
990                if groups > MAX_Q8_GROUP_SCALES {
991                    return Err(ConversionPlanError::Q8OutputChannelCountTooLarge {
992                        name: tensor.source_name.clone(),
993                        rows: groups,
994                        limit: MAX_Q8_GROUP_SCALES,
995                    });
996                }
997            }
998            let scales_name = tensor.scales_name();
999            if !seen_artifacts.insert(scales_name.clone()) {
1000                return Err(ConversionPlanError::DuplicateArtifactTensor { name: scales_name });
1001            }
1002        }
1003    }
1004
1005    for entry in index.entries() {
1006        if !seen_sources.contains(&entry.name) {
1007            return Err(ConversionPlanError::SourceTensorUnplanned {
1008                name: entry.name.clone(),
1009            });
1010        }
1011    }
1012
1013    let mut artifact_plan = FttsqStreamPlan::new(&plan.model_family, &plan.source_sha256)
1014        .license_notice(&plan.license_notice)
1015        .model_config(plan.model_config.clone())
1016        .quantization_manifest(plan.quantization_manifest.clone());
1017
1018    //  One section per access class, tensors located by running offset inside it. The write loop
1019    //  must emit payloads in exactly this order, so both sides iterate [`tensors_in_write_order`].
1020    let mut section_offsets: std::collections::BTreeMap<&'static str, u64> =
1021        std::collections::BTreeMap::new();
1022    let mut declared_sections: Vec<&'static str> = Vec::new();
1023    for tensor in tensors_in_write_order(plan) {
1024        let entry = index.entry(&tensor.source_name).ok_or_else(|| {
1025            ConversionPlanError::SourceTensorMissing {
1026                name: tensor.source_name.clone(),
1027            }
1028        })?;
1029        let shape = artifact_shape(entry, &tensor.source_name)?;
1030        let section = tensor.section_name();
1031        if !declared_sections.contains(&section) {
1032            declared_sections.push(section);
1033        }
1034        let running = section_offsets.entry(section).or_insert(0);
1035        match tensor.storage {
1036            TensorStoragePolicy::Verbatim => {
1037                let length = u64::try_from(entry.byte_len()).map_err(|_| {
1038                    ConversionPlanError::SectionLengthOverflow {
1039                        name: tensor.source_name.clone(),
1040                    }
1041                })?;
1042                artifact_plan = artifact_plan.tensor(ArtifactTensorEntry {
1043                    name: tensor.artifact_name.clone(),
1044                    section: section.to_owned(),
1045                    dtype: stored_dtype(entry.dtype),
1046                    shape,
1047                    offset: *running,
1048                    length,
1049                    scales: None,
1050                });
1051                *running = running.checked_add(length).ok_or_else(|| {
1052                    ConversionPlanError::SectionLengthOverflow {
1053                        name: tensor.source_name.clone(),
1054                    }
1055                })?;
1056            }
1057            TensorStoragePolicy::Q8PerOutputChannel | TensorStoragePolicy::Q8PerGroup64 => {
1058                let rows = entry.shape.first().copied().ok_or_else(|| {
1059                    ConversionPlanError::Q8RequiresMatrix {
1060                        name: tensor.source_name.clone(),
1061                        rank: entry.shape.len(),
1062                    }
1063                })?;
1064                // Per-row storage keeps one scale per output channel; grouped storage keeps one
1065                // per Q8_GROUP_WIDTH-element group. The payload bytes are identical either way;
1066                // only the scale tensor's element count and declared shape differ.
1067                let (scale_count, scales_shape) = if tensor.storage
1068                    == TensorStoragePolicy::Q8PerGroup64
1069                {
1070                    let row_width = entry
1071                        .element_count()
1072                        .checked_div(rows)
1073                        .filter(|width| width.is_multiple_of(Q8_GROUP_WIDTH))
1074                        .ok_or_else(|| ConversionPlanError::Q8EmptyOutputChannel {
1075                            name: tensor.source_name.clone(),
1076                        })?;
1077                    let groups_per_row = row_width / Q8_GROUP_WIDTH;
1078                    let rows_u64 =
1079                        u64::try_from(rows).map_err(|_| ConversionPlanError::ShapeOutOfRange {
1080                            name: tensor.source_name.clone(),
1081                        })?;
1082                    let groups_u64 = u64::try_from(groups_per_row).map_err(|_| {
1083                        ConversionPlanError::ShapeOutOfRange {
1084                            name: tensor.source_name.clone(),
1085                        }
1086                    })?;
1087                    (rows * groups_per_row, vec![rows_u64, groups_u64])
1088                } else {
1089                    (
1090                        rows,
1091                        vec![u64::try_from(rows).map_err(|_| {
1092                            ConversionPlanError::ShapeOutOfRange {
1093                                name: tensor.source_name.clone(),
1094                            }
1095                        })?],
1096                    )
1097                };
1098                let values_len = u64::try_from(entry.element_count()).map_err(|_| {
1099                    ConversionPlanError::SectionLengthOverflow {
1100                        name: tensor.source_name.clone(),
1101                    }
1102                })?;
1103                let scales_len = u64::try_from(scale_count)
1104                    .ok()
1105                    .and_then(|count| {
1106                        count.checked_mul(u64::try_from(std::mem::size_of::<f32>()).ok()?)
1107                    })
1108                    .ok_or_else(|| ConversionPlanError::SectionLengthOverflow {
1109                        name: tensor.source_name.clone(),
1110                    })?;
1111                let section_len = values_len.checked_add(scales_len).ok_or_else(|| {
1112                    ConversionPlanError::SectionLengthOverflow {
1113                        name: tensor.source_name.clone(),
1114                    }
1115                })?;
1116                let scales_name = tensor.scales_name();
1117                artifact_plan = artifact_plan
1118                    .tensor(ArtifactTensorEntry {
1119                        name: tensor.artifact_name.clone(),
1120                        section: section.to_owned(),
1121                        dtype: StoredDtype::Q8,
1122                        shape,
1123                        offset: *running,
1124                        length: values_len,
1125                        scales: Some(scales_name.clone()),
1126                    })
1127                    .tensor(ArtifactTensorEntry {
1128                        name: scales_name,
1129                        section: section.to_owned(),
1130                        dtype: StoredDtype::F32,
1131                        shape: scales_shape,
1132                        offset: running.checked_add(values_len).ok_or_else(|| {
1133                            ConversionPlanError::SectionLengthOverflow {
1134                                name: tensor.source_name.clone(),
1135                            }
1136                        })?,
1137                        length: scales_len,
1138                        scales: None,
1139                    });
1140                *running = running.checked_add(section_len).ok_or_else(|| {
1141                    ConversionPlanError::SectionLengthOverflow {
1142                        name: tensor.source_name.clone(),
1143                    }
1144                })?;
1145            }
1146        }
1147    }
1148
1149    //  Declare the access-class sections in first-touch order with their accumulated lengths;
1150    //  the write loop replays the same order, so every section fills exactly to its declaration.
1151    for section in declared_sections {
1152        let class = section_access_class(section);
1153        let length = section_offsets
1154            .get(section)
1155            .copied()
1156            .expect("declared sections accumulate a length");
1157        artifact_plan = artifact_plan.section(section, class, length);
1158    }
1159
1160    Ok(artifact_plan)
1161}
1162
1163/// The stable payload order shared by planning and writing: grouped by access-class section in
1164/// first-appearance order, original recipe order preserved within each class.
1165fn tensors_in_write_order(plan: &StreamingConversionPlan) -> Vec<&TensorConversion> {
1166    let mut order: Vec<&'static str> = Vec::new();
1167    for tensor in &plan.tensors {
1168        let section = tensor.section_name();
1169        if !order.contains(&section) {
1170            order.push(section);
1171        }
1172    }
1173    let mut grouped = Vec::with_capacity(plan.tensors.len());
1174    for section in order {
1175        grouped.extend(
1176            plan.tensors
1177                .iter()
1178                .filter(|tensor| tensor.section_name() == section),
1179        );
1180    }
1181    grouped
1182}
1183
1184/// Maps a section wire name back to its access class; sections and classes are one-to-one.
1185fn section_access_class(name: &str) -> AccessClass {
1186    for class in [
1187        AccessClass::HotRecurrentMicrodecoder,
1188        AccessClass::HotRecurrentTalker,
1189        AccessClass::HotCodecDecoder,
1190        AccessClass::ColdTextEmbedding,
1191        AccessClass::EnrollmentSpeakerEncoder,
1192        AccessClass::EnrollmentCodecEncoder,
1193        AccessClass::Metadata,
1194    ] {
1195        if class.as_str() == name {
1196            return class;
1197        }
1198    }
1199    unreachable!("section names are minted from AccessClass::as_str")
1200}
1201
1202fn artifact_shape(
1203    entry: &crate::safetensors::TensorEntry,
1204    source_name: &str,
1205) -> Result<Vec<u64>, ConversionPlanError> {
1206    entry
1207        .shape
1208        .iter()
1209        .copied()
1210        .map(u64::try_from)
1211        .collect::<Result<Vec<_>, _>>()
1212        .map_err(|_| ConversionPlanError::ShapeOutOfRange {
1213            name: source_name.to_owned(),
1214        })
1215}
1216
1217const fn stored_dtype(source: Dtype) -> StoredDtype {
1218    match source {
1219        Dtype::Bf16 => StoredDtype::Bf16,
1220        Dtype::F32 => StoredDtype::F32,
1221    }
1222}
1223
1224fn sha256_hex(bytes: &[u8]) -> String {
1225    const HEX: &[u8; 16] = b"0123456789abcdef";
1226    let digest = {
1227        let mut hasher = Sha256::new();
1228        hasher.update(bytes);
1229        hasher.finish()
1230    };
1231    let mut output = String::with_capacity(64);
1232    for byte in digest {
1233        output.push(char::from(HEX[usize::from(byte >> 4)]));
1234        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1235    }
1236    output
1237}
1238
1239/// Quantizes one output channel with the canonical symmetric per-channel Q8 recipe.
1240///
1241/// The returned scale is `max(abs(row)) / 127`. All-zero rows use the explicit scale `1.0`,
1242/// avoiding a NaN-producing divide while preserving zero bytes. Values use ties-to-even rounding
1243/// after clamping to the symmetric `[-127, 127]` domain; `-128` is never emitted.
1244///
1245/// `output` is caller-owned so an offline converter can process a single tile at a time instead
1246/// of widening or retaining an entire checkpoint tensor. Runtime quantization calls this exact
1247/// function too.
1248///
1249/// # Errors
1250///
1251/// Returns an error if the destination length differs from the row length or a source value is
1252/// NaN or infinite.
1253pub fn quantize_output_channel_q8(
1254    row: &[f32],
1255    output: &mut [i8],
1256) -> Result<f32, QuantizationError> {
1257    if output.len() != row.len() {
1258        return Err(QuantizationError::OutputLength {
1259            values: row.len(),
1260            output: output.len(),
1261        });
1262    }
1263
1264    let mut maximum = 0.0_f32;
1265    for (index, &value) in row.iter().enumerate() {
1266        if !value.is_finite() {
1267            return Err(QuantizationError::NonFiniteValue { index, value });
1268        }
1269        maximum = maximum.max(value.abs());
1270    }
1271
1272    if maximum == 0.0 {
1273        output.fill(0);
1274        return Ok(1.0);
1275    }
1276
1277    let scale = maximum / 127.0;
1278    for (&value, slot) in row.iter().zip(output) {
1279        let rounded = (value / scale).clamp(-127.0, 127.0).round_ties_even();
1280        // The clamp above proves this conversion is in the i8 range, and the symmetric contract
1281        // additionally rules out the otherwise-representable -128 value.
1282        *slot = rounded as i8;
1283    }
1284    Ok(scale)
1285}
1286
1287/// Quantizes a safetensors matrix one output channel at a time.
1288///
1289/// This is the bounded-memory bridge between a zero-copy checkpoint view and a streaming artifact
1290/// writer. It allocates exactly two row-sized scratch buffers: one widened `f32` row and one Q8
1291/// row. In particular, it never constructs an `f32` or Q8 copy of the entire matrix. Each row is
1292/// passed through [`quantize_output_channel_q8`], the primitive runtime quantization also calls,
1293/// before it reaches `sink`.
1294///
1295/// The source must be rank 2 or greater, with its outermost axis representing output channels.
1296/// Vectors are rejected explicitly so a caller must choose their precision policy rather than
1297/// silently treating every scalar as an independently scaled output channel.
1298///
1299/// # Errors
1300///
1301/// Returns a named error for an unsupported shape, malformed source row, non-finite source value,
1302/// or destination failure. A failure never emits a partial row.
1303pub fn quantize_matrix_q8_rows<S: Q8RowSink>(
1304    matrix: &TensorView<'_>,
1305    sink: &mut S,
1306) -> Result<(), MatrixQuantizationError<S::Error>> {
1307    let shape = matrix.shape();
1308    if shape.len() < 2 {
1309        return Err(MatrixQuantizationError::ExpectedMatrix { rank: shape.len() });
1310    }
1311
1312    let Some(&row_count) = shape.first() else {
1313        return Err(MatrixQuantizationError::ExpectedMatrix { rank: 0 });
1314    };
1315    let row_width = matrix.row_len();
1316    if row_width == 0 {
1317        return Err(MatrixQuantizationError::EmptyOutputChannel {
1318            shape: shape.to_vec(),
1319        });
1320    }
1321    if row_width > MAX_Q8_OUTPUT_CHANNEL_WIDTH {
1322        return Err(MatrixQuantizationError::OutputChannelTooWide {
1323            width: row_width,
1324            limit: MAX_Q8_OUTPUT_CHANNEL_WIDTH,
1325        });
1326    }
1327
1328    let mut source_row = vec![0.0_f32; row_width];
1329    let mut quantized_row = vec![0_i8; row_width];
1330    for row in 0..row_count {
1331        if !matrix.copy_row_f32(row, &mut source_row) {
1332            return Err(MatrixQuantizationError::SourceRowUnavailable { row });
1333        }
1334        let scale = quantize_output_channel_q8(&source_row, &mut quantized_row)
1335            .map_err(|source| MatrixQuantizationError::Quantization { row, source })?;
1336        sink.write_q8_row(row, scale, &quantized_row)
1337            .map_err(|source| MatrixQuantizationError::Sink { row, source })?;
1338    }
1339    Ok(())
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344    use super::*;
1345    use crate::census::ExpectedTensor;
1346    use crate::fttsq::{AccessClass, FttsqReader, FttsqStreamPlan, StoredDtype, TensorEntry};
1347    use crate::safetensors::SafetensorsIndex;
1348    use serde_json::json;
1349    use std::convert::Infallible;
1350    use std::io::Cursor;
1351
1352    #[derive(Default)]
1353    struct RecordingSink {
1354        rows: Vec<(usize, f32, Vec<i8>)>,
1355    }
1356
1357    impl Q8RowSink for RecordingSink {
1358        type Error = Infallible;
1359
1360        fn write_q8_row(
1361            &mut self,
1362            row: usize,
1363            scale: f32,
1364            values: &[i8],
1365        ) -> Result<(), Self::Error> {
1366            self.rows.push((row, scale, values.to_vec()));
1367            Ok(())
1368        }
1369    }
1370
1371    fn f32_matrix(rows: usize, columns: usize, values: &[f32]) -> Vec<u8> {
1372        assert_eq!(values.len(), rows * columns);
1373        let payload: Vec<u8> = values
1374            .iter()
1375            .flat_map(|value| value.to_le_bytes())
1376            .collect();
1377        let header = serde_json::to_vec(&json!({
1378            "matrix": {
1379                "dtype": "F32",
1380                "shape": [rows, columns],
1381                "data_offsets": [0, payload.len()],
1382            }
1383        }))
1384        .expect("fixture directory serializes");
1385
1386        let mut bytes = (header.len() as u64).to_le_bytes().to_vec();
1387        bytes.extend_from_slice(&header);
1388        bytes.extend_from_slice(&payload);
1389        bytes
1390    }
1391
1392    fn safetensors(parts: &[(&str, Dtype, &[usize], &[u8])]) -> Vec<u8> {
1393        let mut directory = serde_json::Map::new();
1394        let mut payload = Vec::new();
1395        for (name, dtype, shape, bytes) in parts {
1396            let begin = payload.len();
1397            payload.extend_from_slice(bytes);
1398            directory.insert(
1399                (*name).to_owned(),
1400                json!({
1401                    "dtype": dtype.as_str(),
1402                    "shape": shape,
1403                    "data_offsets": [begin, payload.len()],
1404                }),
1405            );
1406        }
1407        let header = serde_json::to_vec(&serde_json::Value::Object(directory))
1408            .expect("fixture directory serializes");
1409        let mut source = (header.len() as u64).to_le_bytes().to_vec();
1410        source.extend_from_slice(&header);
1411        source.extend_from_slice(&payload);
1412        source
1413    }
1414
1415    #[test]
1416    fn q8_uses_symmetric_ties_to_even_rounding_and_never_emits_negative_128() {
1417        let row = [
1418            -127.0, -126.5, -125.5, -1.5, -0.5, 0.5, 1.5, 125.5, 126.5, 127.0,
1419        ];
1420        let mut output = [0_i8; 10];
1421
1422        let scale = quantize_output_channel_q8(&row, &mut output).expect("finite row");
1423
1424        assert_eq!(scale, 1.0);
1425        assert_eq!(output, [-127, -126, -126, -2, 0, 0, 2, 126, 126, 127]);
1426        assert!(!output.contains(&i8::MIN));
1427    }
1428
1429    #[test]
1430    fn q8_all_zero_row_has_a_finite_unit_scale() {
1431        let row = [0.0_f32; 4];
1432        let mut output = [9_i8; 4];
1433
1434        let scale = quantize_output_channel_q8(&row, &mut output).expect("zero row is valid");
1435
1436        assert_eq!(scale, 1.0);
1437        assert_eq!(output, [0; 4]);
1438    }
1439
1440    #[test]
1441    fn q8_refuses_length_mismatch_and_non_finite_input() {
1442        let error = quantize_output_channel_q8(&[1.0, 2.0], &mut [0]).expect_err("wrong length");
1443        assert_eq!(
1444            error,
1445            QuantizationError::OutputLength {
1446                values: 2,
1447                output: 1,
1448            }
1449        );
1450
1451        let error = quantize_output_channel_q8(&[1.0, f32::NAN], &mut [0; 2])
1452            .expect_err("NaN cannot be quantized deterministically");
1453        assert!(matches!(
1454            error,
1455            QuantizationError::NonFiniteValue { index: 1, value } if value.is_nan()
1456        ));
1457    }
1458
1459    #[test]
1460    fn runtime_and_offline_callers_receive_byte_identical_q8_rows() {
1461        let row = [-3.0_f32, -0.75, 0.5, 1.5, 3.0];
1462        let mut runtime = [0_i8; 5];
1463        let mut offline = [0_i8; 5];
1464
1465        let runtime_scale = quantize_output_channel_q8(&row, &mut runtime).expect("runtime Q8");
1466        let offline_scale = quantize_output_channel_q8(&row, &mut offline).expect("offline Q8");
1467
1468        assert_eq!(runtime, offline);
1469        assert_eq!(runtime_scale.to_bits(), offline_scale.to_bits());
1470    }
1471
1472    #[test]
1473    fn matrix_rows_stream_through_the_shared_primitive_in_order() {
1474        let bytes = f32_matrix(2, 3, &[1.0, -2.0, 0.5, 3.0, 0.0, -3.0]);
1475        let index = SafetensorsIndex::parse(&bytes).expect("fixture parses");
1476        let matrix = index.view("matrix", &bytes).expect("matrix view exists");
1477        let mut sink = RecordingSink::default();
1478
1479        quantize_matrix_q8_rows(&matrix, &mut sink).expect("finite matrix quantizes");
1480
1481        assert_eq!(sink.rows.len(), 2);
1482        assert_eq!(sink.rows[0].0, 0);
1483        assert_eq!(sink.rows[0].1.to_bits(), (2.0_f32 / 127.0).to_bits());
1484        assert_eq!(sink.rows[0].2, vec![64, -127, 32]);
1485        assert_eq!(sink.rows[1].0, 1);
1486        assert_eq!(sink.rows[1].1.to_bits(), (3.0_f32 / 127.0).to_bits());
1487        assert_eq!(sink.rows[1].2, vec![127, 0, -127]);
1488    }
1489
1490    #[test]
1491    fn matrix_q8_section_streams_values_then_bounded_scale_tail() {
1492        let source = f32_matrix(2, 3, &[1.0, -2.0, 0.5, 3.0, 0.0, -3.0]);
1493        let index = SafetensorsIndex::parse(&source).expect("fixture parses");
1494        let matrix = index.view("matrix", &source).expect("matrix view exists");
1495        let plan = FttsqStreamPlan::new("test-model", "a".repeat(64))
1496            .license_notice("Copyright 2026 Alibaba Cloud\nApache-2.0")
1497            .section("matrix", AccessClass::HotRecurrentTalker, 14)
1498            .tensor(TensorEntry {
1499                name: "matrix.weight".to_owned(),
1500                section: "matrix".to_owned(),
1501                dtype: StoredDtype::Q8,
1502                shape: vec![2, 3],
1503                offset: 0,
1504                length: 6,
1505                scales: Some("matrix.weight.scales".to_owned()),
1506            })
1507            .tensor(TensorEntry {
1508                name: "matrix.weight.scales".to_owned(),
1509                section: "matrix".to_owned(),
1510                dtype: StoredDtype::F32,
1511                shape: vec![2],
1512                offset: 6,
1513                length: 8,
1514                scales: None,
1515            });
1516        let mut writer = plan
1517            .begin(Cursor::new(Vec::new()))
1518            .expect("section metadata is valid");
1519
1520        stream_matrix_q8_section(&matrix, &mut writer, "matrix")
1521            .expect("matrix streams through the canonical Q8 primitive");
1522        let artifact = writer
1523            .finish()
1524            .expect("completed section finalizes its digest")
1525            .into_inner();
1526        let reader = FttsqReader::open(&artifact).expect("artifact verifies");
1527
1528        assert_eq!(
1529            reader
1530                .tensor_bytes("matrix.weight", &artifact)
1531                .expect("Q8 bytes resolve"),
1532            &[64, 129, 32, 127, 0, 129]
1533        );
1534        let scales = reader
1535            .tensor_bytes("matrix.weight.scales", &artifact)
1536            .expect("scale bytes resolve");
1537        assert_eq!(
1538            scales,
1539            &[
1540                (2.0_f32 / 127.0).to_le_bytes(),
1541                (3.0_f32 / 127.0).to_le_bytes(),
1542            ]
1543            .concat()
1544        );
1545    }
1546
1547    #[test]
1548    fn grouped_q8_section_carries_one_scale_per_group_and_dequantizes_per_group() {
1549        // Two rows of two groups each: within each row, one loud group and one quiet group.
1550        // A per-row scale would quantize the quiet group at the loud group's step; per-group
1551        // scales must recover it exactly at this tiny size (each group has <= 127 magnitudes).
1552        let quiet = [0.00127_f32, -0.0005];
1553        let source = f32_matrix(
1554            2,
1555            2 * Q8_GROUP_WIDTH,
1556            &[
1557                std::iter::repeat_n(1.27_f32, Q8_GROUP_WIDTH).collect::<Vec<_>>(),
1558                quiet.iter().copied().cycle().take(Q8_GROUP_WIDTH).collect(),
1559                std::iter::repeat_n(-2.54_f32, Q8_GROUP_WIDTH).collect(),
1560                quiet.iter().copied().cycle().take(Q8_GROUP_WIDTH).collect(),
1561            ]
1562            .concat(),
1563        );
1564        let index = SafetensorsIndex::parse(&source).expect("fixture parses");
1565        let matrix = index.view("matrix", &source).expect("matrix view exists");
1566        let values_len = 2 * 2 * Q8_GROUP_WIDTH as u64;
1567        let plan = FttsqStreamPlan::new("test-model", "a".repeat(64))
1568            .license_notice("Copyright 2026 Alibaba Cloud\nApache-2.0")
1569            .section("matrix", AccessClass::ColdTextEmbedding, values_len + 16)
1570            .tensor(TensorEntry {
1571                name: "matrix.weight".to_owned(),
1572                section: "matrix".to_owned(),
1573                dtype: StoredDtype::Q8,
1574                shape: vec![2, 2 * Q8_GROUP_WIDTH as u64],
1575                offset: 0,
1576                length: values_len,
1577                scales: Some("matrix.weight.scales".to_owned()),
1578            })
1579            .tensor(TensorEntry {
1580                name: "matrix.weight.scales".to_owned(),
1581                section: "matrix".to_owned(),
1582                dtype: StoredDtype::F32,
1583                shape: vec![2, 2],
1584                offset: values_len,
1585                length: 16,
1586                scales: None,
1587            });
1588        let mut writer = plan
1589            .begin(Cursor::new(Vec::new()))
1590            .expect("section metadata is valid");
1591        stream_matrix_q8_group64_section(&matrix, &mut writer, "matrix")
1592            .expect("grouped matrix streams through the canonical primitive");
1593        let artifact = writer
1594            .finish()
1595            .expect("completed section finalizes its digest")
1596            .into_inner();
1597        let reader = FttsqReader::open(&artifact).expect("artifact verifies");
1598
1599        let scales: Vec<f32> = reader
1600            .tensor_bytes("matrix.weight.scales", &artifact)
1601            .expect("scale bytes resolve")
1602            .as_chunks::<4>()
1603            .0
1604            .iter()
1605            .map(|bytes| f32::from_le_bytes(*bytes))
1606            .collect();
1607        assert_eq!(
1608            scales.iter().map(|s| s.to_bits()).collect::<Vec<_>>(),
1609            [
1610                1.27_f32 / 127.0,
1611                0.00127_f32 / 127.0,
1612                2.54_f32 / 127.0,
1613                0.00127_f32 / 127.0,
1614            ]
1615            .iter()
1616            .map(|s| s.to_bits())
1617            .collect::<Vec<_>>(),
1618            "each group carries its own max-abs scale"
1619        );
1620        // The quiet groups dequantize exactly: their values are exact multiples of their own
1621        // group scale, which the loud rows' scales could never represent.
1622        let bytes = reader
1623            .tensor_bytes("matrix.weight", &artifact)
1624            .expect("Q8 bytes resolve");
1625        let quiet_group_of_row_0 = &bytes[Q8_GROUP_WIDTH..2 * Q8_GROUP_WIDTH];
1626        for (index, &byte) in quiet_group_of_row_0.iter().enumerate() {
1627            let value = f32::from(i8::from_ne_bytes([byte])) * scales[1];
1628            let expected = quiet[index % 2];
1629            assert!(
1630                (value - expected).abs() < 1e-9,
1631                "quiet element {index}: {value} vs {expected}"
1632            );
1633        }
1634    }
1635
1636    #[test]
1637    fn manifest_verified_multi_tensor_stream_is_deterministic_and_verbatim_where_required() {
1638        let weight = [1.0_f32, -2.0, 0.5, 3.0, 0.0, -3.0]
1639            .iter()
1640            .flat_map(|value| value.to_le_bytes())
1641            .collect::<Vec<_>>();
1642        let bias = [0x80_u16, 0x3f80]
1643            .iter()
1644            .flat_map(|value| value.to_le_bytes())
1645            .collect::<Vec<_>>();
1646        let source = safetensors(&[
1647            ("weight", Dtype::F32, &[2, 3], &weight),
1648            ("bias", Dtype::Bf16, &[2], &bias),
1649        ]);
1650        let manifest = WeightsManifest::from_expectations(
1651            "small pinned fixture",
1652            [
1653                ExpectedTensor::new("weight", vec![2, 3], Dtype::F32),
1654                ExpectedTensor::new("bias", vec![2], Dtype::Bf16),
1655            ],
1656        );
1657        let plan = StreamingConversionPlan::new("qwen3-tts-fixture", sha256_hex(&source))
1658            .license_notice("Copyright 2026 Alibaba Cloud\nApache-2.0")
1659            .model_config(json!({ "fixture": true }))
1660            .quantization_manifest(json!({
1661                "weight": "q8_per_output_channel",
1662                "bias": "verbatim_bf16",
1663            }))
1664            .tensor(TensorConversion::q8_per_output_channel(
1665                "weight",
1666                "weight",
1667                AccessClass::HotRecurrentTalker,
1668            ))
1669            .tensor(TensorConversion::verbatim(
1670                "bias",
1671                "bias",
1672                AccessClass::Metadata,
1673            ));
1674
1675        let first =
1676            convert_safetensors_streaming(&source, &manifest, &plan, Cursor::new(Vec::new()))
1677                .expect("fixture converts")
1678                .into_inner();
1679        let second =
1680            convert_safetensors_streaming(&source, &manifest, &plan, Cursor::new(Vec::new()))
1681                .expect("second fixture conversion is deterministic")
1682                .into_inner();
1683        assert_eq!(
1684            first, second,
1685            "identical source and plan must be byte-identical"
1686        );
1687
1688        let reader = FttsqReader::open(&first).expect("artifact verifies its section digests");
1689        let mut runtime_q8 = [0_i8; 6];
1690        let runtime_first_scale =
1691            quantize_output_channel_q8(&[1.0_f32, -2.0, 0.5], &mut runtime_q8[..3])
1692                .expect("shared runtime primitive quantizes the first row");
1693        let runtime_second_scale =
1694            quantize_output_channel_q8(&[3.0_f32, 0.0, -3.0], &mut runtime_q8[3..])
1695                .expect("shared runtime primitive quantizes the second row");
1696        assert_eq!(
1697            reader
1698                .tensor_bytes("weight", &first)
1699                .expect("Q8 weights resolve"),
1700            runtime_q8.map(|value| value as u8)
1701        );
1702        assert_eq!(
1703            reader
1704                .tensor_bytes("weight.scales", &first)
1705                .expect("Q8 scales resolve"),
1706            &[
1707                runtime_first_scale.to_le_bytes(),
1708                runtime_second_scale.to_le_bytes(),
1709            ]
1710            .concat()
1711        );
1712        assert_eq!(
1713            reader
1714                .tensor_bytes("bias", &first)
1715                .expect("protected BF16 values resolve"),
1716            bias
1717        );
1718    }
1719
1720    #[test]
1721    fn streaming_conversion_refuses_unpinned_source_before_writing() {
1722        let source = f32_matrix(1, 2, &[1.0, -1.0]);
1723        let manifest = WeightsManifest::from_expectations(
1724            "digest fixture",
1725            [ExpectedTensor::new("matrix", vec![1, 2], Dtype::F32)],
1726        );
1727        let plan = StreamingConversionPlan::new("qwen3-tts-fixture", "0".repeat(64))
1728            .license_notice("Copyright 2026 Alibaba Cloud\nApache-2.0")
1729            .tensor(TensorConversion::q8_per_output_channel(
1730                "matrix",
1731                "matrix",
1732                AccessClass::HotRecurrentTalker,
1733            ));
1734
1735        let error =
1736            convert_safetensors_streaming(&source, &manifest, &plan, Cursor::new(Vec::new()))
1737                .expect_err("a wrong source digest must refuse before artifact construction");
1738        assert!(matches!(
1739            error,
1740            StreamingConversionError::SourceDigestMismatch { .. }
1741        ));
1742    }
1743
1744    #[test]
1745    fn matrix_quantization_refuses_vector_policy_ambiguity() {
1746        let header = serde_json::to_vec(&json!({
1747            "vector": {
1748                "dtype": "F32",
1749                "shape": [2],
1750                "data_offsets": [0, 8],
1751            }
1752        }))
1753        .expect("fixture directory serializes");
1754        let mut bytes = (header.len() as u64).to_le_bytes().to_vec();
1755        bytes.extend_from_slice(&header);
1756        bytes.extend_from_slice(&1.0_f32.to_le_bytes());
1757        bytes.extend_from_slice(&2.0_f32.to_le_bytes());
1758        let index = SafetensorsIndex::parse(&bytes).expect("fixture parses");
1759        let vector = index.view("vector", &bytes).expect("vector view exists");
1760
1761        let error = quantize_matrix_q8_rows(&vector, &mut RecordingSink::default())
1762            .expect_err("vector policy must be explicit");
1763        assert_eq!(error, MatrixQuantizationError::ExpectedMatrix { rank: 1 });
1764    }
1765
1766    #[test]
1767    fn matrix_quantization_refuses_a_row_that_breaks_its_memory_ceiling() {
1768        let values = vec![0.0_f32; MAX_Q8_OUTPUT_CHANNEL_WIDTH + 1];
1769        let bytes = f32_matrix(1, values.len(), &values);
1770        let index = SafetensorsIndex::parse(&bytes).expect("fixture parses");
1771        let matrix = index.view("matrix", &bytes).expect("matrix view exists");
1772
1773        let error = quantize_matrix_q8_rows(&matrix, &mut RecordingSink::default())
1774            .expect_err("row width must be bounded before scratch allocation");
1775        assert_eq!(
1776            error,
1777            MatrixQuantizationError::OutputChannelTooWide {
1778                width: MAX_Q8_OUTPUT_CHANNEL_WIDTH + 1,
1779                limit: MAX_Q8_OUTPUT_CHANNEL_WIDTH,
1780            }
1781        );
1782    }
1783}