Skip to main content

qualia_core_db/services/
daemon_tensor.rs

1//! Cold-path export: daemon graph `NQuin` slice → binary `Tensor10D` SOA for U2 viewport upload.
2//!
3//! Layout: `TensorBufferHeader` (32 B) + N × `Tensor10D` (40 B). Served at `GET /tensor/slice`.
4//! Identifier/Vault standpoints (class ≥ 2) require Ed25519 over the canonical request string.
5
6use crate::key_vault::KeyVault;
7use crate::render::telemetry::{STANDPOINT_DID, STANDPOINT_VAULT};
8use crate::tensor::bake_pipeline::bake_quin_to_tensor;
9use crate::tensor::buffer_export::{write_tensor_buffer, TensorBufferHeader};
10use crate::NQuin;
11
12pub const DEFAULT_SLICE_MAX_NODES: usize = 12_000;
13pub const ABSOLUTE_SLICE_MAX_NODES: usize = 50_000;
14
15/// Routing lane mirrored from `ObserverStandpoint` (PR-C9c.2 vault vs commons).
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum TensorSliceLane {
18    Commons,
19    Identifier,
20}
21
22impl TensorSliceLane {
23    pub fn from_header(value: &str) -> Self {
24        match value.trim().to_ascii_lowercase().as_str() {
25            "identifier" | "did" | "vault" => Self::Identifier,
26            _ => Self::Commons,
27        }
28    }
29}
30
31/// Standpoint-aligned slice parameters (matches WGSL temporal discard).
32#[derive(Debug, Clone, Copy)]
33pub struct TensorSliceRequest {
34    pub max_nodes: usize,
35    pub t_slice: f32,
36    pub t_window: f32,
37    pub lane: TensorSliceLane,
38    pub standpoint_class: u32,
39}
40
41impl Default for TensorSliceRequest {
42    fn default() -> Self {
43        Self {
44            max_nodes: DEFAULT_SLICE_MAX_NODES,
45            t_slice: 0.5,
46            t_window: 1.0,
47            lane: TensorSliceLane::Commons,
48            standpoint_class: 0,
49        }
50    }
51}
52
53impl TensorSliceRequest {
54    #[inline]
55    pub fn clamp_max_nodes(max_nodes: usize) -> usize {
56        max_nodes.clamp(1, ABSOLUTE_SLICE_MAX_NODES)
57    }
58
59    #[inline]
60    pub fn temporal_passes(&self, tensor_t: f32) -> bool {
61        if self.t_window <= 0.0 {
62            return true;
63        }
64        (tensor_t - self.t_slice).abs() <= self.t_window
65    }
66
67    #[inline]
68    pub fn requires_identifier_auth(&self) -> bool {
69        self.standpoint_class >= STANDPOINT_DID
70    }
71
72    /// Vault standpoints export the full sealed graph (identifier lane, no commons filter).
73    #[inline]
74    pub fn requires_vault_export(&self) -> bool {
75        self.standpoint_class >= STANDPOINT_VAULT
76    }
77
78    /// Resolve the effective routing lane for cold-path tensor export.
79    #[inline]
80    pub fn export_lane(&self) -> TensorSliceLane {
81        if self.requires_vault_export() || self.requires_identifier_auth() {
82            TensorSliceLane::Identifier
83        } else {
84            self.lane
85        }
86    }
87}
88
89#[derive(Debug, PartialEq, Eq)]
90pub enum TensorSliceError {
91    EmptyGraph,
92    BufferTooSmall,
93}
94
95#[derive(Debug, PartialEq, Eq)]
96pub enum TensorSliceAuthError {
97    IdentifierDidRequired,
98    SessionNonceRequired,
99    SignatureRequired,
100    InvalidSignatureEncoding,
101    InvalidSignature,
102}
103
104/// Canonical UTF-8 bytes both client (`crypto.subtle`) and daemon must sign/verify.
105/// Format: `"{nonce}|{standpoint_class}|{t_slice}|{t_window}"`
106pub fn canonical_tensor_slice_payload(
107    nonce: &str,
108    standpoint_class: u32,
109    t_slice: f32,
110    t_window: f32,
111) -> String {
112    format!(
113        "{}|{}|{}|{}",
114        nonce,
115        standpoint_class,
116        format_canonical_f32(t_slice),
117        format_canonical_f32(t_window),
118    )
119}
120
121#[inline]
122fn format_canonical_f32(v: f32) -> String {
123    let s = format!("{:.6}", v);
124    s.trim_end_matches('0').trim_end_matches('.').to_string()
125}
126
127/// Verify Ed25519 signature for identifier/vault tensor slice requests (fail closed).
128pub fn verify_tensor_slice_signature(
129    vault: &KeyVault,
130    identifier_did: &str,
131    nonce: &str,
132    standpoint_class: u32,
133    t_slice: f32,
134    t_window: f32,
135    signature_hex: &str,
136) -> Result<(), TensorSliceAuthError> {
137    if identifier_did.trim().is_empty() {
138        return Err(TensorSliceAuthError::IdentifierDidRequired);
139    }
140    if nonce.trim().is_empty() {
141        return Err(TensorSliceAuthError::SessionNonceRequired);
142    }
143    if signature_hex.trim().is_empty() {
144        return Err(TensorSliceAuthError::SignatureRequired);
145    }
146
147    let payload = canonical_tensor_slice_payload(nonce, standpoint_class, t_slice, t_window);
148    let sig_bytes = hex::decode(signature_hex.trim())
149        .map_err(|_| TensorSliceAuthError::InvalidSignatureEncoding)?;
150    if sig_bytes.len() != 64 {
151        return Err(TensorSliceAuthError::InvalidSignatureEncoding);
152    }
153    let mut sig_arr = [0u8; 64];
154    sig_arr.copy_from_slice(&sig_bytes);
155
156    let pk = vault.public_key_bytes_for_context(identifier_did);
157    KeyVault::verify_signature(&pk, payload.as_bytes(), &sig_arr)
158        .map_err(|_| TensorSliceAuthError::InvalidSignature)
159}
160
161/// Commons = public sensitivity only; identifier/vault = full sealed graph.
162#[inline]
163pub fn quin_matches_lane(quin: &NQuin, lane: TensorSliceLane) -> bool {
164    match lane {
165        TensorSliceLane::Commons => quin.get_sensitivity_byte() == NQuin::SENSITIVITY_PUBLIC,
166        TensorSliceLane::Identifier => true,
167    }
168}
169
170/// Bake filtered graph quins into a tensor buffer blob (cold path; heap OK).
171pub fn build_tensor_slice_bytes(
172    quins: &[NQuin],
173    req: &TensorSliceRequest,
174) -> Result<Vec<u8>, TensorSliceError> {
175    if quins.is_empty() {
176        return Err(TensorSliceError::EmptyGraph);
177    }
178
179    let cap = TensorSliceRequest::clamp_max_nodes(req.max_nodes);
180    let mut tensors = Vec::with_capacity(cap.min(quins.len()));
181
182    let lane = req.export_lane();
183    for q in quins {
184        if !quin_matches_lane(q, lane) {
185            continue;
186        }
187        let t = bake_quin_to_tensor(q);
188        if !req.temporal_passes(t.t) {
189            continue;
190        }
191        tensors.push(t);
192        if tensors.len() >= cap {
193            break;
194        }
195    }
196
197    if tensors.is_empty() {
198        return Err(TensorSliceError::EmptyGraph);
199    }
200
201    let need = TensorBufferHeader::total_bytes(tensors.len());
202    let mut buf = vec![0u8; need];
203    write_tensor_buffer(&tensors, &mut buf).map_err(|_| TensorSliceError::BufferTooSmall)?;
204    Ok(buf)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use ed25519_dalek::Signer;
211
212    fn triple_quin(subject: &str, predicate: &str, object: &str, context: &str) -> NQuin {
213        let subject = crate::q_hash(subject);
214        let predicate = crate::q_hash(predicate);
215        let object = crate::q_hash(object);
216        let context = crate::q_hash(context) & 0x00FF_FFFF_FFFF_FFFF;
217        NQuin {
218            subject,
219            predicate,
220            object,
221            context,
222            metadata: 0,
223            parity: subject ^ predicate ^ object ^ context,
224        }
225    }
226
227    fn sample_quin() -> NQuin {
228        triple_quin(
229            "http://q.test/s/0",
230            "http://q.test/p/geo",
231            "http://q.test/o/0",
232            "did:qualia:commons",
233        )
234    }
235
236    fn test_vault() -> KeyVault {
237        KeyVault::new()
238    }
239
240    #[test]
241    fn canonical_payload_matches_spec_example() {
242        assert_eq!(
243            canonical_tensor_slice_payload("a1b2c3d4e5f6", 2, 0.5, 0.1),
244            "a1b2c3d4e5f6|2|0.5|0.1"
245        );
246    }
247
248    #[test]
249    fn verify_tensor_slice_signature_round_trip() {
250        let vault = test_vault();
251        let did = "did:qualia:alice";
252        let nonce = "a1b2c3d4e5f6";
253        let sk = vault.derive_key(did);
254        let payload = canonical_tensor_slice_payload(nonce, STANDPOINT_DID, 0.5, 0.1);
255        let sig = sk.sign(payload.as_bytes());
256        let sig_hex = hex::encode(sig.to_bytes());
257        verify_tensor_slice_signature(&vault, did, nonce, STANDPOINT_DID, 0.5, 0.1, &sig_hex)
258            .expect("valid signature");
259    }
260
261    #[test]
262    fn verify_rejects_wrong_nonce() {
263        let vault = test_vault();
264        let did = "did:qualia:alice";
265        let sk = vault.derive_key(did);
266        let payload = canonical_tensor_slice_payload("nonce-a", STANDPOINT_DID, 0.5, 0.1);
267        let sig_hex = hex::encode(sk.sign(payload.as_bytes()).to_bytes());
268        assert_eq!(
269            verify_tensor_slice_signature(
270                &vault,
271                did,
272                "nonce-b",
273                STANDPOINT_DID,
274                0.5,
275                0.1,
276                &sig_hex
277            ),
278            Err(TensorSliceAuthError::InvalidSignature)
279        );
280    }
281
282    #[test]
283    fn commons_lane_excludes_restricted_quins() {
284        let public = sample_quin();
285        let mut restricted = sample_quin();
286        restricted.set_sensitivity_byte(NQuin::SENSITIVITY_RESTRICTED);
287        let req = TensorSliceRequest {
288            lane: TensorSliceLane::Commons,
289            t_window: 0.0,
290            max_nodes: 8,
291            ..Default::default()
292        };
293        let buf = build_tensor_slice_bytes(&[public, restricted], &req).expect("public only");
294        assert_eq!(
295            crate::tensor::buffer_export::tensor_node_count(&buf).expect("count"),
296            1
297        );
298    }
299
300    #[test]
301    fn identifier_lane_includes_restricted_quins() {
302        let mut restricted = sample_quin();
303        restricted.set_sensitivity_byte(NQuin::SENSITIVITY_RESTRICTED);
304        let req = TensorSliceRequest {
305            lane: TensorSliceLane::Identifier,
306            standpoint_class: STANDPOINT_VAULT,
307            t_window: 0.0,
308            max_nodes: 8,
309            ..Default::default()
310        };
311        let buf = build_tensor_slice_bytes(&[restricted], &req).expect("vault slice");
312        assert_eq!(
313            crate::tensor::buffer_export::tensor_node_count(&buf).expect("count"),
314            1
315        );
316    }
317
318    #[test]
319    fn build_slice_respects_max_nodes() {
320        let quins = [sample_quin(); 4];
321        let req = TensorSliceRequest {
322            max_nodes: 2,
323            t_window: 0.0,
324            ..Default::default()
325        };
326        let buf = build_tensor_slice_bytes(&quins, &req).expect("slice");
327        let count = crate::tensor::buffer_export::tensor_node_count(&buf).expect("header");
328        assert_eq!(count, 2);
329    }
330
331    #[test]
332    fn temporal_filter_matches_standpoint_window() {
333        let mut q = sample_quin();
334        q.metadata = (100u64) << 32;
335        let baked = bake_quin_to_tensor(&q);
336        let req = TensorSliceRequest {
337            t_slice: baked.t,
338            t_window: 0.01,
339            max_nodes: 8,
340            ..Default::default()
341        };
342        let buf = build_tensor_slice_bytes(&[q], &req).expect("in window");
343        assert_eq!(
344            crate::tensor::buffer_export::tensor_node_count(&buf).expect("count"),
345            1
346        );
347
348        let req_out = TensorSliceRequest {
349            t_slice: baked.t + 1.0,
350            t_window: 0.01,
351            ..req
352        };
353        assert_eq!(
354            build_tensor_slice_bytes(&[q], &req_out),
355            Err(TensorSliceError::EmptyGraph)
356        );
357    }
358}