Skip to main content

aft/
synapse_embed.rs

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    /// Raw successful discovery envelope, exposed for the gated live fixture probe.
239    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}