Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions crates/khive-pack-kg/docs/api/resolve-verb.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ filter every real match out (#849) — `entities.kind` only ever holds a granula
baked into `query_entities`); two entities sharing an exact name resolve as `Ambiguous`,
mirroring the ring's contract.
4. **Hybrid search fallback** — a ref with no exact-name match falls through to hybrid search
at a confidence below the exact-name stage's 0.98. A ref matching nothing at any stage is
`NotFound`.
at a confidence below the exact-name stage's 0.98. The fallback searches deeper than the
caller's `limit` so a match ranked just outside a small `limit` is still ranked against the
alternatives. When the result stays ambiguous, the returned `candidates` are a bounded sample
capped at `limit`. That bound is intentional. Outside exact matches there is no oracle for a
single "canonical" candidate (an exact identity would have resolved at stage 3), so `resolve`
returns a bounded ambiguous sample instead of silently picking one, and raising `limit`
surfaces deeper-ranked matches. A ref matching nothing at any stage is `NotFound`.

`resolve`'s `kind` param is entity-only: a note kind is rejected with a clear error rather
than silently over-filtering to zero matches. `resolve` is registered as a public verb and
Expand Down
58 changes: 58 additions & 0 deletions crates/khive-pack-kg/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,64 @@ mod tests {
assert_eq!(candidates.len(), 2);
}

/// Stage 4 searches deeper than the caller-facing candidate limit so its
/// decisiveness check sees enough alternatives, but the serialized
/// ambiguity payload must still honor the requested (or default) limit.
#[tokio::test]
async fn resolve_stage4_ambiguous_candidates_respect_wire_limit() {
let rt = KhiveRuntime::memory().expect("in-memory runtime");
let token = rt.authorize(Namespace::local()).unwrap();

for i in 0..8 {
rt.create_entity(
&token,
"concept",
None,
&format!("Limit Candidate {i}"),
Some("stage four bounded ambiguity"),
None,
vec![],
)
.await
.expect("create candidate entity");
}

let mut builder = VerbRegistryBuilder::new();
builder.with_default_namespace("local");
builder.register(KgPack::new(rt));
let registry = builder.build().expect("registry build");

for (limit, expected) in [(Some(1_u32), 1_usize), (None, 5_usize)] {
let mut params = json!({"refs": ["stage four bounded ambiguity"]});
if let Some(limit) = limit {
params["limit"] = json!(limit);
}

let result = registry
.dispatch("resolve", params)
.await
.expect("resolve must succeed");
let resolution = &result
.get("results")
.and_then(Value::as_array)
.expect("resolve must return results")[0];
assert_eq!(
resolution.get("status").and_then(Value::as_str),
Some("ambiguous"),
"the deeper search pool must keep a tied result ambiguous"
);
let candidates = resolution
.get("candidates")
.and_then(Value::as_array)
.expect("ambiguous result must carry candidates");
assert_eq!(
candidates.len(),
expected,
"the serialized candidate count must honor limit={limit:?}"
);
}
}

/// A ref matching nothing in the ring or hybrid search is `NotFound`. See `docs/api/resolve-verb.md`.
#[tokio::test]
async fn resolve_not_found_when_nothing_matches() {
Expand Down
35 changes: 23 additions & 12 deletions crates/khive-pack-kg/src/handler_defs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -852,13 +852,20 @@ pub(crate) static KG_HANDLERS: [HandlerDef; 18] = [
HandlerDef {
name: "resolve",
description: "Resolve natural-language references to ids. Each ref in \
`refs` is resolved through: (1) id-string passthrough \
(UUID / 8+ hex prefix) via the existing by-ID path; \
(2) this actor's recently-referenced ring; (3) hybrid \
search over the namespace. Returns one of \
Resolved{id,confidence} | Ambiguous{candidates} | \
NotFound per ref — never a silent pick among close \
candidates. Read-only: performs no mutation.",
`refs` is resolved through, in order: (1) id-string \
passthrough (UUID / 8+ hex prefix) via the by-ID path; \
(2) this actor's recently-referenced ring; (3) an exact, \
case-sensitive entity-name match, which resolves \
deterministically regardless of search rank (one match \
-> Resolved; several identically-named entities -> \
Ambiguous over exactly that set); (4) hybrid search over \
the namespace. Returns one of Resolved{id,confidence} | \
Ambiguous{candidates} | NotFound per ref — never a silent \
pick among close candidates. For a non-exact ref that \
stays ambiguous, `candidates` is a bounded sample capped \
at `limit` (raise `limit` to surface deeper-ranked \
matches); an exact-name match is an identity and is \
exempt from that bound. Read-only: performs no mutation.",
visibility: Visibility::Verb,
category: VerbCategory::Assertive,
params: &[
Expand All @@ -874,16 +881,20 @@ pub(crate) static KG_HANDLERS: [HandlerDef; 18] = [
name: "kind",
param_type: "string",
required: false,
description: "Restrict the hybrid-search fallback (stage 3) \
to an entity kind (e.g. \"concept\", \"project\"). \
Has no effect on the id-string or ring stages.",
description: "Restrict the exact-name (stage 3) and \
hybrid-search (stage 4) stages to an entity kind \
(e.g. \"concept\", \"project\"). Has no effect on \
the id-string or ring stages.",
},
ParamDef {
name: "limit",
param_type: "integer",
required: false,
description: "Max candidates returned per ref from the \
hybrid-search fallback. Default 5, max 20.",
description: "Max candidates in a non-exact ref's Ambiguous \
payload from the hybrid-search fallback; raise it \
to surface deeper-ranked matches. An exact-name \
match resolves to a single id and ignores this \
bound. Default 5, max 20.",
},
],
},
Expand Down
Loading
Loading