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/// Hybrid-search hits below this score never enter the candidate set at all.
83const SEARCH_SCORE_FLOOR: f64 = 0.0;
84/// Confidence reported on a search-stage `Resolved` outcome: not the raw
85/// RRF score, which lives on a much smaller scale (`sum 1/(k + rank)`, e.g.
86/// ~0.016-0.033) and would never clear a 0..1 confidence bar. Fixed below
87/// both ring bands so callers can tell "the ring recognized this" from
88/// "search picked this out" by confidence alone; the raw RRF value is still
89/// preserved in `ReferenceCandidate.score` for `Ambiguous` listings.
90const SEARCH_RESOLVED_CONFIDENCE: f64 = 0.6;
91
92/// Resolve one natural-language reference for `token`'s actor.
93///
94/// `limit` bounds the hybrid-search fallback candidate count (Layer-0 stage
95/// 3); it has no effect on the id-string or ring stages, which are always
96/// exact-or-nothing / small in-memory scans. `entity_kind`, if set, restricts
97/// stage 3 to that entity kind (e.g. `"concept"`); the id-string and ring
98/// stages are kind-agnostic by construction (a ring entry or an explicit id
99/// is not filtered by kind).
100pub async fn resolve_reference(
101 runtime: &KhiveRuntime,
102 ring: &ReferenceRing,
103 token: &NamespaceToken,
104 nl_ref: &str,
105 limit: u32,
106 entity_kind: Option<&str>,
107) -> RuntimeResult<ReferenceResolution> {
108 let trimmed = nl_ref.trim();
109 if trimmed.is_empty() {
110 return Ok(ReferenceResolution::NotFound);
111 }
112
113 // Stage 1: id-string passthrough (UUID / 8+ hex prefix) via the existing
114 // by-ID path. A ref shaped like an id but absent from storage is
115 // NotFound, not a fallthrough to ring/search: the caller named a
116 // specific id, so a miss there is the true answer. Scoped to entity ids
117 // only (both full-UUID and prefix forms) to match the ring's entity-only
118 // contract (`reference_ring::substrate_admits_as_entity`); a non-entity
119 // id-string is `NotFound` here: callers needing those already have `get`.
120 if let Ok(uuid) = Uuid::from_str(trimmed) {
121 return match runtime.resolve_by_id(token, uuid).await? {
122 Some(Resolved::Entity(_)) => Ok(ReferenceResolution::Resolved {
123 id: uuid,
124 confidence: 1.0,
125 }),
126 Some(_) | None => Ok(ReferenceResolution::NotFound),
127 };
128 }
129 if is_hex_prefix(trimmed) {
130 return match runtime.resolve_prefix_unfiltered(trimmed).await {
131 Ok(Some(uuid)) => match runtime.resolve_by_id(token, uuid).await? {
132 Some(Resolved::Entity(_)) => Ok(ReferenceResolution::Resolved {
133 id: uuid,
134 confidence: 1.0,
135 }),
136 Some(_) | None => Ok(ReferenceResolution::NotFound),
137 },
138 Ok(None) => Ok(ReferenceResolution::NotFound),
139 Err(RuntimeError::AmbiguousPrefix { matches, .. }) => {
140 let mut entity_matches = Vec::with_capacity(matches.len());
141 for id in matches {
142 if matches!(
143 runtime.resolve_by_id(token, id).await?,
144 Some(Resolved::Entity(_))
145 ) {
146 entity_matches.push(id);
147 }
148 }
149 match entity_matches.len() {
150 0 => Ok(ReferenceResolution::NotFound),
151 1 => Ok(ReferenceResolution::Resolved {
152 id: entity_matches[0],
153 confidence: 1.0,
154 }),
155 _ => Ok(ReferenceResolution::Ambiguous {
156 candidates: entity_matches
157 .into_iter()
158 .map(|id| ReferenceCandidate {
159 id,
160 name: None,
161 score: 1.0,
162 })
163 .collect(),
164 }),
165 }
166 }
167 Err(e) => Err(e),
168 };
169 }
170
171 // Stage 2: recently-referenced ring.
172 let actor = token.actor();
173 let actor_key = format!("{}:{}", actor.kind, actor.id);
174 let ring_entries = ring.snapshot(token.namespace().as_str(), &actor_key);
175 let needle = trimmed.to_ascii_lowercase();
176
177 let exact: Vec<ReferenceCandidate> = ring_entries
178 .iter()
179 .filter(|e| {
180 e.name
181 .as_deref()
182 .is_some_and(|n| n.to_ascii_lowercase() == needle)
183 })
184 .map(|e| ReferenceCandidate {
185 id: e.id,
186 name: e.name.clone(),
187 score: RING_EXACT_CONFIDENCE,
188 })
189 .collect();
190 if let Some(resolution) = resolve_from_candidates(exact) {
191 return Ok(resolution);
192 }
193
194 let substring: Vec<ReferenceCandidate> = ring_entries
195 .iter()
196 .filter(|e| {
197 e.name.as_deref().is_some_and(|n| {
198 let n_lower = n.to_ascii_lowercase();
199 n_lower.contains(&needle) || needle.contains(&n_lower)
200 })
201 })
202 .map(|e| ReferenceCandidate {
203 id: e.id,
204 name: e.name.clone(),
205 score: RING_SUBSTRING_CONFIDENCE,
206 })
207 .collect();
208 if let Some(resolution) = resolve_from_candidates(substring) {
209 return Ok(resolution);
210 }
211
212 // Stage 3: exact-name storage lookup (#849) — a deterministic,
213 // case-sensitive match against `entities.name` in the caller's
214 // namespace, run before the hybrid-search fallback so an existing exact
215 // name always resolves regardless of FTS ranking, RRF score, or whether
216 // this actor's session ever referenced the entity (the ring's blind
217 // spot). Single match resolves; multiple exact matches are `Ambiguous`;
218 // none falls through to hybrid search unchanged.
219 if let Some(resolution) = exact_name_match(runtime, token, trimmed, entity_kind).await? {
220 return Ok(resolution);
221 }
222
223 // Stage 4: hybrid-search fallback over the namespace.
224 let hits = runtime
225 .hybrid_search(
226 token,
227 trimmed,
228 None,
229 limit.max(1),
230 entity_kind,
231 None,
232 &[],
233 None,
234 )
235 .await?;
236 let candidates: Vec<ReferenceCandidate> = hits
237 .into_iter()
238 .filter(|h| h.score.to_f64() > SEARCH_SCORE_FLOOR)
239 .map(|h| ReferenceCandidate {
240 id: h.entity_id,
241 name: h.title,
242 score: h.score.to_f64(),
243 })
244 .collect();
245
246 match candidates.len() {
247 0 => Ok(ReferenceResolution::NotFound),
248 // A lone hit is presence-decisive: there is no competing candidate to
249 // be ambiguous against, regardless of its raw RRF magnitude (see
250 // `SEARCH_RESOLVED_CONFIDENCE`).
251 1 => Ok(ReferenceResolution::Resolved {
252 id: candidates[0].id,
253 confidence: SEARCH_RESOLVED_CONFIDENCE,
254 }),
255 _ => {
256 let top_score = candidates[0].score;
257 let second_score = candidates[1].score;
258 let decisive =
259 second_score <= f64::EPSILON || top_score / second_score >= SEARCH_MARGIN_RATIO;
260 if decisive {
261 Ok(ReferenceResolution::Resolved {
262 id: candidates[0].id,
263 confidence: SEARCH_RESOLVED_CONFIDENCE,
264 })
265 } else {
266 Ok(ReferenceResolution::Ambiguous { candidates })
267 }
268 }
269 }
270}
271
272/// Apply the shared "single-above-bar resolves, multiple is ambiguous"
273/// contract to a candidate set already known to be an exact or substring
274/// ring match. Returns `None` when `candidates` is empty — the caller falls
275/// through to the next resolution stage instead of reporting `NotFound`
276/// prematurely.
277fn resolve_from_candidates(candidates: Vec<ReferenceCandidate>) -> Option<ReferenceResolution> {
278 match candidates.len() {
279 0 => None,
280 1 => {
281 let top = &candidates[0];
282 Some(if top.score >= RING_AUTO_RESOLVE_CONFIDENCE {
283 ReferenceResolution::Resolved {
284 id: top.id,
285 confidence: top.score,
286 }
287 } else {
288 ReferenceResolution::Ambiguous { candidates }
289 })
290 }
291 _ => Some(ReferenceResolution::Ambiguous { candidates }),
292 }
293}
294
295/// Stage 3 of `resolve_reference` (#849): a deterministic, case-sensitive
296/// exact match against `entities.name`, scoped to `token.namespace()` (the
297/// same single-namespace default the rest of this pipeline and the sibling
298/// by-name lookup in `khive-pack-kg`'s `resolve_name_async` use) and to
299/// `entity_kind` when the caller filtered by one. `query_entities` already
300/// excludes soft-deleted rows (`deleted_at IS NULL` is baked into every
301/// query — see `khive-db::stores::entity::build_entity_where`), so no
302/// separate filter is needed here. Returns `None` (fall through to the next
303/// stage) when nothing matches; `Some(Resolved)` on a single hit; and
304/// `Some(Ambiguous)` when the name is not unique.
305async fn exact_name_match(
306 runtime: &KhiveRuntime,
307 token: &NamespaceToken,
308 name: &str,
309 entity_kind: Option<&str>,
310) -> RuntimeResult<Option<ReferenceResolution>> {
311 let filter = EntityFilter {
312 name_exact: Some(name.to_string()),
313 kinds: entity_kind.map(|k| vec![k.to_string()]).unwrap_or_default(),
314 ..EntityFilter::default()
315 };
316 // A storage-level `name = ?` predicate (not `name_prefix` + in-memory
317 // filter) so a namespace with many newer case variants of `name` can
318 // never page the exact target out from under a `created_at DESC` sort
319 // (#849, #852) — every row this query returns already equals `name`.
320 // The page is small (10, not 1), but the zero/one/many decision is made
321 // from `page.total` (the storage-computed COUNT(*) under the same
322 // predicate), never from `page.items.len()` — with 11+ byte-identical
323 // exact names the fetched page is still only 10 rows, and deciding from
324 // its length alone would under-report cardinality. `Ambiguous.candidates`
325 // is a bounded sample of up to 10 of the `page.total` matches, not the
326 // complete set — the variant carries no total field, so callers must not
327 // assume `candidates.len() == page.total` (#852).
328 let page = runtime
329 .entities(token)?
330 .query_entities(
331 token.namespace().as_str(),
332 filter,
333 PageRequest {
334 offset: 0,
335 limit: 10,
336 },
337 )
338 .await
339 .map_err(RuntimeError::Storage)?;
340
341 let total = page.total.unwrap_or(page.items.len() as u64);
342
343 let exact: Vec<ReferenceCandidate> = page
344 .items
345 .into_iter()
346 .map(|e| ReferenceCandidate {
347 id: e.id,
348 name: Some(e.name),
349 score: EXACT_NAME_CONFIDENCE,
350 })
351 .collect();
352
353 Ok(match total {
354 0 => None,
355 1 => exact
356 .into_iter()
357 .next()
358 .map(|top| ReferenceResolution::Resolved {
359 id: top.id,
360 confidence: EXACT_NAME_CONFIDENCE,
361 }),
362 _ => Some(ReferenceResolution::Ambiguous { candidates: exact }),
363 })
364}
365
366fn is_hex_prefix(s: &str) -> bool {
367 s.len() >= 8 && s.chars().all(|c| c.is_ascii_hexdigit())
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::config::NamespaceToken as TokenCtor;
374 use khive_gate::ActorRef;
375 use khive_types::namespace::Namespace;
376
377 fn actor_token(actor_id: &str) -> NamespaceToken {
378 TokenCtor::mint_authorized(Namespace::local(), ActorRef::new("agent", actor_id))
379 }
380
381 #[tokio::test]
382 async fn id_string_passthrough_resolves_full_uuid() {
383 let rt = KhiveRuntime::memory().expect("in-memory runtime");
384 let token = actor_token("resolver-test");
385 let ring = ReferenceRing::new();
386
387 let entity = rt
388 .create_entity(
389 &token,
390 "concept",
391 None,
392 "PassthroughTarget",
393 None,
394 None,
395 vec![],
396 )
397 .await
398 .expect("create entity");
399
400 let resolution = resolve_reference(&rt, &ring, &token, &entity.id.to_string(), 5, None)
401 .await
402 .expect("resolve_reference");
403 assert_eq!(
404 resolution,
405 ReferenceResolution::Resolved {
406 id: entity.id,
407 confidence: 1.0
408 }
409 );
410 }
411
412 #[tokio::test]
413 async fn id_string_passthrough_never_errors_on_a_miss() {
414 let rt = KhiveRuntime::memory().expect("in-memory runtime");
415 let token = actor_token("resolver-test");
416 let ring = ReferenceRing::new();
417
418 let missing = Uuid::new_v4();
419 let resolution = resolve_reference(&rt, &ring, &token, &missing.to_string(), 5, None)
420 .await
421 .expect("must not error, only report NotFound");
422 assert_eq!(resolution, ReferenceResolution::NotFound);
423 }
424
425 #[tokio::test]
426 async fn ring_exact_match_resolves_without_search() {
427 let rt = KhiveRuntime::memory().expect("in-memory runtime");
428 let token = actor_token("resolver-test");
429 let ring = ReferenceRing::new();
430 let actor = token.actor();
431 let actor_key = format!("{}:{}", actor.kind, actor.id);
432
433 let id = Uuid::new_v4();
434 ring.admit(
435 token.namespace().as_str(),
436 &actor_key,
437 id,
438 Some("the old record".to_string()),
439 );
440
441 let resolution = resolve_reference(&rt, &ring, &token, "the old record", 5, None)
442 .await
443 .expect("resolve_reference");
444 assert_eq!(
445 resolution,
446 ReferenceResolution::Resolved {
447 id,
448 confidence: RING_EXACT_CONFIDENCE
449 }
450 );
451 }
452
453 #[tokio::test]
454 async fn ring_ambiguous_on_multiple_exact_matches() {
455 let rt = KhiveRuntime::memory().expect("in-memory runtime");
456 let token = actor_token("resolver-test");
457 let ring = ReferenceRing::new();
458 let actor = token.actor();
459 let actor_key = format!("{}:{}", actor.kind, actor.id);
460
461 let id_a = Uuid::new_v4();
462 let id_b = Uuid::new_v4();
463 ring.admit(
464 token.namespace().as_str(),
465 &actor_key,
466 id_a,
467 Some("duplicate name".to_string()),
468 );
469 ring.admit(
470 token.namespace().as_str(),
471 &actor_key,
472 id_b,
473 Some("duplicate name".to_string()),
474 );
475
476 let resolution = resolve_reference(&rt, &ring, &token, "duplicate name", 5, None)
477 .await
478 .expect("resolve_reference");
479 match resolution {
480 ReferenceResolution::Ambiguous { candidates } => {
481 assert_eq!(candidates.len(), 2);
482 }
483 other => panic!("expected Ambiguous, got {other:?}"),
484 }
485 }
486
487 #[tokio::test]
488 async fn no_ring_entry_and_no_search_hit_is_not_found() {
489 let rt = KhiveRuntime::memory().expect("in-memory runtime");
490 let token = actor_token("resolver-test");
491 let ring = ReferenceRing::new();
492
493 let resolution =
494 resolve_reference(&rt, &ring, &token, "nothing matches this at all", 5, None)
495 .await
496 .expect("resolve_reference");
497 assert_eq!(resolution, ReferenceResolution::NotFound);
498 }
499
500 #[tokio::test]
501 async fn actor_isolation_blocks_cross_actor_ring_reads() {
502 let rt = KhiveRuntime::memory().expect("in-memory runtime");
503 let token_a = actor_token("actor-a");
504 let token_b = actor_token("actor-b");
505 let ring = ReferenceRing::new();
506 let actor_a = token_a.actor();
507 let actor_key_a = format!("{}:{}", actor_a.kind, actor_a.id);
508
509 let id = Uuid::new_v4();
510 ring.admit(
511 token_a.namespace().as_str(),
512 &actor_key_a,
513 id,
514 Some("shared-namespace-name".to_string()),
515 );
516
517 // actor-b, same namespace, must NOT resolve via actor-a's ring entry.
518 let resolution = resolve_reference(&rt, &ring, &token_b, "shared-namespace-name", 5, None)
519 .await
520 .expect("resolve_reference");
521 assert_eq!(resolution, ReferenceResolution::NotFound);
522 }
523
524 // Regression for #849/#852: the stage-3 exact-name lookup used to filter
525 // `name_prefix` (`LIKE 'RoLoRA%'`) in memory, and `query_entities` ranks
526 // a `name_prefix` page by `CASE WHEN LOWER(name) = prefix THEN 0 ELSE 1
527 // END, created_at DESC`. Case-insensitive variants of the target name
528 // tie for priority 0 with the true exact match, so 100+ *newer*
529 // lowercase variants can fill the `LIMIT 100` page and page the older,
530 // case-exact target out entirely — the stage then falls through to
531 // hybrid search instead of resolving deterministically. The fix issues a
532 // storage-level `name = ?` (binary) predicate instead, so decoys that
533 // merely match case-insensitively never enter the result set at all.
534 #[tokio::test]
535 async fn exact_name_stage_survives_many_newer_case_variant_decoys() {
536 let rt = KhiveRuntime::memory().expect("in-memory runtime");
537 let token = actor_token("resolver-test");
538 let ring = ReferenceRing::new();
539
540 let target = rt
541 .create_entity(&token, "concept", None, "RoLoRA", None, None, vec![])
542 .await
543 .expect("create target entity");
544
545 // Case variants of the same name, not suffixed variants: SQLite's
546 // `LIKE` is case-insensitive for ASCII, so a `rolora`-named decoy
547 // still matches the `LIKE 'RoLoRA%'` pattern the buggy `name_prefix`
548 // stage used, and the exact-match-ranking `CASE WHEN LOWER(name) =
549 // ...` ties every one of these decoys with the true target at
550 // priority 0 — leaving `created_at DESC` as the only tiebreak.
551 let decoy_cases = ["rolora", "ROLORA", "RoLoRa", "roLORA"];
552 for i in 0..120 {
553 rt.create_entity(
554 &token,
555 "concept",
556 None,
557 decoy_cases[i % decoy_cases.len()],
558 None,
559 None,
560 vec![],
561 )
562 .await
563 .expect("create decoy entity");
564 }
565
566 let resolution = resolve_reference(&rt, &ring, &token, "RoLoRA", 5, None)
567 .await
568 .expect("resolve_reference");
569 assert_eq!(
570 resolution,
571 ReferenceResolution::Resolved {
572 id: target.id,
573 confidence: EXACT_NAME_CONFIDENCE,
574 }
575 );
576 }
577
578 /// Issue #852: the zero/one/many decision must come from the
579 /// storage-computed `page.total` (a full `COUNT(*)` under the exact-name
580 /// predicate), not from `page.items.len()`, which the stage's own
581 /// `LIMIT 10` caps regardless of true cardinality. 11 byte-identical
582 /// exact names exceed that page limit, so the fetched page can only ever
583 /// carry 10 rows — `Ambiguous` must still fire (storage says 11 total,
584 /// not the truncated 10), and the returned `candidates` are a bounded
585 /// sample of the match set, not its entirety. That truncation is an
586 /// intentional, documented contract of this stage, not a bug: this test
587 /// pins both halves so a future change can't silently drop one.
588 #[tokio::test]
589 async fn exact_name_ambiguous_decision_uses_storage_total_not_page_len() {
590 let rt = KhiveRuntime::memory().expect("in-memory runtime");
591 let token = actor_token("resolver-test");
592 let ring = ReferenceRing::new();
593
594 for _ in 0..11 {
595 rt.create_entity(&token, "concept", None, "DupeExactName", None, None, vec![])
596 .await
597 .expect("create duplicate-named entity");
598 }
599
600 let resolution = resolve_reference(&rt, &ring, &token, "DupeExactName", 5, None)
601 .await
602 .expect("resolve_reference");
603
604 match resolution {
605 ReferenceResolution::Ambiguous { candidates } => {
606 assert_eq!(
607 candidates.len(),
608 10,
609 "candidate set is a bounded 10-row sample of the 11 storage matches, \
610 not the complete set"
611 );
612 }
613 other => panic!("expected Ambiguous driven by storage total (11), got {other:?}"),
614 }
615 }
616}