From 7df718d643f2ef43d6204a5b4b2a9fe03882664e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:25:46 +0200 Subject: [PATCH] Mind::rank returns the refs it dropped; delete Daemon::unheld Daemon::unheld called mind.view once per hit, and every call decodes the whole mind: 3.27 s on the loop thread at 2000 documents and 200 hits, to feed one log line, and a second judge of whether the mind holds a document. rank already reads the rows once and is the only judge now: it returns the hits it found no document for in Ranked { page, dropped }, and Daemon::finish reports a count and the first five. Also in rank: candidates are filtered to held documents before they are cut to the best 200 by score, so an id the mind lacks cannot take a place from one it holds and select never sees more than it can answer (its own cut is by ordinal). A semantic selection with descending is refused SelectionInvalid, as cursor is: the answer's order is the score's and was silently ignored. Co-Authored-By: Claude Sonnet 5 --- crates/huginn-daemon/src/daemon.rs | 22 ++-- crates/huginn-daemon/src/semantic_tests.rs | 20 ---- crates/huginn-mind/src/lib.rs | 2 +- crates/huginn-mind/src/query.rs | 123 ++++++++++++++++----- 4 files changed, 105 insertions(+), 62 deletions(-) diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index ceb86ef..51a9fe6 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -161,13 +161,6 @@ impl> Daemon { self.index.searched() } - /// The ids among `hits` that the mind holds no document for: the index - /// keeps points the mind no longer has (a restored mind, a second store), - /// and a hit is never shown on the index's word alone. - pub(crate) fn unheld<'a>(&self, hits: &'a Hits) -> Vec<&'a str> { - hits.iter().filter(|(id, _)| !matches!(self.mind.view(id), Ok(Some(_)))).map(|(id, _)| id.id.0.as_str()).collect() - } - /// The answer to a search: the index's candidates ranked through the mind /// (`Mind::rank` drops what the mind does not hold and lets the selection /// decide the rest), or, when the index could not answer, a typed @@ -177,11 +170,18 @@ impl> Daemon { Ok(hits) => hits, Err(detail) => return HuginnMindResponse::Refused(MindRefusal::Unavailable { detail }), }; - for id in self.unheld(&hits) { - eprintln!("huginn: the index returned {id}, which this mind does not hold; it is left out"); - } match self.mind.rank(&search.selection, &search.semantic, &hits) { - Ok(page) => HuginnMindResponse::Query(page), + Ok(ranked) => { + if !ranked.dropped.is_empty() { + let named: Vec<&str> = ranked.dropped.iter().take(5).map(|hit| hit.id.0.as_str()).collect(); + eprintln!( + "huginn: the index returned {} ids this mind does not hold (first {:?}); they are left out", + ranked.dropped.len(), + named + ); + } + HuginnMindResponse::Query(ranked.page) + } Err(refusal) => HuginnMindResponse::Refused(refusal), } } diff --git a/crates/huginn-daemon/src/semantic_tests.rs b/crates/huginn-daemon/src/semantic_tests.rs index 311a51e..ad8bb19 100644 --- a/crates/huginn-daemon/src/semantic_tests.rs +++ b/crates/huginn-daemon/src/semantic_tests.rs @@ -412,23 +412,3 @@ fn an_index_that_cannot_answer_is_unavailable_and_plain_queries_still_work() { assert!(detail.contains("refused") && detail.contains("no Huginn metadata"), "{detail}"); assert_eq!(documents(&mut client, "m-plain-3"), held); } - -/// The ids a daemon reports as unheld are exactly the hits its mind has no -/// document for: an absent id, and an id under a kind it is not. -#[test] -fn a_hit_the_mind_holds_no_document_for_is_named_unheld() { - let root = tempfile::tempdir().unwrap(); - let mut daemon = crate::daemon::tests::open_unindexed(root.path(), &slug(INSTANCE)).unwrap(); - let identity = crate::daemon::tests::identity(INSTANCE); - let HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { writes, .. }) = - daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![identity])), now()).answered() - else { - panic!("the identity was not admitted") - }; - let held = writes[0].clone(); - let absent = PipelineRef { kind: held.kind, id: Short(id("instance", "nobody")) }; - let mis_kinded = PipelineRef { kind: PipelineKind::Question, id: held.id.clone() }; - let hits = vec![(held.clone(), 0.9), (absent.clone(), 0.8), (mis_kinded.clone(), 0.7)]; - assert_eq!(daemon.unheld(&hits), vec![absent.id.0.as_str(), mis_kinded.id.0.as_str()]); - assert!(daemon.unheld(&vec![(held, 0.9)]).is_empty()); -} diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index 8029548..0fd85e7 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -44,7 +44,7 @@ pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome} pub use mind::{HuginnMindEpoch, Mind}; pub use query::{ AdmissionFacts, PipelineDocumentSummary, PipelineDocumentView, PipelineEdge, PipelineFacts, PipelinePageItems, - PipelineSelectionPage, PipelineStatus, PipelineStatusSummary, SUMMARY_MAX_BYTES, SemanticQuery, + PipelineSelectionPage, PipelineStatus, PipelineStatusSummary, Ranked, SUMMARY_MAX_BYTES, SemanticQuery, }; pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION}; pub use refusal::MindRefusal; diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 87efa27..274d902 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -275,6 +275,15 @@ pub struct PipelineSelectionPage { pub edges: Option>, } +/// A ranked semantic answer: the page, and the hits the index named that the +/// mind holds no document for (a restored mind, a second store). Local to the +/// process; it is never on the wire. +#[derive(Clone, Debug, PartialEq)] +pub struct Ranked { + pub page: PipelineSelectionPage, + pub dropped: Vec, +} + /// The admission facts of every stored document, built once per read call from /// the receipts. `writes` only: a document a later batch cited as a strong read /// keeps the receipt that wrote it. A10 admits one write per identity, so two @@ -455,8 +464,9 @@ impl Mind { } /// What a semantic query must satisfy before anything is embedded: the - /// selection passes the same two doors `query` does, carries no cursor - /// (a ranked answer is not pageable), and asks for between one and + /// selection passes the same two doors `query` does, carries no cursor and + /// no `descending` (a ranked answer is neither pageable nor reversible), + /// and asks for between one and /// `LIMIT_MAX` hits over text that says something. Returns the selection /// as the door leaves it. fn semantic_selection(selection: &Selection, semantic: &SemanticQuery) -> Result { @@ -468,6 +478,9 @@ impl Mind { if selection.cursor.as_deref().is_some_and(|cursor| !cursor.is_empty()) { return Err(refused("cursor", None, "a semantic answer is ranked, not paged: it carries no cursor")); } + if selection.descending { + return Err(refused("descending", Some("true".into()), "a semantic answer is ordered by score: it has no order to reverse")); + } if semantic.text.0.trim().is_empty() { return Err(refused("semantic.text", None, "a semantic query needs text")); } @@ -488,7 +501,8 @@ impl Mind { Self::semantic_selection(selection, semantic).map(drop) } - /// The page a semantic query answers, from the candidates the index gave. + /// The page a semantic query answers, from the candidates the index gave, + /// with the hits the mind holds no document for. /// /// The index supplies ids and scores and nothing else. A hit is a /// candidate: it is dropped unless the mind holds a document of that kind @@ -499,30 +513,44 @@ impl Mind { /// mean in `query`. Nothing about a document's standing is read from the /// index; `in_force` is the caller's to ask for and the mind's to derive. /// + /// This is the one place the mind judges whether it holds a hit, in the + /// one read of its rows that ranking needs: `Ranked::dropped` returns the + /// hits it found no document for, for the caller to report. + /// /// The order is the one thing the substrate cannot know: score, highest /// first, ties by ordinal. The page holds at most the selection's limit /// and at most `top_k` documents, `matched` counts every candidate the - /// selection passed, and `next` is never set. Only the best - /// `LIMIT_MAX` candidates are considered. + /// selection passed, and `next` is never set. The substrate answers at + /// most `LIMIT_MAX` rows and cuts by ordinal, so the candidates are cut + /// to the best `LIMIT_MAX` by score first, among the documents the mind + /// holds: an id the mind does not hold never takes the place of one it + /// does. pub fn rank( &self, selection: &Selection, semantic: &SemanticQuery, hits: &[(PipelineRef, f32)], - ) -> Result { + ) -> Result { let selection = Self::semantic_selection(selection, semantic)?; let head = receipt::head(self)?; let rows = Reader::at(self, head)?.rows()?; - let mut best: Vec<&(PipelineRef, f32)> = hits.iter().collect(); - best.sort_by(|a, b| b.1.total_cmp(&a.1)); - best.truncate(LIMIT_MAX as usize); - let mut scores: BTreeMap<&str, f32> = BTreeMap::new(); - for row in &rows { - if let Some((_, score)) = best.iter().find(|(hit, _)| hit.kind == row.kind && hit.id.0 == row.key) { - scores.insert(row.key.as_str(), *score); + let held: BTreeMap<(PipelineKind, &str), i64> = + rows.iter().map(|row| ((row.kind, row.key.as_str()), row.ordinal())).collect(); + let mut candidates: Vec<(&str, f32, i64)> = Vec::new(); + let mut dropped = Vec::new(); + for (hit, score) in hits { + match held.get(&(hit.kind, hit.id.0.as_str())) { + Some(&ordinal) => candidates.push((hit.id.0.as_str(), *score, ordinal)), + None => dropped.push(hit.clone()), } } + candidates.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.2.cmp(&b.2))); + candidates.truncate(LIMIT_MAX as usize); + let mut scores: BTreeMap<&str, f32> = BTreeMap::new(); + for (key, score, _) in &candidates { + scores.entry(key).or_insert(*score); + } let mut keys: Vec = scores.keys().map(|key| key.to_string()).collect(); if let Some(allowed) = &selection.keys { keys.retain(|key| allowed.contains(key)); @@ -553,7 +581,7 @@ impl Mind { .collect(); kept.sort_by_key(|(at, _)| *at); evaluation.edges = kept.into_iter().map(|(_, edge)| edge).collect(); - Self::page(&selection, evaluation, head) + Ok(Ranked { page: Self::page(&selection, evaluation, head)?, dropped }) } /// The projection of an evaluation: a header or a whole view per row, and @@ -1517,19 +1545,47 @@ mod tests { hit(K::Ruling, &shelf.r4, 0.5), hit(K::Question, &shelf.q2, 0.4), ]; - let everything = shelf.mind.rank(&Selection::default(), &asking(10), &hits).unwrap(); + let ranked = shelf.mind.rank(&Selection::default(), &asking(10), &hits).unwrap(); + assert_eq!( + ranked.dropped, + vec![r(K::Question, &id("question", "Q99")), r(K::Question, &shelf.r4)], + "the ids the mind holds nothing for come back, in the order the index gave them, and no held id does" + ); + let everything = ranked.page; assert_eq!(ids(&everything), vec![shelf.q1.clone(), shelf.withdrawn_resolution.clone(), shelf.r3.clone(), shelf.r4.clone(), shelf.q2.clone()], "without in_force the selection has not asked about standing, and the unheld and mis-kinded hits are gone"); assert_eq!(everything.matched, 5); - let page = shelf.mind.rank(&standing(Selection::default()), &asking(10), &hits).unwrap(); + let page = shelf.mind.rank(&standing(Selection::default()), &asking(10), &hits).unwrap().page; assert_eq!(ids(&page), vec![shelf.r4.clone(), shelf.q2.clone()]); assert_eq!((page.matched, page.next), (2, None)); assert_eq!(page.as_of, receipt::head(&shelf.mind).unwrap()); - let none = shelf.mind.rank(&standing(Selection::default()), &asking(10), &[hit(K::Question, &id("question", "Q99"), 1.0)]).unwrap(); + let none = shelf.mind.rank(&standing(Selection::default()), &asking(10), &[hit(K::Question, &id("question", "Q99"), 1.0)]).unwrap().page; assert_eq!((none.matched, ids(&none)), (0, Vec::::new()), "an empty candidate set is an empty page, not a refusal"); - assert_eq!(shelf.mind.rank(&standing(Selection::default()), &asking(10), &[]).unwrap().matched, 0); + assert_eq!(shelf.mind.rank(&standing(Selection::default()), &asking(10), &[]).unwrap().page.matched, 0); + } + + /// The substrate answers 200 rows and cuts by ordinal, so 250 candidates + /// must be cut by score first: the 200 best are answered, and the 50 lowest + /// scores are the ones that fall away, whichever of them was admitted first. + /// An id the mind does not hold does not take a place among the 200. + #[test] + fn more_candidates_than_the_substrate_answers_are_cut_by_score_not_by_ordinal() { + let mut mind = seeded(); + for start in (0..250).step_by(crate::admission::BATCH_MAX) { + committed(admit(&mut mind, (start..(start + crate::admission::BATCH_MAX as u32).min(250)).map(question_n).collect())); + } + let key = |n: u32| id("question", &format!("Q{n}")); + // Question n scores n, so the later a question was admitted the better it ranks. + let mut hits: Vec<(PipelineRef, f32)> = (0..250).map(|n| hit(K::Question, &key(n), n as f32)).collect(); + // Twenty ids the mind holds nothing for, above every real score. + hits.extend((0..20).map(|n| hit(K::Question, &id("question", &format!("Gone{n}")), 1000.0 + n as f32))); + let ranked = mind.rank(&Selection::default(), &asking(200), &hits).unwrap(); + let expected: Vec = (50..250).rev().map(key).collect(); + assert_eq!(ids(&ranked.page), expected, "the 200 best, best first"); + assert_eq!(ranked.page.matched, 200); + assert_eq!(ranked.dropped.len(), 20); } /// The order is the score's, highest first, and ordinal only settles a @@ -1539,16 +1595,16 @@ mod tests { fn ranked_results_are_ordered_by_score_then_ordinal_and_cut_to_the_limit() { let shelf = shelf(); let by_ordinal = [hit(K::Question, &shelf.q2, 0.4), hit(K::Ruling, &shelf.r4, 0.5)]; - assert_eq!(ids(&shelf.mind.rank(&standing(Selection::default()), &asking(10), &by_ordinal).unwrap()), vec![shelf.r4.clone(), shelf.q2.clone()], + assert_eq!(ids(&shelf.mind.rank(&standing(Selection::default()), &asking(10), &by_ordinal).unwrap().page), vec![shelf.r4.clone(), shelf.q2.clone()], "Q2 was admitted first, and the better score still comes first"); let tied = [hit(K::Ruling, &shelf.r4, 0.5), hit(K::Question, &shelf.q2, 0.5)]; - assert_eq!(ids(&shelf.mind.rank(&standing(Selection::default()), &asking(10), &tied).unwrap()), vec![shelf.q2.clone(), shelf.r4.clone()], + assert_eq!(ids(&shelf.mind.rank(&standing(Selection::default()), &asking(10), &tied).unwrap().page), vec![shelf.q2.clone(), shelf.r4.clone()], "a tie falls to the earlier ordinal"); let limited = Selection { limit: Some(1), ..standing(Selection::default()) }; - let page = shelf.mind.rank(&limited, &asking(10), &by_ordinal).unwrap(); + let page = shelf.mind.rank(&limited, &asking(10), &by_ordinal).unwrap().page; assert_eq!((ids(&page), page.matched, page.next), (vec![shelf.r4.clone()], 2, None), "the limit keeps the best, not the earliest"); - let page = shelf.mind.rank(&standing(Selection::default()), &asking(1), &by_ordinal).unwrap(); + let page = shelf.mind.rank(&standing(Selection::default()), &asking(1), &by_ordinal).unwrap().page; assert_eq!((ids(&page), page.matched), (vec![shelf.r4.clone()], 2), "top_k bounds the page too"); } @@ -1560,16 +1616,16 @@ mod tests { fn the_selections_own_predicates_decide_which_candidates_stay() { let shelf = shelf(); let hits = [hit(K::Question, &shelf.q2, 0.4), hit(K::Ruling, &shelf.r4, 0.5)]; - assert_eq!(ids(&shelf.mind.rank(&of_kinds(&[K::Ruling]), &asking(10), &hits).unwrap()), vec![shelf.r4.clone()]); + assert_eq!(ids(&shelf.mind.rank(&of_kinds(&[K::Ruling]), &asking(10), &hits).unwrap().page), vec![shelf.r4.clone()]); let keyed = Selection { keys: Some(vec![shelf.q2.clone(), shelf.q1.clone()]), ..Selection::default() }; - assert_eq!(ids(&shelf.mind.rank(&keyed, &asking(10), &hits).unwrap()), vec![shelf.q2.clone()], "keys intersect the candidates"); + assert_eq!(ids(&shelf.mind.rank(&keyed, &asking(10), &hits).unwrap().page), vec![shelf.q2.clone()], "keys intersect the candidates"); let disjoint = Selection { keys: Some(vec![shelf.q1.clone()]), ..Selection::default() }; - assert_eq!(shelf.mind.rank(&disjoint, &asking(10), &hits).unwrap().matched, 0); + assert_eq!(shelf.mind.rank(&disjoint, &asking(10), &hits).unwrap().page.matched, 0); let unnamed = Selection { cited: Some(Incoming { role: "subject".into(), exists: false }), ..Selection::default() }; let plain = ids(&shelf.mind.query(&Selection { keys: Some(vec![shelf.q2.clone(), shelf.r4.clone()]), ..unnamed.clone() }).unwrap()); assert_eq!(plain, vec![shelf.r4.clone()], "no resolution names R4 as its subject, and one names Q2"); - assert_eq!(ids(&shelf.mind.rank(&unnamed, &asking(10), &hits).unwrap()), plain, "the same answer as the plain query over the same rows"); - assert_eq!(ids(&shelf.mind.rank(&Selection { projection: "document".into(), ..of_kinds(&[K::Ruling]) }, &asking(10), &hits).unwrap()), vec![shelf.r4.clone()]); + assert_eq!(ids(&shelf.mind.rank(&unnamed, &asking(10), &hits).unwrap().page), plain, "the same answer as the plain query over the same rows"); + assert_eq!(ids(&shelf.mind.rank(&Selection { projection: "document".into(), ..of_kinds(&[K::Ruling]) }, &asking(10), &hits).unwrap().page), vec![shelf.r4.clone()]); } /// A citation hop is answered over the whole mind, and its edges follow the @@ -1581,14 +1637,14 @@ mod tests { let shelf = shelf(); let named = Selection { cited: Some(Incoming { role: "subject".into(), exists: true }), ..of_kinds(&[K::Question]) }; let hits = [hit(K::Question, &shelf.q2, 0.9), hit(K::Question, &shelf.q1, 0.4)]; - let both = shelf.mind.rank(&named, &asking(10), &hits).unwrap(); + let both = shelf.mind.rank(&named, &asking(10), &hits).unwrap().page; assert_eq!(ids(&both), vec![shelf.q2.clone(), shelf.q1.clone()], "Q1 was admitted first and scored lower"); let edges = both.edges.expect("a hop carries its edges"); let targets: Vec<&str> = edges.iter().map(|edge| edge.to.id.0.as_str()).collect(); assert_eq!(targets, [shelf.q2.as_str(), shelf.q1.as_str()], "the edges follow the page order"); assert!(edges.iter().all(|edge| edge.from.kind == K::Resolution && edge.role == CitationRole::Subject)); - let limited = shelf.mind.rank(&Selection { limit: Some(1), ..named.clone() }, &asking(10), &hits).unwrap(); + let limited = shelf.mind.rank(&Selection { limit: Some(1), ..named.clone() }, &asking(10), &hits).unwrap().page; assert_eq!(ids(&limited), vec![shelf.q2.clone()]); let kept = limited.edges.unwrap(); assert_eq!(kept.len(), 1, "the dropped row's edge is dropped with it"); @@ -1596,7 +1652,7 @@ mod tests { assert_eq!(kept[0].from.id.0, id("resolution", "question.Q2.n1")); let empty_cursor = Selection { cursor: Some(String::new()), ..standing(Selection::default()) }; - let with = shelf.mind.rank(&empty_cursor, &asking(10), &[hit(K::Ruling, &shelf.r4, 0.5)]).unwrap(); + let with = shelf.mind.rank(&empty_cursor, &asking(10), &[hit(K::Ruling, &shelf.r4, 0.5)]).unwrap().page; assert_eq!(ids(&with), vec![shelf.r4.clone()]); } @@ -1617,6 +1673,13 @@ mod tests { ] { assert!(on("cursor", &refusal), "{refusal:?}"); } + let reversed = Selection { descending: true, ..Selection::default() }; + for refusal in [ + shelf.mind.rank(&reversed, &asking(10), &hits).unwrap_err(), + shelf.mind.check_semantic(&reversed, &asking(10)).unwrap_err(), + ] { + assert!(on("descending", &refusal), "a ranked answer has no order to reverse: {refusal:?}"); + } assert!(on("semantic.top_k", &shelf.mind.rank(&Selection::default(), &asking(0), &hits).unwrap_err())); assert!(on("semantic.top_k", &shelf.mind.rank(&Selection::default(), &asking(201), &hits).unwrap_err())); let blank = SemanticQuery { text: " ".into(), top_k: 3 };