huginn-daemon: source index (chunker, git mirrors, forge catalog, refresh with embedder failover, SourceIndex on Qdrant), huginn-sources bin, daemon/worker source search not gated by the mind's index

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 06:25:25 +01:00
commit 1c227f5907
15 changed files with 2006 additions and 26 deletions

View file

@ -17,6 +17,12 @@ path = "src/lib.rs"
name = "huginn-daemon"
path = "src/main.rs"
# The source index's one writer: a oneshot job that brings `huginn_sources` to
# the forge's current state and exits. No daemon calls any of its writes.
[[bin]]
name = "huginn-sources"
path = "src/bin/huginn-sources.rs"
[dependencies]
anyhow = "1"
base64 = "0.22"

View file

@ -0,0 +1,93 @@
//! The source index's refresh job: one run brings the `huginn_sources`
//! collection to the forge's current state, journals one line per repo, and
//! exits nonzero if any repo failed or the collection could not be used.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::ExitCode;
use anyhow::{Context, Result, ensure};
use huginn_daemon::index::ollama::OllamaEmbedder;
use huginn_daemon::index::qdrant::QdrantIndex;
use huginn_daemon::sources::failover::FailoverEmbedder;
use huginn_daemon::sources::forge::HttpForge;
use huginn_daemon::sources::refresh;
/// Where the forge answers when the flag is absent: the loopback address of the
/// forge on the host that runs this job.
const DEFAULT_FORGE_URL: &str = "http://127.0.0.1:3310";
struct Options {
state_root: PathBuf,
qdrant_url: String,
/// In order of preference; the first that serves the run's model embeds.
ollama_urls: Vec<String>,
embedding_model: String,
forge_url: String,
}
fn parse(args: impl Iterator<Item = String>) -> Result<Options> {
let mut args = args;
let mut values: BTreeMap<String, String> = BTreeMap::new();
let mut ollama_urls = Vec::new();
while let Some(name) = args.next() {
let name = name.strip_prefix("--").with_context(|| format!("expected --option, got {name:?}"))?;
ensure!(
matches!(name, "state-root" | "qdrant-url" | "ollama-url" | "embedding-model" | "forge-url"),
"unsupported huginn-sources option --{name}"
);
let value = args.next().with_context(|| format!("missing value for --{name}"))?;
if name == "ollama-url" {
ensure!(value.starts_with("http://"), "--ollama-url must be a plain http:// address, got {value:?}");
ollama_urls.push(value);
} else {
ensure!(values.insert(name.to_owned(), value).is_none(), "duplicate option --{name}");
}
}
let take = |name: &str| values.get(name).cloned().with_context(|| format!("--{name} is required"));
let url = |name: &str, value: String| {
ensure!(value.starts_with("http://"), "--{name} must be a plain http:// address, got {value:?}");
Ok(value)
};
let state_root = PathBuf::from(take("state-root")?);
ensure!(state_root.is_absolute(), "--state-root must be an absolute path");
let embedding_model = take("embedding-model")?;
ensure!(!embedding_model.is_empty(), "--embedding-model must name a model");
Ok(Options {
state_root,
qdrant_url: url("qdrant-url", take("qdrant-url")?)?,
ollama_urls: {
ensure!(!ollama_urls.is_empty(), "at least one --ollama-url is required; repeat it in order of preference");
ollama_urls
},
embedding_model,
forge_url: url("forge-url", values.get("forge-url").cloned().unwrap_or_else(|| DEFAULT_FORGE_URL.to_owned()))?,
})
}
fn main() -> ExitCode {
let options = match parse(std::env::args().skip(1)) {
Ok(options) => options,
Err(error) => {
eprintln!("huginn-sources: {error:#}");
return ExitCode::from(2);
}
};
let mut embedder = FailoverEmbedder::new(
options.ollama_urls.iter().map(|url| (url.clone(), OllamaEmbedder::new(url, &options.embedding_model))).collect(),
);
let mut index = QdrantIndex::new(&options.qdrant_url);
let mut forge = HttpForge::new(&options.forge_url);
let mut journal = |report: &huginn_daemon::sources::Report| println!("{report}");
match refresh(&mut embedder, &mut index, &mut forge, &options.state_root, &mut journal) {
Ok(refreshed) if refreshed.failed.is_empty() => ExitCode::SUCCESS,
Ok(refreshed) => {
eprintln!("huginn-sources: {} repos failed: {}", refreshed.failed.len(), refreshed.failed.join(", "));
ExitCode::from(1)
}
Err(error) => {
eprintln!("huginn-sources: {error:#}");
ExitCode::from(1)
}
}
}

View file

