use std::{collections::BTreeMap, error::Error, fmt};
use sim_lib_numbers_stats::{bayesian_update_binary, entropy};
use sim_lib_rank::{EmbeddingStore, retrieve_ids};
const DEFAULT_RADAR_LIMIT: usize = 8;
const DEFAULT_QUERY_TEXT: &str = "atelier radar";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SourceSpan {
pub repo: String,
pub path: String,
pub line: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadarQuery {
pub text: String,
pub repo: Option<String>,
pub crate_name: Option<String>,
pub kind: Option<String>,
pub capability: Option<String>,
pub codec: Option<String>,
pub agent_role: Option<String>,
pub limit: usize,
}
impl RadarQuery {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
repo: None,
crate_name: None,
kind: None,
capability: None,
codec: None,
agent_role: None,
limit: DEFAULT_RADAR_LIMIT,
}
}
fn search_text(&self) -> String {
[
self.text.as_str(),
self.repo.as_deref().unwrap_or_default(),
self.crate_name.as_deref().unwrap_or_default(),
self.kind.as_deref().unwrap_or_default(),
self.capability.as_deref().unwrap_or_default(),
self.codec.as_deref().unwrap_or_default(),
self.agent_role.as_deref().unwrap_or_default(),
]
.into_iter()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadarChunk {
pub chunk_id: String,
pub title: String,
pub span: SourceSpan,
pub crate_name: Option<String>,
pub kind: String,
pub text: String,
pub capabilities: Vec<String>,
pub codecs: Vec<String>,
pub agent_roles: Vec<String>,
pub live: bool,
}
impl RadarChunk {
pub fn new(
chunk_id: impl Into<String>,
title: impl Into<String>,
span: SourceSpan,
kind: impl Into<String>,
text: impl Into<String>,
) -> Self {
Self {
chunk_id: chunk_id.into(),
title: title.into(),
span,
crate_name: None,
kind: kind.into(),
text: text.into(),
capabilities: Vec::new(),
codecs: Vec::new(),
agent_roles: Vec::new(),
live: true,
}
}
fn matches(&self, query: &RadarQuery) -> bool {
matches_field(&self.span.repo, &query.repo)
&& matches_optional(self.crate_name.as_deref(), &query.crate_name)
&& matches_field(&self.kind, &query.kind)
&& matches_list(&self.capabilities, &query.capability)
&& matches_list(&self.codecs, &query.codec)
&& matches_list_or_text(&self.agent_roles, &self.text, &query.agent_role)
}
fn search_text(&self) -> String {
[
self.title.as_str(),
self.kind.as_str(),
self.crate_name.as_deref().unwrap_or_default(),
self.text.as_str(),
&self.capabilities.join(" "),
&self.codecs.join(" "),
&self.agent_roles.join(" "),
]
.into_iter()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RadarIndex {
pub chunks: Vec<RadarChunk>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RadarHint {
pub chunk_id: String,
pub title: String,
pub span: SourceSpan,
pub capabilities: Vec<String>,
pub preferred_codec: Option<String>,
pub confidence: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RadarReport {
pub hints: Vec<RadarHint>,
pub stale_index: bool,
pub stale_chunk_ids: Vec<String>,
}
pub type RadarResult<T> = Result<T, RadarError>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadarError {
message: String,
}
impl RadarError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for RadarError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl Error for RadarError {}
pub fn retrieve_radar_hints(index: &RadarIndex, query: &RadarQuery) -> RadarResult<RadarReport> {
let matching = index
.chunks
.iter()
.filter(|chunk| chunk.matches(query))
.collect::<Vec<_>>();
let stale_chunk_ids = matching
.iter()
.filter(|chunk| !chunk.live)
.map(|chunk| chunk.chunk_id.clone())
.collect::<Vec<_>>();
let live_chunks = matching
.into_iter()
.filter(|chunk| chunk.live)
.collect::<Vec<_>>();
if live_chunks.is_empty() {
return Ok(RadarReport {
hints: Vec::new(),
stale_index: !stale_chunk_ids.is_empty(),
stale_chunk_ids,
});
}
let mut store = EmbeddingStore::new();
let mut by_id = BTreeMap::new();
for chunk in &live_chunks {
store
.insert(chunk.chunk_id.clone(), embedding_vec(&chunk.search_text()))
.map_err(|err| RadarError::new(format!("rank index insert failed: {err}")))?;
by_id.insert(chunk.chunk_id.as_str(), *chunk);
}
let query_embedding = embedding_vec(&query.search_text());
let neighbors = retrieve_ids(
&store,
&query_embedding,
live_chunks.iter().map(|chunk| chunk.chunk_id.as_str()),
query.limit,
)
.map_err(|err| RadarError::new(format!("rank retrieve failed: {err}")))?;
let mut hints = Vec::new();
for neighbor in neighbors {
let Some(chunk) = by_id.get(neighbor.id.as_str()) else {
continue;
};
hints.push(RadarHint {
chunk_id: chunk.chunk_id.clone(),
title: chunk.title.clone(),
span: chunk.span.clone(),
capabilities: chunk.capabilities.clone(),
preferred_codec: preferred_codec(chunk, query),
confidence: confidence_from_score(neighbor.score)?,
});
}
Ok(RadarReport {
hints,
stale_index: !stale_chunk_ids.is_empty(),
stale_chunk_ids,
})
}
fn embedding_vec(text: &str) -> Vec<f32> {
let text = if text
.split(|ch: char| !ch.is_alphanumeric())
.any(|token| !token.is_empty())
{
text
} else {
DEFAULT_QUERY_TEXT
};
crate::embed(text).to_vec()
}
fn confidence_from_score(score: f32) -> RadarResult<f64> {
let likelihood = ((score as f64 + 1.0) / 2.0).clamp(0.0, 1.0);
let posterior = bayesian_update_binary(0.5, likelihood, 0.25)
.map_err(|err| RadarError::new(format!("confidence update failed: {err}")))?;
let uncertainty = entropy(&[posterior, 1.0 - posterior])
.map_err(|err| RadarError::new(format!("confidence entropy failed: {err}")))?;
Ok((posterior * 0.85 + (1.0 - uncertainty) * 0.15).clamp(0.0, 1.0))
}
fn preferred_codec(chunk: &RadarChunk, query: &RadarQuery) -> Option<String> {
query
.codec
.clone()
.or_else(|| chunk.codecs.first().cloned())
}
fn matches_field(value: &str, filter: &Option<String>) -> bool {
filter
.as_deref()
.is_none_or(|filter| value.eq_ignore_ascii_case(filter))
}
fn matches_optional(value: Option<&str>, filter: &Option<String>) -> bool {
filter
.as_deref()
.is_none_or(|filter| value.is_some_and(|value| value.eq_ignore_ascii_case(filter)))
}
fn matches_list(values: &[String], filter: &Option<String>) -> bool {
filter.as_deref().is_none_or(|filter| {
values
.iter()
.any(|value| value.eq_ignore_ascii_case(filter))
})
}
fn matches_list_or_text(values: &[String], text: &str, filter: &Option<String>) -> bool {
filter.as_deref().is_none_or(|filter| {
values
.iter()
.any(|value| value.eq_ignore_ascii_case(filter))
|| text
.to_ascii_lowercase()
.contains(&filter.to_ascii_lowercase())
})
}