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/// Floor on the retrieval depth stage 4 asks `hybrid_search` for, independent
92/// of the caller's requested `limit`.
93///
94/// `KhiveRuntime::hybrid_search` truncates its returned hit list to exactly
95/// the `limit` it is called with (its final step is `fused.truncate(limit as
96/// usize)`), so that `limit` doubles as both "how deep to search" and "how
97/// many hits to hand back" — for `resolve`, whose caller-facing default
98/// `limit` is 5 (`khive_pack_kg::handlers::resolve::DEFAULT_LIMIT`), a
99/// caller asking for a short candidate list was silently also asking for a
100/// shallow search. A genuine canonical-name match ranked, say, 6th-to-10th —
101/// exactly where a qualified natural-language ref (a project prefix ahead of
102/// a short canonical name) lands when it beats the exact-name stage above
103/// but only partially matches the text leg and is corroborated by the vector
104/// leg — was truncated out of the stage-4 candidate set entirely even though
105/// the same query against a wider `limit` (e.g. the `search` verb's own
106/// default of 10) surfaces it as the top hit (#908). Retrieving at this
107/// floor decouples "how deep to search" from "how many candidates the caller
108/// asked for". The full pool is retained for the decisiveness check, while an
109/// `Ambiguous` payload is truncated back to the caller's `limit` before it is
110/// returned.
111const STAGE4_MIN_SEARCH_LIMIT: u32 = 20;
112
113/// Resolve one natural-language reference for `token`'s actor.
114///
115/// `limit` bounds the hybrid-search fallback candidate count (Layer-0 stage
116/// 4); it has no effect on the id-string or ring stages, which are always
117/// exact-or-nothing / small in-memory scans. `entity_kind`, if set, restricts
118/// stage 3 to that entity kind (e.g. `"concept"`); the id-string and ring
119/// stages are kind-agnostic by construction (a ring entry or an explicit id
120/// is not filtered by kind).
121pub async fn resolve_reference(
122    runtime: &KhiveRuntime,
123    ring: &ReferenceRing,
124    token: &NamespaceToken,
125    nl_ref: &str,
126    limit: u32,
127    entity_kind: Option<&str>,
128) -> RuntimeResult<ReferenceResolution> {
129    let trimmed = nl_ref.trim();
130    if trimmed.is_empty() {
131        return Ok(ReferenceResolution::NotFound);
132    }
133
134    // Stage 1: id-string passthrough (UUID / 8+ hex prefix) via the existing
135    // by-ID path. A ref shaped like an id but absent from storage is
136    // NotFound, not a fallthrough to ring/search: the caller named a
137    // specific id, so a miss there is the true answer. Scoped to entity ids
138    // only (both full-UUID and prefix forms) to match the ring's entity-only
139    // contract (`reference_ring::substrate_admits_as_entity`); a non-entity
140    // id-string is `NotFound` here: callers needing those already have `get`.
141    if let Ok(uuid) = Uuid::from_str(trimmed) {
142        return match runtime.resolve_by_id(token, uuid).await? {
143            Some(Resolved::Entity(_)) => Ok(ReferenceResolution::Resolved {
144                id: uuid,
145                confidence: 1.0,
146            }),
147            Some(_) | None => Ok(ReferenceResolution::NotFound),
148        };
149    }
150    if is_hex_prefix(trimmed) {
151        return match runtime.resolve_prefix_unfiltered(trimmed).await {
152            Ok(Some(uuid)) => match runtime.resolve_by_id(token, uuid).await? {
153                Some(Resolved::Entity(_)) => Ok(ReferenceResolution::Resolved {
154                    id: uuid,
155                    confidence: 1.0,
156                }),
157                Some(_) | None => Ok(ReferenceResolution::NotFound),
158            },
159            Ok(None) => Ok(ReferenceResolution::NotFound),
160            Err(RuntimeError::AmbiguousPrefix { matches, .. }) => {
161                let mut entity_matches = Vec::with_capacity(matches.len());
162                for id in matches {
163                    if matches!(
164                        runtime.resolve_by_id(token, id).await?,
165                        Some(Resolved::Entity(_))
166                    ) {
167                        entity_matches.push(id);
168                    }
169                }
170                match entity_matches.len() {
171                    0 => Ok(ReferenceResolution::NotFound),
172                    1 => Ok(ReferenceResolution::Resolved {
173                        id: entity_matches[0],
174                        confidence: 1.0,
175                    }),
176                    _ => Ok(ReferenceResolution::Ambiguous {
177                        candidates: entity_matches
178                            .into_iter()
179                            .map(|id| ReferenceCandidate {
180                                id,
181                                name: None,
182                                score: 1.0,
183                            })
184                            .collect(),
185                    }),
186                }
187            }
188            Err(e) => Err(e),
189        };
190    }
191
192    // Stage 2: recently-referenced ring.
193    let actor = token.actor();
194    let actor_key = format!("{}:{}", actor.kind, actor.id);
195    let ring_entries = ring.snapshot(token.namespace().as_str(), &actor_key);
196    let needle = trimmed.to_ascii_lowercase();
197
198    let exact: Vec<ReferenceCandidate> = ring_entries
199        .iter()
200        .filter(|e| {
201            e.name
202                .as_deref()
203                .is_some_and(|n| n.to_ascii_lowercase() == needle)
204        })
205        .map(|e| ReferenceCandidate {
206            id: e.id,
207            name: e.name.clone(),
208            score: RING_EXACT_CONFIDENCE,
209        })
210        .collect();
211    if let Some(resolution) = resolve_from_candidates(exact) {
212        return Ok(resolution);
213    }
214
215    let substring: Vec<ReferenceCandidate> = ring_entries
216        .iter()
217        .filter(|e| {
218            e.name.as_deref().is_some_and(|n| {
219                let n_lower = n.to_ascii_lowercase();
220                n_lower.contains(&needle) || needle.contains(&n_lower)
221            })
222        })
223        .map(|e| ReferenceCandidate {
224            id: e.id,
225            name: e.name.clone(),
226            score: RING_SUBSTRING_CONFIDENCE,
227        })
228        .collect();
229    if let Some(resolution) = resolve_from_candidates(substring) {
230        return Ok(resolution);
231    }
232
233    // Stage 3: exact-name storage lookup (#849) — a deterministic,
234    // case-sensitive match against `entities.name` in the caller's
235    // namespace, run before the hybrid-search fallback so an existing exact
236    // name always resolves regardless of FTS ranking, RRF score, or whether
237    // this actor's session ever referenced the entity (the ring's blind
238    // spot). Single match resolves; multiple exact matches are `Ambiguous`;
239    // none falls through to hybrid search unchanged.
240    if let Some(resolution) = exact_name_match(runtime, token, trimmed, entity_kind).await? {
241        return Ok(resolution);
242    }
243
244    // Stage 4: hybrid-search fallback over the namespace. Search deeper than
245    // the caller's requested `limit` (see `STAGE4_MIN_SEARCH_LIMIT`) so a
246    // genuine match ranked just outside a small `limit` isn't truncated out
247    // of the pool before it's even ranked against the alternatives; the
248    // caller's `limit` still bounds how many candidates get rendered below.
249    let candidate_limit = limit.max(1);
250    let search_limit = candidate_limit.max(STAGE4_MIN_SEARCH_LIMIT);
251    let hits = runtime
252        .hybrid_search(
253            token,
254            trimmed,
255            None,
256            search_limit,
257            entity_kind,
258            None,
259            &[],
260            None,
261        )
262        .await?;
263    let candidates: Vec<ReferenceCandidate> = hits
264        .into_iter()
265        .filter(|h| h.score.to_f64() > SEARCH_SCORE_FLOOR)
266        .map(|h| ReferenceCandidate {
267            id: h.entity_id,
268            name: h.title,
269            score: h.score.to_f64(),
270        })
271        .collect();
272
273    match candidates.len() {
274        0 => Ok(ReferenceResolution::NotFound),
275        // A lone hit is presence-decisive: there is no competing candidate to
276        // be ambiguous against, regardless of its raw RRF magnitude (see
277        // `SEARCH_RESOLVED_CONFIDENCE`).
278        1 => Ok(ReferenceResolution::Resolved {
279            id: candidates[0].id,
280            confidence: SEARCH_RESOLVED_CONFIDENCE,
281        }),
282        _ => {
283            let top_score = candidates[0].score;
284            let second_score = candidates[1].score;
285            let decisive =
286                second_score <= f64::EPSILON || top_score / second_score >= SEARCH_MARGIN_RATIO;
287            if decisive {
288                Ok(ReferenceResolution::Resolved {
289                    id: candidates[0].id,
290                    confidence: SEARCH_RESOLVED_CONFIDENCE,
291                })
292            } else {
293                // Non-exact ambiguity: bound the payload to the caller's
294                // `limit`. Any deterministic identity (an exact canonical
295                // name) was already resolved by stage 3 above, so nothing
296                // withheld here is "the" answer — outside exact matches there
297                // is no oracle for a single canonical candidate. Raising
298                // `limit` surfaces deeper ranks (#970; resolve() contract).
299                let mut candidates = candidates;
300                candidates.truncate(candidate_limit as usize);
301                Ok(ReferenceResolution::Ambiguous { candidates })
302            }
303        }
304    }
305}
306
307/// Apply the shared "single-above-bar resolves, multiple is ambiguous"
308/// contract to a candidate set already known to be an exact or substring
309/// ring match. Returns `None` when `candidates` is empty — the caller falls
310/// through to the next resolution stage instead of reporting `NotFound`
311/// prematurely.
312fn resolve_from_candidates(candidates: Vec<ReferenceCandidate>) -> Option<ReferenceResolution> {
313    match candidates.len() {
314        0 => None,
315        1 => {
316            let top = &candidates[0];
317            Some(if top.score >= RING_AUTO_RESOLVE_CONFIDENCE {
318                ReferenceResolution::Resolved {
319                    id: top.id,
320                    confidence: top.score,
321                }
322            } else {
323                ReferenceResolution::Ambiguous { candidates }
324            })
325        }
326        _ => Some(ReferenceResolution::Ambiguous { candidates }),
327    }
328}
329
330/// Stage 3 of `resolve_reference` (#849): a deterministic, case-sensitive
331/// exact match against `entities.name`, scoped to `token.namespace()` (the
332/// same single-namespace default the rest of this pipeline and the sibling
333/// by-name lookup in `khive-pack-kg`'s `resolve_name_async` use) and to
334/// `entity_kind` when the caller filtered by one. `query_entities` already
335/// excludes soft-deleted rows (`deleted_at IS NULL` is baked into every
336/// query — see `khive-db::stores::entity::build_entity_where`), so no
337/// separate filter is needed here. Returns `None` (fall through to the next
338/// stage) when nothing matches; `Some(Resolved)` on a single hit; and
339/// `Some(Ambiguous)` when the name is not unique.
340async fn exact_name_match(
341    runtime: &KhiveRuntime,
342    token: &NamespaceToken,
343    name: &str,
344    entity_kind: Option<&str>,
345) -> RuntimeResult<Option<ReferenceResolution>> {
346    let filter = EntityFilter {
347        name_exact: Some(name.to_string()),
348        kinds: entity_kind.map(|k| vec![k.to_string()]).unwrap_or_default(),
349        ..EntityFilter::default()
350    };
351    // A storage-level `name = ?` predicate (not `name_prefix` + in-memory
352    // filter) so a namespace with many newer case variants of `name` can
353    // never page the exact target out from under a `created_at DESC` sort
354    // (#849, #852) — every row this query returns already equals `name`.
355    // The page is small (10, not 1), but the zero/one/many decision is made
356    // from `page.total` (the storage-computed COUNT(*) under the same
357    // predicate), never from `page.items.len()` — with 11+ byte-identical
358    // exact names the fetched page is still only 10 rows, and deciding from
359    // its length alone would under-report cardinality. `Ambiguous.candidates`
360    // is a bounded sample of up to 10 of the `page.total` matches, not the
361    // complete set — the variant carries no total field, so callers must not
362    // assume `candidates.len() == page.total` (#852).
363    let page = runtime
364        .entities(token)?
365        .query_entities(
366            token.namespace().as_str(),
367            filter,
368            PageRequest {
369                offset: 0,
370                limit: 10,
371            },
372        )
373        .await
374        .map_err(RuntimeError::Storage)?;
375
376    let total = page.total.unwrap_or(page.items.len() as u64);
377
378    let exact: Vec<ReferenceCandidate> = page
379        .items
380        .into_iter()
381        .map(|e| ReferenceCandidate {
382            id: e.id,
383            name: Some(e.name),
384            score: EXACT_NAME_CONFIDENCE,
385        })
386        .collect();
387
388    Ok(match total {
389        0 => None,
390        1 => exact
391            .into_iter()
392            .next()
393            .map(|top| ReferenceResolution::Resolved {
394                id: top.id,
395                confidence: EXACT_NAME_CONFIDENCE,
396            }),
397        _ => Some(ReferenceResolution::Ambiguous { candidates: exact }),
398    })
399}
400
401fn is_hex_prefix(s: &str) -> bool {
402    s.len() >= 8 && s.chars().all(|c| c.is_ascii_hexdigit())
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::config::NamespaceToken as TokenCtor;
409    use khive_gate::ActorRef;
410    use khive_types::namespace::Namespace;
411
412    fn actor_token(actor_id: &str) -> NamespaceToken {
413        TokenCtor::mint_authorized(Namespace::local(), ActorRef::new("agent", actor_id))
414    }
415
416    #[tokio::test]
417    async fn id_string_passthrough_resolves_full_uuid() {
418        let rt = KhiveRuntime::memory().expect("in-memory runtime");
419        let token = actor_token("resolver-test");
420        let ring = ReferenceRing::new();
421
422        let entity = rt
423            .create_entity(
424                &token,
425                "concept",
426                None,
427                "PassthroughTarget",
428                None,
429                None,
430                vec![],
431            )
432            .await
433            .expect("create entity");
434
435        let resolution = resolve_reference(&rt, &ring, &token, &entity.id.to_string(), 5, None)
436            .await
437            .expect("resolve_reference");
438        assert_eq!(
439            resolution,
440            ReferenceResolution::Resolved {
441                id: entity.id,
442                confidence: 1.0
443            }
444        );
445    }
446
447    #[tokio::test]
448    async fn id_string_passthrough_never_errors_on_a_miss() {
449        let rt = KhiveRuntime::memory().expect("in-memory runtime");
450        let token = actor_token("resolver-test");
451        let ring = ReferenceRing::new();
452
453        let missing = Uuid::new_v4();
454        let resolution = resolve_reference(&rt, &ring, &token, &missing.to_string(), 5, None)
455            .await
456            .expect("must not error, only report NotFound");
457        assert_eq!(resolution, ReferenceResolution::NotFound);
458    }
459
460    #[tokio::test]
461    async fn ring_exact_match_resolves_without_search() {
462        let rt = KhiveRuntime::memory().expect("in-memory runtime");
463        let token = actor_token("resolver-test");
464        let ring = ReferenceRing::new();
465        let actor = token.actor();
466        let actor_key = format!("{}:{}", actor.kind, actor.id);
467
468        let id = Uuid::new_v4();
469        ring.admit(
470            token.namespace().as_str(),
471            &actor_key,
472            id,
473            Some("the old record".to_string()),
474        );
475
476        let resolution = resolve_reference(&rt, &ring, &token, "the old record", 5, None)
477            .await
478            .expect("resolve_reference");
479        assert_eq!(
480            resolution,
481            ReferenceResolution::Resolved {
482                id,
483                confidence: RING_EXACT_CONFIDENCE
484            }
485        );
486    }
487
488    #[tokio::test]
489    async fn ring_ambiguous_on_multiple_exact_matches() {
490        let rt = KhiveRuntime::memory().expect("in-memory runtime");
491        let token = actor_token("resolver-test");
492        let ring = ReferenceRing::new();
493        let actor = token.actor();
494        let actor_key = format!("{}:{}", actor.kind, actor.id);
495
496        let id_a = Uuid::new_v4();
497        let id_b = Uuid::new_v4();
498        ring.admit(
499            token.namespace().as_str(),
500            &actor_key,
501            id_a,
502            Some("duplicate name".to_string()),
503        );
504        ring.admit(
505            token.namespace().as_str(),
506            &actor_key,
507            id_b,
508            Some("duplicate name".to_string()),
509        );
510
511        let resolution = resolve_reference(&rt, &ring, &token, "duplicate name", 5, None)
512            .await
513            .expect("resolve_reference");
514        match resolution {
515            ReferenceResolution::Ambiguous { candidates } => {
516                assert_eq!(candidates.len(), 2);
517            }
518            other => panic!("expected Ambiguous, got {other:?}"),
519        }
520    }
521
522    #[tokio::test]
523    async fn no_ring_entry_and_no_search_hit_is_not_found() {
524        let rt = KhiveRuntime::memory().expect("in-memory runtime");
525        let token = actor_token("resolver-test");
526        let ring = ReferenceRing::new();
527
528        let resolution =
529            resolve_reference(&rt, &ring, &token, "nothing matches this at all", 5, None)
530                .await
531                .expect("resolve_reference");
532        assert_eq!(resolution, ReferenceResolution::NotFound);
533    }
534
535    #[tokio::test]
536    async fn actor_isolation_blocks_cross_actor_ring_reads() {
537        let rt = KhiveRuntime::memory().expect("in-memory runtime");
538        let token_a = actor_token("actor-a");
539        let token_b = actor_token("actor-b");
540        let ring = ReferenceRing::new();
541        let actor_a = token_a.actor();
542        let actor_key_a = format!("{}:{}", actor_a.kind, actor_a.id);
543
544        let id = Uuid::new_v4();
545        ring.admit(
546            token_a.namespace().as_str(),
547            &actor_key_a,
548            id,
549            Some("shared-namespace-name".to_string()),
550        );
551
552        // actor-b, same namespace, must NOT resolve via actor-a's ring entry.
553        let resolution = resolve_reference(&rt, &ring, &token_b, "shared-namespace-name", 5, None)
554            .await
555            .expect("resolve_reference");
556        assert_eq!(resolution, ReferenceResolution::NotFound);
557    }
558
559    // Regression for #849/#852: the stage-3 exact-name lookup used to filter
560    // `name_prefix` (`LIKE 'RoLoRA%'`) in memory, and `query_entities` ranks
561    // a `name_prefix` page by `CASE WHEN LOWER(name) = prefix THEN 0 ELSE 1
562    // END, created_at DESC`. Case-insensitive variants of the target name
563    // tie for priority 0 with the true exact match, so 100+ *newer*
564    // lowercase variants can fill the `LIMIT 100` page and page the older,
565    // case-exact target out entirely — the stage then falls through to
566    // hybrid search instead of resolving deterministically. The fix issues a
567    // storage-level `name = ?` (binary) predicate instead, so decoys that
568    // merely match case-insensitively never enter the result set at all.
569    #[tokio::test]
570    async fn exact_name_stage_survives_many_newer_case_variant_decoys() {
571        let rt = KhiveRuntime::memory().expect("in-memory runtime");
572        let token = actor_token("resolver-test");
573        let ring = ReferenceRing::new();
574
575        let target = rt
576            .create_entity(&token, "concept", None, "RoLoRA", None, None, vec![])
577            .await
578            .expect("create target entity");
579
580        // Case variants of the same name, not suffixed variants: SQLite's
581        // `LIKE` is case-insensitive for ASCII, so a `rolora`-named decoy
582        // still matches the `LIKE 'RoLoRA%'` pattern the buggy `name_prefix`
583        // stage used, and the exact-match-ranking `CASE WHEN LOWER(name) =
584        // ...` ties every one of these decoys with the true target at
585        // priority 0 — leaving `created_at DESC` as the only tiebreak.
586        let decoy_cases = ["rolora", "ROLORA", "RoLoRa", "roLORA"];
587        for i in 0..120 {
588            rt.create_entity(
589                &token,
590                "concept",
591                None,
592                decoy_cases[i % decoy_cases.len()],
593                None,
594                None,
595                vec![],
596            )
597            .await
598            .expect("create decoy entity");
599        }
600
601        let resolution = resolve_reference(&rt, &ring, &token, "RoLoRA", 5, None)
602            .await
603            .expect("resolve_reference");
604        assert_eq!(
605            resolution,
606            ReferenceResolution::Resolved {
607                id: target.id,
608                confidence: EXACT_NAME_CONFIDENCE,
609            }
610        );
611    }
612
613    /// Issue #852: the zero/one/many decision must come from the
614    /// storage-computed `page.total` (a full `COUNT(*)` under the exact-name
615    /// predicate), not from `page.items.len()`, which the stage's own
616    /// `LIMIT 10` caps regardless of true cardinality. 11 byte-identical
617    /// exact names exceed that page limit, so the fetched page can only ever
618    /// carry 10 rows — `Ambiguous` must still fire (storage says 11 total,
619    /// not the truncated 10), and the returned `candidates` are a bounded
620    /// sample of the match set, not its entirety. That truncation is an
621    /// intentional, documented contract of this stage, not a bug: this test
622    /// pins both halves so a future change can't silently drop one.
623    #[tokio::test]
624    async fn exact_name_ambiguous_decision_uses_storage_total_not_page_len() {
625        let rt = KhiveRuntime::memory().expect("in-memory runtime");
626        let token = actor_token("resolver-test");
627        let ring = ReferenceRing::new();
628
629        for _ in 0..11 {
630            rt.create_entity(&token, "concept", None, "DupeExactName", None, None, vec![])
631                .await
632                .expect("create duplicate-named entity");
633        }
634
635        let resolution = resolve_reference(&rt, &ring, &token, "DupeExactName", 5, None)
636            .await
637            .expect("resolve_reference");
638
639        match resolution {
640            ReferenceResolution::Ambiguous { candidates } => {
641                assert_eq!(
642                    candidates.len(),
643                    10,
644                    "candidate set is a bounded 10-row sample of the 11 storage matches, \
645                     not the complete set"
646                );
647            }
648            other => panic!("expected Ambiguous driven by storage total (11), got {other:?}"),
649        }
650    }
651
652    // Regression for #908 / #970: an exact canonical-name ref resolves to a
653    // single id through the stage-3 exact-name lookup, which short-circuits
654    // BEFORE the stage-4 hybrid fallback and its `limit` bound. Many other
655    // entities are strong hybrid matches for the same term — enough that
656    // hybrid alone would rank them close together and return `Ambiguous` — yet
657    // the exact name resolves deterministically to its owner. The returned
658    // `EXACT_NAME_CONFIDENCE` (not the stage-4 `SEARCH_RESOLVED_CONFIDENCE`)
659    // proves the result came from stage 3, regardless of the target's hybrid
660    // rank. An exact name is an identity, not a ranked candidate.
661    #[tokio::test]
662    async fn exact_name_resolves_ahead_of_competing_hybrid_matches() {
663        let rt = KhiveRuntime::memory().expect("in-memory runtime");
664        let token = actor_token("resolver-test");
665        let ring = ReferenceRing::new();
666
667        let target = rt
668            .create_entity(
669                &token,
670                "concept",
671                None,
672                "ADR-040",
673                Some("khive ADR-040"),
674                None,
675                vec![],
676            )
677            .await
678            .expect("create target entity");
679
680        // Competitors that also match "ADR-040" strongly in hybrid search but
681        // do NOT carry it as an exact name; only the target owns the exact
682        // name, so only the target satisfies the stage-3 lookup.
683        for i in 0..12 {
684            rt.create_entity(
685                &token,
686                "concept",
687                None,
688                &format!("ADR-040 companion {i}"),
689                Some("ADR-040 ADR-040 ADR-040 ADR-040 ADR-040"),
690                None,
691                vec![],
692            )
693            .await
694            .expect("create competing entity");
695        }
696
697        // Even at limit = 1 — the tightest bound — the exact name resolves to
698        // the target, at the stage-3 confidence, never a ranked hybrid pick.
699        let resolution = resolve_reference(&rt, &ring, &token, "ADR-040", 1, None)
700            .await
701            .expect("resolve_reference");
702        assert_eq!(
703            resolution,
704            ReferenceResolution::Resolved {
705                id: target.id,
706                confidence: EXACT_NAME_CONFIDENCE,
707            }
708        );
709    }
710
711    // The complement of the exact-name contract: a NON-exact ref (no entity
712    // carries it as an exact name) that stays ambiguous returns a bounded
713    // sample capped at the caller's `limit`. Outside exact matches there is no
714    // oracle for a single "canonical" candidate, so the bound is the
715    // intentional, documented resolve() contract (raise `limit` to surface
716    // deeper ranks) — not a withheld identity. Any deterministic identity
717    // would have resolved upstream in stage 3.
718    #[tokio::test]
719    async fn fallback_stage_bounds_non_exact_payload_to_limit() {
720        let rt = KhiveRuntime::memory().expect("in-memory runtime");
721        let token = actor_token("resolver-test");
722        let ring = ReferenceRing::new();
723
724        // Twelve near-equal matches, each with a distinct name and the same
725        // descriptive text — none is the "right" answer, so the result is a
726        // genuinely bounded ambiguous sample, not a suppressed target.
727        for i in 0..12 {
728            rt.create_entity(
729                &token,
730                "concept",
731                None,
732                &format!("Retrieval Fusion Note {i}"),
733                Some("khive retrieval fusion ranking note"),
734                None,
735                vec![],
736            )
737            .await
738            .expect("create entity");
739        }
740
741        let resolution = resolve_reference(
742            &rt,
743            &ring,
744            &token,
745            "khive retrieval fusion ranking",
746            5,
747            None,
748        )
749        .await
750        .expect("resolve_reference");
751
752        match resolution {
753            ReferenceResolution::Ambiguous { candidates } => {
754                assert_eq!(
755                    candidates.len(),
756                    5,
757                    "a non-exact ref's ambiguity payload is bounded to the caller's limit"
758                );
759            }
760            other => panic!("expected a bounded Ambiguous sample, got {other:?}"),
761        }
762    }
763
764    // Regression for #908: genuine ambiguity — two entities with the exact
765    // same canonical name — must still return `Ambiguous`, never a silent
766    // pick, after widening stage 4's retrieval floor.
767    #[tokio::test]
768    async fn fallback_stage_still_reports_ambiguous_on_genuine_tie() {
769        let rt = KhiveRuntime::memory().expect("in-memory runtime");
770        let token = actor_token("resolver-test");
771        let ring = ReferenceRing::new();
772
773        let a = rt
774            .create_entity(
775                &token,
776                "concept",
777                None,
778                "Twin Record",
779                Some("khive Twin Record document"),
780                None,
781                vec![],
782            )
783            .await
784            .expect("create entity a");
785        let b = rt
786            .create_entity(
787                &token,
788                "concept",
789                None,
790                "Twin Record",
791                Some("khive Twin Record document"),
792                None,
793                vec![],
794            )
795            .await
796            .expect("create entity b");
797
798        // Exact byte-identical names hit stage 3 (exact-name storage
799        // lookup), not stage 4 — but the same "must not silently pick"
800        // contract applies at both stages, and stage 3 is a cheaper,
801        // deterministic way to pin it.
802        let resolution = resolve_reference(&rt, &ring, &token, "Twin Record", 5, None)
803            .await
804            .expect("resolve_reference");
805
806        match resolution {
807            ReferenceResolution::Ambiguous { candidates } => {
808                let ids: std::collections::HashSet<Uuid> =
809                    candidates.iter().map(|c| c.id).collect();
810                assert!(ids.contains(&a.id) && ids.contains(&b.id));
811            }
812            other => panic!("expected Ambiguous on a genuine name tie, got {other:?}"),
813        }
814    }
815
816    // Regression for #908: garbage input that matches nothing must still be
817    // `NotFound` after widening stage 4's retrieval floor — the widened pool
818    // must not turn "nothing relevant exists" into a spurious pick.
819    #[tokio::test]
820    async fn fallback_stage_still_not_found_on_garbage() {
821        let rt = KhiveRuntime::memory().expect("in-memory runtime");
822        let token = actor_token("resolver-test");
823        let ring = ReferenceRing::new();
824
825        rt.create_entity(
826            &token,
827            "concept",
828            None,
829            "ADR-040",
830            Some("khive ADR-040"),
831            None,
832            vec![],
833        )
834        .await
835        .expect("create unrelated entity");
836
837        let resolution = resolve_reference(
838            &rt,
839            &ring,
840            &token,
841            "zzqxw completely unrelated garbage nonsense",
842            5,
843            None,
844        )
845        .await
846        .expect("resolve_reference");
847        assert_eq!(resolution, ReferenceResolution::NotFound);
848    }
849}