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;