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}