1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::time::{Duration, Instant};
6
7use serde_json::{json, Map, Value};
8use sha2::{Digest, Sha256};
9use subc_client_rs::{CallOptions, ConsumerOptions, RouteHandle, SubcConsumer};
10use subc_protocol::manifest::ProviderRole;
11use subc_protocol::{BindIdentity, RouteTarget};
12
13use crate::config::SemanticBackendConfig;
14
15const SYNAPSE_MODULE_ID: &str = "synapse";
16const MODELS_LIST_OPERATION: &str = "models.list";
17const QUERY_OPERATION: &str = "embed.query";
18const BATCH_OPERATION: &str = "embed.batch";
19const MAX_RESULT_PAGE_BYTES: usize = 512 * 1024;
20const MAX_CALL_ATTEMPTS: usize = 4;
21const MAX_NO_PROGRESS_POLLS: usize = 5;
22const CIRCUIT_TIMEOUT_THRESHOLD: usize = 3;
23const CIRCUIT_COOLDOWN: Duration = Duration::from_secs(5);
24const RETRY_BACKOFF_MS: [u64; 3] = [100, 200, 400];
25static LIVE_CAPTURE_SEQUENCE: AtomicUsize = AtomicUsize::new(0);
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum SynapseEmbeddingError {
29 MissingConnectionFile,
30 InvalidConnectionFile(String),
31 MissingModel,
32 DaemonUnavailable(String),
33 CapabilityUnavailable(String),
34 ModelUnavailable {
35 requested: String,
36 served: Vec<String>,
37 },
38 ModelNotCertified(String),
39 InvalidEnvelope(String),
40 ContentHashMismatch {
41 id: String,
42 expected: String,
43 actual: String,
44 },
45 FingerprintMismatch {
46 expected: String,
47 served: String,
48 },
49 TableEpochMismatch {
50 expected: u64,
51 served: u64,
52 },
53 CircuitOpen,
54 Timeout(String),
55 NoProgress(String),
56}
57
58impl fmt::Display for SynapseEmbeddingError {
59 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60 match self {
61 Self::MissingConnectionFile => write!(
62 formatter,
63 "synapse_missing_connection_file: semantic.backend=synapse requires user config subc.connection_file"
64 ),
65 Self::InvalidConnectionFile(path) => write!(
66 formatter,
67 "synapse_invalid_connection_file: subc.connection_file must be an absolute existing file: {path}"
68 ),
69 Self::MissingModel => write!(
70 formatter,
71 "synapse_missing_model: semantic.backend=synapse requires semantic.model"
72 ),
73 Self::DaemonUnavailable(error) => {
74 write!(formatter, "synapse_daemon_unavailable: {error}")
75 }
76 Self::CapabilityUnavailable(error) => {
77 write!(formatter, "synapse_capability_unavailable: {error}")
78 }
79 Self::ModelUnavailable { requested, served } => write!(
80 formatter,
81 "synapse_model_unavailable: requested {requested}; served models: {}",
82 served.join(", ")
83 ),
84 Self::ModelNotCertified(model) => {
85 write!(formatter, "synapse_model_not_certified: {model}")
86 }
87 Self::InvalidEnvelope(error) => {
88 write!(formatter, "synapse_invalid_envelope: {error}")
89 }
90 Self::ContentHashMismatch { id, expected, actual } => write!(
91 formatter,
92 "synapse_content_sha256_mismatch: item {id} expected {expected}, received {actual}"
93 ),
94 Self::FingerprintMismatch { expected, served } => write!(
95 formatter,
96 "synapse_fingerprint_mismatch: expected {expected}, served {served}"
97 ),
98 Self::TableEpochMismatch { expected, served } => write!(
99 formatter,
100 "synapse_table_epoch_mismatch: expected {expected}, served {served}"
101 ),
102 Self::CircuitOpen => write!(
103 formatter,
104 "synapse_circuit_open: repeated daemon timeouts temporarily paused embedding calls"
105 ),
106 Self::Timeout(operation) => {
107 write!(formatter, "synapse_timeout: {operation} exceeded its deadline")
108 }
109 Self::NoProgress(job) => write!(
110 formatter,
111 "synapse_batch_no_progress: job {job} returned no new chunks repeatedly"
112 ),
113 }
114 }
115}
116
117impl std::error::Error for SynapseEmbeddingError {}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct SynapseIdentity {
121 pub fingerprint: String,
122 pub table_epoch: u64,
123 pub equivalent_to: Vec<String>,
124}
125
126impl SynapseIdentity {
127 pub fn accepts(&self, fingerprint: &str) -> bool {
128 fingerprint == self.fingerprint
129 || self.equivalent_to.iter().any(|alias| alias == fingerprint)
130 }
131
132 fn observe_equivalence(&mut self, served: &str, aliases: &[String]) -> bool {
133 let connected = self.accepts(served)
134 || aliases.iter().any(|alias| self.accepts(alias))
135 || aliases.iter().any(|alias| alias == &self.fingerprint);
136 if !connected {
137 return false;
138 }
139 let mut class = HashSet::from([self.fingerprint.as_str(), served]);
140 class.extend(self.equivalent_to.iter().map(String::as_str));
141 class.extend(aliases.iter().map(String::as_str));
142 self.equivalent_to = class
143 .into_iter()
144 .filter(|candidate| *candidate != self.fingerprint)
145 .map(str::to_string)
146 .collect();
147 self.equivalent_to.sort();
148 true
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct SynapseModelMetadata {
154 pub model: String,
155 pub dims: Option<usize>,
156 pub recommended_rows: usize,
157 pub recommended_token_budget: usize,
158 pub certified: bool,
159 pub identity: SynapseIdentity,
160}
161
162#[derive(Debug, Clone)]
163struct BatchItem {
164 id: String,
165 text: String,
166 content_sha256: String,
167}
168
169struct SynapseState {
170 connection_file: PathBuf,
171 route_project_root: PathBuf,
172 route_harness: String,
173 model: String,
174 call_timeout: Duration,
175 consumer: Option<SubcConsumer>,
176 route: Option<RouteHandle>,
177 consecutive_timeouts: usize,
178 circuit_open_until: Option<Instant>,
179}
180
181pub struct SynapseEmbeddingClient {
182 runtime: tokio::runtime::Runtime,
183 state: SynapseState,
184 metadata: SynapseModelMetadata,
185 models_list_envelope: Vec<u8>,
186}
187
188impl SynapseEmbeddingClient {
189 pub fn from_config(config: &SemanticBackendConfig) -> Result<Self, SynapseEmbeddingError> {
190 let connection_file = config
191 .subc_connection_file
192 .clone()
193 .ok_or(SynapseEmbeddingError::MissingConnectionFile)?;
194 validate_connection_file(&connection_file)?;
195 let model = config.model.trim();
196 if model.is_empty() {
197 return Err(SynapseEmbeddingError::MissingModel);
198 }
199 let runtime = tokio::runtime::Builder::new_current_thread()
200 .enable_all()
201 .build()
202 .map_err(|error| SynapseEmbeddingError::DaemonUnavailable(error.to_string()))?;
203 let mut state = SynapseState {
204 connection_file,
205 route_project_root: config
206 .route_project_root
207 .clone()
208 .or_else(|| std::env::current_dir().ok())
209 .unwrap_or_else(|| PathBuf::from(".")),
210 route_harness: config
211 .route_harness
212 .clone()
213 .unwrap_or_else(|| "aft".to_string()),
214 model: model.to_string(),
215 call_timeout: Duration::from_millis(config.timeout_ms.max(1)),
216 consumer: None,
217 route: None,
218 consecutive_timeouts: 0,
219 circuit_open_until: None,
220 };
221 let (metadata, models_list_envelope) = runtime.block_on(state.discover_model())?;
222 Ok(Self {
223 runtime,
224 state,
225 metadata,
226 models_list_envelope,
227 })
228 }
229
230 pub fn metadata(&self) -> &SynapseModelMetadata {
231 &self.metadata
232 }
233
234 pub fn identity(&self) -> &SynapseIdentity {
235 &self.metadata.identity
236 }
237
238 pub fn models_list_envelope(&self) -> &[u8] {
240 &self.models_list_envelope
241 }
242
243 pub fn probe_dimension(&mut self, timeout: Duration) -> Result<usize, SynapseEmbeddingError> {
244 let vector = self.embed_query("semantic index fingerprint probe", timeout)?;
245 Ok(vector.len())
246 }
247
248 pub fn embed_query(
249 &mut self,
250 text: &str,
251 timeout: Duration,
252 ) -> Result<Vec<f32>, SynapseEmbeddingError> {
253 let item = BatchItem {
254 id: "query:0".to_string(),
255 text: text.to_string(),
256 content_sha256: content_sha256(text),
257 };
258 let params = constrained_params(
259 &self.state.model,
260 &self.metadata.identity,
261 json!({
262 "id": item.id,
263 "text": item.text,
264 "content_sha256": item.content_sha256,
265 "deadline_ms": u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX),
266 }),
267 );
268 let raw = self
269 .runtime
270 .block_on(self.state.call(QUERY_OPERATION, params, timeout))?;
271 capture_live_envelope(QUERY_OPERATION, &raw);
272 let parsed = parse_embedding_page(&raw, &[item.clone()])?;
273 self.validate_page_identity(&parsed)?;
274 let vector = parsed
275 .vectors
276 .get(&item.id)
277 .or_else(|| parsed.vectors.values().next())
278 .cloned()
279 .ok_or_else(|| {
280 SynapseEmbeddingError::InvalidEnvelope(
281 "embed.query response returned no vector".to_string(),
282 )
283 })?;
284 self.validate_dimension(&vector)?;
285 Ok(vector)
286 }
287
288 pub fn embed_batch(
289 &mut self,
290 texts: &[String],
291 ) -> Result<Vec<Vec<f32>>, SynapseEmbeddingError> {
292 let items = texts
293 .iter()
294 .enumerate()
295 .map(|(index, text)| BatchItem {
296 id: format!("item:{index}"),
297 text: text.clone(),
298 content_sha256: content_sha256(text),
299 })
300 .collect::<Vec<_>>();
301 let pages = split_batch_pages(
302 &items,
303 self.metadata.recommended_rows,
304 self.metadata.recommended_token_budget,
305 );
306 let mut vectors = HashMap::with_capacity(items.len());
307 for page in pages {
308 vectors.extend(self.embed_batch_page(&page)?);
309 }
310 items
311 .iter()
312 .map(|item| {
313 vectors.remove(&item.id).ok_or_else(|| {
314 SynapseEmbeddingError::InvalidEnvelope(format!(
315 "embed.batch response omitted item {}",
316 item.id
317 ))
318 })
319 })
320 .collect()
321 }
322
323 fn embed_batch_page(
324 &mut self,
325 items: &[BatchItem],
326 ) -> Result<HashMap<String, Vec<f32>>, SynapseEmbeddingError> {
327 let request_key = batch_request_key(&self.state.model, &self.metadata.identity, items);
328 let wire_items = items
329 .iter()
330 .map(|item| {
331 json!({
332 "id": item.id,
333 "text": item.text,
334 "content_sha256": item.content_sha256,
335 })
336 })
337 .collect::<Vec<_>>();
338 let submit = constrained_params(
339 &self.state.model,
340 &self.metadata.identity,
341 json!({ "items": wire_items, "request_key": request_key }),
342 );
343 let timeout = self.state.call_timeout;
344 let raw = self
345 .runtime
346 .block_on(self.state.call(BATCH_OPERATION, submit, timeout))?;
347 capture_live_envelope(BATCH_OPERATION, &raw);
348 let mut page = parse_embedding_page(&raw, items)?;
349 self.validate_page_identity(&page)?;
350 let Some(job_id) = page.job_id.clone() else {
351 self.validate_vectors(&page.vectors)?;
352 return Ok(page.vectors);
353 };
354
355 let deadline = Instant::now() + timeout;
356 let mut vectors = HashMap::new();
357 vectors.extend(page.vectors.drain());
358 let mut next_chunk = page.next_chunk_id.clone();
359 let mut last_progress = (vectors.len(), next_chunk.clone());
360 let mut no_progress_polls = 0usize;
361 loop {
362 if page.done && next_chunk.is_none() {
363 break;
364 }
365 let remaining = deadline
366 .checked_duration_since(Instant::now())
367 .ok_or_else(|| SynapseEmbeddingError::Timeout(BATCH_OPERATION.to_string()))?;
368 let mut continuation = Map::new();
369 continuation.insert("job_id".to_string(), Value::String(job_id.clone()));
370 continuation.insert(
371 "request_key".to_string(),
372 Value::String(request_key.clone()),
373 );
374 if let Some(chunk_id) = next_chunk.clone() {
375 continuation.insert("chunk_id".to_string(), Value::String(chunk_id));
376 }
377 let poll = constrained_params(
378 &self.state.model,
379 &self.metadata.identity,
380 Value::Object(continuation),
381 );
382 let raw = self
383 .runtime
384 .block_on(self.state.call(BATCH_OPERATION, poll, remaining))?;
385 capture_live_envelope(BATCH_OPERATION, &raw);
386 page = parse_embedding_page(&raw, items)?;
387 self.validate_page_identity(&page)?;
388 vectors.extend(page.vectors.drain());
389 next_chunk = page.next_chunk_id.clone();
390 let progress = (vectors.len(), next_chunk.clone());
391 if progress == last_progress {
392 no_progress_polls += 1;
393 if no_progress_polls >= MAX_NO_PROGRESS_POLLS {
394 return Err(SynapseEmbeddingError::NoProgress(job_id));
395 }
396 std::thread::sleep(Duration::from_millis(100));
397 } else {
398 no_progress_polls = 0;
399 last_progress = progress;
400 }
401 if page.done && next_chunk.is_none() {
402 break;
403 }
404 }
405 self.validate_vectors(&vectors)?;
406 Ok(vectors)
407 }
408
409 fn validate_page_identity(
410 &mut self,
411 page: &ParsedEmbeddingPage,
412 ) -> Result<(), SynapseEmbeddingError> {
413 if page.table_epoch != self.metadata.identity.table_epoch {
414 return Err(SynapseEmbeddingError::TableEpochMismatch {
415 expected: self.metadata.identity.table_epoch,
416 served: page.table_epoch,
417 });
418 }
419 if !self
420 .metadata
421 .identity
422 .observe_equivalence(&page.fingerprint, &page.equivalent_to)
423 || !self.metadata.identity.accepts(&page.fingerprint)
424 {
425 return Err(SynapseEmbeddingError::FingerprintMismatch {
426 expected: self.metadata.identity.fingerprint.clone(),
427 served: page.fingerprint.clone(),
428 });
429 }
430 Ok(())
431 }
432
433 fn validate_vectors(
434 &mut self,
435 vectors: &HashMap<String, Vec<f32>>,
436 ) -> Result<(), SynapseEmbeddingError> {
437 for vector in vectors.values() {
438 self.validate_dimension(vector)?;
439 }
440 Ok(())
441 }
442
443 fn validate_dimension(&mut self, vector: &[f32]) -> Result<(), SynapseEmbeddingError> {
444 if vector.is_empty() || vector.iter().any(|value| !value.is_finite()) {
445 return Err(SynapseEmbeddingError::InvalidEnvelope(
446 "embedding vector must contain finite values".to_string(),
447 ));
448 }
449 match self.metadata.dims {
450 Some(dims) if dims != vector.len() => {
451 Err(SynapseEmbeddingError::InvalidEnvelope(format!(
452 "embedding dimension mismatch: catalog={dims}, response={}",
453 vector.len()
454 )))
455 }
456 None => {
457 self.metadata.dims = Some(vector.len());
458 Ok(())
459 }
460 Some(_) => Ok(()),
461 }
462 }
463}
464
465impl SynapseState {
466 async fn discover_model(
467 &mut self,
468 ) -> Result<(SynapseModelMetadata, Vec<u8>), SynapseEmbeddingError> {
469 let timeout = self.call_timeout.min(Duration::from_secs(3));
470 let raw = self.call(MODELS_LIST_OPERATION, json!({}), timeout).await?;
471 let models = parse_models_list(&raw)?;
472 if let Some(model) = models.iter().find(|model| model.model == self.model) {
473 if !model.certified {
474 return Err(SynapseEmbeddingError::ModelNotCertified(self.model.clone()));
475 }
476 if model.recommended_rows == 0 || model.recommended_token_budget == 0 {
477 return Err(SynapseEmbeddingError::InvalidEnvelope(format!(
478 "configured model {} has no usable recommended_batch rows/token_budget",
479 self.model
480 )));
481 }
482 return Ok((model.clone(), raw));
483 }
484 let mut served = models
485 .into_iter()
486 .map(|model| model.model)
487 .collect::<Vec<_>>();
488 served.sort();
489 Err(SynapseEmbeddingError::ModelUnavailable {
490 requested: self.model.clone(),
491 served,
492 })
493 }
494
495 async fn call(
496 &mut self,
497 operation: &str,
498 params: Value,
499 timeout: Duration,
500 ) -> Result<Vec<u8>, SynapseEmbeddingError> {
501 if self
502 .circuit_open_until
503 .is_some_and(|until| Instant::now() < until)
504 {
505 return Err(SynapseEmbeddingError::CircuitOpen);
506 }
507 self.circuit_open_until = None;
508 let body = serde_json::to_vec(&json!({ "method": operation, "params": params }))
509 .map_err(|error| SynapseEmbeddingError::InvalidEnvelope(error.to_string()))?;
510 let mut last_error = String::new();
511 for attempt in 0..MAX_CALL_ATTEMPTS {
512 if let Err(error) = self.ensure_route().await {
513 last_error = error.to_string();
514 } else {
515 let request = self
516 .consumer
517 .as_ref()
518 .expect("consumer exists when route exists")
519 .request(
520 self.route.as_ref().expect("route ensured"),
521 body.clone(),
522 CallOptions::default(),
523 );
524 match tokio::time::timeout(timeout, request).await {
525 Ok(Ok(response)) => {
526 self.consecutive_timeouts = 0;
527 if response.len() > MAX_RESULT_PAGE_BYTES {
528 return Err(SynapseEmbeddingError::InvalidEnvelope(format!(
529 "{operation} response exceeded 512KiB page bound"
530 )));
531 }
532 return decode_result_envelope(response);
533 }
534 Ok(Err(error)) => {
535 last_error = error.to_string();
536 self.reset_connection();
537 }
538 Err(_) => {
539 last_error = format!("{operation} timed out");
540 self.note_timeout();
541 self.reset_connection();
542 if self.circuit_open_until.is_some() {
543 return Err(SynapseEmbeddingError::CircuitOpen);
544 }
545 }
546 }
547 }
548 if attempt + 1 < MAX_CALL_ATTEMPTS {
549 tokio::time::sleep(Duration::from_millis(RETRY_BACKOFF_MS[attempt.min(2)])).await;
550 }
551 }
552 if last_error.to_ascii_lowercase().contains("timed out")
553 || last_error.to_ascii_lowercase().contains("timeout")
554 {
555 Err(SynapseEmbeddingError::Timeout(operation.to_string()))
556 } else {
557 Err(SynapseEmbeddingError::DaemonUnavailable(last_error))
558 }
559 }
560
561 async fn ensure_route(&mut self) -> Result<(), SynapseEmbeddingError> {
562 if self.consumer.is_some() && self.route.is_some() {
563 return Ok(());
564 }
565 let options = ConsumerOptions {
566 call_timeout: self.call_timeout,
567 ..ConsumerOptions::default()
568 };
569 let consumer = SubcConsumer::connect(&self.connection_file, options)
570 .await
571 .map_err(|error| SynapseEmbeddingError::DaemonUnavailable(error.to_string()))?;
572 let catalog = consumer
573 .catalog_list()
574 .await
575 .map_err(|error| SynapseEmbeddingError::DaemonUnavailable(error.to_string()))?;
576 if !catalog_advertises_synapse(&catalog.modules) {
577 return Err(SynapseEmbeddingError::CapabilityUnavailable(
578 "synapse management surface does not advertise models.list, embed.query, and embed.batch"
579 .to_string(),
580 ));
581 }
582 let route = consumer
583 .open_route(
584 RouteTarget::ManagementSurface {
585 module_id: SYNAPSE_MODULE_ID.to_string(),
586 },
587 BindIdentity {
588 project_root: self
589 .route_project_root
590 .to_string_lossy()
591 .into_owned()
592 .into(),
593 harness: self.route_harness.clone(),
594 session: format!("aft-semantic-{}", std::process::id()),
595 },
596 CallOptions::default(),
597 )
598 .await
599 .map_err(|error| SynapseEmbeddingError::DaemonUnavailable(error.to_string()))?;
600 self.consumer = Some(consumer);
601 self.route = Some(route);
602 Ok(())
603 }
604
605 fn reset_connection(&mut self) {
606 self.route = None;
607 self.consumer = None;
608 }
609
610 fn note_timeout(&mut self) {
611 self.consecutive_timeouts += 1;
612 if self.consecutive_timeouts >= CIRCUIT_TIMEOUT_THRESHOLD {
613 self.circuit_open_until = Some(Instant::now() + CIRCUIT_COOLDOWN);
614 }
615 }
616}
617
618fn capture_live_envelope(operation: &str, raw: &[u8]) {
619 let Some(directory) = capture_directory_with(|name| std::env::var_os(name)) else {
620 return;
621 };
622 let sequence = LIVE_CAPTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
623 let operation = operation.replace('.', "-");
624 let _ = std::fs::write(
625 directory.join(format!("{operation}-{sequence}-live.json")),
626 raw,
627 );
628}
629
630fn capture_directory_with(
631 lookup: impl FnOnce(&str) -> Option<std::ffi::OsString>,
632) -> Option<PathBuf> {
633 lookup("AFT_SYNAPSE_CAPTURE_DIR")
634 .filter(|value| !value.is_empty())
635 .map(PathBuf::from)
636}
637
638fn validate_connection_file(path: &Path) -> Result<(), SynapseEmbeddingError> {
639 if !path.is_absolute() || !path.is_file() {
640 return Err(SynapseEmbeddingError::InvalidConnectionFile(
641 path.to_string_lossy().into_owned(),
642 ));
643 }
644 Ok(())
645}
646
647fn catalog_advertises_synapse(entries: &[subc_client_rs::CatalogEntry]) -> bool {
648 entries.iter().any(|entry| {
649 entry.module_id == SYNAPSE_MODULE_ID
650 && entry.roles.iter().any(|role| {
651 matches!(
652 role,
653 ProviderRole::ManagementSurface { operations, .. }
654 if [MODELS_LIST_OPERATION, QUERY_OPERATION, BATCH_OPERATION]
655 .iter()
656 .all(|required| operations.iter().any(|operation| operation.name == *required))
657 )
658 })
659 })
660}
661
662fn decode_result_envelope(response: Vec<u8>) -> Result<Vec<u8>, SynapseEmbeddingError> {
663 let value: Value = serde_json::from_slice(&response)
664 .map_err(|error| SynapseEmbeddingError::InvalidEnvelope(error.to_string()))?;
665 let result = value.get("result").cloned().unwrap_or(value);
666 if let Some(error) = result.get("error") {
667 let code = error
668 .get("code")
669 .and_then(Value::as_str)
670 .unwrap_or("unknown");
671 let message = error
672 .get("message")
673 .and_then(Value::as_str)
674 .unwrap_or("Synapse returned an unspecified error");
675 return match code {
676 "not_certified" => Err(SynapseEmbeddingError::ModelNotCertified(
677 message.to_string(),
678 )),
679 "substitution_rejected" => Err(SynapseEmbeddingError::FingerprintMismatch {
680 expected: "required fingerprint".to_string(),
681 served: message.to_string(),
682 }),
683 _ => Err(SynapseEmbeddingError::DaemonUnavailable(format!(
684 "{code}: {message}"
685 ))),
686 };
687 }
688 serde_json::to_vec(&result)
689 .map_err(|error| SynapseEmbeddingError::InvalidEnvelope(error.to_string()))
690}
691
692fn parse_models_list(raw: &[u8]) -> Result<Vec<SynapseModelMetadata>, SynapseEmbeddingError> {
693 let value: Value = serde_json::from_slice(raw)
694 .map_err(|error| SynapseEmbeddingError::InvalidEnvelope(error.to_string()))?;
695 let envelope = value.get("result").unwrap_or(&value);
696 let table_epoch = integer_field(envelope, &["table_epoch", "tableEpoch"]);
697 let entries = if let Some(entries) = envelope.get("models").and_then(Value::as_array) {
698 entries
699 } else if let Some(entries) = envelope.get("entries").and_then(Value::as_array) {
700 entries
701 } else if let Some(entries) = envelope.as_array() {
702 entries
703 } else {
704 return Err(SynapseEmbeddingError::InvalidEnvelope(
705 "models.list response has no models array".to_string(),
706 ));
707 };
708 let mut models = Vec::with_capacity(entries.len());
709 for entry in entries {
710 let model = string_field(entry, &["model", "model_id"]).ok_or_else(|| {
711 SynapseEmbeddingError::InvalidEnvelope("model entry has no id".to_string())
712 })?;
713 let certified = entry.get("certified").and_then(Value::as_bool) != Some(false)
714 && string_field(entry, &["status", "state"]).as_deref() != Some("not_certified");
715 let fingerprint = string_field(entry, &["fingerprint"])
716 .or_else(|| {
717 entry
718 .get("fingerprints")
719 .and_then(Value::as_array)
720 .and_then(|values| values.first())
721 .and_then(Value::as_str)
722 .map(str::to_string)
723 })
724 .ok_or_else(|| {
725 SynapseEmbeddingError::InvalidEnvelope(format!("model {model} has no fingerprint"))
726 })?;
727 let model_epoch = integer_field(entry, &["table_epoch", "tableEpoch"])
728 .or(table_epoch)
729 .ok_or_else(|| {
730 SynapseEmbeddingError::InvalidEnvelope(format!("model {model} has no table_epoch"))
731 })?;
732 let dims = integer_field(entry, &["dims", "dimensions"])
733 .and_then(|value| usize::try_from(value).ok());
734 let (recommended_rows, recommended_token_budget) = entry
735 .get("recommended_batch")
736 .or_else(|| entry.get("recommendedBatch"))
737 .and_then(parse_recommended_batch)
738 .unwrap_or((0, 0));
739 models.push(SynapseModelMetadata {
740 model,
741 dims,
742 recommended_rows,
743 recommended_token_budget,
744 certified,
745 identity: SynapseIdentity {
746 fingerprint,
747 table_epoch: model_epoch,
748 equivalent_to: string_array_field(entry, "equivalent_to"),
749 },
750 });
751 }
752 Ok(models)
753}
754
755fn parse_recommended_batch(value: &Value) -> Option<(usize, usize)> {
756 let object = value.as_object()?;
757 let rows = object
758 .get("rows")?
759 .as_u64()
760 .and_then(|value| usize::try_from(value).ok())?;
761 let token_budget = object
762 .get("token_budget")
763 .or_else(|| object.get("tokenBudget"))?
764 .as_u64()
765 .and_then(|value| usize::try_from(value).ok())?;
766 (rows > 0 && token_budget > 0).then_some((rows, token_budget))
767}
768
769#[derive(Debug)]
770struct ParsedEmbeddingPage {
771 vectors: HashMap<String, Vec<f32>>,
772 fingerprint: String,
773 table_epoch: u64,
774 equivalent_to: Vec<String>,
775 job_id: Option<String>,
776 next_chunk_id: Option<String>,
777 done: bool,
778}
779
780fn parse_embedding_page(
781 raw: &[u8],
782 expected_items: &[BatchItem],
783) -> Result<ParsedEmbeddingPage, SynapseEmbeddingError> {
784 let value: Value = serde_json::from_slice(raw)
785 .map_err(|error| SynapseEmbeddingError::InvalidEnvelope(error.to_string()))?;
786 let envelope = value.get("result").unwrap_or(&value);
787 let fingerprint =
788 string_field(envelope, &["fingerprint", "served_fingerprint"]).ok_or_else(|| {
789 SynapseEmbeddingError::InvalidEnvelope("response has no fingerprint".to_string())
790 })?;
791 let table_epoch = integer_field(envelope, &["table_epoch", "tableEpoch"]).ok_or_else(|| {
792 SynapseEmbeddingError::InvalidEnvelope("response has no table_epoch".to_string())
793 })?;
794 let expected = expected_items
795 .iter()
796 .map(|item| (item.id.as_str(), item))
797 .collect::<HashMap<_, _>>();
798 let mut vectors = HashMap::new();
799 if let Some(items) = envelope
800 .get("vectors")
801 .or_else(|| envelope.get("items"))
802 .or_else(|| envelope.get("results"))
803 .and_then(Value::as_array)
804 {
805 for (index, item) in items.iter().enumerate() {
806 let id = string_field(item, &["id"])
807 .or_else(|| expected_items.get(index).map(|item| item.id.clone()))
808 .ok_or_else(|| {
809 SynapseEmbeddingError::InvalidEnvelope("vector item has no id".to_string())
810 })?;
811 let expected_item = expected.get(id.as_str()).ok_or_else(|| {
812 SynapseEmbeddingError::InvalidEnvelope(format!("response returned unknown id {id}"))
813 })?;
814 verify_content_hash(item, expected_item)?;
815 vectors.insert(id, parse_vector(item)?);
816 }
817 } else if envelope.get("vector").is_some() || envelope.get("embedding").is_some() {
818 let expected_item = expected_items.first().ok_or_else(|| {
819 SynapseEmbeddingError::InvalidEnvelope("unexpected unkeyed vector".to_string())
820 })?;
821 verify_content_hash(envelope, expected_item)?;
822 vectors.insert(expected_item.id.clone(), parse_vector(envelope)?);
823 }
824 let next_chunk_id = string_field(
825 envelope,
826 &["next_chunk_id", "nextChunkId", "next_cursor", "cursor"],
827 );
828 let done = envelope.get("done").and_then(Value::as_bool) == Some(true)
829 || envelope.get("complete").and_then(Value::as_bool) == Some(true)
830 || (envelope.get("job_id").is_some() && next_chunk_id.is_none() && !vectors.is_empty());
831 Ok(ParsedEmbeddingPage {
832 vectors,
833 fingerprint,
834 table_epoch,
835 equivalent_to: string_array_field(envelope, "equivalent_to"),
836 job_id: string_field(envelope, &["job_id", "jobId"]),
837 next_chunk_id,
838 done,
839 })
840}
841
842fn parse_vector(value: &Value) -> Result<Vec<f32>, SynapseEmbeddingError> {
843 let values = value
844 .get("vector")
845 .or_else(|| value.get("embedding"))
846 .and_then(Value::as_array)
847 .ok_or_else(|| {
848 SynapseEmbeddingError::InvalidEnvelope("vector item has no vector".to_string())
849 })?;
850 values
851 .iter()
852 .map(|value| {
853 value
854 .as_f64()
855 .filter(|number| number.is_finite())
856 .map(|number| number as f32)
857 .ok_or_else(|| {
858 SynapseEmbeddingError::InvalidEnvelope(
859 "embedding vector contains a non-finite value".to_string(),
860 )
861 })
862 })
863 .collect()
864}
865
866fn verify_content_hash(
867 response: &Value,
868 expected: &BatchItem,
869) -> Result<(), SynapseEmbeddingError> {
870 let actual = response
871 .get("content_sha256")
872 .and_then(Value::as_str)
873 .ok_or_else(|| {
874 SynapseEmbeddingError::InvalidEnvelope(format!(
875 "response item {} omitted content_sha256",
876 expected.id
877 ))
878 })?;
879 if actual != expected.content_sha256 {
880 return Err(SynapseEmbeddingError::ContentHashMismatch {
881 id: expected.id.clone(),
882 expected: expected.content_sha256.clone(),
883 actual: actual.to_string(),
884 });
885 }
886 Ok(())
887}
888
889fn constrained_params(model: &str, identity: &SynapseIdentity, extra: Value) -> Value {
890 let mut params = Map::new();
891 params.insert("model".to_string(), Value::String(model.to_string()));
892 params.insert(
893 "required_fingerprint".to_string(),
894 Value::String(identity.fingerprint.clone()),
895 );
896 params.insert("required_epoch".to_string(), json!(identity.table_epoch));
897 params.insert("allow_equivalent".to_string(), Value::Bool(true));
898 params.insert("accept_declared".to_string(), Value::Bool(false));
899 if let Some(extra) = extra.as_object() {
900 params.extend(extra.clone());
901 }
902 Value::Object(params)
903}
904
905fn batch_request_key(model: &str, identity: &SynapseIdentity, items: &[BatchItem]) -> String {
906 let mut stable = BTreeMap::new();
907 stable.insert("accept_declared", json!(false));
908 stable.insert("allow_equivalent", json!(true));
909 stable.insert(
910 "content_sha256",
911 json!(items
912 .iter()
913 .map(|item| &item.content_sha256)
914 .collect::<Vec<_>>()),
915 );
916 stable.insert(
917 "ids",
918 json!(items.iter().map(|item| &item.id).collect::<Vec<_>>()),
919 );
920 stable.insert("model", json!(model));
921 stable.insert("op", json!(BATCH_OPERATION));
922 stable.insert("required_epoch", json!(identity.table_epoch));
923 stable.insert("required_fingerprint", json!(identity.fingerprint));
924 let encoded = serde_json::to_vec(&stable).expect("stable request-key value is serializable");
925 hex_sha256(&encoded)
926}
927
928fn split_batch_pages<'a>(
929 items: &'a [BatchItem],
930 rows: usize,
931 token_budget: usize,
932) -> Vec<Vec<BatchItem>> {
933 let mut pages = Vec::new();
934 let mut page = Vec::new();
935 let mut page_tokens = 0usize;
936 for item in items {
937 let item_tokens = item.text.chars().count().div_ceil(4).max(1);
938 if !page.is_empty()
939 && (page.len() >= rows || page_tokens.saturating_add(item_tokens) > token_budget)
940 {
941 pages.push(std::mem::take(&mut page));
942 page_tokens = 0;
943 }
944 page.push(item.clone());
945 page_tokens = page_tokens.saturating_add(item_tokens);
946 }
947 if !page.is_empty() {
948 pages.push(page);
949 }
950 pages
951}
952
953fn string_field(value: &Value, names: &[&str]) -> Option<String> {
954 names
955 .iter()
956 .find_map(|name| value.get(*name).and_then(Value::as_str))
957 .map(str::to_string)
958}
959
960fn integer_field(value: &Value, names: &[&str]) -> Option<u64> {
961 names
962 .iter()
963 .find_map(|name| value.get(*name).and_then(Value::as_u64))
964}
965
966fn string_array_field(value: &Value, name: &str) -> Vec<String> {
967 value
968 .get(name)
969 .and_then(Value::as_array)
970 .into_iter()
971 .flatten()
972 .filter_map(Value::as_str)
973 .map(str::to_string)
974 .collect()
975}
976
977fn content_sha256(text: &str) -> String {
978 hex_sha256(text.as_bytes())
979}
980
981fn hex_sha256(bytes: &[u8]) -> String {
982 let digest = Sha256::digest(bytes);
983 digest.iter().map(|byte| format!("{byte:02x}")).collect()
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989
990 #[test]
991 fn empty_synapse_capture_directory_is_unset_with_an_injected_lookup() {
992 assert_eq!(
993 capture_directory_with(|key| {
994 assert_eq!(key, "AFT_SYNAPSE_CAPTURE_DIR");
995 Some(std::ffi::OsString::new())
996 }),
997 None
998 );
999 assert_eq!(
1000 capture_directory_with(|_| Some(std::ffi::OsString::from("/captures"))),
1001 Some(PathBuf::from("/captures"))
1002 );
1003 }
1004
1005 fn item(id: &str, text: &str) -> BatchItem {
1006 BatchItem {
1007 id: id.to_string(),
1008 text: text.to_string(),
1009 content_sha256: content_sha256(text),
1010 }
1011 }
1012
1013 #[test]
1014 fn models_list_live_shapes_allow_optional_dims() {
1015 let models = parse_models_list(include_bytes!(
1016 "../tests/fixtures/synapse/models-list-live-raw.json"
1017 ))
1018 .unwrap();
1019 assert_eq!(models.len(), 7);
1020 let selected = models
1021 .iter()
1022 .find(|model| model.model == "gte-modernbert-base-ane-fp16")
1023 .unwrap();
1024 assert_eq!(selected.dims, None);
1025 assert_eq!(selected.recommended_rows, 8);
1026 assert_eq!(selected.recommended_token_budget, 4096);
1027 }
1028
1029 #[test]
1030 fn captured_live_error_envelope_is_typed() {
1031 let raw = include_bytes!("../tests/fixtures/synapse/embed-query-0-live.json").to_vec();
1032 let error = decode_result_envelope(raw).unwrap_err();
1033 assert!(matches!(error, SynapseEmbeddingError::ModelNotCertified(_)));
1034 }
1035
1036 #[test]
1037 fn content_hash_mismatch_is_loud_and_typed() {
1038 let expected = item("item:0", "hello");
1039 let raw = br#"{
1040 "fingerprint":"fp-a","table_epoch":7,
1041 "vectors":[{"id":"item:0","content_sha256":"wrong","vector":[1.0]}]
1042 }"#;
1043 let error = parse_embedding_page(raw, &[expected]).unwrap_err();
1044 assert!(matches!(
1045 error,
1046 SynapseEmbeddingError::ContentHashMismatch { .. }
1047 ));
1048 assert!(error
1049 .to_string()
1050 .contains("synapse_content_sha256_mismatch"));
1051 }
1052
1053 #[test]
1054 fn fingerprint_equivalence_class_accepts_aliases() {
1055 let mut identity = SynapseIdentity {
1056 fingerprint: "fp-current".to_string(),
1057 table_epoch: 7,
1058 equivalent_to: vec!["fp-old".to_string()],
1059 };
1060 assert!(identity.accepts("fp-old"));
1061 assert!(identity.observe_equivalence("fp-new", &["fp-current".to_string()]));
1062 assert!(identity.accepts("fp-new"));
1063 assert!(!identity.accepts("foreign"));
1064 assert!(!identity.observe_equivalence("foreign", &[]));
1065 }
1066
1067 #[test]
1068 fn pages_reassemble_by_chunk_and_item_id() {
1069 let expected = vec![item("item:0", "a"), item("item:1", "b")];
1070 let first = parse_embedding_page(
1071 include_bytes!("../tests/fixtures/synapse/embed-batch-page-1.json"),
1072 &expected,
1073 )
1074 .unwrap();
1075 let second = parse_embedding_page(
1076 include_bytes!("../tests/fixtures/synapse/embed-batch-page-2.json"),
1077 &expected,
1078 )
1079 .unwrap();
1080 assert_eq!(first.next_chunk_id.as_deref(), Some("chunk-2"));
1081 let mut vectors = first.vectors;
1082 vectors.extend(second.vectors);
1083 assert_eq!(vectors.len(), 2);
1084 assert_eq!(vectors["item:1"], vec![0.0, 1.0]);
1085 }
1086
1087 #[test]
1088 fn request_key_is_stable_for_idempotent_retry() {
1089 let identity = SynapseIdentity {
1090 fingerprint: "fp-a".to_string(),
1091 table_epoch: 7,
1092 equivalent_to: Vec::new(),
1093 };
1094 let items = vec![item("item:0", "hello")];
1095 assert_eq!(
1096 batch_request_key("configured-model", &identity, &items),
1097 batch_request_key("configured-model", &identity, &items)
1098 );
1099 }
1100}