Skip to main content

khive_runtime/
reference_resolution.rs

1//! `resolve_reference`: the Layer-0 deterministic reference resolver from the
2//! "unified-verb" draft ADR (Slice 1 — resolver + ring).
3//!
4//! Turns a natural-language reference into an id through four ordered
5//! stages, never guessing among close candidates:
6//!
7//! 1. **Id-string passthrough.** A ref that already looks like a UUID or an
8//!    8+ hex-char prefix resolves through the existing by-ID path
9//!    (`KhiveRuntime::resolve_by_id` / `resolve_prefix_unfiltered`) instead of
10//!    being treated as free text — it must not error just because it arrived
11//!    through `resolve_reference` rather than `get`. Scoped to entity ids
12//!    only, identically for the full-UUID and prefix forms (matching the
13//!    ring's entity-only contract); a note/edge/event id-string is
14//!    `NotFound` here, not an error — a caller resolving those uses `get`.
15//! 2. **Recently-referenced ring.** An exact (case-insensitive) or substring
16//!    match against this actor's ring (`reference_ring::ReferenceRing`).
17//! 3. **Exact-name storage lookup.** A deterministic, case-sensitive match
18//!    against `entities.name` in the caller's namespace (`deleted_at IS
19//!    NULL`) — covers any entity that already exists but was never
20//!    created/get/updated/deleted/merged/linked by this actor in this
21//!    session, so stage 2's ring never saw it (#849).
22//! 4. **Hybrid-search fallback.** `KhiveRuntime::hybrid_search` over the
23//!    caller's namespace, ranked by RRF score.
24//!
25//! A single candidate clearing the stage's confidence bar resolves; multiple
26//! viable candidates or none never silently pick — they return `Ambiguous`
27//! or `NotFound` for the caller to disambiguate.
28
29use std::str::FromStr;
30
31use uuid::Uuid;
32
33use khive_storage::types::PageRequest;
34use khive_storage::EntityFilter;
35
36use crate::error::{RuntimeError, RuntimeResult};
37use crate::operations::Resolved;
38use crate::reference_ring::ReferenceRing;
39use crate::runtime::{KhiveRuntime, NamespaceToken};
40
41/// A candidate id surfaced when a reference did not resolve outright.
42#[derive(Clone, Debug, PartialEq)]
43pub struct ReferenceCandidate {
44    pub id: Uuid,
45    pub name: Option<String>,
46    pub score: f64,
47}
48
49/// Outcome of `resolve_reference`. Never a silent pick among close
50/// candidates: `Ambiguous` always lists what it found instead of guessing.
51#[derive(Clone, Debug, PartialEq)]
52pub enum ReferenceResolution {
53    Resolved { id: Uuid, confidence: f64 },
54    Ambiguous { candidates: Vec<ReferenceCandidate> },
55    NotFound,
56}
57
58/// Ring-match confidence for an exact (case-insensitive) name match.
59const RING_EXACT_CONFIDENCE: f64 = 0.95;
60/// Ring-match confidence for a substring match (either direction).
61const RING_SUBSTRING_CONFIDENCE: f64 = 0.7;
62/// A single ring candidate auto-resolves only at or above this bar; below
63/// it the candidate is still surfaced as `Ambiguous` rather than silently
64/// accepted or dropped. Ring scores are fixed constants on a 0..1 scale, so
65/// a fixed bar is meaningful here: the search stage below needs a
66/// different rule (`SEARCH_RESOLVED_CONFIDENCE`) because RRF scores aren't
67/// on that scale.
68const RING_AUTO_RESOLVE_CONFIDENCE: f64 = 0.7;
69/// Confidence for a stage-3 exact-name storage match: a deterministic,
70/// case-sensitive equality on `entities.name` — stronger evidence than the
71/// ring's case-insensitive session cache (`RING_EXACT_CONFIDENCE`), so it
72/// sits above both ring bands, but still below the absolute certainty of an
73/// id-string passthrough (1.0), which the caller supplied directly rather
74/// than by name.
75const EXACT_NAME_CONFIDENCE: f64 = 0.98;
76/// Hybrid-search fallback: the top hit auto-resolves over a runner-up only
77/// when it leads by at least this ratio — RRF scores are not on a fixed
78/// 0..1 confidence scale, so a fixed absolute bar can't express "decisively
79/// best" the way it can for the ring. Below the margin, every hit above the
80/// score floor is surfaced as a candidate instead.
81const SEARCH_MARGIN_RATIO: f64 = 2.0;
82/// Vector hits below this raw cosine-similarity score are removed before RRF
83/// fusion. Lexical hits remain admissible because RRF magnitude encodes rank,
84/// not textual relevance, and genuine partial-name matches score below this floor.
85///
86/// This is a raw cosine value in `[-1.0, 1.0]`, matching the canonical
87/// vector-store score produced by `khive-db` (`1 - cosine_distance`).
88const SEARCH_VECTOR_SIMILARITY_FLOOR: f64 = 0.3;
89/// Confidence reported on a search-stage `Resolved` outcome: not the raw
90/// RRF score, which lives on a much smaller scale (`sum 1/(k + rank)`, e.g.
91/// ~0.016-0.033) and would never clear a 0..1 confidence bar. Fixed below
92/// both ring bands so callers can tell "the ring recognized this" from
93/// "search picked this out" by confidence alone; the raw RRF value is still
94/// preserved in `ReferenceCandidate.score` for `Ambiguous` listings.
95const SEARCH_RESOLVED_CONFIDENCE: f64 = 0.6;
96/// Floor on the retrieval depth stage 4 asks `hybrid_search` for, independent
97/// of the caller's requested `limit`.
98///
99/// `KhiveRuntime::hybrid_search` truncates its returned hit list to exactly
100/// the `limit` it is called with (its final step is `fused.truncate(limit as
101/// usize)`), so that `limit` doubles as both "how deep to search" and "how
102/// many hits to hand back" — for `resolve`, whose caller-facing default
103/// `limit` is 5 (`khive_pack_kg::handlers::resolve::DEFAULT_LIMIT`), a
104/// caller asking for a short candidate list was silently also asking for a
105/// shallow search. A genuine canonical-name match ranked, say, 6th-to-10th —
106/// exactly where a qualified natural-language ref (a project prefix ahead of
107/// a short canonical name) lands when it beats the exact-name stage above
108/// but only partially matches the text leg and is corroborated by the vector
109/// leg — was truncated out of the stage-4 candidate set entirely even though
110/// the same query against a wider `limit` (e.g. the `search` verb's own
111/// default of 10) surfaces it as the top hit (#908). Retrieving at this
112/// floor decouples "how deep to search" from "how many candidates the caller
113/// asked for". The full pool is retained for the decisiveness check, while an
114/// `Ambiguous` payload is truncated back to the caller's `limit` before it is
115/// returned.
116const STAGE4_MIN_SEARCH_LIMIT: u32 = 20;
117
118/// Resolve one natural-language reference for `token`'s actor.
119///
120/// `limit` bounds the hybrid-search fallback candidate count (Layer-0 stage
121/// 4); it has no effect on the id-string or ring stages, which are always
122/// exact-or-nothing / small in-memory scans. `entity_kind`, if set, restricts
123/// stage 3 to that entity kind (e.g. `"concept"`); the id-string and ring
124/// stages are kind-agnostic by construction (a ring entry or an explicit id
125/// is not filtered by kind).
126pub async fn resolve_reference(
127    runtime: &KhiveRuntime,
128    ring: &ReferenceRing,
129    token: &NamespaceToken,
130    nl_ref: &str,
131    limit: u32,
132    entity_kind: Option<&str>,
133) -> RuntimeResult<ReferenceResolution> {
134    resolve_reference_with_entity_type(runtime, ring, token, nl_ref, limit, entity_kind, None).await
135}
136
137/// Resolve with an optional canonical entity subtype in the exact-name and
138/// search stages. ID and ring matches retain their existing kind-agnostic
139/// behavior, so a subtype filter cannot turn an explicit ID into a fuzzy hit.
140#[allow(clippy::too_many_arguments)]
141pub async fn resolve_reference_with_entity_type(
142    runtime: &KhiveRuntime,
143    ring: &ReferenceRing,
144    token: &NamespaceToken,
145    nl_ref: &str,
146    limit: u32,
147    entity_kind: Option<&str>,
148    entity_type: Option<&str>,
149) -> RuntimeResult<ReferenceResolution> {
150    let trimmed = nl_ref.trim();
151    if trimmed.is_empty() {
152        return Ok(ReferenceResolution::NotFound);
153    }
154
155    // Stage 1: id-string passthrough (UUID / 8+ hex prefix) via the existing
156    // by-ID path. A ref shaped like an id but absent from storage is
157    // NotFound, not a fallthrough to ring/search: the caller named a
158    // specific id, so a miss there is the true answer. Scoped to entity ids
159    // only (both full-UUID and prefix forms) to match the ring's entity-only
160    // contract (`reference_ring::substrate_admits_as_entity`); a non-entity
161    // id-string is `NotFound` here: callers needing those already have `get`.
162    if let Ok(uuid) = Uuid::from_str(trimmed) {
163        return match runtime.resolve_by_id(token, uuid).await? {
164            Some(Resolved::Entity(_)) => Ok(ReferenceResolution::Resolved {
165                id: uuid,
166                confidence: 1.0,
167            }),
168            Some(_) | None => Ok(ReferenceResolution::NotFound),
169        };
170    }
171    if is_hex_prefix(trimmed) {
172        return match runtime.resolve_prefix_unfiltered(trimmed).await {
173            Ok(Some(uuid)) => match runtime.resolve_by_id(token, uuid).await? {
174                Some(Resolved::Entity(_)) => Ok(ReferenceResolution::Resolved {
175                    id: uuid,
176                    confidence: 1.0,
177                }),
178                Some(_) | None => Ok(ReferenceResolution::NotFound),
179            },
180            Ok(None) => Ok(ReferenceResolution::NotFound),
181            Err(RuntimeError::AmbiguousPrefix { matches, .. }) => {
182                let mut entity_matches = Vec::with_capacity(matches.len());
183                for id in matches {
184                    if matches!(
185                        runtime.resolve_by_id(token, id).await?,
186                        Some(Resolved::Entity(_))
187                    ) {
188                        entity_matches.push(id);
189                    }
190                }
191                match entity_matches.len() {
192                    0 => Ok(ReferenceResolution::NotFound),
193                    1 => Ok(ReferenceResolution::Resolved {
194                        id: entity_matches[0],
195                        confidence: 1.0,
196                    }),
197                    _ => Ok(ReferenceResolution::Ambiguous {
198                        candidates: entity_matches
199                            .into_iter()
200                            .map(|id| ReferenceCandidate {
201                                id,
202                                name: None,
203                                score: 1.0,
204                            })
205                            .collect(),
206                    }),
207                }
208            }
209            Err(e) => Err(e),
210        };
211    }
212
213    // Stage 2: recently-referenced ring.
214    let actor = token.actor();
215    let actor_key = format!("{}:{}", actor.kind, actor.id);
216    let ring_entries = ring.snapshot(token.namespace().as_str(), &actor_key);
217    let needle = trimmed.to_ascii_lowercase();
218
219    let exact: Vec<ReferenceCandidate> = ring_entries
220        .iter()
221        .filter(|e| {
222            e.name
223                .as_deref()
224                .is_some_and(|n| n.to_ascii_lowercase() == needle)
225        })
226        .map(|e| ReferenceCandidate {
227            id: e.id,
228            name: e.name.clone(),
229            score: RING_EXACT_CONFIDENCE,
230        })
231        .collect();
232    if let Some(resolution) = resolve_from_candidates(exact) {
233        return Ok(resolution);
234    }
235
236    let substring: Vec<ReferenceCandidate> = ring_entries
237        .iter()
238        .filter(|e| {
239            e.name.as_deref().is_some_and(|n| {
240                let n_lower = n.to_ascii_lowercase();
241                n_lower.contains(&needle) || needle.contains(&n_lower)
242            })
243        })
244        .map(|e| ReferenceCandidate {
245            id: e.id,
246            name: e.name.clone(),
247            score: RING_SUBSTRING_CONFIDENCE,
248        })
249        .collect();
250    if let Some(resolution) = resolve_from_candidates(substring) {
251        return Ok(resolution);
252    }
253
254    // Stage 3: exact-name storage lookup (#849) — a deterministic,
255    // case-sensitive match against `entities.name` in the caller's
256    // namespace, run before the hybrid-search fallback so an existing exact
257    // name always resolves regardless of FTS ranking, RRF score, or whether
258    // this actor's session ever referenced the entity (the ring's blind
259    // spot). Single match resolves; multiple exact matches are `Ambiguous`;
260    // none falls through to hybrid search unchanged.
261    if let Some(resolution) =
262        exact_name_match(runtime, token, trimmed, entity_kind, entity_type).await?
263    {
264        return Ok(resolution);
265    }
266
267    // Stage 4: hybrid-search fallback over the namespace. Search deeper than
268    // the caller's requested `limit` (see `STAGE4_MIN_SEARCH_LIMIT`) so a
269    // genuine match ranked just outside a small `limit` isn't truncated out
270    // of the pool before it's even ranked against the alternatives; the
271    // caller's `limit` still bounds how many candidates get rendered below.
272    let candidate_limit = limit.max(1);
273    let search_limit = candidate_limit.max(STAGE4_MIN_SEARCH_LIMIT);
274    let hits = runtime
275        .hybrid_search_with_vector_similarity_floor(
276            token,
277            trimmed,
278            None,
279            search_limit,
280            entity_kind,
281            entity_type,
282            &[],
283            None,
284            SEARCH_VECTOR_SIMILARITY_FLOOR,
285        )
286        .await?;
287    let candidates: Vec<ReferenceCandidate> = hits
288        .into_iter()
289        .map(|h| ReferenceCandidate {
290            id: h.entity_id,
291            name: h.title,
292            score: h.score.to_f64(),
293        })
294        .collect();
295
296    match candidates.len() {
297        0 => Ok(ReferenceResolution::NotFound),
298        // A lone hit is presence-decisive: there is no competing candidate to
299        // be ambiguous against, regardless of its raw RRF magnitude (see
300        // `SEARCH_RESOLVED_CONFIDENCE`).
301        1 => Ok(ReferenceResolution::Resolved {
302            id: candidates[0].id,
303            confidence: SEARCH_RESOLVED_CONFIDENCE,
304        }),
305        _ => {
306            let top_score = candidates[0].score;
307            let second_score = candidates[1].score;
308            let decisive =
309                second_score <= f64::EPSILON || top_score / second_score >= SEARCH_MARGIN_RATIO;
310            if decisive {
311                Ok(ReferenceResolution::Resolved {
312                    id: candidates[0].id,
313                    confidence: SEARCH_RESOLVED_CONFIDENCE,
314                })
315            } else {
316                // Non-exact ambiguity: bound the payload to the caller's
317                // `limit`. Any deterministic identity (an exact canonical
318                // name) was already resolved by stage 3 above, so nothing
319                // withheld here is "the" answer — outside exact matches there
320                // is no oracle for a single canonical candidate. Raising
321                // `limit` surfaces deeper ranks (#970; resolve() contract).
322                let mut candidates = candidates;
323                candidates.truncate(candidate_limit as usize);
324                Ok(ReferenceResolution::Ambiguous { candidates })
325            }
326        }
327    }
328}
329
330/// Apply the shared "single-above-bar resolves, multiple is ambiguous"
331/// contract to a candidate set already known to be an exact or substring
332/// ring match. Returns `None` when `candidates` is empty — the caller falls
333/// through to the next resolution stage instead of reporting `NotFound`
334/// prematurely.
335fn resolve_from_candidates(candidates: Vec<ReferenceCandidate>) -> Option<ReferenceResolution> {
336    match candidates.len() {
337        0 => None,
338        1 => {
339            let top = &candidates[0];
340            Some(if top.score >= RING_AUTO_RESOLVE_CONFIDENCE {
341                ReferenceResolution::Resolved {
342                    id: top.id,
343                    confidence: top.score,
344                }
345            } else {
346                ReferenceResolution::Ambiguous { candidates }
347            })
348        }
349        _ => Some(ReferenceResolution::Ambiguous { candidates }),
350    }
351}
352
353/// Stage 3 of `resolve_reference` (#849): a deterministic, case-sensitive
354/// exact match against `entities.name`, scoped to `token.namespace()` (the
355/// same single-namespace default the rest of this pipeline and the sibling
356/// by-name lookup in `khive-pack-kg`'s `resolve_name_async` use) and to
357/// `entity_kind` when the caller filtered by one. `query_entities` already
358/// excludes soft-deleted rows (`deleted_at IS NULL` is baked into every
359/// query — see `khive-db::stores::entity::build_entity_where`), so no
360/// separate filter is needed here. Returns `None` (fall through to the next
361/// stage) when nothing matches; `Some(Resolved)` on a single hit; and
362/// `Some(Ambiguous)` when the name is not unique.
363async fn exact_name_match(
364    runtime: &KhiveRuntime,
365    token: &NamespaceToken,
366    name: &str,
367    entity_kind: Option<&str>,
368    entity_type: Option<&str>,
369) -> RuntimeResult<Option<ReferenceResolution>> {
370    let mut filter = EntityFilter {
371        name_exact: Some(name.to_string()),
372        kinds: entity_kind.map(|k| vec![k.to_string()]).unwrap_or_default(),
373        ..EntityFilter::default()
374    };
375    if let (Some(kind), Some(entity_type)) = (entity_kind, entity_type) {
376        filter
377            .entity_types_by_kind
378            .insert(kind.to_string(), vec![entity_type.to_string()]);
379    }
380    // A storage-level `name = ?` predicate (not `name_prefix` + in-memory
381    // filter) so a namespace with many newer case variants of `name` can
382    // never page the exact target out from under a `created_at DESC` sort
383    // (#849, #852) — every row this query returns already equals `name`.
384    // The page is small (10, not 1), but the zero/one/many decision is made
385    // from `page.total` (the storage-computed COUNT(*) under the same
386    // predicate), never from `page.items.len()` — with 11+ byte-identical
387    // exact names the fetched page is still only 10 rows, and deciding from
388    // its length alone would under-report cardinality. `Ambiguous.candidates`
389    // is a bounded sample of up to 10 of the `page.total` matches, not the
390    // complete set — the variant carries no total field, so callers must not
391    // assume `candidates.len() == page.total` (#852).
392    let page = runtime
393        .entities(token)?
394        .query_entities(
395            token.namespace().as_str(),
396            filter,
397            PageRequest {
398                offset: 0,
399                limit: 10,
400            },
401        )
402        .await
403        .map_err(RuntimeError::Storage)?;
404
405    let total = page.total.unwrap_or(page.items.len() as u64);
406
407    let exact: Vec<ReferenceCandidate> = page
408        .items
409        .into_iter()
410        .map(|e| ReferenceCandidate {
411            id: e.id,
412            name: Some(e.name),
413            score: EXACT_NAME_CONFIDENCE,
414        })
415        .collect();
416
417    Ok(match total {
418        0 => None,
419        1 => exact
420            .into_iter()
421            .next()
422            .map(|top| ReferenceResolution::Resolved {
423                id: top.id,
424                confidence: EXACT_NAME_CONFIDENCE,
425            }),
426        _ => Some(ReferenceResolution::Ambiguous { candidates: exact }),
427    })
428}
429
430fn is_hex_prefix(s: &str) -> bool {
431    s.len() >= 8 && s.chars().all(|c| c.is_ascii_hexdigit())
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::config::{NamespaceToken as TokenCtor, RuntimeConfig};
438    use crate::embedder_registry::EmbedderProvider;
439    use crate::retrieval::SearchSource;
440    use khive_gate::ActorRef;
441    use khive_types::{namespace::Namespace, SubstrateKind};
442    use lattice_embed::{EmbeddingModel, EmbeddingService};
443    use std::sync::Arc;
444
445    struct ConstantEmbeddingService {
446        dimensions: usize,
447    }
448
449    #[async_trait::async_trait]
450    impl EmbeddingService for ConstantEmbeddingService {
451        async fn embed(
452            &self,
453            texts: &[String],
454            _model: EmbeddingModel,
455        ) -> Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
456            Ok(texts.iter().map(|_| vec![1.0; self.dimensions]).collect())
457        }
458
459        fn supports_model(&self, _model: EmbeddingModel) -> bool {
460            true
461        }
462
463        fn name(&self) -> &'static str {
464            "resolve-test-constant-embedding"
465        }
466    }
467
468    struct ConstantEmbedderProvider {
469        name: String,
470        dimensions: usize,
471    }
472
473    #[async_trait::async_trait]
474    impl EmbedderProvider for ConstantEmbedderProvider {
475        fn name(&self) -> &str {
476            &self.name
477        }
478
479        fn dimensions(&self) -> usize {
480            self.dimensions
481        }
482
483        async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
484            Ok(Arc::new(ConstantEmbeddingService {
485                dimensions: self.dimensions,
486            }))
487        }
488    }
489
490    fn runtime_with_constant_embeddings() -> KhiveRuntime {
491        let model = EmbeddingModel::AllMiniLmL6V2;
492        let runtime = KhiveRuntime::new(RuntimeConfig {
493            db_path: None,
494            embedding_model: Some(model),
495            packs: vec!["kg".to_string()],
496            ..RuntimeConfig::no_embeddings()
497        })
498        .expect("in-memory runtime");
499        runtime.register_embedder(ConstantEmbedderProvider {
500            name: model.to_string(),
501            dimensions: model.dimensions(),
502        });
503        runtime
504    }
505
506    fn actor_token(actor_id: &str) -> NamespaceToken {
507        TokenCtor::mint_authorized(Namespace::local(), ActorRef::new("agent", actor_id))
508    }
509
510    #[tokio::test]
511    async fn id_string_passthrough_resolves_full_uuid() {
512        let rt = KhiveRuntime::memory().expect("in-memory runtime");
513        let token = actor_token("resolver-test");
514        let ring = ReferenceRing::new();
515
516        let entity = rt
517            .create_entity(
518                &token,
519                "concept",
520                None,
521                "PassthroughTarget",
522                None,
523                None,
524                vec![],
525            )
526            .await
527            .expect("create entity");
528
529        let resolution = resolve_reference(&rt, &ring, &token, &entity.id.to_string(), 5, None)
530            .await
531            .expect("resolve_reference");
532        assert_eq!(
533            resolution,
534            ReferenceResolution::Resolved {
535                id: entity.id,
536                confidence: 1.0
537            }
538        );
539    }
540
541    #[tokio::test]
542    async fn id_string_passthrough_never_errors_on_a_miss() {
543        let rt = KhiveRuntime::memory().expect("in-memory runtime");
544        let token = actor_token("resolver-test");
545        let ring = ReferenceRing::new();
546
547        let missing = Uuid::new_v4();
548        let resolution = resolve_reference(&rt, &ring, &token, &missing.to_string(), 5, None)
549            .await
550            .expect("must not error, only report NotFound");
551        assert_eq!(resolution, ReferenceResolution::NotFound);
552    }
553
554    #[tokio::test]
555    async fn ring_exact_match_resolves_without_search() {
556        let rt = KhiveRuntime::memory().expect("in-memory runtime");
557        let token = actor_token("resolver-test");
558        let ring = ReferenceRing::new();
559        let actor = token.actor();
560        let actor_key = format!("{}:{}", actor.kind, actor.id);
561
562        let id = Uuid::new_v4();
563        ring.admit(
564            token.namespace().as_str(),
565            &actor_key,
566            id,
567            Some("the old record".to_string()),
568        );
569
570        let resolution = resolve_reference(&rt, &ring, &token, "the old record", 5, None)
571            .await
572            .expect("resolve_reference");
573        assert_eq!(
574            resolution,
575            ReferenceResolution::Resolved {
576                id,
577                confidence: RING_EXACT_CONFIDENCE
578            }
579        );
580    }
581
582    #[tokio::test]
583    async fn ring_ambiguous_on_multiple_exact_matches() {
584        let rt = KhiveRuntime::memory().expect("in-memory runtime");
585        let token = actor_token("resolver-test");
586        let ring = ReferenceRing::new();
587        let actor = token.actor();
588        let actor_key = format!("{}:{}", actor.kind, actor.id);
589
590        let id_a = Uuid::new_v4();
591        let id_b = Uuid::new_v4();
592        ring.admit(
593            token.namespace().as_str(),
594            &actor_key,
595            id_a,
596            Some("duplicate name".to_string()),
597        );
598        ring.admit(
599            token.namespace().as_str(),
600            &actor_key,
601            id_b,
602            Some("duplicate name".to_string()),
603        );
604
605        let resolution = resolve_reference(&rt, &ring, &token, "duplicate name", 5, None)
606            .await
607            .expect("resolve_reference");
608        match resolution {
609            ReferenceResolution::Ambiguous { candidates } => {
610                assert_eq!(candidates.len(), 2);
611            }
612            other => panic!("expected Ambiguous, got {other:?}"),
613        }
614    }
615
616    #[tokio::test]
617    async fn no_ring_entry_and_no_search_hit_is_not_found() {
618        let rt = KhiveRuntime::memory().expect("in-memory runtime");
619        let token = actor_token("resolver-test");
620        let ring = ReferenceRing::new();
621
622        let resolution =
623            resolve_reference(&rt, &ring, &token, "nothing matches this at all", 5, None)
624                .await
625                .expect("resolve_reference");
626        assert_eq!(resolution, ReferenceResolution::NotFound);
627    }
628
629    #[tokio::test]
630    async fn actor_isolation_blocks_cross_actor_ring_reads() {
631        let rt = KhiveRuntime::memory().expect("in-memory runtime");
632        let token_a = actor_token("actor-a");
633        let token_b = actor_token("actor-b");
634        let ring = ReferenceRing::new();
635        let actor_a = token_a.actor();
636        let actor_key_a = format!("{}:{}", actor_a.kind, actor_a.id);
637
638        let id = Uuid::new_v4();
639        ring.admit(
640            token_a.namespace().as_str(),
641            &actor_key_a,
642            id,
643            Some("shared-namespace-name".to_string()),
644        );
645
646        // actor-b, same namespace, must NOT resolve via actor-a's ring entry.
647        let resolution = resolve_reference(&rt, &ring, &token_b, "shared-namespace-name", 5, None)
648            .await
649            .expect("resolve_reference");
650        assert_eq!(resolution, ReferenceResolution::NotFound);
651    }
652
653    // Regression for #849/#852: the stage-3 exact-name lookup used to filter
654    // `name_prefix` (`LIKE 'RoLoRA%'`) in memory, and `query_entities` ranks
655    // a `name_prefix` page by `CASE WHEN LOWER(name) = prefix THEN 0 ELSE 1
656    // END, created_at DESC`. Case-insensitive variants of the target name
657    // tie for priority 0 with the true exact match, so 100+ *newer*
658    // lowercase variants can fill the `LIMIT 100` page and page the older,
659    // case-exact target out entirely — the stage then falls through to
660    // hybrid search instead of resolving deterministically. The fix issues a
661    // storage-level `name = ?` (binary) predicate instead, so decoys that
662    // merely match case-insensitively never enter the result set at all.
663    #[tokio::test]
664    async fn exact_name_stage_survives_many_newer_case_variant_decoys() {
665        let rt = KhiveRuntime::memory().expect("in-memory runtime");
666        let token = actor_token("resolver-test");
667        let ring = ReferenceRing::new();
668
669        let target = rt
670            .create_entity(&token, "concept", None, "RoLoRA", None, None, vec![])
671            .await
672            .expect("create target entity");
673
674        // Case variants of the same name, not suffixed variants: SQLite's
675        // `LIKE` is case-insensitive for ASCII, so a `rolora`-named decoy
676        // still matches the `LIKE 'RoLoRA%'` pattern the buggy `name_prefix`
677        // stage used, and the exact-match-ranking `CASE WHEN LOWER(name) =
678        // ...` ties every one of these decoys with the true target at
679        // priority 0 — leaving `created_at DESC` as the only tiebreak.
680        let decoy_cases = ["rolora", "ROLORA", "RoLoRa", "roLORA"];
681        for i in 0..120 {
682            rt.create_entity(
683                &token,
684                "concept",
685                None,
686                decoy_cases[i % decoy_cases.len()],
687                None,
688                None,
689                vec![],
690            )
691            .await
692            .expect("create decoy entity");
693        }
694
695        let resolution = resolve_reference(&rt, &ring, &token, "RoLoRA", 5, None)
696            .await
697            .expect("resolve_reference");
698        assert_eq!(
699            resolution,
700            ReferenceResolution::Resolved {
701                id: target.id,
702                confidence: EXACT_NAME_CONFIDENCE,
703            }
704        );
705    }
706
707    /// Issue #852: the zero/one/many decision must come from the
708    /// storage-computed `page.total` (a full `COUNT(*)` under the exact-name
709    /// predicate), not from `page.items.len()`, which the stage's own
710    /// `LIMIT 10` caps regardless of true cardinality. 11 byte-identical
711    /// exact names exceed that page limit, so the fetched page can only ever
712    /// carry 10 rows — `Ambiguous` must still fire (storage says 11 total,
713    /// not the truncated 10), and the returned `candidates` are a bounded
714    /// sample of the match set, not its entirety. That truncation is an
715    /// intentional, documented contract of this stage, not a bug: this test
716    /// pins both halves so a future change can't silently drop one.
717    #[tokio::test]
718    async fn exact_name_ambiguous_decision_uses_storage_total_not_page_len() {
719        let rt = KhiveRuntime::memory().expect("in-memory runtime");
720        let token = actor_token("resolver-test");
721        let ring = ReferenceRing::new();
722
723        for _ in 0..11 {
724            rt.create_entity(&token, "concept", None, "DupeExactName", None, None, vec![])
725                .await
726                .expect("create duplicate-named entity");
727        }
728
729        let resolution = resolve_reference(&rt, &ring, &token, "DupeExactName", 5, None)
730            .await
731            .expect("resolve_reference");
732
733        match resolution {
734            ReferenceResolution::Ambiguous { candidates } => {
735                assert_eq!(
736                    candidates.len(),
737                    10,
738                    "candidate set is a bounded 10-row sample of the 11 storage matches, \
739                     not the complete set"
740                );
741            }
742            other => panic!("expected Ambiguous driven by storage total (11), got {other:?}"),
743        }
744    }
745
746    // Regression for #908 / #970: an exact canonical-name ref resolves to a
747    // single id through the stage-3 exact-name lookup, which short-circuits
748    // BEFORE the stage-4 hybrid fallback and its `limit` bound. Many other
749    // entities are strong hybrid matches for the same term — enough that
750    // hybrid alone would rank them close together and return `Ambiguous` — yet
751    // the exact name resolves deterministically to its owner. The returned
752    // `EXACT_NAME_CONFIDENCE` (not the stage-4 `SEARCH_RESOLVED_CONFIDENCE`)
753    // proves the result came from stage 3, regardless of the target's hybrid
754    // rank. An exact name is an identity, not a ranked candidate.
755    #[tokio::test]
756    async fn exact_name_resolves_ahead_of_competing_hybrid_matches() {
757        let rt = KhiveRuntime::memory().expect("in-memory runtime");
758        let token = actor_token("resolver-test");
759        let ring = ReferenceRing::new();
760
761        let target = rt
762            .create_entity(
763                &token,
764                "concept",
765                None,
766                "ADR-040",
767                Some("khive ADR-040"),
768                None,
769                vec![],
770            )
771            .await
772            .expect("create target entity");
773
774        // Competitors that also match "ADR-040" strongly in hybrid search but
775        // do NOT carry it as an exact name; only the target owns the exact
776        // name, so only the target satisfies the stage-3 lookup.
777        for i in 0..12 {
778            rt.create_entity(
779                &token,
780                "concept",
781                None,
782                &format!("ADR-040 companion {i}"),
783                Some("ADR-040 ADR-040 ADR-040 ADR-040 ADR-040"),
784                None,
785                vec![],
786            )
787            .await
788            .expect("create competing entity");
789        }
790
791        // Even at limit = 1 — the tightest bound — the exact name resolves to
792        // the target, at the stage-3 confidence, never a ranked hybrid pick.
793        let resolution = resolve_reference(&rt, &ring, &token, "ADR-040", 1, None)
794            .await
795            .expect("resolve_reference");
796        assert_eq!(
797            resolution,
798            ReferenceResolution::Resolved {
799                id: target.id,
800                confidence: EXACT_NAME_CONFIDENCE,
801            }
802        );
803    }
804
805    // The complement of the exact-name contract: a NON-exact ref (no entity
806    // carries it as an exact name) that stays ambiguous returns a bounded
807    // sample capped at the caller's `limit`. Outside exact matches there is no
808    // oracle for a single "canonical" candidate, so the bound is the
809    // intentional, documented resolve() contract (raise `limit` to surface
810    // deeper ranks) — not a withheld identity. Any deterministic identity
811    // would have resolved upstream in stage 3.
812    #[tokio::test]
813    async fn fallback_stage_bounds_non_exact_payload_to_limit() {
814        let rt = KhiveRuntime::memory().expect("in-memory runtime");
815        let token = actor_token("resolver-test");
816        let ring = ReferenceRing::new();
817
818        // Twelve near-equal matches, each with a distinct name and the same
819        // descriptive text — none is the "right" answer, so the result is a
820        // genuinely bounded ambiguous sample, not a suppressed target.
821        for i in 0..12 {
822            rt.create_entity(
823                &token,
824                "concept",
825                None,
826                &format!("Retrieval Fusion Note {i}"),
827                Some("khive retrieval fusion ranking note"),
828                None,
829                vec![],
830            )
831            .await
832            .expect("create entity");
833        }
834
835        let resolution = resolve_reference(
836            &rt,
837            &ring,
838            &token,
839            "khive retrieval fusion ranking",
840            5,
841            None,
842        )
843        .await
844        .expect("resolve_reference");
845
846        match resolution {
847            ReferenceResolution::Ambiguous { candidates } => {
848                assert_eq!(
849                    candidates.len(),
850                    5,
851                    "a non-exact ref's ambiguity payload is bounded to the caller's limit"
852                );
853            }
854            other => panic!("expected a bounded Ambiguous sample, got {other:?}"),
855        }
856    }
857
858    // Regression for #908: genuine ambiguity — two entities with the exact
859    // same canonical name — must still return `Ambiguous`, never a silent
860    // pick, after widening stage 4's retrieval floor.
861    #[tokio::test]
862    async fn fallback_stage_still_reports_ambiguous_on_genuine_tie() {
863        let rt = KhiveRuntime::memory().expect("in-memory runtime");
864        let token = actor_token("resolver-test");
865        let ring = ReferenceRing::new();
866
867        let a = rt
868            .create_entity(
869                &token,
870                "concept",
871                None,
872                "Twin Record",
873                Some("khive Twin Record document"),
874                None,
875                vec![],
876            )
877            .await
878            .expect("create entity a");
879        let b = rt
880            .create_entity(
881                &token,
882                "concept",
883                None,
884                "Twin Record",
885                Some("khive Twin Record document"),
886                None,
887                vec![],
888            )
889            .await
890            .expect("create entity b");
891
892        // Exact byte-identical names hit stage 3 (exact-name storage
893        // lookup), not stage 4 — but the same "must not silently pick"
894        // contract applies at both stages, and stage 3 is a cheaper,
895        // deterministic way to pin it.
896        let resolution = resolve_reference(&rt, &ring, &token, "Twin Record", 5, None)
897            .await
898            .expect("resolve_reference");
899
900        match resolution {
901            ReferenceResolution::Ambiguous { candidates } => {
902                let ids: std::collections::HashSet<Uuid> =
903                    candidates.iter().map(|c| c.id).collect();
904                assert!(ids.contains(&a.id) && ids.contains(&b.id));
905            }
906            other => panic!("expected Ambiguous on a genuine name tie, got {other:?}"),
907        }
908    }
909
910    #[tokio::test]
911    async fn fallback_stage_resolves_high_similarity_semantic_only_candidate() {
912        let rt = runtime_with_constant_embeddings();
913        let token = actor_token("resolver-test");
914        let ring = ReferenceRing::new();
915
916        let entity = rt
917            .create_entity(&token, "concept", None, "Canine", None, None, vec![])
918            .await
919            .expect("create semantic match");
920
921        let raw_hits = rt
922            .vector_search(
923                &token,
924                None,
925                Some("domestic dog"),
926                5,
927                Some(SubstrateKind::Entity),
928            )
929            .await
930            .expect("vector search");
931        assert_eq!(raw_hits.len(), 1);
932        assert_eq!(raw_hits[0].subject_id, entity.id);
933        // Identical constant embeddings have canonical cosine score 1.0,
934        // well above the raw-cosine floor of 0.3.
935        assert!((raw_hits[0].score.to_f64() - 1.0).abs() < 1e-6);
936
937        let hits = rt
938            .hybrid_search(&token, "domestic dog", None, 5, None, None, &[], None)
939            .await
940            .expect("hybrid search");
941        assert_eq!(hits.len(), 1);
942        assert_eq!(hits[0].source, SearchSource::Vector);
943
944        let resolution = resolve_reference(&rt, &ring, &token, "domestic dog", 5, None)
945            .await
946            .expect("resolve_reference");
947
948        assert_eq!(
949            resolution,
950            ReferenceResolution::Resolved {
951                id: entity.id,
952                confidence: SEARCH_RESOLVED_CONFIDENCE,
953            }
954        );
955    }
956
957    #[tokio::test]
958    async fn fallback_stage_resolves_moderate_similarity_semantic_only_candidate() {
959        // A canonical cosine score of 0.5 clears the raw-cosine floor of 0.3.
960        // This guards against reintroducing the legacy `(1 + cos) / 2` floor
961        // conversion, which would incorrectly raise the comparison floor to
962        // 0.65 after vector-store scores moved to the canonical cosine scale.
963        let rt = runtime_with_constant_embeddings();
964        let token = actor_token("resolver-test");
965        let ring = ReferenceRing::new();
966        let dimensions = EmbeddingModel::AllMiniLmL6V2.dimensions();
967
968        let entity = rt
969            .create_entity(
970                &token,
971                "concept",
972                None,
973                "Moderately Similar Candidate",
974                None,
975                None,
976                vec![],
977            )
978            .await
979            .expect("create moderate-similarity entity");
980        let vectors = rt.vectors(&token).expect("vector store");
981        vectors
982            .delete(entity.id)
983            .await
984            .expect("delete generated vector");
985        let aligned = dimensions * 3 / 4;
986        let mut moderate_vector = vec![1.0f32; aligned];
987        moderate_vector.extend(vec![-1.0f32; dimensions - aligned]);
988        vectors
989            .insert(
990                entity.id,
991                SubstrateKind::Entity,
992                token.namespace().as_str(),
993                "entity.body",
994                vec![moderate_vector],
995            )
996            .await
997            .expect("insert moderate-similarity vector");
998
999        let query = "totally-nonexistent-moderate-zzz";
1000        let raw_hits = rt
1001            .vector_search(&token, None, Some(query), 5, Some(SubstrateKind::Entity))
1002            .await
1003            .expect("vector search");
1004        assert_eq!(raw_hits.len(), 1);
1005        assert_eq!(raw_hits[0].subject_id, entity.id);
1006        assert!((raw_hits[0].score.to_f64() - 0.5).abs() < 1e-6);
1007
1008        let resolution = resolve_reference(&rt, &ring, &token, query, 5, None)
1009            .await
1010            .expect("resolve_reference");
1011
1012        assert_eq!(
1013            resolution,
1014            ReferenceResolution::Resolved {
1015                id: entity.id,
1016                confidence: SEARCH_RESOLVED_CONFIDENCE,
1017            }
1018        );
1019    }
1020
1021    #[tokio::test]
1022    async fn fallback_stage_drops_orthogonal_semantic_only_candidate() {
1023        // An orthogonal vector has canonical cosine score 0.0, below the
1024        // raw-cosine floor of 0.3. A sole semantic candidate at this
1025        // similarity must be dropped, not resolved.
1026        let rt = runtime_with_constant_embeddings();
1027        let token = actor_token("resolver-test");
1028        let ring = ReferenceRing::new();
1029        let dimensions = EmbeddingModel::AllMiniLmL6V2.dimensions();
1030
1031        let entity = rt
1032            .create_entity(
1033                &token,
1034                "concept",
1035                None,
1036                "Unrelated Orthogonal",
1037                None,
1038                None,
1039                vec![],
1040            )
1041            .await
1042            .expect("create unrelated entity");
1043        let vectors = rt.vectors(&token).expect("vector store");
1044        vectors
1045            .delete(entity.id)
1046            .await
1047            .expect("delete generated vector");
1048        let mut orthogonal_vector = vec![1.0f32; dimensions / 2];
1049        orthogonal_vector.extend(vec![-1.0f32; dimensions - dimensions / 2]);
1050        vectors
1051            .insert(
1052                entity.id,
1053                SubstrateKind::Entity,
1054                token.namespace().as_str(),
1055                "entity.body",
1056                vec![orthogonal_vector],
1057            )
1058            .await
1059            .expect("insert orthogonal vector");
1060
1061        let raw_hits = rt
1062            .vector_search(
1063                &token,
1064                None,
1065                Some("totally-nonexistent-orthogonal-zzz"),
1066                5,
1067                Some(SubstrateKind::Entity),
1068            )
1069            .await
1070            .expect("vector search");
1071        assert_eq!(raw_hits.len(), 1);
1072        assert!((raw_hits[0].score.to_f64() - 0.0).abs() < 1e-6);
1073
1074        let resolution = resolve_reference(
1075            &rt,
1076            &ring,
1077            &token,
1078            "totally-nonexistent-orthogonal-zzz",
1079            5,
1080            None,
1081        )
1082        .await
1083        .expect("resolve_reference");
1084
1085        assert_eq!(resolution, ReferenceResolution::NotFound);
1086    }
1087
1088    #[tokio::test]
1089    async fn fallback_stage_drops_low_similarity_semantic_only_candidates() {
1090        let rt = runtime_with_constant_embeddings();
1091        let token = actor_token("resolver-test");
1092        let ring = ReferenceRing::new();
1093        let dimensions = EmbeddingModel::AllMiniLmL6V2.dimensions();
1094
1095        let entity = rt
1096            .create_entity(
1097                &token,
1098                "concept",
1099                None,
1100                "Unrelated Alpha",
1101                None,
1102                None,
1103                vec![],
1104            )
1105            .await
1106            .expect("create unrelated entity");
1107        let vectors = rt.vectors(&token).expect("vector store");
1108        vectors
1109            .delete(entity.id)
1110            .await
1111            .expect("delete generated vector");
1112        // A quarter of dimensions aligned, three-quarters opposed against the
1113        // constant all-ones query embedding: canonical cosine score
1114        // 2*0.25 - 1 == -0.5. This is below the floor without being the
1115        // maximally-opposite case, so it exercises a genuine below-floor
1116        // mismatch rather than the degenerate cosine -1 extreme.
1117        let quarter = dimensions / 4;
1118        let mut mismatched_vector = vec![1.0f32; quarter];
1119        mismatched_vector.extend(vec![-1.0f32; dimensions - quarter]);
1120        vectors
1121            .insert(
1122                entity.id,
1123                SubstrateKind::Entity,
1124                token.namespace().as_str(),
1125                "entity.body",
1126                vec![mismatched_vector],
1127            )
1128            .await
1129            .expect("insert mismatched vector");
1130
1131        let raw_hits = rt
1132            .vector_search(
1133                &token,
1134                None,
1135                Some("totally-nonexistent-zzz"),
1136                5,
1137                Some(SubstrateKind::Entity),
1138            )
1139            .await
1140            .expect("vector search");
1141        assert_eq!(raw_hits.len(), 1);
1142        assert!((raw_hits[0].score.to_f64() + 0.5).abs() < 1e-6);
1143
1144        let resolution = resolve_reference(&rt, &ring, &token, "totally-nonexistent-zzz", 5, None)
1145            .await
1146            .expect("resolve_reference");
1147
1148        assert_eq!(resolution, ReferenceResolution::NotFound);
1149    }
1150
1151    // Regression for #908: garbage input that matches nothing must still be
1152    // `NotFound` after widening stage 4's retrieval floor — the widened pool
1153    // must not turn "nothing relevant exists" into a spurious pick.
1154    #[tokio::test]
1155    async fn fallback_stage_still_not_found_on_garbage() {
1156        let rt = KhiveRuntime::memory().expect("in-memory runtime");
1157        let token = actor_token("resolver-test");
1158        let ring = ReferenceRing::new();
1159
1160        rt.create_entity(
1161            &token,
1162            "concept",
1163            None,
1164            "ADR-040",
1165            Some("khive ADR-040"),
1166            None,
1167            vec![],
1168        )
1169        .await
1170        .expect("create unrelated entity");
1171
1172        let resolution = resolve_reference(
1173            &rt,
1174            &ring,
1175            &token,
1176            "zzqxw completely unrelated garbage nonsense",
1177            5,
1178            None,
1179        )
1180        .await
1181        .expect("resolve_reference");
1182        assert_eq!(resolution, ReferenceResolution::NotFound);
1183    }
1184}