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)]
435#[path = "reference_resolution_tests.rs"]
436mod tests;