use khive_score::DeterministicScore;
use khive_storage::note::Note;
use khive_storage::types::{Direction, SqlStatement, SqlValue};
use khive_storage::{EdgeRelation, Entity, Event};
use uuid::Uuid;
use crate::error::{RuntimeError, RuntimeResult};
#[cfg(doc)]
use crate::runtime::KhiveRuntime;
#[derive(Clone, Debug)]
pub struct NoteSearchHit {
pub note_id: Uuid,
pub score: DeterministicScore,
pub rank_score_kind: crate::RankScoreKind,
pub signals: crate::SearchSignals,
pub source: crate::SearchSource,
pub title: Option<String>,
pub snippet: Option<String>,
}
pub(super) fn salience_weighted_rank(
score: DeterministicScore,
salience: Option<f64>,
) -> DeterministicScore {
const SCALE_RAW: i128 = 1_i128 << 32;
let salience = DeterministicScore::from_f64(salience.unwrap_or(0.5));
let weight_raw = SCALE_RAW / 2 + i128::from(salience.to_raw()) / 2;
let weighted_raw = i128::from(score.to_raw()) * weight_raw / SCALE_RAW;
DeterministicScore::from_raw(weighted_raw.clamp(
i128::from(DeterministicScore::NEG_INF.to_raw()),
i128::from(DeterministicScore::MAX.to_raw()),
) as i64)
}
#[derive(Clone, Debug)]
pub struct NoteSearchOutcome {
pub hits: Vec<NoteSearchHit>,
pub vector_error: Option<String>,
}
pub fn hex_prefix_to_uuid_pattern(prefix: &str) -> String {
if prefix.contains('-') {
return prefix.to_string();
}
const BOUNDARIES: [usize; 4] = [8, 13, 18, 23]; let mut out = String::with_capacity(36);
for c in prefix.chars() {
if BOUNDARIES.contains(&out.len()) {
out.push('-');
}
out.push(c);
}
out
}
pub fn uuid_prefix_bounds(prefix: &str) -> Option<(String, String)> {
const HYPHEN_POSITIONS: [usize; 4] = [8, 13, 18, 23];
let compact = if prefix.contains('-') {
if prefix.len() > 36 {
return None;
}
let mut compact = String::with_capacity(32);
for (index, byte) in prefix.bytes().enumerate() {
if HYPHEN_POSITIONS.contains(&index) {
if byte != b'-' {
return None;
}
} else if byte.is_ascii_hexdigit() {
compact.push(char::from(byte.to_ascii_lowercase()));
} else {
return None;
}
}
compact
} else {
if prefix.is_empty()
|| prefix.len() > 32
|| !prefix.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return None;
}
prefix.to_ascii_lowercase()
};
if compact.is_empty() || compact.len() > 32 {
return None;
}
let lower = hex_prefix_to_uuid_pattern(&compact);
let mut successor = compact.into_bytes();
let mut carried_past_start = true;
for index in (0..successor.len()).rev() {
let next = match successor[index] {
b'0'..=b'8' | b'a'..=b'e' => Some(successor[index] + 1),
b'9' => Some(b'a'),
b'f' => None,
_ => return None,
};
if let Some(next) = next {
successor[index] = next;
successor.truncate(index + 1);
carried_past_start = false;
break;
}
}
let upper = if carried_past_start {
"g".to_string()
} else {
let compact_upper = String::from_utf8(successor).ok()?;
hex_prefix_to_uuid_pattern(&compact_upper)
};
Some((lower, upper))
}
pub(super) fn resolve_prefix_statement(
table: &str,
has_deleted_at: bool,
include_deleted: bool,
namespaces: Option<&[String]>,
lower: &str,
upper: &str,
) -> SqlStatement {
let namespace_clause = namespaces.map(|namespaces| {
let placeholders: Vec<String> = (0..namespaces.len())
.map(|index| format!("?{}", index + 3))
.collect();
format!(" AND +namespace IN ({})", placeholders.join(", "))
});
let deleted_filter = if has_deleted_at && !include_deleted {
" AND deleted_at IS NULL"
} else {
""
};
let mut params = vec![
SqlValue::Text(lower.to_owned()),
SqlValue::Text(upper.to_owned()),
];
if let Some(namespaces) = namespaces {
params.extend(
namespaces
.iter()
.map(|namespace| SqlValue::Text(namespace.clone())),
);
}
SqlStatement {
sql: format!(
"SELECT id FROM {table} \
WHERE id >= ?1 AND id < ?2{namespace_clause}{deleted_filter} ORDER BY id LIMIT 2",
namespace_clause = namespace_clause.as_deref().unwrap_or("")
),
params,
label: Some("resolve_prefix".into()),
}
}
fn text_preview(text: &str, max_chars: usize) -> Option<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.chars().take(max_chars).collect())
}
}
pub(super) fn normalize_symmetric_direction(
direction: Direction,
relations: Option<&[EdgeRelation]>,
) -> Direction {
let Some(rels) = relations else {
return direction;
};
if rels.is_empty() {
return direction;
}
let all_symmetric = rels
.iter()
.all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
if all_symmetric {
Direction::Both
} else {
direction
}
}
pub(super) fn direction_sort_rank(direction: &Direction) -> u8 {
match direction {
Direction::Out => 0,
Direction::In => 1,
Direction::Both => 2,
}
}
pub(super) fn note_title(note: &Note) -> Option<String> {
note.name
.clone()
.filter(|s| !s.trim().is_empty())
.or_else(|| Some(format!("[{}]", note.kind.as_str())))
}
pub(super) fn note_snippet(note: &Note) -> Option<String> {
text_preview(¬e.content, 200)
}
#[derive(Clone, Debug)]
pub enum Resolved {
Entity(Entity),
Note(Note),
Event(Event),
PackRecord {
pack: String,
kind: String,
data: serde_json::Value,
},
}
pub(super) async fn drain_embed_join_set<T: Send + 'static>(
mut join_set: tokio::task::JoinSet<(usize, RuntimeResult<T>)>,
model_count: usize,
) -> RuntimeResult<Vec<T>> {
let mut vectors: Vec<Option<T>> = (0..model_count).map(|_| None).collect();
while let Some(joined) = join_set.join_next().await {
match joined {
Ok((idx, Ok(vector))) => vectors[idx] = Some(vector),
Ok((_idx, Err(e))) => {
join_set.abort_all();
return Err(e);
}
Err(join_err) => {
join_set.abort_all();
return Err(RuntimeError::Internal(format!(
"embed task panicked: {join_err}"
)));
}
}
}
Ok(vectors
.into_iter()
.map(|v| v.expect("every model index observed exactly once by join_set drain"))
.collect())
}