1use 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#[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#[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 #[inline]
74 pub fn requires_vault_export(&self) -> bool {
75 self.standpoint_class >= STANDPOINT_VAULT
76 }
77
78 #[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
104pub 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
127pub 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#[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
170pub 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}