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) = 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}