Skip to main content

av_loopdetect/
vector_sink.rs

1//! Off-path vector persistence for semantic-loop observability.
2
3use std::future::Future;
4use std::pin::Pin;
5
6/// Future returned by vector sinks.
7pub type VectorSinkFuture<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
8
9/// Future returned by vector similarity lookups.
10pub type VectorSearchFuture<'a> = Pin<Box<dyn Future<Output = Result<Option<f32>, String>> + Send + 'a>>;
11
12/// Storage boundary for reasoning-step embeddings.
13pub trait VectorSink: Send + Sync {
14    /// Return the highest cosine similarity among prior vectors for a session.
15    fn nearest_similarity<'a>(&'a self, _session_id: &'a str, _vector: &'a [f32]) -> VectorSearchFuture<'a> {
16        Box::pin(async { Ok(None) })
17    }
18
19    /// Persist one session vector without participating in the hot path.
20    fn record<'a>(&'a self, session_id: &'a str, vector: &'a [f32]) -> VectorSinkFuture<'a>;
21
22    /// Delete every vector recorded under
23    /// `session_scope`. Called best-effort when a session finalizes so
24    /// the external store does not grow without bound — the
25    /// `id#generation` scoping makes prior-generation points
26    /// permanently unreachable dead weight otherwise. Default no-op
27    /// for sinks without external state.
28    fn delete_scope<'a>(&'a self, _session_scope: &'a str) -> VectorSinkFuture<'a> {
29        Box::pin(async { Ok(()) })
30    }
31}
32
33/// Sink used when external vector persistence is disabled.
34#[derive(Debug, Default)]
35pub struct NoopVectorSink;
36
37impl VectorSink for NoopVectorSink {
38    fn record<'a>(&'a self, _session_id: &'a str, _vector: &'a [f32]) -> VectorSinkFuture<'a> {
39        Box::pin(async { Ok(()) })
40    }
41}
42
43/// Qdrant HTTP sink for local, edge, or customer-managed deployments.
44#[cfg(feature = "qdrant")]
45pub struct QdrantVectorSink {
46    client: reqwest::Client,
47    base_url: String,
48    collection: String,
49}
50
51#[cfg(feature = "qdrant")]
52impl QdrantVectorSink {
53    /// Create a Qdrant sink. Collection creation remains an operator decision
54    /// because distance metric and replication are deployment policy.
55    pub fn new(base_url: impl Into<String>, collection: impl Into<String>) -> Result<Self, String> {
56        Ok(Self {
57            // No redirect following: a hostile or misconfigured Qdrant host
58            // returning a 3xx would let it pivot the harness into an SSRF
59            // probe against private services on the harness's network.
60            client: reqwest::Client::builder()
61                .connect_timeout(std::time::Duration::from_secs(1))
62                .timeout(std::time::Duration::from_secs(2))
63                .redirect(reqwest::redirect::Policy::none())
64                .build()
65                .map_err(|error| error.to_string())?,
66            base_url: base_url.into().trim_end_matches('/').to_owned(),
67            collection: collection.into(),
68        })
69    }
70
71    /// Idempotent create — GET the collection first,
72    /// verify size + distance if it exists, PUT-create only when
73    /// absent. The prior code used PUT unconditionally, but Qdrant's
74    /// `PUT /collections/{name}` is CREATE, not create-or-update (see
75    /// qdrant/qdrant#3217/#3422). Every daemon restart hit the existing
76    /// collection and boot aborted with a misleading "provision Qdrant
77    /// collection" error; an embedder-dimension change also surfaced
78    /// as the same message rather than a precise dim-conflict.
79    pub async fn ensure_collection(&self, dimension: usize) -> Result<(), String> {
80        let url = format!("{}/collections/{}", self.base_url, self.collection);
81        let get_response = self
82            .client
83            .get(&url)
84            .send()
85            .await
86            .map_err(classify_qdrant_error)?;
87        let status = get_response.status();
88        if status.is_success() {
89            let body: serde_json::Value = get_response.json().await.map_err(classify_qdrant_error)?;
90            let params = body.pointer("/result/config/params/vectors");
91            let existing_size = params
92                .and_then(|value| value.get("size"))
93                .and_then(serde_json::Value::as_u64);
94            let existing_distance = params
95                .and_then(|value| value.get("distance"))
96                .and_then(serde_json::Value::as_str);
97            match (existing_size, existing_distance) {
98                (Some(size), Some(distance))
99                    if usize::try_from(size) == Ok(dimension) && distance.eq_ignore_ascii_case("Cosine") =>
100                {
101                    return Ok(());
102                }
103                (Some(size), Some(distance)) => {
104                    return Err(format!(
105                        "Qdrant collection {:?} exists with size={size}, distance={distance:?} \
106                         but embedder configured for size={dimension}, distance=\"Cosine\" — \
107                         refusing to overwrite; delete the collection or reconfigure the embedder",
108                        self.collection
109                    ));
110                }
111                _ => {
112                    // Existing but with an unrecognized shape — treat
113                    // as a hostile/unexpected environment and refuse
114                    // rather than silently reprovisioning.
115                    return Err(format!(
116                        "Qdrant collection {:?} exists but its config shape is unrecognized \
117                         (no `vectors.size`/`vectors.distance`); refusing to overwrite",
118                        self.collection
119                    ));
120                }
121            }
122        }
123        if status.as_u16() != 404 {
124            return Err(format!(
125                "Qdrant returned {status} when probing collection {:?}",
126                self.collection
127            ));
128        }
129        // Absent: create.
130        let created = self
131            .client
132            .put(url)
133            .json(&serde_json::json!({
134                "vectors": {
135                    "size": dimension,
136                    "distance": "Cosine"
137                }
138            }))
139            .send()
140            .await
141            .map_err(classify_qdrant_error)?;
142        let created_status = created.status();
143        if !created_status.is_success() {
144            return Err(format!(
145                "Qdrant returned {created_status} creating collection {:?}",
146                self.collection
147            ));
148        }
149        Ok(())
150    }
151}
152
153/// Qdrant reqwest errors embed the request URL in
154/// `Display`, leaking the internal vector-store hostname/collection to
155/// every downstream log (worker warn, boot bail). Strip the URL so the
156/// logged text carries only the failure class. `without_url` moves the
157/// error so we accept ownership; the callers were about to `to_string`
158/// and drop it anyway.
159#[cfg(feature = "qdrant")]
160fn classify_qdrant_error(error: reqwest::Error) -> String {
161    error.without_url().to_string()
162}
163
164#[cfg(feature = "qdrant")]
165impl VectorSink for QdrantVectorSink {
166    fn nearest_similarity<'a>(&'a self, session_id: &'a str, vector: &'a [f32]) -> VectorSearchFuture<'a> {
167        Box::pin(async move {
168            let url = format!("{}/collections/{}/points/search", self.base_url, self.collection);
169            // `error_for_status` accepts 3xx, and redirect following is
170            // deliberately disabled (see the client builder): a
171            // redirecting endpoint would be acknowledged as success
172            // without the operation ever executing. Require 2xx
173            // explicitly — here and in record/delete_scope/create below.
174            let raw = self
175                .client
176                .post(url)
177                .json(&serde_json::json!({
178                    "vector": vector,
179                    "filter": {
180                        "must": [{
181                            "key": "session_id",
182                            "match": { "value": session_id }
183                        }]
184                    },
185                    "limit": 1,
186                    "with_payload": false,
187                    "with_vector": false
188                }))
189                .send()
190                .await
191                .map_err(classify_qdrant_error)?;
192            let status = raw.status();
193            if !status.is_success() {
194                return Err(format!(
195                    "Qdrant returned {status} searching collection {:?}",
196                    self.collection
197                ));
198            }
199            let response: serde_json::Value = raw.json().await.map_err(classify_qdrant_error)?;
200            let Some(score) = response
201                .pointer("/result/0/score")
202                .and_then(serde_json::Value::as_f64)
203            else {
204                return Ok(None);
205            };
206            // Cosine similarity is mathematically bounded to [-1, 1];
207            // Qdrant's f64 dot products of unit-normalized f32 vectors
208            // drift by ~1e-6 in EITHER direction, so the slop must be
209            // symmetric: a legitimate antipodal score of -1.0000003 is
210            // as valid as an identical-vector score of +1.0000003.
211            // Truly wild values (a compromised Qdrant) still error out.
212            if !score.is_finite() || !(-1.000_001..=1.000_001).contains(&score) {
213                return Err(format!("Qdrant returned invalid cosine score {score}"));
214            }
215            #[allow(clippy::cast_possible_truncation)]
216            let score = score.clamp(-1.0, 1.0) as f32;
217            Ok(Some(score))
218        })
219    }
220
221    fn record<'a>(&'a self, session_id: &'a str, vector: &'a [f32]) -> VectorSinkFuture<'a> {
222        Box::pin(async move {
223            let recorded_at = av_core::time::now_ms();
224            let id = av_core::new_event_uid();
225            let url = format!(
226                "{}/collections/{}/points?wait=true",
227                self.base_url, self.collection
228            );
229            let recorded = self
230                .client
231                .put(url)
232                .json(&serde_json::json!({
233                    "points": [{
234                        "id": id,
235                        "vector": vector,
236                        "payload": {
237                            "session_id": session_id,
238                            "recorded_at": recorded_at,
239                        }
240                    }]
241                }))
242                .send()
243                .await
244                .map_err(classify_qdrant_error)?;
245            let status = recorded.status();
246            if !status.is_success() {
247                return Err(format!(
248                    "Qdrant returned {status} recording a vector in collection {:?}",
249                    self.collection
250                ));
251            }
252            Ok(())
253        })
254    }
255
256    fn delete_scope<'a>(&'a self, session_scope: &'a str) -> VectorSinkFuture<'a> {
257        Box::pin(async move {
258            let url = format!(
259                "{}/collections/{}/points/delete?wait=true",
260                self.base_url, self.collection
261            );
262            let deleted = self
263                .client
264                .post(url)
265                .json(&serde_json::json!({
266                    "filter": {
267                        "must": [{
268                            "key": "session_id",
269                            "match": { "value": session_scope }
270                        }]
271                    }
272                }))
273                .send()
274                .await
275                .map_err(classify_qdrant_error)?;
276            let status = deleted.status();
277            if !status.is_success() {
278                return Err(format!(
279                    "Qdrant returned {status} deleting a session scope from collection {:?}",
280                    self.collection
281                ));
282            }
283            Ok(())
284        })
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    #![allow(clippy::unwrap_used)]
291
292    use super::*;
293
294    #[tokio::test]
295    async fn noop_sink_is_total() {
296        NoopVectorSink.record("session", &[0.0, 1.0]).await.unwrap();
297    }
298}