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) = std::env::var_os("AFT_SYNAPSE_CAPTURE_DIR").map(PathBuf::from) 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 validate_connection_file(path: &Path) -> Result<(), SynapseEmbeddingError> {
631 if !path.is_absolute() || !path.is_file() {
632 return Err(SynapseEmbeddingError::InvalidConnectionFile(
633 path.to_string_lossy().into_owned(),
634 ));
635 }
636 Ok(())
637}
638
639fn catalog_advertises_synapse(entries: &[subc_client_rs::CatalogEntry]) -> bool {
640 entries.iter().any(|entry| {
641 entry.module_id == SYNAPSE_MODULE_ID
642 && entry.roles.iter().any(|role| {
643 matches!(
644 role,
645 ProviderRole::ManagementSurface { operations, .. }
646 if [MODELS_LIST_OPERATION, QUERY_OPERATION, BATCH_OPERATION]
647 .iter()
648 .all(|required| operations.iter().any(|operation| operation.name == *required))
649 )
650 })
651 })
652}
653
654fn decode_result_envelope(response: Vec<u8>) -> Result<Vec<u8>, SynapseEmbeddingError> {
655 let value: Value = serde_json::from_slice(&response)
656 .map_err(|error| SynapseEmbeddingError::InvalidEnvelope(error.to_string()))?;
657 let result = value.get("result").cloned().unwrap_or(value);
658 if let Some(error) = result.get("error") {
659 let code = error
660 .get("code")
661 .and_then(Value::as_str)
662 .unwrap_or("unknown");
663 let message = error
664 .get("message")
665 .and_then(Value::as_str)
666 .unwrap_or("Synapse returned an unspecified error");
667 return match code {
668 "not_certified" => Err(SynapseEmbeddingError::ModelNotCertified(
669 message.to_string(),
670 )),
671 "substitution_rejected" => Err(SynapseEmbeddingError::FingerprintMismatch {
672 expected: "required fingerprint".to_string(),
673 served: message.to_string(),
674 }),
675 _ => Err(SynapseEmbeddingError::DaemonUnavailable(format!(
676 "{code}: {message}"
677 ))),
678 };
679 }
680 serde_json::to_vec(&result)
681 .map_err(|error| SynapseEmbeddingError::InvalidEnvelope(error.to_string()))
682}
683
684fn parse_models_list(raw: &[u8]) -> Result<Vec<SynapseModelMetadata>, SynapseEmbeddingError> {
685 let value: Value = serde_json::from_slice(raw)
686 .map_err(|error| SynapseEmbeddingError::InvalidEnvelope(error.to_string()))?;
687 let envelope = value.get("result").unwrap_or(&value);
688 let table_epoch = integer_field(envelope, &["table_epoch", "tableEpoch"]);
689 let entries = if let Some(entries) = envelope.get("models").and_then(Value::as_array) {
690 entries
691 } else if let Some(entries) = envelope.get("entries").and_then(Value::as_array) {
692 entries
693 } else if let Some(entries) = envelope.as_array() {
694 entries
695 } else {
696 return Err(SynapseEmbeddingError::InvalidEnvelope(
697 "models.list response has no models array".to_string(),
698 ));
699 };
700 let mut models = Vec::with_capacity(entries.len());
701 for entry in entries {
702 let model = string_field(entry, &["model", "model_id"]).ok_or_else(|| {
703 SynapseEmbeddingError::InvalidEnvelope("model entry has no id".to_string())
704 })?;
705 let certified = entry.get("certified").and_then(Value::as_bool) != Some(false)
706 && string_field(entry, &["status", "state"]).as_deref() != Some("not_certified");
707 let fingerprint = string_field(entry, &["fingerprint"])
708 .or_else(|| {
709 entry
710 .get("fingerprints")
711 .and_then(Value::as_array)
712 .and_then(|values| values.first())
713 .and_then(Value::as_str)
714 .map(str::to_string)
715 })
716 .ok_or_else(|| {
717 SynapseEmbeddingError::InvalidEnvelope(format!("model {model} has no fingerprint"))
718 })?;
719 let model_epoch = integer_field(entry, &["table_epoch", "tableEpoch"])
720 .or(table_epoch)
721 .ok_or_else(|| {
722 SynapseEmbeddingError::InvalidEnvelope(format!("model {model} has no table_epoch"))
723 })?;
724 let dims = integer_field(entry, &["dims", "dimensions"])
725 .and_then(|value| usize::try_from(value).ok());
726 let (recommended_rows, recommended_token_budget) = entry
727 .get("recommended_batch")
728 .or_else(|| entry.get("recommendedBatch"))
729 .and_then(parse_recommended_batch)
730 .unwrap_or((0, 0));
731 models.push(SynapseModelMetadata {
732 model,
733 dims,
734 recommended_rows,
735 recommended_token_budget,
736 certified,
737 identity: SynapseIdentity {
738 fingerprint,
739 table_epoch: model_epoch,
740 equivalent_to: string_array_field(entry, "equivalent_to"),
741 },
742 });
743 }
744 Ok(models)
745}
746
747fn parse_recommended_batch(value: &Value) -> Option<(usize, usize)> {
748 let object = value.as_object()?;
749 let rows = object
750 .get("rows")?
751 .as_u64()
752 .and_then(|value| usize::try_from(value).ok())?;
753 let token_budget = object
754 .get("token_budget")
755 .or_else(|| object.get("tokenBudget"))?
756 .as_u64()
757 .and_then(|value| usize::try_from(value).ok())?;
758 (rows > 0 && token_budget > 0).then_some((rows, token_budget))
759}
760
761#[derive(Debug)]
762struct ParsedEmbeddingPage {
763 vectors: HashMap<String, Vec<f32>>,
764 fingerprint: String,
765 table_epoch: u64,
766 equivalent_to: Vec<String>,
767 job_id: Option<String>,
768 next_chunk_id: Option<String>,
769 done: bool,
770}
771
772fn parse_embedding_page(
773 raw: &[u8],
774 expected_items: &[BatchItem],
775) -> Result<ParsedEmbeddingPage, SynapseEmbeddingError> {
776 let value: Value = serde_json::from_slice(raw)
777 .map_err(|error| SynapseEmbeddingError::InvalidEnvelope(error.to_string()))?;
778 let envelope = value.get("result").unwrap_or(&value);
779 let fingerprint =
780 string_field(envelope, &["fingerprint", "served_fingerprint"]).ok_or_else(|| {
781 SynapseEmbeddingError::InvalidEnvelope("response has no fingerprint".to_string())
782 })?;
783 let table_epoch = integer_field(envelope, &["table_epoch", "tableEpoch"]).ok_or_else(|| {
784 SynapseEmbeddingError::InvalidEnvelope("response has no table_epoch".to_string())
785 })?;
786 let expected = expected_items
787 .iter()
788 .map(|item| (item.id.as_str(), item))
789 .collect::<HashMap<_, _>>();
790 let mut vectors = HashMap::new();
791 if let Some(items) = envelope
792 .get("vectors")
793 .or_else(|| envelope.get("items"))
794 .or_else(|| envelope.get("results"))
795 .and_then(Value::as_array)
796 {
797 for (index, item) in items.iter().enumerate() {
798 let id = string_field(item, &["id"])
799 .or_else(|| expected_items.get(index).map(|item| item.id.clone()))
800 .ok_or_else(|| {
801 SynapseEmbeddingError::InvalidEnvelope("vector item has no id".to_string())
802 })?;
803 let expected_item = expected.get(id.as_str()).ok_or_else(|| {
804 SynapseEmbeddingError::InvalidEnvelope(format!("response returned unknown id {id}"))
805 })?;
806 verify_content_hash(item, expected_item)?;
807 vectors.insert(id, parse_vector(item)?);
808 }
809 } else if envelope.get("vector").is_some() || envelope.get("embedding").is_some() {
810 let expected_item = expected_items.first().ok_or_else(|| {
811 SynapseEmbeddingError::InvalidEnvelope("unexpected unkeyed vector".to_string())
812 })?;
813 verify_content_hash(envelope, expected_item)?;
814 vectors.insert(expected_item.id.clone(), parse_vector(envelope)?);
815 }
816 let next_chunk_id = string_field(
817 envelope,
818 &["next_chunk_id", "nextChunkId", "next_cursor", "cursor"],
819 );
820 let done = envelope.get("done").and_then(Value::as_bool) == Some(true)
821 || envelope.get("complete").and_then(Value::as_bool) == Some(true)
822 || (envelope.get("job_id").is_some() && next_chunk_id.is_none() && !vectors.is_empty());
823 Ok(ParsedEmbeddingPage {
824 vectors,
825 fingerprint,
826 table_epoch,
827 equivalent_to: string_array_field(envelope, "equivalent_to"),
828 job_id: string_field(envelope, &["job_id", "jobId"]),
829 next_chunk_id,
830 done,
831 })
832}
833
834fn parse_vector(value: &Value) -> Result<Vec<f32>, SynapseEmbeddingError> {
835 let values = value
836 .get("vector")
837 .or_else(|| value.get("embedding"))
838 .and_then(Value::as_array)
839 .ok_or_else(|| {
840 SynapseEmbeddingError::InvalidEnvelope("vector item has no vector".to_string())
841 })?;
842 values
843 .iter()
844 .map(|value| {
845 value
846 .as_f64()
847 .filter(|number| number.is_finite())
848 .map(|number| number as f32)
849 .ok_or_else(|| {
850 SynapseEmbeddingError::InvalidEnvelope(
851 "embedding vector contains a non-finite value".to_string(),
852 )
853 })
854 })
855 .collect()
856}
857
858fn verify_content_hash(
859 response: &Value,
860 expected: &BatchItem,
861) -> Result<(), SynapseEmbeddingError> {
862 let actual = response
863 .get("content_sha256")
864 .and_then(Value::as_str)
865 .ok_or_else(|| {
866 SynapseEmbeddingError::InvalidEnvelope(format!(
867 "response item {} omitted content_sha256",
868 expected.id
869 ))
870 })?;
871 if actual != expected.content_sha256 {
872 return Err(SynapseEmbeddingError::ContentHashMismatch {
873 id: expected.id.clone(),
874 expected: expected.content_sha256.clone(),
875 actual: actual.to_string(),
876 });
877 }
878 Ok(())
879}
880
881fn constrained_params(model: &str, identity: &SynapseIdentity, extra: Value) -> Value {
882 let mut params = Map::new();
883 params.insert("model".to_string(), Value::String(model.to_string()));
884 params.insert(
885 "required_fingerprint".to_string(),
886 Value::String(identity.fingerprint.clone()),
887 );
888 params.insert("required_epoch".to_string(), json!(identity.table_epoch));
889 params.insert("allow_equivalent".to_string(), Value::Bool(true));
890 params.insert("accept_declared".to_string(), Value::Bool(false));
891 if let Some(extra) = extra.as_object() {
892 params.extend(extra.clone());
893 }
894 Value::Object(params)
895}
896
897fn batch_request_key(model: &str, identity: &SynapseIdentity, items: &[BatchItem]) -> String {
898 let mut stable = BTreeMap::new();
899 stable.insert("accept_declared", json!(false));
900 stable.insert("allow_equivalent", json!(true));
901 stable.insert(
902 "content_sha256",
903 json!(items
904 .iter()
905 .map(|item| &item.content_sha256)
906 .collect::<Vec<_>>()),
907 );
908 stable.insert(
909 "ids",
910 json!(items.iter().map(|item| &item.id).collect::<Vec<_>>()),
911 );
912 stable.insert("model", json!(model));
913 stable.insert("op", json!(BATCH_OPERATION));
914 stable.insert("required_epoch", json!(identity.table_epoch));
915 stable.insert("required_fingerprint", json!(identity.fingerprint));
916 let encoded = serde_json::to_vec(&stable).expect("stable request-key value is serializable");
917 hex_sha256(&encoded)
918}
919
920fn split_batch_pages<'a>(
921 items: &'a [BatchItem],
922 rows: usize,
923 token_budget: usize,
924) -> Vec<Vec<BatchItem>> {
925 let mut pages = Vec::new();
926 let mut page = Vec::new();
927 let mut page_tokens = 0usize;
928 for item in items {
929 let item_tokens = item.text.chars().count().div_ceil(4).max(1);
930 if !page.is_empty()
931 && (page.len() >= rows || page_tokens.saturating_add(item_tokens) > token_budget)
932 {
933 pages.push(std::mem::take(&mut page));
934 page_tokens = 0;
935 }
936 page.push(item.clone());
937 page_tokens = page_tokens.saturating_add(item_tokens);
938 }
939 if !page.is_empty() {
940 pages.push(page);
941 }
942 pages
943}
944
945fn string_field(value: &Value, names: &[&str]) -> Option<String> {
946 names
947 .iter()
948 .find_map(|name| value.get(*name).and_then(Value::as_str))
949 .map(str::to_string)
950}
951
952fn integer_field(value: &Value, names: &[&str]) -> Option<u64> {
953 names
954 .iter()
955 .find_map(|name| value.get(*name).and_then(Value::as_u64))
956}
957
958fn string_array_field(value: &Value, name: &str) -> Vec<String> {
959 value
960 .get(name)
961 .and_then(Value::as_array)
962 .into_iter()
963 .flatten()
964 .filter_map(Value::as_str)
965 .map(str::to_string)
966 .collect()
967}
968
969fn content_sha256(text: &str) -> String {
970 hex_sha256(text.as_bytes())
971}
972
973fn hex_sha256(bytes: &[u8]) -> String {
974 let digest = Sha256::digest(bytes);
975 digest.iter().map(|byte| format!("{byte:02x}")).collect()
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981
982 fn item(id: &str, text: &str) -> BatchItem {
983 BatchItem {
984 id: id.to_string(),
985 text: text.to_string(),
986 content_sha256: content_sha256(text),
987 }
988 }
989
990 #[test]
991 fn models_list_live_shapes_allow_optional_dims() {
992 let models = parse_models_list(include_bytes!(
993 "../tests/fixtures/synapse/models-list-live-raw.json"
994 ))
995 .unwrap();
996 assert_eq!(models.len(), 7);
997 let selected = models
998 .iter()
999 .find(|model| model.model == "gte-modernbert-base-ane-fp16")
1000 .unwrap();
1001 assert_eq!(selected.dims, None);
1002 assert_eq!(selected.recommended_rows, 8);
1003 assert_eq!(selected.recommended_token_budget, 4096);
1004 }
1005
1006 #[test]
1007 fn captured_live_error_envelope_is_typed() {
1008 let raw = include_bytes!("../tests/fixtures/synapse/embed-query-0-live.json").to_vec();
1009 let error = decode_result_envelope(raw).unwrap_err();
1010 assert!(matches!(error, SynapseEmbeddingError::ModelNotCertified(_)));
1011 }
1012
1013 #[test]
1014 fn content_hash_mismatch_is_loud_and_typed() {
1015 let expected = item("item:0", "hello");
1016 let raw = br#"{
1017 "fingerprint":"fp-a","table_epoch":7,
1018 "vectors":[{"id":"item:0","content_sha256":"wrong","vector":[1.0]}]
1019 }"#;
1020 let error = parse_embedding_page(raw, &[expected]).unwrap_err();
1021 assert!(matches!(
1022 error,
1023 SynapseEmbeddingError::ContentHashMismatch { .. }
1024 ));
1025 assert!(error
1026 .to_string()
1027 .contains("synapse_content_sha256_mismatch"));
1028 }
1029
1030 #[test]
1031 fn fingerprint_equivalence_class_accepts_aliases() {
1032 let mut identity = SynapseIdentity {
1033 fingerprint: "fp-current".to_string(),
1034 table_epoch: 7,
1035 equivalent_to: vec!["fp-old".to_string()],
1036 };
1037 assert!(identity.accepts("fp-old"));
1038 assert!(identity.observe_equivalence("fp-new", &["fp-current".to_string()]));
1039 assert!(identity.accepts("fp-new"));
1040 assert!(!identity.accepts("foreign"));
1041 assert!(!identity.observe_equivalence("foreign", &[]));
1042 }
1043
1044 #[test]
1045 fn pages_reassemble_by_chunk_and_item_id() {
1046 let expected = vec![item("item:0", "a"), item("item:1", "b")];
1047 let first = parse_embedding_page(
1048 include_bytes!("../tests/fixtures/synapse/embed-batch-page-1.json"),
1049 &expected,
1050 )
1051 .unwrap();
1052 let second = parse_embedding_page(
1053 include_bytes!("../tests/fixtures/synapse/embed-batch-page-2.json"),
1054 &expected,
1055 )
1056 .unwrap();
1057 assert_eq!(first.next_chunk_id.as_deref(), Some("chunk-2"));
1058 let mut vectors = first.vectors;
1059 vectors.extend(second.vectors);
1060 assert_eq!(vectors.len(), 2);
1061 assert_eq!(vectors["item:1"], vec![0.0, 1.0]);
1062 }
1063
1064 #[test]
1065 fn request_key_is_stable_for_idempotent_retry() {
1066 let identity = SynapseIdentity {
1067 fingerprint: "fp-a".to_string(),
1068 table_epoch: 7,
1069 equivalent_to: Vec::new(),
1070 };
1071 let items = vec![item("item:0", "hello")];
1072 assert_eq!(
1073 batch_request_key("configured-model", &identity, &items),
1074 batch_request_key("configured-model", &identity, &items)
1075 );
1076 }
1077}