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
233/// Invalid cache identity for a prepared model input.
234#[derive(Debug, thiserror::Error)]
235pub enum PreparedInputCacheIdentityError {
236    /// Semantic tensor content was not identified by the caller or processor.
237    #[error("prepared-input semantic content fingerprint must not be empty")]
238    EmptySemanticContent,
239    /// The ordered prepared-input description could not be encoded canonically.
240    #[error("prepared-input cache identity is invalid: {0}")]
241    Prepared(PreparedInputError),
242}
243
244impl<Tensor> PreparedModelInput<Tensor> {
245    /// Validates and owns ordered prepared input parts.
246    pub fn new(
247        parts: Vec<PreparedInputPart<Tensor>>,
248        describe: impl Fn(&Tensor) -> Result<InputTensorIdentity, PreparedInputError>,
249    ) -> Result<Self, PreparedInputError> {
250        let identity = PreparedInputIdentity::new(
251            parts
252                .iter()
253                .map(|part| part.descriptor(&describe))
254                .collect::<Result<Vec<_>, _>>()?,
255        )?;
256        Ok(Self { parts, identity })
257    }
258
259    /// Exact payload-free identity used for rank agreement and persistence.
260    pub const fn identity(&self) -> &PreparedInputIdentity {
261        &self.identity
262    }
263
264    /// Derives the canonical prompt-cache content identity for these exact prepared parts.
265    pub fn cache_identity(
266        &self,
267        semantic_content_fingerprint: impl Into<String>,
268    ) -> Result<PreparedInputCacheIdentity, PreparedInputCacheIdentityError> {
269        PreparedInputCacheIdentity::new(self.identity.clone(), semantic_content_fingerprint)
270    }
271
272    /// Ordered owned parts.
273    pub fn parts(&self) -> &[PreparedInputPart<Tensor>] {
274        &self.parts
275    }
276
277    /// Number of ordered parts.
278    pub fn len(&self) -> usize {
279        self.parts.len()
280    }
281
282    /// This input is always non-empty after construction.
283    pub fn is_empty(&self) -> bool {
284        self.parts.is_empty()
285    }
286
287    /// Borrows payloads and metadata tensors in deterministic wire order.
288    pub fn wire_values(&self) -> Vec<&Tensor> {
289        let mut values = Vec::new();
290        for part in &self.parts {
291            values.push(part.payload.value());
292            values.extend(part.metadata.values());
293        }
294        values
295    }
296
297    /// Reconstructs and validates input received in deterministic wire order.
298    pub fn from_identity_wire_values(
299        identity: PreparedInputIdentity,
300        values: Vec<Tensor>,
301        describe: impl Fn(&Tensor) -> Result<InputTensorIdentity, PreparedInputError>,
302    ) -> Result<Self, PreparedInputError> {
303        let expected_values = identity
304            .parts()
305            .iter()
306            .map(|part| 1 + part.metadata().len())
307            .sum::<usize>();
308        if values.len() != expected_values {
309            return Err(PreparedInputError::WireValueCount {
310                expected: expected_values,
311                actual: values.len(),
312            });
313        }
314        let mut values = values.into_iter();
315        let mut parts = Vec::with_capacity(identity.len());
316        for descriptor in identity.parts() {
317            let payload = values.next().expect("validated prepared-input value count");
318            let payload = match descriptor.payload_kind() {
319                InputPayloadKind::TokenIds => PreparedInputPayload::TokenIds(payload),
320                InputPayloadKind::Tensor => PreparedInputPayload::Tensor(payload),
321                InputPayloadKind::Embeddings => PreparedInputPayload::Embeddings(payload),
322                payload_kind => {
323                    return Err(PreparedInputError::IncompatiblePayload {
324                        modality: descriptor.modality(),
325                        payload: payload_kind,
326                    });
327                }
328            };
329            let metadata = descriptor
330                .metadata()
331                .keys()
332                .copied()
333                .map(|key| {
334                    (
335                        key,
336                        values.next().expect("validated prepared-input value count"),
337                    )
338                })
339                .collect::<Vec<_>>();
340            parts.push(PreparedInputPart::new_with_extents(
341                descriptor.modality(),
342                payload,
343                metadata,
344                descriptor.extents(),
345            )?);
346        }
347        let actual = Self::new(parts, describe)?;
348        if actual.identity != identity {
349            return Err(PreparedInputError::WireIdentityMismatch);
350        }
351        Ok(actual)
352    }
353
354    /// Consumes the lifecycle container and returns its ordered parts.
355    pub fn into_parts(self) -> Vec<PreparedInputPart<Tensor>> {
356        self.parts
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use eredu_core::{checkpoint::TensorDtype, PreparedInputError};
363
364    use super::*;
365
366    #[derive(Debug, Clone, Eq, PartialEq)]
367    struct FakeTensor {
368        dtype: TensorDtype,
369        shape: Vec<usize>,
370        marker: u8,
371    }
372
373    fn fake(dtype: TensorDtype, shape: &[usize], marker: u8) -> FakeTensor {
374        FakeTensor {
375            dtype,
376            shape: shape.to_vec(),
377            marker,
378        }
379    }
380
381    fn describe(value: &FakeTensor) -> Result<InputTensorIdentity, PreparedInputError> {
382        InputTensorIdentity::new(value.dtype.clone(), value.shape.clone())
383    }
384
385    #[test]
386    fn composite_input_extension_binds_typed_parts_to_a_multi_group_graph() {
387        let graph = crate::ExecutionGraph::new(
388            vec![
389                crate::ExecutionGroupSpec::root("vision"),
390                crate::ExecutionGroupSpec::with_dependencies("text", ["vision"]),
391            ],
392            "text",
393        )
394        .unwrap();
395        let input = PreparedModelInput::new(
396            vec![
397                PreparedInputPart::new(
398                    InputModality::Text,
399                    PreparedInputPayload::TokenIds(fake(TensorDtype::U32, &[1, 2], 1)),
400                    [],
401                )
402                .unwrap(),
403                PreparedInputPart::new_with_extents(
404                    InputModality::Image,
405                    PreparedInputPayload::Tensor(fake(TensorDtype::F32, &[4, 12], 2)),
406                    [(
407                        InputMetadataKey::PatchGrid,
408                        fake(TensorDtype::I32, &[1, 3], 3),
409                    )],
410                    [InputExtent::PatchGrid {
411                        time: 1,
412                        height: 2,
413                        width: 2,
414                    }],
415                )
416                .unwrap(),
417            ],
418            describe,
419        )
420        .unwrap();
421        let identity = input.identity().clone();
422        let values = input.wire_values().into_iter().cloned().collect();
423
424        let rebuilt =
425            PreparedModelInput::from_identity_wire_values(identity, values, describe).unwrap();
426        assert_eq!(rebuilt, input);
427        assert_eq!(graph.execution_order(), [0, 1]);
428        assert_eq!(graph.output(), 1);
429        assert_eq!(rebuilt.wire_values()[2].marker, 3);
430        assert_eq!(
431            rebuilt.parts()[1].extents(),
432            &[InputExtent::PatchGrid {
433                time: 1,
434                height: 2,
435                width: 2,
436            }]
437        );
438    }
439
440    #[test]
441    fn rejects_payload_geometry_that_disagrees_with_wire_identity() {
442        let input = PreparedModelInput::new(
443            vec![PreparedInputPart::new(
444                InputModality::Text,
445                PreparedInputPayload::TokenIds(fake(TensorDtype::U32, &[1, 2], 1)),
446                [],
447            )
448            .unwrap()],
449            describe,
450        )
451        .unwrap();
452        let wrong = vec![fake(TensorDtype::U32, &[1, 3], 1)];
453
454        assert!(matches!(
455            PreparedModelInput::from_identity_wire_values(
456                input.identity().clone(),
457                wrong,
458                describe
459            ),
460            Err(PreparedInputError::WireIdentityMismatch)
461        ));
462    }
463
464    #[test]
465    fn rejects_incompatible_payload_at_part_construction() {
466        let result = PreparedInputPart::new(
467            InputModality::Text,
468            PreparedInputPayload::Tensor(fake(TensorDtype::F32, &[1, 2], 1)),
469            [],
470        );
471
472        assert!(matches!(
473            result,
474            Err(PreparedInputError::IncompatiblePayload {
475                modality: InputModality::Text,
476                payload: InputPayloadKind::Tensor,
477            })
478        ));
479    }
480
481    #[test]
482    fn rejects_incompatible_metadata_at_part_construction() {
483        let result = PreparedInputPart::new(
484            InputModality::Text,
485            PreparedInputPayload::TokenIds(fake(TensorDtype::U32, &[1, 2], 1)),
486            [(
487                InputMetadataKey::PatchGrid,
488                fake(TensorDtype::I32, &[1, 3], 2),
489            )],
490        );
491
492        assert!(matches!(
493            result,
494            Err(PreparedInputError::IncompatibleMetadata {
495                modality: InputModality::Text,
496                key: InputMetadataKey::PatchGrid,
497            })
498        ));
499    }
500
501    #[test]
502    fn prompt_cache_identity_requires_both_prepared_description_and_semantic_content() {
503        let first = PreparedModelInput::new(
504            vec![PreparedInputPart::new(
505                InputModality::Image,
506                PreparedInputPayload::Tensor(fake(TensorDtype::F32, &[1, 3, 4, 4], 1)),
507                [],
508            )
509            .unwrap()],
510            describe,
511        )
512        .unwrap();
513        let reshaped = PreparedModelInput::new(
514            vec![PreparedInputPart::new(
515                InputModality::Image,
516                PreparedInputPayload::Tensor(fake(TensorDtype::F32, &[1, 3, 8, 8], 2)),
517                [],
518            )
519            .unwrap()],
520            describe,
521        )
522        .unwrap();
523
524        let image_a = first.cache_identity("sha256:image-a").unwrap();
525        let image_b = first.cache_identity("sha256:image-b").unwrap();
526        let reshaped_a = reshaped.cache_identity("sha256:image-a").unwrap();
527
528        assert_ne!(
529            image_a.prefix_content_fingerprint(),
530            image_b.prefix_content_fingerprint()
531        );
532        assert_ne!(
533            image_a.prefix_content_fingerprint(),
534            reshaped_a.prefix_content_fingerprint()
535        );
536        assert_eq!(image_a.prepared(), first.identity());
537        assert!(first.cache_identity(" ").is_err());
538    }
539}