Skip to main content

eredu_runtime/
input.rs

1//! Backend-neutral ownership of prepared multimodal tensors.
2
3use std::collections::BTreeMap;
4
5use eredu_core::{
6    CapabilityError, InputExtent, InputMetadataKey, InputModality, InputPartDescriptor,
7    InputPayloadKind, InputTensorIdentity, PreparedInputError, PreparedInputIdentity,
8};
9use sha2::{Digest, Sha256};
10
11/// Mechanism for describing native prepared tensors and reading bounded metadata.
12///
13/// Architecture admission owns which metadata values are semantically relevant.
14/// Implementations expose only tensor identity and evaluation of the small integer
15/// or Boolean arrays requested by that admission logic.
16pub trait PreparedInputInspector<Tensor> {
17    /// Returns the portable identity of a native tensor.
18    fn identity(&self, tensor: &Tensor) -> Result<InputTensorIdentity, PreparedInputError>;
19
20    /// Reads an evaluated signed-integer metadata tensor in row-major order.
21    fn i32_values(&self, tensor: &Tensor) -> Result<Vec<i32>, CapabilityError>;
22
23    /// Reads an evaluated Boolean metadata tensor in row-major order.
24    fn bool_values(&self, tensor: &Tensor) -> Result<Vec<bool>, CapabilityError>;
25}
26
27/// Primary tensor and its semantic role for one prepared input part.
28#[derive(Debug, Clone, Eq, PartialEq)]
29#[non_exhaustive]
30pub enum PreparedInputPayload<Tensor> {
31    /// Tokenizer vocabulary IDs.
32    TokenIds(Tensor),
33    /// Model-native features or patches that still require an encoder.
34    Tensor(Tensor),
35    /// Already projected decoder-width embeddings.
36    Embeddings(Tensor),
37}
38
39impl<Tensor> PreparedInputPayload<Tensor> {
40    /// Semantic payload kind.
41    pub const fn kind(&self) -> InputPayloadKind {
42        match self {
43            Self::TokenIds(_) => InputPayloadKind::TokenIds,
44            Self::Tensor(_) => InputPayloadKind::Tensor,
45            Self::Embeddings(_) => InputPayloadKind::Embeddings,
46        }
47    }
48
49    /// Borrows the backend-native tensor.
50    pub const fn value(&self) -> &Tensor {
51        match self {
52            Self::TokenIds(value) | Self::Tensor(value) | Self::Embeddings(value) => value,
53        }
54    }
55}
56
57/// One owned, typed, ordered prepared input part.
58#[derive(Debug, Clone, Eq, PartialEq)]
59pub struct PreparedInputPart<Tensor> {
60    modality: InputModality,
61    payload: PreparedInputPayload<Tensor>,
62    metadata: BTreeMap<InputMetadataKey, Tensor>,
63    extents: Vec<InputExtent>,
64}
65
66impl<Tensor> PreparedInputPart<Tensor> {
67    /// Creates a part with compatible payload and unique, compatible metadata.
68    pub fn new(
69        modality: InputModality,
70        payload: PreparedInputPayload<Tensor>,
71        metadata: impl IntoIterator<Item = (InputMetadataKey, Tensor)>,
72    ) -> Result<Self, PreparedInputError> {
73        Self::new_with_extents(modality, payload, metadata, [])
74    }
75
76    /// Creates a part with compatible host-known execution extents.
77    pub fn new_with_extents(
78        modality: InputModality,
79        payload: PreparedInputPayload<Tensor>,
80        metadata: impl IntoIterator<Item = (InputMetadataKey, Tensor)>,
81        extents: impl IntoIterator<Item = InputExtent>,
82    ) -> Result<Self, PreparedInputError> {
83        let payload_kind = payload.kind();
84        if !payload_kind.accepts(modality) {
85            return Err(PreparedInputError::IncompatiblePayload {
86                modality,
87                payload: payload_kind,
88            });
89        }
90        let mut typed_metadata = BTreeMap::new();
91        for (key, value) in metadata {
92            if !key.accepts(modality) {
93                return Err(PreparedInputError::IncompatibleMetadata { modality, key });
94            }
95            if typed_metadata.insert(key, value).is_some() {
96                return Err(PreparedInputError::DuplicateMetadata { key });
97            }
98        }
99        let extents = extents.into_iter().collect::<Vec<_>>();
100        for (index, extent) in extents.iter().copied().enumerate() {
101            if !extent.accepts(modality) {
102                return Err(PreparedInputError::IncompatibleExtent { modality, extent });
103            }
104            if extents[..index]
105                .iter()
106                .any(|prior| std::mem::discriminant(prior) == std::mem::discriminant(&extent))
107            {
108                return Err(PreparedInputError::DuplicateExtent { extent });
109            }
110        }
111        Ok(Self {
112            modality,
113            payload,
114            metadata: typed_metadata,
115            extents,
116        })
117    }
118
119    /// Part modality.
120    pub const fn modality(&self) -> InputModality {
121        self.modality
122    }
123
124    /// Primary tensor and semantic role.
125    pub const fn payload(&self) -> &PreparedInputPayload<Tensor> {
126        &self.payload
127    }
128
129    /// Typed metadata tensors in stable key order.
130    pub const fn metadata(&self) -> &BTreeMap<InputMetadataKey, Tensor> {
131        &self.metadata
132    }
133
134    /// Looks up one metadata tensor.
135    pub fn metadata_value(&self, key: InputMetadataKey) -> Option<&Tensor> {
136        self.metadata.get(&key)
137    }
138
139    /// Host-known extents needed by accelerator execution.
140    pub fn extents(&self) -> &[InputExtent] {
141        &self.extents
142    }
143
144    /// Builds and validates the core descriptor for this exact tensor part.
145    pub fn descriptor(
146        &self,
147        describe: &impl Fn(&Tensor) -> Result<InputTensorIdentity, PreparedInputError>,
148    ) -> Result<InputPartDescriptor, PreparedInputError> {
149        InputPartDescriptor::new_with_extents(
150            self.modality,
151            self.payload.kind(),
152            describe(self.payload.value())?,
153            self.metadata
154                .iter()
155                .map(|(key, value)| Ok((*key, describe(value)?)))
156                .collect::<Result<Vec<_>, PreparedInputError>>()?,
157            self.extents.iter().copied(),
158        )
159    }
160}
161
162/// Backend-neutral prepared input that owns backend-native tensor handles.
163///
164/// The identity is validated at construction and remains coupled to the exact
165/// ordered payload and metadata values used by runtime and distributed paths.
166#[derive(Debug, Clone, Eq, PartialEq)]
167pub struct PreparedModelInput<Tensor> {
168    parts: Vec<PreparedInputPart<Tensor>>,
169    identity: PreparedInputIdentity,
170}
171
172/// Cache identity coupling an ordered prepared-input description to caller-owned content.
173///
174/// [`PreparedInputIdentity`] deliberately excludes tensor payload bytes. Prompt caches need
175/// both that stable description and a digest of the semantic content that produced the native
176/// tensors, so equal shapes alone can never make two media requests cache-equivalent.
177#[derive(Debug, Clone, Eq, PartialEq)]
178pub struct PreparedInputCacheIdentity {
179    prepared: PreparedInputIdentity,
180    semantic_content_fingerprint: String,
181    prefix_content_fingerprint: String,
182}
183
184impl PreparedInputCacheIdentity {
185    /// Couples one prepared-input description to a nonempty semantic-content fingerprint.
186    pub fn new(
187        prepared: PreparedInputIdentity,
188        semantic_content_fingerprint: impl Into<String>,
189    ) -> Result<Self, PreparedInputCacheIdentityError> {
190        let semantic_content_fingerprint = semantic_content_fingerprint.into();
191        if semantic_content_fingerprint.trim().is_empty() {
192            return Err(PreparedInputCacheIdentityError::EmptySemanticContent);
193        }
194        let words = prepared
195            .encode_words()
196            .map_err(PreparedInputCacheIdentityError::Prepared)?;
197        let mut digest = Sha256::new();
198        digest.update(b"eredu-prepared-input-cache-v1\0");
199        digest.update((words.len() as u64).to_le_bytes());
200        for word in words {
201            digest.update(word.to_le_bytes());
202        }
203        digest.update((semantic_content_fingerprint.len() as u64).to_le_bytes());
204        digest.update(semantic_content_fingerprint.as_bytes());
205        let prefix_content_fingerprint = digest
206            .finalize()
207            .iter()
208            .map(|byte| format!("{byte:02x}"))
209            .collect();
210        Ok(Self {
211            prepared,
212            semantic_content_fingerprint,
213            prefix_content_fingerprint,
214        })
215    }
216
217    /// Exact ordered payload-free prepared-input description.
218    pub const fn prepared(&self) -> &PreparedInputIdentity {
219        &self.prepared
220    }
221
222    /// Caller-owned digest identifying the semantic tensor payloads.
223    pub fn semantic_content_fingerprint(&self) -> &str {
224        &self.semantic_content_fingerprint
225    }
226
227    /// Canonical fingerprint stored in [`eredu_core::cache::PromptCacheDescriptor`].
228    pub fn prefix_content_fingerprint(&self) -> &str {
229        &self.prefix_content_fingerprint
230    }
231
232    /// Logical host storage retained by a copied identity, including descriptions
233    /// and both fingerprints. Allocator capacity/overhead is excluded.
234    pub fn logical_metadata_bytes(&self) -> Option<u64> {
235        u64::try_from(std::mem::size_of::<Self>())
236            .ok()?
237            .checked_add(self.prepared.logical_metadata_bytes()?)?
238            .checked_add(u64::try_from(self.semantic_content_fingerprint.len()).ok()?)?
239            .checked_add(u64::try_from(self.prefix_content_fingerprint.len()).ok()?)
240    }
241}
242
243/// Invalid cache identity for a prepared model input.
244#[derive(Debug, thiserror::Error)]
245pub enum PreparedInputCacheIdentityError {
246    /// Semantic tensor content was not identified by the caller or processor.
247    #[error("prepared-input semantic content fingerprint must not be empty")]
248    EmptySemanticContent,
249    /// The ordered prepared-input description could not be encoded canonically.
250    #[error("prepared-input cache identity is invalid: {0}")]
251    Prepared(PreparedInputError),
252}
253
254impl<Tensor> PreparedModelInput<Tensor> {
255    /// Validates and owns ordered prepared input parts.
256    pub fn new(
257        parts: Vec<PreparedInputPart<Tensor>>,
258        describe: impl Fn(&Tensor) -> Result<InputTensorIdentity, PreparedInputError>,
259    ) -> Result<Self, PreparedInputError> {
260        let identity = PreparedInputIdentity::new(
261            parts
262                .iter()
263                .map(|part| part.descriptor(&describe))
264                .collect::<Result<Vec<_>, _>>()?,
265        )?;
266        Ok(Self { parts, identity })
267    }
268
269    /// Exact payload-free identity used for rank agreement and persistence.
270    pub const fn identity(&self) -> &PreparedInputIdentity {
271        &self.identity
272    }
273
274    /// Derives the canonical prompt-cache content identity for these exact prepared parts.
275    pub fn cache_identity(
276        &self,
277        semantic_content_fingerprint: impl Into<String>,
278    ) -> Result<PreparedInputCacheIdentity, PreparedInputCacheIdentityError> {
279        PreparedInputCacheIdentity::new(self.identity.clone(), semantic_content_fingerprint)
280    }
281
282    /// Ordered owned parts.
283    pub fn parts(&self) -> &[PreparedInputPart<Tensor>] {
284        &self.parts
285    }
286
287    /// Number of ordered parts.
288    pub fn len(&self) -> usize {
289        self.parts.len()
290    }
291
292    /// This input is always non-empty after construction.
293    pub fn is_empty(&self) -> bool {
294        self.parts.is_empty()
295    }
296
297    /// Borrows payloads and metadata tensors in deterministic wire order.
298    pub fn wire_values(&self) -> Vec<&Tensor> {
299        let mut values = Vec::new();
300        for part in &self.parts {
301            values.push(part.payload.value());
302            values.extend(part.metadata.values());
303        }
304        values
305    }
306
307    /// Reconstructs and validates input received in deterministic wire order.
308    pub fn from_identity_wire_values(
309        identity: PreparedInputIdentity,
310        values: Vec<Tensor>,
311        describe: impl Fn(&Tensor) -> Result<InputTensorIdentity, PreparedInputError>,
312    ) -> Result<Self, PreparedInputError> {
313        let expected_values = identity
314            .parts()
315            .iter()
316            .map(|part| 1 + part.metadata().len())
317            .sum::<usize>();
318        if values.len() != expected_values {
319            return Err(PreparedInputError::WireValueCount {
320                expected: expected_values,
321                actual: values.len(),
322            });
323        }
324        let mut values = values.into_iter();
325        let mut parts = Vec::with_capacity(identity.len());
326        for descriptor in identity.parts() {
327            let payload = values.next().expect("validated prepared-input value count");
328            let payload = match descriptor.payload_kind() {
329                InputPayloadKind::TokenIds => PreparedInputPayload::TokenIds(payload),
330                InputPayloadKind::Tensor => PreparedInputPayload::Tensor(payload),
331                InputPayloadKind::Embeddings => PreparedInputPayload::Embeddings(payload),
332                payload_kind => {
333                    return Err(PreparedInputError::IncompatiblePayload {
334                        modality: descriptor.modality(),
335                        payload: payload_kind,
336                    });
337                }
338            };
339            let metadata = descriptor
340                .metadata()
341                .keys()
342                .copied()
343                .map(|key| {
344                    (
345                        key,
346                        values.next().expect("validated prepared-input value count"),
347                    )
348                })
349                .collect::<Vec<_>>();
350            parts.push(PreparedInputPart::new_with_extents(
351                descriptor.modality(),
352                payload,
353                metadata,
354                descriptor.extents(),
355            )?);
356        }
357        let actual = Self::new(parts, describe)?;
358        if actual.identity != identity {
359            return Err(PreparedInputError::WireIdentityMismatch);
360        }
361        Ok(actual)
362    }
363
364    /// Consumes the lifecycle container and returns its ordered parts.
365    pub fn into_parts(self) -> Vec<PreparedInputPart<Tensor>> {
366        self.parts
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use eredu_core::{checkpoint::TensorDtype, PreparedInputError};
373
374    use super::*;
375
376    #[derive(Debug, Clone, Eq, PartialEq)]
377    struct FakeTensor {
378        dtype: TensorDtype,
379        shape: Vec<usize>,
380        marker: u8,
381    }
382
383    fn fake(dtype: TensorDtype, shape: &[usize], marker: u8) -> FakeTensor {
384        FakeTensor {
385            dtype,
386            shape: shape.to_vec(),
387            marker,
388        }
389    }
390
391    fn describe(value: &FakeTensor) -> Result<InputTensorIdentity, PreparedInputError> {
392        InputTensorIdentity::new(value.dtype.clone(), value.shape.clone())
393    }
394
395    #[test]
396    fn composite_input_extension_binds_typed_parts_to_a_multi_group_graph() {
397        let graph = crate::ExecutionGraph::new(
398            vec![
399                crate::ExecutionGroupSpec::root("vision"),
400                crate::ExecutionGroupSpec::with_dependencies("text", ["vision"]),
401            ],
402            "text",
403        )
404        .unwrap();
405        let input = PreparedModelInput::new(
406            vec![
407                PreparedInputPart::new(
408                    InputModality::Text,
409                    PreparedInputPayload::TokenIds(fake(TensorDtype::U32, &[1, 2], 1)),
410                    [],
411                )
412                .unwrap(),
413                PreparedInputPart::new_with_extents(
414                    InputModality::Image,
415                    PreparedInputPayload::Tensor(fake(TensorDtype::F32, &[4, 12], 2)),
416                    [(
417                        InputMetadataKey::PatchGrid,
418                        fake(TensorDtype::I32, &[1, 3], 3),
419                    )],
420                    [InputExtent::PatchGrid {
421                        time: 1,
422                        height: 2,
423                        width: 2,
424                    }],
425                )
426                .unwrap(),
427            ],
428            describe,
429        )
430        .unwrap();
431        let identity = input.identity().clone();
432        let values = input.wire_values().into_iter().cloned().collect();
433
434        let rebuilt =
435            PreparedModelInput::from_identity_wire_values(identity, values, describe).unwrap();
436        assert_eq!(rebuilt, input);
437        assert_eq!(graph.execution_order(), [0, 1]);
438        assert_eq!(graph.output(), 1);
439        assert_eq!(rebuilt.wire_values()[2].marker, 3);
440        assert_eq!(
441            rebuilt.parts()[1].extents(),
442            &[InputExtent::PatchGrid {
443                time: 1,
444                height: 2,
445                width: 2,
446            }]
447        );
448    }
449
450    #[test]
451    fn rejects_payload_geometry_that_disagrees_with_wire_identity() {
452        let input = PreparedModelInput::new(
453            vec![PreparedInputPart::new(
454                InputModality::Text,
455                PreparedInputPayload::TokenIds(fake(TensorDtype::U32, &[1, 2], 1)),
456                [],
457            )
458            .unwrap()],
459            describe,
460        )
461        .unwrap();
462        let wrong = vec![fake(TensorDtype::U32, &[1, 3], 1)];
463
464        assert!(matches!(
465            PreparedModelInput::from_identity_wire_values(
466                input.identity().clone(),
467                wrong,
468                describe
469            ),
470            Err(PreparedInputError::WireIdentityMismatch)
471        ));
472    }
473
474    #[test]
475    fn rejects_incompatible_payload_at_part_construction() {
476        let result = PreparedInputPart::new(
477            InputModality::Text,
478            PreparedInputPayload::Tensor(fake(TensorDtype::F32, &[1, 2], 1)),
479            [],
480        );
481
482        assert!(matches!(
483            result,
484            Err(PreparedInputError::IncompatiblePayload {
485                modality: InputModality::Text,
486                payload: InputPayloadKind::Tensor,
487            })
488        ));
489    }
490
491    #[test]
492    fn rejects_incompatible_metadata_at_part_construction() {
493        let result = PreparedInputPart::new(
494            InputModality::Text,
495            PreparedInputPayload::TokenIds(fake(TensorDtype::U32, &[1, 2], 1)),
496            [(
497                InputMetadataKey::PatchGrid,
498                fake(TensorDtype::I32, &[1, 3], 2),
499            )],
500        );
501
502        assert!(matches!(
503            result,
504            Err(PreparedInputError::IncompatibleMetadata {
505                modality: InputModality::Text,
506                key: InputMetadataKey::PatchGrid,
507            })
508        ));
509    }
510
511    #[test]
512    fn prompt_cache_identity_requires_both_prepared_description_and_semantic_content() {
513        let first = PreparedModelInput::new(
514            vec![PreparedInputPart::new(
515                InputModality::Image,
516                PreparedInputPayload::Tensor(fake(TensorDtype::F32, &[1, 3, 4, 4], 1)),
517                [],
518            )
519            .unwrap()],
520            describe,
521        )
522        .unwrap();
523        let reshaped = PreparedModelInput::new(
524            vec![PreparedInputPart::new(
525                InputModality::Image,
526                PreparedInputPayload::Tensor(fake(TensorDtype::F32, &[1, 3, 8, 8], 2)),
527                [],
528            )
529            .unwrap()],
530            describe,
531        )
532        .unwrap();
533
534        let image_a = first.cache_identity("sha256:image-a").unwrap();
535        let image_b = first.cache_identity("sha256:image-b").unwrap();
536        let reshaped_a = reshaped.cache_identity("sha256:image-a").unwrap();
537
538        assert_ne!(
539            image_a.prefix_content_fingerprint(),
540            image_b.prefix_content_fingerprint()
541        );
542        assert_ne!(
543            image_a.prefix_content_fingerprint(),
544            reshaped_a.prefix_content_fingerprint()
545        );
546        assert_eq!(image_a.prepared(), first.identity());
547        assert!(first.cache_identity(" ").is_err());
548    }
549}