@ -6,7 +6,7 @@ use chrono::{DateTime, Utc};
use cultnet_rs::Selection;
use huginn_mind::eureka_pipeline::{PipelineRef, Slug};
use huginn_mind::wire::{HuginnMindRequest, HuginnMindResponse, IndexStatus};
use huginn_mind::{Members, Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, SemanticQuery};
use huginn_mind::{Members, Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, SemanticQuery, SourceHits, SourceQuery};
/// Where the index attaches. Called after a batch commits, with the refs the
/// batch landed, derived writes included; the sink may read each through
@ -29,6 +29,11 @@ pub trait IndexSink<S: MindStore> {
fn search(&mut self, text: &str, top_k: u32, among: Vec<String>) -> Result<SearchTicket, String>;
/// How long after it was asked a search is worth answering: the index's
/// own bound on it, which the loop waiting for the answer must share.
/// Starts a source search. The source index is not the mind's: it needs no
/// member ids, and nothing about the mind's own index state refuses it.
fn search_sources(&mut self, query: &SourceQuery) -> Result<SearchTicket, String>;
/// Every source search that finished since the last call, by ticket.
fn sources_searched(&mut self) -> Vec<(SearchTicket, Result<SourceHits, String>)>;
fn search_deadline(&self) -> std::time::Duration;
/// Every search that finished since the last call, by ticket: its
/// candidates and scores, or why it failed.
@ -53,9 +58,22 @@ pub type Hits = Vec<(PipelineRef, f32)>;
/// `Daemon::finish` with the index's answer.
pub struct Search {
pub ticket: SearchTicket,
selection: Selection,
semantic: SemanticQuery,
members: Members,
pending: Pending,
}
/// What the asker is owed: a ranked page of the mind's documents, or the
/// source index's chunks as the index holds them.
enum Pending {
Mind { selection: Selection, semantic: SemanticQuery, members: Members },
Sources,
}
/// What a finished search came to: the mind's candidates, the source index's
/// hits, or why it failed.
pub enum Found {
Mind(Hits),
Sources(SourceHits),
Failed(String),
}
/// What a request came to: an answer, or a search the index has yet to
@ -170,6 +188,15 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
HuginnMindRequest::Query { selection, semantic: Some(semantic), .. } => {
return self.begin_search(selection, semantic);
}
HuginnMindRequest::SearchSources { query, .. } => {
if let Err(refusal) = query.check() {
return Handled::Answered(HuginnMindResponse::Refused(refusal));
}
return match self.index.search_sources(&query) {
Ok(ticket) => Handled::Searching(Search { ticket, pending: Pending::Sources }),
Err(detail) => Handled::Answered(HuginnMindResponse::Refused(MindRefusal::Unavailable { detail })),
};
}
};
Handled::Answered(answer)
}
@ -193,7 +220,7 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
}
let among = members.ids.iter().map(|id| id.id.0.clone()).collect();
match self.index.search(&semantic.text.0, semantic.top_k, among) {
Ok(ticket) => Handled::Searching(Search { ticket, selection, semantic, members }),
Ok(ticket) => Handled::Searching(Search { ticket, pending: Pending::Mind { selection, semantic, members } }),
Err(detail) => refused(MindRefusal::Unavailable { detail }),
}
}
@ -204,8 +231,10 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
}
/// Searches the index has finished since this was last called.
pub fn searched(&mut self) -> Vec<(SearchTicket, Result<Hits, String>)> {
self.index.searched()
pub fn searched(&mut self) -> Vec<(SearchTicket, Found)> {
let mind = self.index.searched().into_iter().map(|(ticket, found)| (ticket, found.map_or_else(Found::Failed, Found::Mind)));
let sources = self.index.sources_searched().into_iter().map(|(ticket, found)| (ticket, found.map_or_else(Found::Failed, Found::Sources)));
mind.chain(sources).collect()
}
/// The client has stopped waiting for `ticket`.
@ -217,12 +246,15 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
/// through the mind (`Mind::rank` answers as of the snapshot the members
/// were taken at and drops what is not a member), or, when the index could not answer, a typed
/// `Unavailable`. Never an empty page in place of a failure.
pub fn finish(&mut self, search: Search, found: Result<Hits, String>) -> HuginnMindResponse {
let hits = match found {
Ok(hits) => hits,
Err(detail) => return HuginnMindResponse::Refused(MindRefusal::Unavailable { detail }),
pub fn finish(&mut self, search: Search, found: Found) -> HuginnMindResponse {
let unavailable = |detail: String| HuginnMindResponse::Refused(MindRefusal::Unavailable { detail });
let (selection, semantic, members, hits) = match (search.pending, found) {
(_, Found::Failed(detail)) => return unavailable(detail),
(Pending::Sources, Found::Sources(hits)) => return HuginnMindResponse::Sources(hits),
(Pending::Mind { selection, semantic, members }, Found::Mind(hits)) => (selection, semantic, members, hits),
_ => return unavailable("the index answered another kind of search than the one asked".into()),
};
match self.mind.rank(&search.selection, &search.semantic, &search.members, &hits) {
match self.mind.rank(&selection, &semantic, &members, &hits) {
Ok(ranked) => {
if let Some(note) = dropped_note(&ranked.dropped) {
eprintln!("{note}");
@ -497,6 +529,14 @@ pub(crate) mod tests {
Err("this index cannot search".into())
}
fn search_sources(&mut self, _query: &SourceQuery) -> Result<SearchTicket, String> {
Err("this index cannot search".into())
}
fn sources_searched(&mut self) -> Vec<(SearchTicket, Result<SourceHits, String>)> {
Vec::new()
}
fn search_deadline(&self) -> std::time::Duration {
std::time::Duration::ZERO
}
@ -539,6 +579,14 @@ pub(crate) mod tests {
Err("this index cannot search".into())
}
fn search_sources(&mut self, _query: &SourceQuery) -> Result<SearchTicket, String> {
Err("this index cannot search".into())
}
fn sources_searched(&mut self) -> Vec<(SearchTicket, Result<SourceHits, String>)> {
Vec::new()
}
fn search_deadline(&self) -> std::time::Duration {
std::time::Duration::ZERO
}
@ -565,6 +613,14 @@ pub(crate) mod tests {
Err("this index cannot search".into())
}
fn search_sources(&mut self, _query: &SourceQuery) -> Result<SearchTicket, String> {
Err("this index cannot search".into())
}
fn sources_searched(&mut self) -> Vec<(SearchTicket, Result<SourceHits, String>)> {
Vec::new()
}
fn search_deadline(&self) -> std::time::Duration {
std::time::Duration::ZERO
}

View file

@ -8,6 +8,8 @@ use std::time::Duration;
use anyhow::{Result, bail, ensure};
use crate::sources::{DescribedSources, SourceIndex, SourcePoint, SourceRow, SourcesMeta};
use super::{CollectionMeta, Described, Embedder, Hit, ModelIdentity, Point, VectorIndex, point_id};
pub(crate) fn identity(digest: &str) -> ModelIdentity {
@ -162,6 +164,9 @@ pub(crate) struct IndexState {
#[derive(Clone, Default)]
pub(crate) struct FakeIndex {
pub(crate) state: Arc<Mutex<IndexState>>,
/// The source collection, held beside the mind's so one fake serves a
/// worker that owns both.
pub(crate) sources: FakeSources,
}
impl FakeIndex {
@ -241,3 +246,138 @@ impl VectorIndex for FakeIndex {
pub(crate) fn pair() -> (FakeEmbedder, FakeIndex) {
(FakeEmbedder::new("d1"), FakeIndex::default())
}
#[derive(Default)]
pub(crate) struct SourcesState {
pub(crate) described: Option<DescribedSources>,
pub(crate) points: BTreeMap<String, SourcePoint>,
/// How many times the collection was recreated.
pub(crate) recreated: u32,
pub(crate) upsert_calls: u32,
/// Every upsert call refuses once this many have succeeded.
pub(crate) fail_upserts_after: Option<u32>,
pub(crate) cursor_writes: Vec<std::collections::BTreeMap<String, String>>,
pub(crate) deletes: Vec<(String, Option<Vec<String>>)>,
/// The repo filter and limit of every search, in order.
pub(crate) searches: Vec<(Option<String>, u32)>,
}
/// An in-memory source collection: a brute-force cosine search over what it
/// holds, so a test sees what a filter and a limit do.
#[derive(Clone, Default)]
pub(crate) struct FakeSources {
pub(crate) state: Arc<Mutex<SourcesState>>,
}
impl FakeSources {
pub(crate) fn plant(&self, described: DescribedSources) {
self.state.lock().unwrap().described = Some(described);
}
pub(crate) fn cursors(&self) -> BTreeMap<String, String> {
match &self.state.lock().unwrap().described {
Some(DescribedSources::Labelled { cursors, .. }) => cursors.clone(),
_ => BTreeMap::new(),
}
}
/// (repo, path, chunk_index, commit) of every point held, sorted.
pub(crate) fn held(&self) -> Vec<(String, String, u32, String)> {
let state = self.state.lock().unwrap();
let mut held: Vec<_> = state
.points
.values()
.map(|point| (point.payload.repo.clone(), point.payload.path.clone(), point.payload.chunk_index, point.payload.commit.clone()))
.collect();
held.sort();
held
}
pub(crate) fn ids(&self) -> BTreeSet<String> {
self.state.lock().unwrap().points.keys().cloned().collect()
}
}
fn cosine(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let norm = |v: &[f32]| v.iter().map(|x| x * x).sum::<f32>().sqrt();
dot / (norm(a) * norm(b)).max(f32::MIN_POSITIVE)
}
impl SourceIndex for FakeSources {
fn describe_sources(&mut self, _within: Duration) -> Result<DescribedSources> {
Ok(self.state.lock().unwrap().described.clone().unwrap_or(DescribedSources::Absent))
}
fn recreate_sources(&mut self, meta: &SourcesMeta) -> Result<()> {
let mut state = self.state.lock().unwrap();
state.recreated += 1;
state.points.clear();
state.described = Some(DescribedSources::Labelled { meta: meta.clone(), cursors: BTreeMap::new() });
Ok(())
}
fn set_cursors(&mut self, cursors: &BTreeMap<String, String>) -> Result<()> {
let mut state = self.state.lock().unwrap();
state.cursor_writes.push(cursors.clone());
let Some(DescribedSources::Labelled { cursors: held, .. }) = &mut state.described else { bail!("no source collection") };
*held = cursors.clone();
Ok(())
}
fn delete_by(&mut self, repo: &str, paths: Option<&[String]>) -> Result<()> {
let mut state = self.state.lock().unwrap();
state.deletes.push((repo.to_owned(), paths.map(<[String]>::to_vec)));
state.points.retain(|_, point| {
!(point.payload.repo == repo && paths.is_none_or(|paths| paths.contains(&point.payload.path)))
});
Ok(())
}
fn upsert_sources(&mut self, points: &[SourcePoint]) -> Result<()> {
let mut state = self.state.lock().unwrap();
if state.fail_upserts_after.is_some_and(|after| state.upsert_calls >= after) {
bail!("the vector store refused the upsert");
}
state.upsert_calls += 1;
for point in points {
state.points.insert(point.id.clone(), point.clone());
}
Ok(())
}
fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, limit: u32, _within: Duration) -> Result<Vec<SourceRow>> {
let mut state = self.state.lock().unwrap();
state.searches.push((repo.map(str::to_owned), limit));
let mut rows: Vec<SourceRow> = state
.points
.values()
.filter(|point| repo.is_none_or(|repo| point.payload.repo == repo))
.map(|point| SourceRow { score: cosine(vector, &point.vector), payload: point.payload.clone() })
.collect();
rows.sort_by(|a, b| b.score.total_cmp(&a.score));
rows.truncate(limit as usize);
Ok(rows)
}
}
impl SourceIndex for FakeIndex {
fn describe_sources(&mut self, within: Duration) -> Result<DescribedSources> {
self.sources.describe_sources(within)
}
fn recreate_sources(&mut self, meta: &SourcesMeta) -> Result<()> {
self.sources.recreate_sources(meta)
}
fn set_cursors(&mut self, cursors: &BTreeMap<String, String>) -> Result<()> {
self.sources.set_cursors(cursors)
}
fn delete_by(&mut self, repo: &str, paths: Option<&[String]>) -> Result<()> {
self.sources.delete_by(repo, paths)
}
fn upsert_sources(&mut self, points: &[SourcePoint]) -> Result<()> {
self.sources.upsert_sources(points)
}
fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, limit: u32, within: Duration) -> Result<Vec<SourceRow>> {
self.sources.search_sources(vector, repo, limit, within)
}
}

View file

@ -105,6 +105,8 @@ use huginn_mind::{INDEX_TEXT_VERSION, IndexEntry, Mind, MindStore};
use sha2::{Digest, Sha256};
use crate::daemon::{Hits, IndexSink, SearchTicket};
use crate::sources::{self, SourceIndex};
use huginn_mind::{SourceHits, SourceQuery};
use ollama::QUERY_INSTRUCTION;
/// The `managed_by` a collection carries when this organ made it.
@ -262,17 +264,17 @@ pub enum Advance {
/// The time one operation has left. Every call the operation makes is bounded
/// by what remains, not by a bound of its own, so their sum cannot outlast it.
struct Budget {
pub(crate) struct Budget {
end: Instant,
total: Duration,
}
impl Budget {
fn new(total: Duration) -> Self {
pub(crate) fn new(total: Duration) -> Self {
Self { end: Instant::now() + total, total }
}
fn left(&self) -> Result<Duration> {
pub(crate) fn left(&self) -> Result<Duration> {
let left = self.end.saturating_duration_since(Instant::now());
ensure!(!left.is_zero(), "the time allowed ({:?}) has run out", self.total);
Ok(left)
@ -669,6 +671,14 @@ struct Queued {
due: Instant,
}
/// A source search waiting for the worker. It is not the mind's: nothing about
/// the mind's reconciliation gates it.
struct QueuedSource {
ticket: SearchTicket,
query: SourceQuery,
due: Instant,
}
struct Shared {
inbox: Vec<IndexEntry>,
/// The mind's identity, once a commit has revealed it to a mind that had none.
@ -681,6 +691,8 @@ struct Shared {
searches: VecDeque<Queued>,
/// Searches the worker finished and the serve loop has not collected.
found: Vec<(SearchTicket, Result<Hits, String>)>,
source_searches: VecDeque<QueuedSource>,
sources_found: Vec<(SearchTicket, Result<SourceHits, String>)>,
next_ticket: u64,
}
@ -711,7 +723,7 @@ fn wait_for_work(shared: &Lock, wait: Wait) {
if guard.closed {
return;
}
if wakes_on_work && (!guard.inbox.is_empty() || guard.mind.is_some() || !guard.searches.is_empty()) {
if !guard.source_searches.is_empty() || (wakes_on_work && (!guard.inbox.is_empty() || guard.mind.is_some() || !guard.searches.is_empty())) {
return;
}
let left = deadline.saturating_duration_since(Instant::now());
@ -792,6 +804,21 @@ fn serve_search<E: Embedder, V: VectorIndex>(projector: &mut Projector<E, V>, sh
/// owed (documents to write, a reconciliation to make) unless the index
/// cannot be asked at all, in which case the search is taken to be refused:
/// waiting would not change the answer.
/// Answers the oldest queued source search, whatever state the mind's
/// projection is in: it asks the embedder and the source collection and nothing
/// of the projector's reconciliation.
fn serve_source_search<E: Embedder, V: VectorIndex + SourceIndex>(projector: &mut Projector<E, V>, shared: &Lock) {
let mut guard = lock(shared);
if guard.closed {
return;
}
let Some(QueuedSource { ticket, query, due }) = guard.source_searches.pop_front() else { return };
drop(guard);
let found = sources::search(&mut projector.embedder, &mut projector.index, &query, due.saturating_duration_since(Instant::now()))
.map_err(|error| format!("{error:#}"));
lock(shared).sources_found.push((ticket, found));
}
fn take_search<E: Embedder, V: VectorIndex>(projector: &Projector<E, V>, guard: &mut Shared) -> Option<Queued> {
if !guard.inbox.is_empty() {
return None;
@ -852,7 +879,7 @@ fn absorb_into<E: Embedder, V: VectorIndex>(
true
}
fn work<E: Embedder, V: VectorIndex>(mut projector: Projector<E, V>, shared: Lock, backoff: Backoff) {
fn work<E: Embedder, V: VectorIndex + SourceIndex>(mut projector: Projector<E, V>, shared: Lock, backoff: Backoff) {
let mut failing: Option<(StepError, u32)> = None;
let mut delay = backoff.initial;
// When the worker next verifies the model and the collection whether or
@ -878,6 +905,7 @@ fn work<E: Embedder, V: VectorIndex>(mut projector: Projector<E, V>, shared: Loc
}
publish(&shared, projector.progress_status());
serve_search(&mut projector, &shared);
serve_source_search(&mut projector, &shared);
// A model or collection the search found changed has left the
// reconciliation to redo: do it now, not at the next wake.
if advance == Advance::Idle && !projector.stale() {
@ -894,7 +922,17 @@ fn work<E: Embedder, V: VectorIndex>(mut projector: Projector<E, V>, shared: Loc
publish(&shared, projector.failure_status(&error, attempts));
refuse_searches(&shared, &unsearchable(&projector.failure_status(&error, attempts)).unwrap_or_default());
failing = Some((error, attempts));
wait_for_work(&shared, Wait::Backoff(delay));
// The mind's index is down; a source search does not depend on
// it, so it is answered while the backoff runs out.
let until = Instant::now() + delay;
loop {
serve_source_search(&mut projector, &shared);
let left = until.saturating_duration_since(Instant::now());
if left.is_zero() || lock(&shared).closed {
break;
}
wait_for_work(&shared, Wait::Backoff(left));
}
delay = backoff.after(delay);
}
}
@ -928,7 +966,7 @@ impl WorkerSink {
) -> Self
where
E: Embedder + Send + 'static,
V: VectorIndex + Send + 'static,
V: VectorIndex + SourceIndex + Send + 'static,
{
let identified = mind.is_some();
let mut projector = Projector::new(embedder, index, instance, mind);
@ -941,6 +979,8 @@ impl WorkerSink {
closed: false,
searches: VecDeque::new(),
found: Vec::new(),
source_searches: VecDeque::new(),
sources_found: Vec::new(),
next_ticket: 0,
}),
Condvar::new(),
@ -1017,10 +1057,10 @@ impl<S: MindStore> IndexSink<S> for WorkerSink {
if let Some(why) = unsearchable(&status) {
return Err(why);
}
if guard.searches.len() >= SEARCHES_QUEUED_MAX {
if guard.searches.len() + guard.source_searches.len() >= SEARCHES_QUEUED_MAX {
return Err(format!(
"the semantic index has {} searches waiting and takes no more until it has answered some",
guard.searches.len()
guard.searches.len() + guard.source_searches.len()
));
}
let ticket = SearchTicket(guard.next_ticket);
@ -1031,6 +1071,26 @@ impl<S: MindStore> IndexSink<S> for WorkerSink {
Ok(ticket)
}
fn search_sources(&mut self, query: &SourceQuery) -> Result<SearchTicket, String> {
let mut guard = lock(&self.shared);
if guard.searches.len() + guard.source_searches.len() >= SEARCHES_QUEUED_MAX {
return Err(format!(
"the index has {} searches waiting and takes no more until it has answered some",
guard.searches.len() + guard.source_searches.len()
));
}
let ticket = SearchTicket(guard.next_ticket);
guard.next_ticket += 1;
guard.source_searches.push_back(QueuedSource { ticket, query: query.clone(), due: Instant::now() + self.deadline });
drop(guard);
self.shared.1.notify_all();
Ok(ticket)
}
fn sources_searched(&mut self) -> Vec<(SearchTicket, Result<SourceHits, String>)> {
std::mem::take(&mut lock(&self.shared).sources_found)
}
fn search_deadline(&self) -> Duration {
self.deadline
}
@ -1043,7 +1103,9 @@ impl<S: MindStore> IndexSink<S> for WorkerSink {
/// the worker already holds cannot be recalled; its answer is collected
/// and dropped by the loop, which no longer waits for it.
fn abandon(&mut self, ticket: SearchTicket) {
lock(&self.shared).searches.retain(|queued| queued.ticket != ticket);
let mut guard = lock(&self.shared);
guard.searches.retain(|queued| queued.ticket != ticket);
guard.source_searches.retain(|queued| queued.ticket != ticket);
}
}
@ -1061,6 +1123,7 @@ pub(crate) enum Method {
Put,
Post,
Delete,
Patch,
}
impl Http {
@ -1084,6 +1147,7 @@ impl Http {
Method::Delete => self.agent.delete(&url).call(),
Method::Post => self.agent.post(&url).header("content-type", json).send(body.unwrap_or_default()),
Method::Put => self.agent.put(&url).header("content-type", json).send(body.unwrap_or_default()),
Method::Patch => self.agent.patch(&url).header("content-type", json).send(body.unwrap_or_default()),
};
let mut response = sent.with_context(|| format!("no answer from {url}"))?;
let status = response.status().as_u16();

View file

@ -1,12 +1,14 @@
//! The vector-store port over Qdrant's HTTP API. With `ollama.rs`, the xenos
//! boundary: JSON exists here and nowhere else in the organ.
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;
use anyhow::{Context, Result, ensure};
use serde_json::{Value, json};
use crate::sources::{COLLECTION, DescribedSources, SourceIndex, SourcePayload, SourcePoint, SourceRow, SourcesMeta};
use super::{CollectionMeta, INDEX_CALL_TIMEOUT, Described, Hit, Http, Method, Point, VectorIndex};
/// One Qdrant. Every path names a collection this organ was told to own; it
@ -163,3 +165,138 @@ impl VectorIndex for QdrantIndex {
.collect()
}
}
fn payload_json(payload: &SourcePayload) -> Value {
json!({
"repo": payload.repo,
"path": payload.path,
"commit": payload.commit,
"blob": payload.blob,
"line_start": payload.line_start,
"line_end": payload.line_end,
"chunk_index": payload.chunk_index,
"ext": payload.ext,
"text": payload.text,
})
}
fn payload_of(payload: &Value) -> Result<SourcePayload> {
let text = |key: &str| payload[key].as_str().map(str::to_owned).with_context(|| format!("a chunk carries no {key}"));
let number = |key: &str| {
payload[key].as_u64().and_then(|value| u32::try_from(value).ok()).with_context(|| format!("a chunk carries no {key}"))
};
Ok(SourcePayload {
repo: text("repo")?,
path: text("path")?,
commit: text("commit")?,
blob: text("blob")?,
line_start: number("line_start")?,
line_end: number("line_end")?,
chunk_index: number("chunk_index")?,
ext: text("ext")?,
text: text("text")?,
})
}
/// The source collection's label and cursors, as they sit in its metadata.
fn sources_described(metadata: &Value) -> DescribedSources {
let text = |key: &str| metadata[key].as_str().map(str::to_owned);
let Some(managed_by) = text("managed_by") else { return DescribedSources::Unlabelled };
let number = |key: &str| metadata[key].as_u64().and_then(|value| u32::try_from(value).ok()).unwrap_or(0);
let cursors: BTreeMap<String, String> = metadata["cursors"]
.as_object()
.map(|held| held.iter().filter_map(|(repo, commit)| Some((repo.clone(), commit.as_str()?.to_owned()))).collect())
.unwrap_or_default();
DescribedSources::Labelled {
meta: SourcesMeta {
managed_by,
corpus: text("corpus").unwrap_or_default(),
model: text("model").unwrap_or_default(),
model_digest: text("model_digest").unwrap_or_default(),
dimensions: number("dimensions"),
chunker_version: number("chunker_version"),
},
cursors,
}
}
impl SourceIndex for QdrantIndex {
fn describe_sources(&mut self, within: Duration) -> Result<DescribedSources> {
let http = self.http.bounded(within);
let (status, answer) = self.ask_with(&http, Method::Get, &format!("/collections/{COLLECTION}"), None, &[200, 404])?;
if status == 404 {
return Ok(DescribedSources::Absent);
}
Ok(sources_described(&answer["result"]["config"]["metadata"]))
}
fn recreate_sources(&mut self, meta: &SourcesMeta) -> Result<()> {
let path = format!("/collections/{COLLECTION}");
self.ask(Method::Delete, &format!("{path}?wait=true"), None, &[200, 404])?;
let body = json!({
"vectors": { "size": meta.dimensions, "distance": "Cosine", "on_disk": true },
"metadata": {
"managed_by": meta.managed_by,
"corpus": meta.corpus,
"model": meta.model,
"model_digest": meta.model_digest,
"dimensions": meta.dimensions,
"chunker_version": meta.chunker_version,
"cursors": {},
},
});
self.ask(Method::Put, &format!("{path}?wait=true"), Some(&body), &[200])?;
for field in ["repo", "path"] {
let index = json!({ "field_name": field, "field_schema": "keyword" });
self.ask(Method::Put, &format!("{path}/index?wait=true"), Some(&index), &[200])?;
}
Ok(())
}
fn set_cursors(&mut self, cursors: &BTreeMap<String, String>) -> Result<()> {
// Qdrant merges metadata by top-level key and replaces a nested object
// whole, so this is the whole cursors object and nothing beside it.
let body = json!({ "metadata": { "cursors": cursors } });
self.ask(Method::Patch, &format!("/collections/{COLLECTION}?wait=true"), Some(&body), &[200])?;
Ok(())
}
fn delete_by(&mut self, repo: &str, paths: Option<&[String]>) -> Result<()> {
let mut must = vec![json!({ "key": "repo", "match": { "value": repo } })];
if let Some(paths) = paths {
must.push(json!({ "key": "path", "match": { "any": paths } }));
}
let body = json!({ "filter": { "must": must } });
self.ask(Method::Post, &format!("/collections/{COLLECTION}/points/delete?wait=true"), Some(&body), &[200])?;
Ok(())
}
fn upsert_sources(&mut self, points: &[SourcePoint]) -> Result<()> {
let points: Vec<Value> = points
.iter()
.map(|point| json!({ "id": point.id, "vector": point.vector, "payload": payload_json(&point.payload) }))
.collect();
self.ask(Method::Put, &format!("/collections/{COLLECTION}/points?wait=true"), Some(&json!({ "points": points })), &[200])?;
Ok(())
}
fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, limit: u32, within: Duration) -> Result<Vec<SourceRow>> {
let http = self.http.bounded(within);
let mut body = json!({ "vector": vector, "limit": limit, "with_payload": true, "with_vector": false });
if let Some(repo) = repo {
body["filter"] = json!({ "must": [{ "key": "repo", "match": { "value": repo } }] });
}
let (_, answer) =
self.ask_with(&http, Method::Post, &format!("/collections/{COLLECTION}/points/search"), Some(&body), &[200])?;
let found = answer["result"].as_array().context("Qdrant's search has no result")?;
found
.iter()
.map(|point| {
Ok(SourceRow {
score: point["score"].as_f64().context("a hit carries no score")? as f32,
payload: payload_of(&point["payload"])?,
})
})
.collect()
}
}

View file

@ -1968,3 +1968,90 @@ fn the_sink_owns_the_searchs_deadline_and_tells_the_loop() {
assert_eq!(<WorkerSink as IndexSink<OwnedRedbMessagePackBackingStore>>::search_deadline(&sink), deadline);
assert_eq!(Daemon::new(mind, sink).search_deadline(), deadline);
}
fn ask_sources(sink: &mut WorkerSink, text: &str) -> Result<SearchTicket, String> {
let query = SourceQuery { text: huginn_mind::eureka_pipeline::Line(text.into()), top_k: 5, repo: None };
<WorkerSink as IndexSink<OwnedRedbMessagePackBackingStore>>::search_sources(sink, &query)
}
fn sources_answers(sink: &mut WorkerSink, count: usize) -> Vec<(SearchTicket, Result<SourceHits, String>)> {
let mut all = Vec::new();
eventually("the source searches being answered", || {
all.extend(<WorkerSink as IndexSink<OwnedRedbMessagePackBackingStore>>::sources_searched(sink));
all.len() >= count
});
all
}
/// The mind's reconciliation does not gate a source search: where the sink
/// refuses a mind search because the mind's index is being rebuilt, failing or
/// refused, it takes a source search.
#[test]
fn source_search_is_not_gated_by_the_minds_index_state() {
use IndexStatus as S;
for status in [
S::Reconciling { pending: 4 },
S::Failing { pending: 1, attempts: 1, error: "boom".into() },
S::Refused { pending: 1, attempts: 1, reason: "not ours".into() },
] {
let shared = shared_with(Vec::new(), None, Vec::new(), false);
lock(&shared).status = status.clone();
let mut sink = WorkerSink { shared, identified: true, deadline: SEARCH_DEADLINE };
assert!(ask_sink(&mut sink, "anything", 3).is_err(), "{status:?} refuses a mind search");
assert!(ask_sources(&mut sink, "anything").is_ok(), "{status:?} must not refuse a source search");
}
}
/// A worker whose mind collection is another mind's (it cannot advance and
/// backs off) still answers a source search while it waits, and the answer is
/// the source collection's, not an error from the mind's.
#[test]
fn a_worker_stuck_on_the_minds_collection_still_answers_a_source_search() {
use crate::sources::{DescribedSources, SourcePayload, SourcePoint, SourcesMeta};
let (embedder, index) = pair();
let mind_label = Described::Labelled(CollectionMeta {
managed_by: MANAGED_BY.into(),
instance: INSTANCE.into(),
mind: "another-mind".into(),
model: "model".into(),
model_digest: "d1".into(),
dimensions: 4,
index_text_version: INDEX_TEXT_VERSION,
});
index.plant(&collection_name(&slug(INSTANCE)), mind_label);
let meta = SourcesMeta {
managed_by: MANAGED_BY.into(),
corpus: "forge:GameCult".into(),
model: "model".into(),
model_digest: "d1".into(),
dimensions: 4,
chunker_version: 1,
};
index.sources.plant(DescribedSources::Labelled { meta, cursors: Default::default() });
index.sources.state.lock().unwrap().points.insert(
"p1".into(),
SourcePoint {
id: "p1".into(),
vector: vec![1.0; 4],
payload: SourcePayload {
repo: "Huginn".into(),
path: "a.rs".into(),
commit: "abc".into(),
blob: "b".into(),
line_start: 1,
line_end: 2,
chunk_index: 0,
ext: "rs".into(),
text: "fn a() {}".into(),
},
},
);
let mut sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), Some(MIND.into()), entries_of(1), quick());
let refused = sink.wait_status(|status| matches!(status, IndexStatus::Refused { .. }));
assert!(matches!(refused, IndexStatus::Refused { .. }), "{refused:?}");
ask_sources(&mut sink, "a function").unwrap();
let answered = sources_answers(&mut sink, 1).remove(0).1.expect("the source search answers");
assert_eq!(answered.hits.len(), 1);
assert_eq!((answered.hits[0].repo.as_str(), answered.hits[0].path.as_str()), ("Huginn", "a.rs"));
assert_eq!(answered.indexed_under.digest, "d1");
}

View file

@ -17,9 +17,10 @@ pub mod bodies;
pub mod daemon;
pub mod index;
pub mod serve;
pub mod sources;
#[cfg(test)]
mod semantic_tests;
pub use daemon::{Daemon, Handled, Hits, IndexSink, Search, SearchTicket, runtime_id};
pub use daemon::{Daemon, Found, Handled, Hits, IndexSink, Search, SearchTicket, runtime_id};
pub use serve::{Options, ServeOptions, parse_options, run, startup};

View file

@ -522,14 +522,14 @@ pub fn run<S: MindStore, I: IndexSink<S>>(
fn collect_searches<S: MindStore, I: IndexSink<S>>(
daemon: &mut Daemon<S, I>,
waiting: &BTreeMap<SearchTicket, Waiting>,
) -> Vec<(SearchTicket, Result<crate::daemon::Hits, String>)> {
) -> Vec<(SearchTicket, crate::daemon::Found)> {
let deadline = daemon.search_deadline();
let mut collected = daemon.searched();
let now = Instant::now();
for (ticket, asked) in waiting {
if asked.until <= now {
daemon.abandon(*ticket);
collected.push((*ticket, Err(format!("the semantic search did not finish within {deadline:?}"))));
collected.push((*ticket, crate::daemon::Found::Failed(format!("the search did not finish within {deadline:?}"))));
}
}
collected

View file

@ -0,0 +1,134 @@
//! The source chunker: line windows, no overlap. A chunk is the unit the index
//! holds and a search answers, so its line range is a citation.
/// Bumped whenever the windows or the embedded text would change for the same
/// blob: it is part of every chunk's identity and of the collection's label, so
/// a change rebuilds the collection instead of mixing two chunkings.
pub const CHUNKER_VERSION: u32 = 1;
/// The most lines a chunk holds.
pub const CHUNK_LINES_MAX: usize = 48;
/// The most characters a chunk's text holds.
pub const CHUNK_CHARS_MAX: usize = 1_400;
/// One window of a file: its 1-based inclusive line range and its text.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Chunk {
pub line_start: u32,
pub line_end: u32,
pub text: String,
}
/// The file cut into windows of at most `CHUNK_LINES_MAX` lines or
/// `CHUNK_CHARS_MAX` characters. CRLF is read as LF and a trailing newline ends
/// the last line without starting another. A line longer than the character
/// bound is a chunk of its own, cut at the bound on a character boundary.
/// A window of blank lines holds nothing to find and is dropped.
pub fn chunk(text: &str) -> Vec<Chunk> {
let normalised = text.replace("\r\n", "\n");
let body = normalised.strip_suffix('\n').unwrap_or(&normalised);
if body.is_empty() {
return Vec::new();
}
let mut chunks = Vec::new();
let mut window: Vec<&str> = Vec::new();
let mut window_chars = 0_usize;
let mut window_start = 1_u32;
let flush = |window: &mut Vec<&str>, window_chars: &mut usize, window_start: u32, chunks: &mut Vec<Chunk>| {
if !window.is_empty() {
let joined = window.join("\n");
if !joined.trim().is_empty() {
let end = window_start + u32::try_from(window.len()).unwrap_or(u32::MAX) - 1;
chunks.push(Chunk { line_start: window_start, line_end: end, text: joined });
}
}
window.clear();
*window_chars = 0;
};
for (index, line) in body.split('\n').enumerate() {
let number = u32::try_from(index + 1).unwrap_or(u32::MAX);
let length = line.chars().count();
if length > CHUNK_CHARS_MAX {
flush(&mut window, &mut window_chars, window_start, &mut chunks);
let cut: String = line.chars().take(CHUNK_CHARS_MAX).collect();
chunks.push(Chunk { line_start: number, line_end: number, text: cut });
continue;
}
let joined = window_chars + length + usize::from(!window.is_empty());
if !window.is_empty() && (window.len() >= CHUNK_LINES_MAX || joined > CHUNK_CHARS_MAX) {
flush(&mut window, &mut window_chars, window_start, &mut chunks);
}
if window.is_empty() {
window_start = number;
window_chars = length;
} else {
window_chars += length + 1;
}
window.push(line);
}
flush(&mut window, &mut window_chars, window_start, &mut chunks);
chunks
}
/// What is embedded for a chunk: where it lives, then what it says. The path
/// is part of the meaning of a line of code.
pub fn embedded_text(repo: &str, path: &str, chunk: &Chunk) -> String {
format!("{repo}/{path}\n{}", chunk.text)
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(count: usize) -> String {
(1..=count).map(|n| format!("line {n}\n")).collect()
}
/// Every chunk's range is the lines it holds, counted from one, whatever
/// the file's line endings or last newline.
#[test]
fn chunk_lines_are_exact() {
for text in [lines(100), lines(100).replace('\n', "\r\n"), lines(100).trim_end().to_owned()] {
let source: Vec<String> =
text.replace("\r\n", "\n").trim_end_matches('\n').split('\n').map(str::to_owned).collect();
let chunks = chunk(&text);
assert_eq!(chunks.len(), 3, "100 lines at 48 per window");
let mut next = 1;
for held in &chunks {
assert_eq!(held.line_start, next, "no gap and no overlap");
let expected = source[held.line_start as usize - 1..held.line_end as usize].join("\n");
assert_eq!(held.text, expected, "lines {}-{}", held.line_start, held.line_end);
assert!(!held.text.contains('\r'));
next = held.line_end + 1;
}
assert_eq!(next, 101, "every line is in a chunk");
}
assert_eq!(chunk("a\n"), vec![Chunk { line_start: 1, line_end: 1, text: "a".into() }]);
assert!(chunk("").is_empty() && chunk("\n").is_empty() && chunk("\n\n \n").is_empty());
}
/// A window closes at the character bound before it closes at the line
/// bound, and a line past the bound stands alone, cut on a boundary.
#[test]
fn a_window_closes_at_the_character_bound_and_a_long_line_stands_alone() {
let wide = "x".repeat(700);
let chunks = chunk(&format!("{wide}\n{wide}\n{wide}\n"));
let ranges: Vec<(u32, u32)> = chunks.iter().map(|held| (held.line_start, held.line_end)).collect();
assert_eq!(ranges, [(1, 1), (2, 2), (3, 3)], "two 700-char lines would join to 1,401");
let fits = chunk(&format!("{}\n{}\n", "y".repeat(699), "y".repeat(700)));
assert_eq!(fits.len(), 1, "699 + newline + 700 is exactly 1,400");
let long = "\u{e9}".repeat(CHUNK_CHARS_MAX + 50);
let chunks = chunk(&format!("before\n{long}\nafter\n"));
let shown: Vec<(u32, u32, usize)> =
chunks.iter().map(|held| (held.line_start, held.line_end, held.text.chars().count())).collect();
assert_eq!(shown, [(1, 1, 6), (2, 2, CHUNK_CHARS_MAX), (3, 3, 5)]);
assert!(chunks[1].text.chars().all(|c| c == '\u{e9}'));
}
#[test]
fn the_embedded_text_names_the_file_and_the_payload_text_does_not() {
let held = Chunk { line_start: 3, line_end: 4, text: "fn a() {}".into() };
assert_eq!(embedded_text("Huginn", "src/a.rs", &held), "Huginn/src/a.rs\nfn a() {}");
}
}

View file

@ -0,0 +1,150 @@
//! The writer's embedder: an ordered list of endpoints serving one model. The
//! first that answers with the run's model identity embeds; one that is
//! unreachable, failing, or serving another model (name, digest or length) is
//! skipped and named in the log, and is never written with. Daemons do not use
//! this: a query embeds on its daemon's own embedder.
use std::time::Duration;
use anyhow::{Result, bail};
use crate::index::{Embedder, ModelIdentity};
/// Endpoints in the order they are preferred, each with the name the log uses.
/// The first identity read of a run pins the model; every endpoint must then
/// report that identity before it is written with.
pub struct FailoverEmbedder<E: Embedder> {
endpoints: Vec<(String, E)>,
pinned: Option<ModelIdentity>,
}
impl<E: Embedder> FailoverEmbedder<E> {
pub fn new(endpoints: Vec<(String, E)>) -> Self {
Self { endpoints, pinned: None }
}
/// The first endpoint at or after `from` that answers with the pinned
/// identity. The others are named in the log and in the error if none does.
fn serving(&mut self, from: usize, within: Duration) -> Result<usize> {
let mut skipped = Vec::new();
for at in from..self.endpoints.len() {
let name = self.endpoints[at].0.clone();
match self.endpoints[at].1.model_identity(within) {
Ok(identity) => {
let pinned = self.pinned.get_or_insert_with(|| identity.clone());
if *pinned == identity {
return Ok(at);
}
eprintln!("huginn-sources: embedder {name} serves {identity:?}, not {pinned:?}; skipped");
skipped.push(format!("{name}: another model"));
}
Err(error) => {
eprintln!("huginn-sources: embedder {name} is unavailable: {error:#}; trying the next");
skipped.push(format!("{name}: {error:#}"));
}
}
}
bail!("no embedder endpoint can serve this run: {}", skipped.join("; "))
}
}
impl<E: Embedder> Embedder for FailoverEmbedder<E> {
fn model_identity(&mut self, within: Duration) -> Result<ModelIdentity> {
self.serving(0, within)?;
Ok(self.pinned.clone().expect("serving pins the model"))
}
fn embed(&mut self, texts: &[String], within: Duration) -> Result<Vec<Vec<f32>>> {
let mut failures = Vec::new();
let mut from = 0;
while from < self.endpoints.len() {
let at = match self.serving(from, within) {
Ok(at) => at,
Err(error) => bail!("no embedder endpoint embedded the batch: {}{error:#}", failures.join("; ")),
};
match self.endpoints[at].1.embed(texts, within) {
Ok(vectors) => return Ok(vectors),
Err(error) => {
let name = &self.endpoints[at].0;
eprintln!("huginn-sources: embedder {name} failed an embed: {error:#}; trying the next");
failures.push(format!("{name}: {error:#}; "));
from = at + 1;
}
}
}
bail!("no embedder endpoint embedded the batch: {}", failures.concat())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::fakes::FakeEmbedder;
const WITHIN: Duration = Duration::from_secs(1);
fn failover(spec: &[(&str, &str)]) -> (FailoverEmbedder<FakeEmbedder>, Vec<FakeEmbedder>) {
let fakes: Vec<FakeEmbedder> = spec.iter().map(|(_, digest)| FakeEmbedder::new(digest)).collect();
let endpoints = spec.iter().zip(&fakes).map(|((name, _), fake)| ((*name).to_owned(), fake.clone())).collect();
(FailoverEmbedder::new(endpoints), fakes)
}
fn used(fake: &FakeEmbedder) -> usize {
fake.texts().len()
}
/// The first healthy endpoint in order embeds; the others are not touched.
#[test]
fn the_first_healthy_endpoint_embeds() {
let (mut embedder, fakes) = failover(&[("raven", "d1"), ("starfire", "d1"), ("yggdrasil", "d1")]);
embedder.embed(&["a".into()], WITHIN).unwrap();
assert_eq!((used(&fakes[0]), used(&fakes[1]), used(&fakes[2])), (1, 0, 0));
}
/// A down endpoint hands the batch to the next, the CPU box embeds when
/// both desktops are down, and the preferred one is used again once it
/// answers.
#[test]
fn a_down_endpoint_hands_over_to_the_next_and_comes_back() {
let (mut embedder, fakes) = failover(&[("raven", "d1"), ("starfire", "d1"), ("yggdrasil", "d1")]);
fakes[0].state.lock().unwrap().down = true;
embedder.embed(&["a".into()], WITHIN).unwrap();
assert_eq!((used(&fakes[0]), used(&fakes[1]), used(&fakes[2])), (0, 1, 0));
fakes[1].state.lock().unwrap().down = true;
embedder.embed(&["b".into()], WITHIN).unwrap();
assert_eq!(used(&fakes[2]), 1, "both desktops down, the CPU embeds");
fakes[0].state.lock().unwrap().down = false;
embedder.embed(&["c".into()], WITHIN).unwrap();
assert_eq!(used(&fakes[0]), 1, "the preferred endpoint is used again when it answers");
}
/// An endpoint serving another digest or another length is skipped and
/// never written with, even though it answers; with nothing left the run
/// fails naming each endpoint.
#[test]
fn a_mismatched_endpoint_is_skipped_and_never_embeds() {
let (mut embedder, fakes) = failover(&[("raven", "d1"), ("starfire", "d2"), ("yggdrasil", "d1")]);
embedder.model_identity(WITHIN).unwrap();
fakes[0].state.lock().unwrap().down = true;
embedder.embed(&["a".into()], WITHIN).unwrap();
assert_eq!((used(&fakes[1]), used(&fakes[2])), (0, 1), "starfire serves d2 and is skipped");
fakes[2].state.lock().unwrap().identity.dimensions = 8;
let error = format!("{:#}", embedder.embed(&["b".into()], WITHIN).unwrap_err());
assert!(error.contains("raven") && error.contains("starfire: another model") && error.contains("yggdrasil: another model"), "{error}");
assert_eq!(used(&fakes[1]), 0);
}
/// With every endpoint down the identity read fails naming them all, and
/// the first one back up serves.
#[test]
fn with_every_endpoint_down_the_run_fails_naming_them_all() {
let (mut embedder, fakes) = failover(&[("raven", "d1"), ("yggdrasil", "d1")]);
for fake in &fakes {
fake.state.lock().unwrap().down = true;
}
let error = format!("{:#}", embedder.model_identity(WITHIN).unwrap_err());
assert!(error.contains("raven") && error.contains("yggdrasil"), "{error}");
fakes[1].state.lock().unwrap().down = false;
assert_eq!(embedder.model_identity(WITHIN).unwrap().digest, "d1");
}
}

View file

@ -0,0 +1,93 @@
//! The forge's catalog over its own loopback API. JSON for the forge is spoken
//! here and in `index/qdrant.rs`, and nowhere else.
use std::time::Duration;
use anyhow::{Context, Result, ensure};
use serde_json::Value;
use super::{Forge, SourceRepo};
use crate::index::{Http, Method};
/// The organisation whose repositories are the corpus.
pub const ORG: &str = "GameCult";
const PAGE: usize = 50;
const CALL_TIMEOUT: Duration = Duration::from_secs(60);
/// The forge as one HTTP address. Repos are listed through the organisation's
/// API, the unauthenticated view of it: what an anonymous reader may see is
/// what is indexed.
pub struct HttpForge {
http: Http,
base: String,
}
impl HttpForge {
pub fn new(base_url: &str) -> Self {
Self { http: Http::new(base_url, CALL_TIMEOUT), base: base_url.trim_end_matches('/').to_owned() }
}
}
/// The repos worth indexing in one page of the forge's answer: public,
/// unarchived, not a mirror, not empty. A flag the forge does not state counts
/// against the repo.
pub(crate) fn indexable(page: &Value) -> Result<Vec<SourceRepo>> {
let repos = page.as_array().context("the forge's repo page is not a list")?;
let flag = |repo: &Value, key: &str| repo[key].as_bool().unwrap_or(true);
Ok(repos
.iter()
.filter(|repo| !flag(repo, "private") && !flag(repo, "archived") && !flag(repo, "mirror") && !flag(repo, "empty"))
.filter_map(|repo| {
Some(SourceRepo { name: repo["name"].as_str()?.to_owned(), default_branch: repo["default_branch"].as_str()?.to_owned() })
})
.collect())
}
impl Forge for HttpForge {
fn catalog(&mut self) -> Result<Vec<SourceRepo>> {
let mut found = Vec::new();
for page in 1.. {
let path = format!("/api/v1/orgs/{ORG}/repos?page={page}&limit={PAGE}");
let (status, answer) = self.http.request(Method::Get, &path, None)?;
ensure!(status == 200, "the forge answered {status} to {path}");
let parsed: Value = serde_json::from_str(&answer).context("the forge's repo page is not JSON")?;
let listed = parsed.as_array().map_or(0, Vec::len);
found.extend(indexable(&parsed)?);
if listed < PAGE {
break;
}
}
Ok(found)
}
fn clone_url(&self, name: &str) -> String {
format!("{}/{ORG}/{name}.git", self.base)
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
/// Only public, live, original, non-empty repos are the corpus; a repo the
/// forge does not say is safe is not indexed.
#[test]
fn only_public_unarchived_original_nonempty_repos_are_indexed() {
let repo = |name: &str, private: bool, archived: bool, mirror: bool, empty: bool| {
json!({ "name": name, "default_branch": "main", "private": private, "archived": archived, "mirror": mirror, "empty": empty })
};
let page = json!([
repo("Open", false, false, false, false),
repo("Secret", true, false, false, false),
repo("Old", false, true, false, false),
repo("Copy", false, false, true, false),
repo("Void", false, false, false, true),
{ "name": "Silent", "default_branch": "main" },
]);
let names: Vec<String> = indexable(&page).unwrap().into_iter().map(|repo| repo.name).collect();
assert_eq!(names, ["Open"]);
}
}

View file

@ -0,0 +1,202 @@
//! Git, by shelling out: bare mirrors of the forge's repos, what changed
//! between two commits, and the blobs at one. Nothing here knows an index or an
//! embedder; the mirrors are cache and can be deleted whenever.
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use anyhow::{Context, Result, bail, ensure};
/// A blob over this many bytes is not indexed.
pub const BLOB_BYTES_MAX: u64 = 256 * 1024;
/// Extensions of files that are not text worth finding, VoidBot's list, plus
/// Unity's serialised YAML (huge, machine written) and lock files.
const SKIPPED_EXTENSIONS: &[&str] = &[
"7z", "avi", "bmp", "class", "dll", "dylib", "eot", "exe", "gif", "gz", "ico", "jar", "jpeg", "jpg", "lock", "map", "mp3",
"mp4", "mov", "ogg", "otf", "pdf", "png", "rar", "so", "svg", "tar", "ttf", "wav", "webm", "webp", "woff", "woff2", "zip",
"meta", "prefab", "unity", "asset", "mat", "anim", "controller", "physicmaterial",
];
/// Lock files whose extension is not `.lock`.
const SKIPPED_NAMES: &[&str] = &["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "composer.lock"];
const LFS_POINTER: &[u8] = b"version https://git-lfs.github.com/spec/v1";
/// Whether a path is not worth reading, by name alone.
pub fn skipped_path(path: &str) -> bool {
let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
if SKIPPED_NAMES.contains(&name.as_str()) {
return true;
}
name.rsplit_once('.').is_some_and(|(_, extension)| SKIPPED_EXTENSIONS.contains(&extension))
}
/// The text of a blob worth indexing, or `None`: a NUL byte, bytes that are
/// not UTF-8, or a Git LFS pointer make it not text.
pub fn indexable_text(bytes: &[u8]) -> Option<String> {
if bytes.contains(&0) || bytes.starts_with(LFS_POINTER) {
return None;
}
String::from_utf8(bytes.to_vec()).ok()
}
/// One file of a tree: its path, the blob it holds and that blob's size.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeFile {
pub path: String,
pub blob: String,
pub size: u64,
}
fn git(dir: &Path, args: &[&str]) -> Command {
let mut command = Command::new("git");
command.arg("-C").arg(dir).args(args).env("GIT_TERMINAL_PROMPT", "0");
command
}
fn run(mut command: Command, what: &str) -> Result<Vec<u8>> {
let output = command.output().with_context(|| format!("starting git to {what}"))?;
ensure!(
output.status.success(),
"git could not {what}: {}",
String::from_utf8_lossy(&output.stderr).trim().chars().take(400).collect::<String>()
);
Ok(output.stdout)
}
/// A bare mirror of one forge repo's default branch.
pub struct Mirror {
dir: PathBuf,
branch: String,
}
impl Mirror {
pub fn path_for(root: &Path, name: &str) -> PathBuf {
root.join(format!("{name}.git"))
}
pub fn new(root: &Path, name: &str, branch: &str) -> Self {
Self { dir: Self::path_for(root, name), branch: branch.to_owned() }
}
/// Fetches the default branch, creating the mirror if it is absent, and
/// returns the commit it now points at. The branch is forced: a rewritten
/// history is followed, not refused.
pub fn fetch(&self, remote: &str) -> Result<String> {
if !self.dir.join("HEAD").exists() {
std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?;
run(Command::new("git").arg("init").arg("--bare").arg("--quiet").arg(&self.dir), "initialise a mirror")?;
}
let refspec = format!("+refs/heads/{0}:refs/heads/{0}", self.branch);
run(git(&self.dir, &["fetch", "--quiet", "--no-tags", remote, &refspec]), "fetch the default branch")?;
self.head()
}
pub fn head(&self) -> Result<String> {
let out = run(git(&self.dir, &["rev-parse", "--verify", &format!("refs/heads/{}", self.branch)]), "read the branch head")?;
Ok(String::from_utf8_lossy(&out).trim().to_owned())
}
/// Whether `old` is an ancestor of `new`. A commit the mirror no longer
/// holds is not.
pub fn is_ancestor(&self, old: &str, new: &str) -> bool {
git(&self.dir, &["merge-base", "--is-ancestor", old, new]).status().is_ok_and(|status| status.success())
}
/// The paths that differ between two commits, a rename counted as a delete
/// and an add.
pub fn changed_paths(&self, old: &str, new: &str) -> Result<Vec<String>> {
let out = run(git(&self.dir, &["diff", "--no-renames", "--name-only", "-z", old, new]), "diff two commits")?;
Ok(out.split(|byte| *byte == 0).filter(|path| !path.is_empty()).map(|path| String::from_utf8_lossy(path).into_owned()).collect())
}
/// Every regular file at `commit`; symlinks and submodules are not files.
pub fn files(&self, commit: &str) -> Result<Vec<TreeFile>> {
let out = run(git(&self.dir, &["ls-tree", "-r", "-z", "-l", commit]), "list a tree")?;
let mut files = Vec::new();
for entry in out.split(|byte| *byte == 0).filter(|entry| !entry.is_empty()) {
let text = String::from_utf8_lossy(entry);
let (meta, path) = text.split_once('\t').with_context(|| format!("an ls-tree entry has no tab: {text:?}"))?;
let mut fields = meta.split_whitespace();
let (mode, kind, blob, size) = (fields.next(), fields.next(), fields.next(), fields.next());
let (Some(mode), Some("blob"), Some(blob), Some(size)) = (mode, kind, blob, size) else { continue };
if mode == "120000" {
continue;
}
let Ok(size) = size.parse::<u64>() else { continue };
files.push(TreeFile { path: path.to_owned(), blob: blob.to_owned(), size });
}
Ok(files)
}
/// A reader of blobs by id, one `cat-file --batch` process.
pub fn blobs(&self) -> Result<Blobs> {
let mut child = git(&self.dir, &["cat-file", "--batch"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("starting git cat-file")?;
let stdin = child.stdin.take().context("git cat-file has no stdin")?;
let stdout = BufReader::new(child.stdout.take().context("git cat-file has no stdout")?);
Ok(Blobs { child, stdin, stdout })
}
}
/// `git cat-file --batch`, asked one blob at a time.
pub struct Blobs {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
}
impl Blobs {
pub fn read(&mut self, blob: &str) -> Result<Vec<u8>> {
writeln!(self.stdin, "{blob}").and_then(|()| self.stdin.flush()).context("asking git cat-file for a blob")?;
let mut header = String::new();
self.stdout.read_line(&mut header).context("reading git cat-file's header")?;
let mut fields = header.split_whitespace();
let (Some(_), Some(kind), Some(size)) = (fields.next(), fields.next(), fields.next()) else {
bail!("git cat-file does not hold blob {blob}: {}", header.trim());
};
ensure!(kind == "blob", "git cat-file says {blob} is a {kind}");
let size: usize = size.parse().with_context(|| format!("a blob size of {size:?}"))?;
let mut bytes = vec![0; size];
self.stdout.read_exact(&mut bytes).context("reading a blob")?;
let mut newline = [0_u8; 1];
self.stdout.read_exact(&mut newline).context("reading a blob's end")?;
Ok(bytes)
}
}
impl Drop for Blobs {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binary_unity_and_lock_paths_are_skipped_by_name_and_source_is_not() {
for path in ["a.png", "dir/B.PNG", "x.lock", "Assets/a.prefab", "Assets/a.unity", "a.cs.meta", "p/package-lock.json", "Cargo.lock"] {
assert!(skipped_path(path), "{path}");
}
for path in ["src/main.rs", "README.md", "docs/lock.md", "Assets/Scripts/Player.cs", "Makefile", ".gitignore", "locksmith"] {
assert!(!skipped_path(path), "{path}");
}
}
#[test]
fn a_nul_byte_an_lfs_pointer_and_invalid_utf8_are_not_text() {
assert_eq!(indexable_text(b"fn main() {}\n").as_deref(), Some("fn main() {}\n"));
assert_eq!(indexable_text(b"a\0b"), None);
assert_eq!(indexable_text(b"version https://git-lfs.github.com/spec/v1\noid sha256:ab\nsize 12\n"), None);
assert_eq!(indexable_text(&[0xff, 0xfe, b'a']), None);
}
}

View file

@ -0,0 +1,409 @@
//! The source index: the public GameCult forge repos' code and docs as chunks
//! in one Qdrant collection, `huginn_sources`, for semantic search.
//!
//! Two truths, one writer. The forge is the truth of which repos exist and what
//! they hold; the collection is derived from it and can be deleted whenever.
//! `refresh` is the only writer of the collection, run by the `huginn-sources`
//! oneshot job: first build, incremental refresh, rebuild after a model or
//! chunker change, and retirement are all the one routine (delete by filter,
//! ingest at a commit, rewrite the cursors). The daemon only reads: `search`
//! is its whole part, and nothing in `serve` or the worker calls a write.
//!
//! A cursor is the commit whose chunks the collection holds for a repo. It
//! lives in the collection's own metadata, so it is lost with the collection
//! and the two cannot disagree about what is held. It is advanced only after
//! the repo's deletes and upserts are acknowledged: it may lag and never leads.
//! The cursors object is written whole by this job alone, once per repo.
pub mod chunk;
pub mod failover;
pub mod forge;
pub mod git;
#[cfg(test)]
mod tests;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail, ensure};
use huginn_mind::{SourceHit, SourceHits, SourceModel, SourceQuery};
use crate::index::{BATCH, BULK_EMBED_TIMEOUT, Budget, Embedder, INDEX_CALL_TIMEOUT, MANAGED_BY, point_id};
use chunk::{CHUNKER_VERSION, embedded_text};
use git::{BLOB_BYTES_MAX, Mirror, TreeFile};
/// The collection this organ's source index lives in.
pub const COLLECTION: &str = "huginn_sources";
/// What the collection says its corpus is.
pub const CORPUS: &str = "forge:GameCult";
/// The instruction Qwen3 embedding models take in front of a query about code.
pub const SOURCE_QUERY_INSTRUCTION: &str =
"Given a question about a codebase, retrieve the source code and documentation passages that answer it";
/// Paths named in one delete call.
const DELETE_PATHS_PER_CALL: usize = 256;
/// What the collection says about how it was made. A model, a digest, a length
/// or a chunker version that differs from the embedder's and this build's makes
/// every vector in it incomparable, so it is rebuilt.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SourcesMeta {
pub managed_by: String,
pub corpus: String,
pub model: String,
pub model_digest: String,
pub dimensions: u32,
pub chunker_version: u32,
}
/// What the vector store holds under `COLLECTION`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DescribedSources {
Absent,
/// A collection that carries no metadata this organ wrote.
Unlabelled,
Labelled { meta: SourcesMeta, cursors: BTreeMap<String, String> },
}
/// What travels beside a chunk's vector.
#[derive(Clone, Debug, PartialEq)]
pub struct SourcePayload {
pub repo: String,
pub path: String,
pub commit: String,
pub blob: String,
pub line_start: u32,
pub line_end: u32,
pub chunk_index: u32,
pub ext: String,
pub text: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SourcePoint {
pub id: String,
pub vector: Vec<f32>,
pub payload: SourcePayload,
}
/// One chunk a vector search returned and how near it scored.
#[derive(Clone, Debug, PartialEq)]
pub struct SourceRow {
pub score: f32,
pub payload: SourcePayload,
}
/// The vector store as the source index uses it. The writes are
/// `huginn-sources`'s alone; `describe_sources` and `search_sources` are what a
/// daemon calls.
pub trait SourceIndex {
fn describe_sources(&mut self, within: Duration) -> Result<DescribedSources>;
/// Drops the collection if it exists and creates it empty with this
/// metadata, no cursors, and keyword indexes on `repo` and `path`.
fn recreate_sources(&mut self, meta: &SourcesMeta) -> Result<()>;
/// Replaces the cursors object whole.
fn set_cursors(&mut self, cursors: &BTreeMap<String, String>) -> Result<()>;
/// Deletes a repo's chunks: those at `paths`, or all of them when `None`.
/// Acknowledged before it returns.
fn delete_by(&mut self, repo: &str, paths: Option<&[String]>) -> Result<()>;
/// Writes chunks, acknowledged before it returns.
fn upsert_sources(&mut self, points: &[SourcePoint]) -> Result<()>;
/// The `limit` nearest chunks, best first, in `repo` alone when given.
fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, limit: u32, within: Duration) -> Result<Vec<SourceRow>>;
}
/// A repo the forge lists as indexable.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SourceRepo {
pub name: String,
pub default_branch: String,
}
/// The forge: which repos exist, and where to fetch them from.
pub trait Forge {
fn catalog(&mut self) -> Result<Vec<SourceRepo>>;
fn clone_url(&self, name: &str) -> String;
}
/// What one repo's refresh did, for the journal.
#[derive(Clone, Debug, PartialEq)]
pub struct Report {
pub name: String,
pub old: Option<String>,
pub new: String,
pub files: u32,
pub chunks: u32,
pub seconds: f64,
}
impl std::fmt::Display for Report {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"{} {}..{} files={} chunks={} seconds={:.1}",
self.name,
self.old.as_deref().map_or("-", |old| &old[..old.len().min(7)]),
&self.new[..self.new.len().min(7)],
self.files,
self.chunks,
self.seconds
)
}
}
/// Where the mirrors live under the state root.
pub fn mirrors_dir(state_root: &Path) -> std::path::PathBuf {
state_root.join("sources").join("repos")
}
/// The id of a chunk: content-addressed by where it is, which blob it was cut
/// from, the chunker and its position, so the same blob chunked the same way
/// is the same point.
pub fn chunk_id(repo: &str, path: &str, blob: &str, index: u32) -> String {
point_id(&format!("{repo}\0{path}\0{blob}\0{CHUNKER_VERSION}\0{index}"))
}
fn extension(path: &str) -> String {
let name = path.rsplit('/').next().unwrap_or(path);
name.rsplit_once('.').map(|(_, extension)| extension.to_ascii_lowercase()).unwrap_or_default()
}
/// The names the refresh reports as failed; empty when every repo converged.
#[derive(Debug, Default)]
pub struct Refreshed {
pub failed: Vec<String>,
}
/// Brings the collection to the forge's current state. A collection that
/// exists without Huginn's label is refused untouched. One repo's failure is
/// logged and the others go on; the caller exits nonzero if any failed.
pub fn refresh<E: Embedder, S: SourceIndex, F: Forge>(
embedder: &mut E,
index: &mut S,
forge: &mut F,
state_root: &Path,
on_repo: &mut dyn FnMut(&Report),
) -> Result<Refreshed> {
let identity = embedder.model_identity(BULK_EMBED_TIMEOUT).context("reading the embedding model's identity")?;
let wanted = SourcesMeta {
managed_by: MANAGED_BY.into(),
corpus: CORPUS.into(),
model: identity.name.clone(),
model_digest: identity.digest.clone(),
dimensions: identity.dimensions,
chunker_version: CHUNKER_VERSION,
};
let mut cursors = match index.describe_sources(INDEX_CALL_TIMEOUT).context("describing the source collection")? {
DescribedSources::Absent => {
index.recreate_sources(&wanted).context("creating the source collection")?;
BTreeMap::new()
}
DescribedSources::Unlabelled => {
bail!("collection {COLLECTION} exists and carries no Huginn metadata; it is not this job's to rewrite")
}
DescribedSources::Labelled { meta, cursors } if meta == wanted => cursors,
DescribedSources::Labelled { .. } => {
index.recreate_sources(&wanted).context("rebuilding the source collection")?;
BTreeMap::new()
}
};
let catalog = forge.catalog().context("listing the forge's repos")?;
let mirrors = mirrors_dir(state_root);
retire(index, &catalog, &mirrors, &mut cursors)?;
let mut refreshed = Refreshed::default();
for repo in &catalog {
let started = Instant::now();
match refresh_repo(embedder, index, forge, &mirrors, repo, &identity.dimensions, &mut cursors) {
Ok(mut report) => {
report.seconds = started.elapsed().as_secs_f64();
on_repo(&report);
}
Err(error) => {
eprintln!("huginn-sources: {} failed, the others go on: {error:#}", repo.name);
refreshed.failed.push(repo.name.clone());
}
}
}
Ok(refreshed)
}
/// Drops every repo the catalog no longer lists: its chunks, its cursor and
/// its mirror, in that order, so a failure leaves the cursor to find it again.
fn retire<S: SourceIndex>(
index: &mut S,
catalog: &[SourceRepo],
mirrors: &Path,
cursors: &mut BTreeMap<String, String>,
) -> Result<()> {
let listed = |name: &str| catalog.iter().any(|repo| repo.name.eq_ignore_ascii_case(name));
let mut gone: BTreeSet<String> = cursors.keys().filter(|name| !listed(name)).cloned().collect();
if let Ok(entries) = std::fs::read_dir(mirrors) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_string_lossy().strip_suffix(".git")
&& !listed(name)
{
gone.insert(name.to_owned());
}
}
}
for name in gone {
index.delete_by(&name, None).with_context(|| format!("deleting the chunks of retired {name}"))?;
if cursors.remove(&name).is_some() {
index.set_cursors(cursors).context("dropping a retired repo's cursor")?;
}
let mirror = Mirror::path_for(mirrors, &name);
if mirror.exists() {
std::fs::remove_dir_all(&mirror).with_context(|| format!("removing {}", mirror.display()))?;
}
}
Ok(())
}
fn refresh_repo<E: Embedder, S: SourceIndex, F: Forge>(
embedder: &mut E,
index: &mut S,
forge: &F,
mirrors: &Path,
repo: &SourceRepo,
dimensions: &u32,
cursors: &mut BTreeMap<String, String>,
) -> Result<Report> {
let mirror = Mirror::new(mirrors, &repo.name, &repo.default_branch);
let head = mirror.fetch(&forge.clone_url(&repo.name))?;
let old = cursors.get(&repo.name).cloned();
let mut report = Report { name: repo.name.clone(), old: old.clone(), new: head.clone(), files: 0, chunks: 0, seconds: 0.0 };
if old.as_deref() == Some(head.as_str()) {
return Ok(report);
}
// Only an ancestor's diff says what changed; anything else (a first
// build, a rewritten history) starts the repo over.
let changed: Option<BTreeSet<String>> = match &old {
Some(old) if mirror.is_ancestor(old, &head) => Some(mirror.changed_paths(old, &head)?.into_iter().collect()),
_ => None,
};
match &changed {
Some(paths) => {
let paths: Vec<String> = paths.iter().cloned().collect();
for slice in paths.chunks(DELETE_PATHS_PER_CALL) {
index.delete_by(&repo.name, Some(slice)).context("deleting the changed paths' chunks")?;
}
}
None => index.delete_by(&repo.name, None).context("deleting the repo's chunks")?,
}
let files: Vec<TreeFile> = mirror
.files(&head)?
.into_iter()
.filter(|file| changed.as_ref().is_none_or(|paths| paths.contains(&file.path)))
.filter(|file| file.size <= BLOB_BYTES_MAX && !git::skipped_path(&file.path))
.collect();
let mut blobs = mirror.blobs()?;
let mut batch = Batch::default();
for file in &files {
let Some(text) = git::indexable_text(&blobs.read(&file.blob)?) else { continue };
let chunks = chunk::chunk(&text);
if chunks.is_empty() {
continue;
}
report.files += 1;
for (position, held) in chunks.iter().enumerate() {
let chunk_index = u32::try_from(position).unwrap_or(u32::MAX);
batch.texts.push(embedded_text(&repo.name, &file.path, held));
batch.payloads.push((
chunk_id(&repo.name, &file.path, &file.blob, chunk_index),
SourcePayload {
repo: repo.name.clone(),
path: file.path.clone(),
commit: head.clone(),
blob: file.blob.clone(),
line_start: held.line_start,
line_end: held.line_end,
chunk_index,
ext: extension(&file.path),
text: held.text.clone(),
},
));
report.chunks += 1;
if batch.texts.len() >= BATCH {
batch.write(embedder, index, *dimensions)?;
}
}
}
batch.write(embedder, index, *dimensions)?;
cursors.insert(repo.name.clone(), head);
index.set_cursors(cursors).context("advancing the cursor")?;
Ok(report)
}
#[derive(Default)]
struct Batch {
texts: Vec<String>,
payloads: Vec<(String, SourcePayload)>,
}
impl Batch {
/// Embeds and writes what has gathered; the batch is empty afterwards.
fn write<E: Embedder, S: SourceIndex>(&mut self, embedder: &mut E, index: &mut S, dimensions: u32) -> Result<()> {
if self.texts.is_empty() {
return Ok(());
}
let vectors = embedder.embed(&self.texts, BULK_EMBED_TIMEOUT).context("embedding chunks")?;
ensure!(vectors.len() == self.texts.len(), "the embedder returned {} vectors for {} chunks", vectors.len(), self.texts.len());
let mut points = Vec::with_capacity(vectors.len());
for ((id, payload), vector) in self.payloads.drain(..).zip(vectors) {
ensure!(vector.len() == dimensions as usize, "the embedder returned a vector of {} where the collection holds {dimensions}", vector.len());
points.push(SourcePoint { id, vector, payload });
}
self.texts.clear();
index.upsert_sources(&points).context("writing chunks")
}
}
/// A source search, which a daemon answers from the collection as it finds it:
/// it embeds the query with its own embedder, and refuses if the collection
/// was built under another model, because vectors of two models do not compare.
pub fn search<E: Embedder, S: SourceIndex>(embedder: &mut E, index: &mut S, query: &SourceQuery, within: Duration) -> Result<SourceHits> {
let budget = Budget::new(within);
let identity = embedder.model_identity(budget.left()?).context("reading the embedding model's identity")?;
let built = match index.describe_sources(budget.left()?).context("describing the source collection")? {
DescribedSources::Absent => bail!("source index not built"),
DescribedSources::Unlabelled => bail!("source index not built: {COLLECTION} carries no Huginn metadata"),
DescribedSources::Labelled { meta, .. } => meta,
};
ensure!(
built.model == identity.name && built.model_digest == identity.digest && built.dimensions == identity.dimensions,
"the source index was built under {} {} ({} dimensions) and this daemon embeds under {} {} ({} dimensions)",
built.model,
built.model_digest,
built.dimensions,
identity.name,
identity.digest,
identity.dimensions
);
let text = format!("Instruct: {SOURCE_QUERY_INSTRUCTION}\nQuery: {}", query.text.0);
let mut vectors = embedder.embed(&[text], budget.left()?).context("embedding the query")?;
ensure!(vectors.len() == 1, "the embedder returned {} vectors for one query", vectors.len());
let vector = vectors.remove(0);
ensure!(vector.len() == identity.dimensions as usize, "the embedder returned a query vector of {} where the collection holds {}", vector.len(), identity.dimensions);
let rows = index.search_sources(&vector, query.repo.as_ref().map(|repo| repo.0.as_str()), query.top_k, budget.left()?)?;
Ok(SourceHits {
indexed_under: SourceModel { name: built.model, digest: built.model_digest },
hits: rows
.into_iter()
.map(|row| SourceHit {
repo: row.payload.repo,
path: row.payload.path,
commit: row.payload.commit,
line_start: row.payload.line_start,
line_end: row.payload.line_end,
score: row.score,
text: row.payload.text,
})
.collect(),
})
}

View file

@ -0,0 +1,408 @@
//! The refresh against a fake vector store, a fake embedder and real git: a
//! working repo on disk stands in for the forge, and each test commits to it as
//! history would.
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::Result;
use huginn_mind::eureka_pipeline::{Line, Short};
use huginn_mind::SourceQuery;
use super::*;
use crate::index::fakes::{FakeEmbedder, FakeSources};
fn git_in(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "-c", "core.autocrlf=false"])
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&output.stderr));
String::from_utf8_lossy(&output.stdout).trim().to_owned()
}
/// A working repo on disk, committed to by the test.
struct Remote {
dir: tempfile::TempDir,
}
impl Remote {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
git_in(dir.path(), &["init", "--quiet", "-b", "main"]);
Self { dir }
}
fn path(&self) -> PathBuf {
self.dir.path().to_path_buf()
}
/// Writes files, removes paths, commits everything and returns the commit.
fn commit(&self, write: &[(&str, &[u8])], remove: &[&str]) -> String {
for (path, bytes) in write {
let full = self.dir.path().join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, bytes).unwrap();
}
for path in remove {
std::fs::remove_file(self.dir.path().join(path)).unwrap();
}
git_in(self.dir.path(), &["add", "-A"]);
git_in(self.dir.path(), &["commit", "--quiet", "--allow-empty", "-m", "c"]);
git_in(self.dir.path(), &["rev-parse", "HEAD"])
}
}
struct FakeForge {
repos: Vec<(SourceRepo, PathBuf)>,
}
impl FakeForge {
fn of(repos: &[(&str, &Remote)]) -> Self {
Self {
repos: repos
.iter()
.map(|(name, remote)| (SourceRepo { name: (*name).into(), default_branch: "main".into() }, remote.path()))
.collect(),
}
}
}
impl Forge for FakeForge {
fn catalog(&mut self) -> Result<Vec<SourceRepo>> {
Ok(self.repos.iter().map(|(repo, _)| repo.clone()).collect())
}
fn clone_url(&self, name: &str) -> String {
self.repos.iter().find(|(repo, _)| repo.name == name).map_or_else(|| "/nowhere".into(), |(_, path)| path.display().to_string())
}
}
struct Rig {
root: tempfile::TempDir,
embedder: FakeEmbedder,
index: FakeSources,
}
impl Rig {
fn new() -> Self {
Self { root: tempfile::tempdir().unwrap(), embedder: FakeEmbedder::new("d1"), index: FakeSources::default() }
}
fn run(&mut self, forge: &mut FakeForge) -> (Result<Refreshed>, Vec<Report>) {
let mut reports = Vec::new();
let refreshed = refresh(&mut self.embedder, &mut self.index, forge, self.root.path(), &mut |report| reports.push(report.clone()));
(refreshed, reports)
}
fn converged(&mut self, forge: &mut FakeForge) -> Vec<Report> {
let (refreshed, reports) = self.run(forge);
assert!(refreshed.unwrap().failed.is_empty());
reports
}
fn mirror(&self, name: &str) -> PathBuf {
Mirror::path_for(&mirrors_dir(self.root.path()), name)
}
}
fn held(repo: &str, path: &str, chunk: u32, commit: &str) -> (String, String, u32, String) {
(repo.into(), path.into(), chunk, commit.into())
}
/// An empty collection plus a repo at a commit ends with that commit's chunks
/// and the cursor on it.
#[test]
fn first_build_indexes_head() {
let remote = Remote::new();
let a = remote.commit(&[("src/a.rs", b"fn a() {}\nfn b() {}\n"), ("README.md", b"# Demo\n")], &[]);
let mut rig = Rig::new();
let reports = rig.converged(&mut FakeForge::of(&[("Demo", &remote)]));
assert_eq!(
rig.index.held(),
[held("Demo", "README.md", 0, &a), held("Demo", "src/a.rs", 0, &a)],
"one chunk per small file, at the head commit"
);
assert_eq!(rig.index.cursors().get("Demo"), Some(&a));
assert_eq!((reports.len(), reports[0].files, reports[0].chunks, reports[0].old.clone()), (1, 2, 2, None));
let point = rig.index.state.lock().unwrap().points.values().find(|point| point.payload.path == "src/a.rs").unwrap().clone();
assert_eq!((point.payload.line_start, point.payload.line_end, point.payload.ext.as_str()), (1, 2, "rs"));
assert_eq!(point.payload.text, "fn a() {}\nfn b() {}", "the payload text is the chunk alone");
assert!(rig.embedder.texts().contains(&"Demo/src/a.rs\nfn a() {}\nfn b() {}".to_owned()), "the embedded text leads with repo and path");
assert!(reports[0].to_string().starts_with("Demo -..") && reports[0].to_string().contains("files=2 chunks=2"));
}
/// From A to B with one file edited and one deleted, only those two paths'
/// points change: untouched files keep their point ids, and the cursor moves.
#[test]
fn refresh_replaces_only_changed_paths() {
let remote = Remote::new();
let a = remote.commit(&[("a.rs", b"one\n"), ("b.rs", b"two\n"), ("c.rs", b"three\n")], &[]);
let mut rig = Rig::new();
let mut forge = FakeForge::of(&[("Demo", &remote)]);
rig.converged(&mut forge);
let ids_before = rig.index.ids();
let untouched: Vec<String> = {
let state = rig.index.state.lock().unwrap();
state.points.iter().filter(|(_, point)| point.payload.path == "c.rs").map(|(id, _)| id.clone()).collect()
};
let embedded_before = rig.embedder.texts().len();
let b = remote.commit(&[("a.rs", b"one edited\n")], &["b.rs"]);
let reports = rig.converged(&mut forge);
assert_eq!(rig.index.held(), [held("Demo", "a.rs", 0, &b), held("Demo", "c.rs", 0, &a)]);
assert!(untouched.iter().all(|id| rig.index.ids().contains(id)), "c.rs's point id survived");
assert!(ids_before.len() == 3 && rig.index.ids().len() == 2);
assert_eq!(rig.index.cursors().get("Demo"), Some(&b));
assert_eq!(rig.embedder.texts().len() - embedded_before, 1, "only the edited file is embedded again");
assert_eq!((reports[0].old.clone(), reports[0].files), (Some(a), 1));
}
/// A head equal to the cursor is the whole refresh: nothing is embedded,
/// written, deleted or advanced.
#[test]
fn unchanged_repo_makes_no_embed_call() {
let remote = Remote::new();
remote.commit(&[("a.rs", b"one\n")], &[]);
let mut rig = Rig::new();
let mut forge = FakeForge::of(&[("Demo", &remote)]);
rig.converged(&mut forge);
let (embeds, upserts, deletes, cursor_writes) = {
let state = rig.index.state.lock().unwrap();
(rig.embedder.state.lock().unwrap().embedded.len(), state.upsert_calls, state.deletes.len(), state.cursor_writes.len())
};
let reports = rig.converged(&mut forge);
let state = rig.index.state.lock().unwrap();
assert_eq!(rig.embedder.state.lock().unwrap().embedded.len(), embeds, "no embed call");
assert_eq!((state.upsert_calls, state.deletes.len(), state.cursor_writes.len()), (upserts, deletes, cursor_writes));
assert_eq!((reports[0].files, reports[0].chunks), (0, 0));
}
/// An upsert that fails mid-repo leaves the cursor on the old commit, and the
/// next run converges to the points a clean run of the same history makes.
#[test]
fn cursor_never_leads() {
let remote = Remote::new();
let many: Vec<(String, Vec<u8>)> = (0..40).map(|n| (format!("f{n}.rs"), format!("file {n}\n").into_bytes())).collect();
let files: Vec<(&str, &[u8])> = many.iter().map(|(path, bytes)| (path.as_str(), bytes.as_slice())).collect();
let a = remote.commit(&files, &[]);
let mut rig = Rig::new();
let mut forge = FakeForge::of(&[("Demo", &remote)]);
rig.converged(&mut forge);
let edited: Vec<(String, Vec<u8>)> = (0..40).map(|n| (format!("f{n}.rs"), format!("file {n} edited\n").into_bytes())).collect();
let files: Vec<(&str, &[u8])> = edited.iter().map(|(path, bytes)| (path.as_str(), bytes.as_slice())).collect();
let b = remote.commit(&files, &[]);
{
let mut state = rig.index.state.lock().unwrap();
state.fail_upserts_after = Some(state.upsert_calls + 1);
}
let (refreshed, _) = rig.run(&mut forge);
assert_eq!(refreshed.unwrap().failed, ["Demo"], "one repo failed, the run finished");
assert_eq!(rig.index.cursors().get("Demo"), Some(&a), "the cursor never leads the points");
rig.index.state.lock().unwrap().fail_upserts_after = None;
rig.converged(&mut forge);
assert_eq!(rig.index.cursors().get("Demo"), Some(&b));
let mut clean = Rig::new();
clean.converged(&mut forge);
assert_eq!(rig.index.held(), clean.index.held());
assert_eq!(rig.index.ids(), clean.index.ids());
}
/// One repo failing is logged and the others go on.
#[test]
fn one_repos_failure_does_not_stop_the_others() {
let good = Remote::new();
let g = good.commit(&[("a.rs", b"one\n")], &[]);
let nowhere = Remote::new();
nowhere.commit(&[("a.rs", b"x\n")], &[]);
let mut forge = FakeForge::of(&[("Broken", &nowhere), ("Good", &good)]);
forge.repos[0].1 = PathBuf::from("/nowhere/at/all");
let mut rig = Rig::new();
let (refreshed, reports) = rig.run(&mut forge);
assert_eq!(refreshed.unwrap().failed, ["Broken"]);
assert_eq!(rig.index.held(), [held("Good", "a.rs", 0, &g)]);
assert_eq!(reports.len(), 1);
}
/// A history that no longer contains the cursor starts the repo over: the
/// old commit's files that the new history lacks do not linger.
#[test]
fn a_rewritten_history_replaces_the_whole_repo() {
let remote = Remote::new();
remote.commit(&[("keep.rs", b"keep\n")], &[]);
let old = remote.commit(&[("stale.rs", b"stale\n")], &[]);
let mut rig = Rig::new();
let mut forge = FakeForge::of(&[("Demo", &remote)]);
rig.converged(&mut forge);
git_in(&remote.path(), &["reset", "--quiet", "--hard", "HEAD~1"]);
let new = remote.commit(&[("other.rs", b"other\n")], &[]);
rig.converged(&mut forge);
assert_ne!(old, new);
assert_eq!(rig.index.cursors().get("Demo"), Some(&new));
let paths: Vec<String> = rig.index.held().into_iter().map(|(_, path, _, _)| path).collect();
assert_eq!(paths, ["keep.rs", "other.rs"], "stale.rs was in the old history only");
}
/// A repo the forge stops listing leaves no points, no cursor key and no
/// mirror; the one still listed is untouched.
#[test]
fn retired_repo_leaves_nothing() {
let (keep, gone) = (Remote::new(), Remote::new());
let k = keep.commit(&[("a.rs", b"a\n")], &[]);
gone.commit(&[("b.rs", b"b\n")], &[]);
let mut rig = Rig::new();
rig.converged(&mut FakeForge::of(&[("Keep", &keep), ("Gone", &gone)]));
assert!(rig.mirror("Gone").exists() && rig.index.cursors().contains_key("Gone"));
rig.converged(&mut FakeForge::of(&[("Keep", &keep)]));
assert_eq!(rig.index.held(), [held("Keep", "a.rs", 0, &k)]);
assert_eq!(rig.index.cursors().keys().collect::<Vec<_>>(), ["Keep"]);
assert!(!rig.mirror("Gone").exists() && rig.mirror("Keep").exists());
}
/// A mirror no cursor knows (a first ingest that never finished) is retired
/// with its points too.
#[test]
fn a_retired_repo_with_no_cursor_is_still_cleaned() {
let keep = Remote::new();
keep.commit(&[("a.rs", b"a\n")], &[]);
let mut rig = Rig::new();
let mut forge = FakeForge::of(&[("Keep", &keep)]);
rig.converged(&mut forge);
std::fs::create_dir_all(rig.mirror("Orphan")).unwrap();
rig.converged(&mut forge);
assert!(!rig.mirror("Orphan").exists());
assert!(rig.index.state.lock().unwrap().deletes.iter().any(|(repo, paths)| repo == "Orphan" && paths.is_none()));
}
/// Another digest or chunker version recreates the collection and refills it;
/// an unlabelled collection is refused untouched.
#[test]
fn rebuild_on_model_or_chunker_change() {
let remote = Remote::new();
let a = remote.commit(&[("a.rs", b"one\n")], &[]);
let mut rig = Rig::new();
let mut forge = FakeForge::of(&[("Demo", &remote)]);
rig.converged(&mut forge);
assert_eq!(rig.index.state.lock().unwrap().recreated, 1, "created once, empty");
rig.converged(&mut forge);
assert_eq!(rig.index.state.lock().unwrap().recreated, 1, "same label, no rebuild");
rig.embedder = FakeEmbedder::new("d2");
rig.converged(&mut forge);
assert_eq!(rig.index.state.lock().unwrap().recreated, 2, "another digest rebuilds");
assert_eq!(rig.index.held(), [held("Demo", "a.rs", 0, &a)], "and refills from the cursorless collection");
let mut old_chunker = match rig.index.state.lock().unwrap().described.clone() {
Some(DescribedSources::Labelled { meta, cursors }) => (meta, cursors),
other => panic!("{other:?}"),
};
old_chunker.0.chunker_version = CHUNKER_VERSION - 1;
rig.index.plant(DescribedSources::Labelled { meta: old_chunker.0, cursors: old_chunker.1 });
rig.converged(&mut forge);
assert_eq!(rig.index.state.lock().unwrap().recreated, 3, "another chunker version rebuilds");
rig.index.plant(DescribedSources::Unlabelled);
let before = rig.index.held();
let (refreshed, _) = rig.run(&mut forge);
assert!(format!("{:#}", refreshed.unwrap_err()).contains("no Huginn metadata"));
assert_eq!((rig.index.state.lock().unwrap().recreated, rig.index.held()), (3, before), "untouched");
}
/// A NUL blob, an LFS pointer, an oversize file, Unity YAML and a lock file
/// yield no chunks; the plain file beside them does.
#[test]
fn skips_binary_lfs_oversize() {
let remote = Remote::new();
let huge = vec![b'x'; 300 * 1024];
let big_but_ok = vec![b'y'; 100 * 1024];
let c = remote.commit(
&[
("nul.txt", b"abc\0def\n"),
("lfs.txt", b"version https://git-lfs.github.com/spec/v1\noid sha256:aa\nsize 9\n"),
("huge.txt", &huge),
("scene.prefab", b"%YAML 1.1\n"),
("Cargo.lock", b"[[package]]\n"),
("ok.txt", b"hello\n"),
("big.txt", &big_but_ok),
],
&[],
);
let mut rig = Rig::new();
rig.converged(&mut FakeForge::of(&[("Demo", &remote)]));
let paths: Vec<String> = rig.index.held().into_iter().map(|(_, path, _, _)| path).collect();
assert_eq!(paths, ["big.txt", "ok.txt"], "100 KiB is under the 256 KiB bound; one long line is one chunk");
assert_eq!(rig.index.cursors().get("Demo"), Some(&c));
}
fn query(text: &str, top_k: u32, repo: Option<&str>) -> SourceQuery {
SourceQuery { text: Line(text.into()), top_k, repo: repo.map(|repo| Short(repo.into())) }
}
/// A daemon whose embedder is not the one the collection was built under
/// refuses and names both identities; a missing collection says it is not built.
#[test]
fn source_search_refuses_foreign_model() {
let remote = Remote::new();
remote.commit(&[("a.rs", b"one\n")], &[]);
let mut rig = Rig::new();
let within = Duration::from_secs(5);
let error = search(&mut FakeEmbedder::new("d1"), &mut rig.index, &query("one", 3, None), within).unwrap_err();
assert_eq!(format!("{error:#}"), "source index not built");
rig.converged(&mut FakeForge::of(&[("Demo", &remote)]));
let mut foreign = FakeEmbedder::new("d2");
let error = format!("{:#}", search(&mut foreign, &mut rig.index, &query("one", 3, None), within).unwrap_err());
assert!(error.contains("built under model d1") && error.contains("embeds under model d2"), "{error}");
assert!(foreign.state.lock().unwrap().embedded.is_empty(), "nothing was embedded for a foreign model");
foreign.state.lock().unwrap().identity.dimensions = 8;
let error = format!("{:#}", search(&mut FakeEmbedder::new("d1"), &mut rig.index, &query("one", 3, None), within).map(|_| ()).err().map_or(String::new(), |e| format!("{e:#}")));
assert_eq!(error, "", "the same model still answers");
}
/// A search answers the chunks as the collection holds them, nearest first,
/// within the repo asked for and no more than `top_k`; the query carries the
/// instruction and not the path prefix.
#[test]
fn a_source_search_answers_held_chunks_within_a_repo_and_a_limit() {
let (one, two) = (Remote::new(), Remote::new());
let c1 = one.commit(&[("a.rs", b"alpha\n"), ("b.rs", b"beta\nbeta\n")], &[]);
two.commit(&[("z.rs", b"zeta\n")], &[]);
let mut rig = Rig::new();
rig.converged(&mut FakeForge::of(&[("One", &one), ("Two", &two)]));
let within = Duration::from_secs(5);
let all = search(&mut rig.embedder, &mut rig.index, &query("alpha", 10, None), within).unwrap();
assert_eq!(all.hits.len(), 3);
assert_eq!((all.indexed_under.name.as_str(), all.indexed_under.digest.as_str()), ("model", "d1"));
assert!(all.hits.windows(2).all(|pair| pair[0].score >= pair[1].score), "nearest first");
let in_one = search(&mut rig.embedder, &mut rig.index, &query("alpha", 10, Some("One")), within).unwrap();
assert_eq!(in_one.hits.len(), 2);
assert!(in_one.hits.iter().all(|hit| hit.repo == "One" && hit.commit == c1));
let b = in_one.hits.iter().find(|hit| hit.path == "b.rs").unwrap();
assert_eq!((b.line_start, b.line_end, b.text.as_str()), (1, 2, "beta\nbeta"));
let limited = search(&mut rig.embedder, &mut rig.index, &query("alpha", 1, None), within).unwrap();
assert_eq!(limited.hits.len(), 1);
let asked = rig.embedder.texts().last().cloned().unwrap();
assert_eq!(asked, format!("Instruct: {SOURCE_QUERY_INSTRUCTION}\nQuery: alpha"));
}
/// Chunk ids are the content address: the same blob chunked the same way is
/// the same point, and any part of the address changes it.
#[test]
fn a_chunk_id_is_its_content_address() {
let base = chunk_id("R", "p", "blob", 0);
assert_eq!(base, chunk_id("R", "p", "blob", 0));
for other in [chunk_id("S", "p", "blob", 0), chunk_id("R", "q", "blob", 0), chunk_id("R", "p", "blob2", 0), chunk_id("R", "p", "blob", 1)] {
assert_ne!(base, other);
}
assert_ne!(chunk_id("R", "pb", "lob", 0), chunk_id("R", "p", "blob", 0), "fields are separated, not concatenated");
}