khive_runtime/operations/
read_helpers.rs1use khive_score::DeterministicScore;
2use khive_storage::note::Note;
3use khive_storage::types::{Direction, SqlStatement, SqlValue};
4use khive_storage::{EdgeRelation, Entity, Event};
5use uuid::Uuid;
6
7use crate::error::{RuntimeError, RuntimeResult};
8#[cfg(doc)]
9use crate::runtime::KhiveRuntime;
10
11#[derive(Clone, Debug)]
13pub struct NoteSearchHit {
14 pub note_id: Uuid,
15 pub score: DeterministicScore,
16 pub rank_score_kind: crate::RankScoreKind,
17 pub signals: crate::SearchSignals,
18 pub source: crate::SearchSource,
19 pub title: Option<String>,
20 pub snippet: Option<String>,
21}
22
23pub(super) fn salience_weighted_rank(
24 score: DeterministicScore,
25 salience: Option<f64>,
26) -> DeterministicScore {
27 const SCALE_RAW: i128 = 1_i128 << 32;
28 let salience = DeterministicScore::from_f64(salience.unwrap_or(0.5));
29 let weight_raw = SCALE_RAW / 2 + i128::from(salience.to_raw()) / 2;
30 let weighted_raw = i128::from(score.to_raw()) * weight_raw / SCALE_RAW;
33 DeterministicScore::from_raw(weighted_raw.clamp(
34 i128::from(DeterministicScore::NEG_INF.to_raw()),
35 i128::from(DeterministicScore::MAX.to_raw()),
36 ) as i64)
37}
38
39#[derive(Clone, Debug)]
43pub struct NoteSearchOutcome {
44 pub hits: Vec<NoteSearchHit>,
45 pub vector_error: Option<String>,
46}
47
48pub fn hex_prefix_to_uuid_pattern(prefix: &str) -> String {
59 if prefix.contains('-') {
60 return prefix.to_string();
61 }
62 const BOUNDARIES: [usize; 4] = [8, 13, 18, 23]; let mut out = String::with_capacity(36);
64 for c in prefix.chars() {
65 if BOUNDARIES.contains(&out.len()) {
66 out.push('-');
67 }
68 out.push(c);
69 }
70 out
71}
72
73pub fn uuid_prefix_bounds(prefix: &str) -> Option<(String, String)> {
83 const HYPHEN_POSITIONS: [usize; 4] = [8, 13, 18, 23];
84
85 let compact = if prefix.contains('-') {
86 if prefix.len() > 36 {
87 return None;
88 }
89 let mut compact = String::with_capacity(32);
90 for (index, byte) in prefix.bytes().enumerate() {
91 if HYPHEN_POSITIONS.contains(&index) {
92 if byte != b'-' {
93 return None;
94 }
95 } else if byte.is_ascii_hexdigit() {
96 compact.push(char::from(byte.to_ascii_lowercase()));
97 } else {
98 return None;
99 }
100 }
101 compact
102 } else {
103 if prefix.is_empty()
104 || prefix.len() > 32
105 || !prefix.bytes().all(|byte| byte.is_ascii_hexdigit())
106 {
107 return None;
108 }
109 prefix.to_ascii_lowercase()
110 };
111
112 if compact.is_empty() || compact.len() > 32 {
113 return None;
114 }
115
116 let lower = hex_prefix_to_uuid_pattern(&compact);
117 let mut successor = compact.into_bytes();
118 let mut carried_past_start = true;
119 for index in (0..successor.len()).rev() {
120 let next = match successor[index] {
121 b'0'..=b'8' | b'a'..=b'e' => Some(successor[index] + 1),
122 b'9' => Some(b'a'),
123 b'f' => None,
124 _ => return None,
125 };
126 if let Some(next) = next {
127 successor[index] = next;
128 successor.truncate(index + 1);
129 carried_past_start = false;
130 break;
131 }
132 }
133
134 let upper = if carried_past_start {
135 "g".to_string()
136 } else {
137 let compact_upper = String::from_utf8(successor).ok()?;
138 hex_prefix_to_uuid_pattern(&compact_upper)
139 };
140 Some((lower, upper))
141}
142
143pub(super) fn resolve_prefix_statement(
144 table: &str,
145 has_deleted_at: bool,
146 include_deleted: bool,
147 namespaces: Option<&[String]>,
148 lower: &str,
149 upper: &str,
150) -> SqlStatement {
151 let namespace_clause = namespaces.map(|namespaces| {
152 let placeholders: Vec<String> = (0..namespaces.len())
153 .map(|index| format!("?{}", index + 3))
154 .collect();
155 format!(" AND +namespace IN ({})", placeholders.join(", "))
161 });
162 let deleted_filter = if has_deleted_at && !include_deleted {
163 " AND deleted_at IS NULL"
164 } else {
165 ""
166 };
167 let mut params = vec![
168 SqlValue::Text(lower.to_owned()),
169 SqlValue::Text(upper.to_owned()),
170 ];
171 if let Some(namespaces) = namespaces {
172 params.extend(
173 namespaces
174 .iter()
175 .map(|namespace| SqlValue::Text(namespace.clone())),
176 );
177 }
178
179 SqlStatement {
180 sql: format!(
181 "SELECT id FROM {table} \
182 WHERE id >= ?1 AND id < ?2{namespace_clause}{deleted_filter} ORDER BY id LIMIT 2",
183 namespace_clause = namespace_clause.as_deref().unwrap_or("")
184 ),
185 params,
186 label: Some("resolve_prefix".into()),
187 }
188}
189
190fn text_preview(text: &str, max_chars: usize) -> Option<String> {
191 let trimmed = text.trim();
192 if trimmed.is_empty() {
193 None
194 } else {
195 Some(trimmed.chars().take(max_chars).collect())
196 }
197}
198
199pub(super) fn normalize_symmetric_direction(
205 direction: Direction,
206 relations: Option<&[EdgeRelation]>,
207) -> Direction {
208 let Some(rels) = relations else {
209 return direction;
210 };
211 if rels.is_empty() {
212 return direction;
213 }
214 let all_symmetric = rels
215 .iter()
216 .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
217 if all_symmetric {
218 Direction::Both
219 } else {
220 direction
221 }
222}
223
224pub(super) fn direction_sort_rank(direction: &Direction) -> u8 {
231 match direction {
232 Direction::Out => 0,
233 Direction::In => 1,
234 Direction::Both => 2,
235 }
236}
237
238pub(super) fn note_title(note: &Note) -> Option<String> {
239 note.name
240 .clone()
241 .filter(|s| !s.trim().is_empty())
242 .or_else(|| Some(format!("[{}]", note.kind.as_str())))
243}
244
245pub(super) fn note_snippet(note: &Note) -> Option<String> {
246 text_preview(¬e.content, 200)
247}
248
249#[derive(Clone, Debug)]
251pub enum Resolved {
252 Entity(Entity),
253 Note(Note),
254 Event(Event),
255 PackRecord {
262 pack: String,
263 kind: String,
264 data: serde_json::Value,
265 },
266}
267
268pub(super) async fn drain_embed_join_set<T: Send + 'static>(
282 mut join_set: tokio::task::JoinSet<(usize, RuntimeResult<T>)>,
283 model_count: usize,
284) -> RuntimeResult<Vec<T>> {
285 let mut vectors: Vec<Option<T>> = (0..model_count).map(|_| None).collect();
286
287 while let Some(joined) = join_set.join_next().await {
288 match joined {
289 Ok((idx, Ok(vector))) => vectors[idx] = Some(vector),
290 Ok((_idx, Err(e))) => {
291 join_set.abort_all();
292 return Err(e);
293 }
294 Err(join_err) => {
295 join_set.abort_all();
296 return Err(RuntimeError::Internal(format!(
297 "embed task panicked: {join_err}"
298 )));
299 }
300 }
301 }
302
303 Ok(vectors
304 .into_iter()
305 .map(|v| v.expect("every model index observed exactly once by join_set drain"))
306 .collect())
307}