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/// 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 review r3).
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    /// #852 review r3: 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}