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
16 changes: 16 additions & 0 deletions crates/khive-pack-memory/src/handlers/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,22 @@ pub(super) struct RecallParams {
/// a silent fallback to defaults.
#[serde(default)]
pub(super) profile_id: Option<String>,
/// ADR-007 Rev 6 §"multi-record ops default to local + explicit escape"
/// (#733): exact-match read-namespace override. When absent, recall reads
/// the caller token's namespace (which defaults to `local`) — byte-identical
/// to pre-#733 behavior. When present, the candidate fetch (FTS + vector +
/// the ANN over-fetch retry loop) is scoped to exactly this namespace
/// instead of the token's (possibly wider) visible-namespace set. Invalid
/// values are rejected via the same `Namespace::parse` machinery used
/// elsewhere — never silently coerced.
///
/// This is normally already pre-applied by `VerbRegistry::dispatch`'s
/// Rule-3 explicit-namespace escape (the token this handler receives
/// already carries `visible=[namespace]` in that path) — this field's
/// handling below is defense-in-depth for direct (non-dispatch) callers,
/// mirroring `RememberParams::namespace` / `handle_remember`.
#[serde(default)]
pub(super) namespace: Option<String>,
}

impl RecallParams {
Expand Down
803 changes: 801 additions & 2 deletions crates/khive-pack-memory/src/handlers/recall.rs

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions crates/khive-pack-memory/src/handlers/sub_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,20 @@ impl MemoryPack {
});

if candidates.vector_hits_per_model.len() > 1 {
// Review finding (#733 fix-round 1, High): same fix as
// `handle_recall`'s verbose multi-model breakdown — the global
// per-model ANN candidate lists are pre-hydration and must be
// filtered through `memory_ids` (the visible-namespace
// post-filter already applied to `vector_candidates` above)
// before serialization, or this diagnostic view can leak
// off-namespace candidate UUIDs.
let per_model: serde_json::Map<String, Value> = candidates
.vector_hits_per_model
.iter()
.map(|(model, hits)| {
let hits_json: Vec<Value> = hits
.iter()
.filter(|h| memory_ids.contains(&h.subject_id))
.map(|h| {
json!({
"id": h.subject_id.to_string(),
Expand Down
5 changes: 5 additions & 0 deletions crates/khive-pack-memory/src/handlers/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ fn effective_config_uses_defaults() {
entity_names: None,
full_content: None,
profile_id: None,
namespace: None,
};
let cfg = p.effective_config(RecallConfig::default());
assert!((cfg.relevance_weight - 0.70).abs() < 1e-12);
Expand All @@ -93,6 +94,7 @@ fn effective_config_legacy_overrides() {
entity_names: None,
full_content: None,
profile_id: None,
namespace: None,
};
let cfg = p.effective_config(RecallConfig::default());
assert!((cfg.min_score - 0.5).abs() < 1e-12);
Expand Down Expand Up @@ -121,6 +123,7 @@ fn effective_config_explicit_config_wins() {
entity_names: None,
full_content: None,
profile_id: None,
namespace: None,
};
let cfg = p.effective_config(RecallConfig::default());
assert!((cfg.relevance_weight - 0.50).abs() < 1e-12);
Expand Down Expand Up @@ -153,6 +156,7 @@ fn test_weighted_strategy_preserves_pack_weights() {
entity_names: None,
full_content: None,
profile_id: None,
namespace: None,
};

let mut cfg = p.effective_config(base);
Expand Down Expand Up @@ -208,6 +212,7 @@ fn test_weighted_strategy_from_rrf_config_uses_vector_heavy_defaults() {
entity_names: None,
full_content: None,
profile_id: None,
namespace: None,
};

let mut cfg = p.effective_config(base);
Expand Down
6 changes: 6 additions & 0 deletions crates/khive-pack-memory/src/pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ static MEMORY_HANDLERS: [HandlerDef; 10] = [
required: false,
description: "Tag filter mode: \"any\" (OR, default) or \"all\" (AND). Only applies when tags is non-empty.",
},
ParamDef {
name: "namespace",
param_type: "string",
required: false,
description: "Exact-match read-namespace override (ADR-007 Rev 6 escape hatch). When absent, reads the caller's default visible namespace set (unchanged default behavior). When present, scopes the candidate fetch to exactly this namespace; invalid values are rejected.",
},
],
},
HandlerDef {
Expand Down
2 changes: 1 addition & 1 deletion crates/khive-runtime/src/pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2041,7 +2041,7 @@ pub fn resolve_explicit_namespace(
None => Namespace::parse(default_namespace)
.map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}"))),
Some(Value::String(ns_str)) => Namespace::parse(ns_str)
.map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}"))),
.map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace {ns_str:?}: {e}"))),
Some(other) => Err(RuntimeError::InvalidInput(format!(
"invalid namespace: expected string when present, got {}",
json_type_name(other),
Expand Down
79 changes: 79 additions & 0 deletions docs/benchmarks/recall-arm-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Isolating a recall measurement arm

`memory.recall` supports an exact-match `namespace` parameter (issue #733) that
scopes the candidate fetch — FTS, vector search, and the ANN over-fetch retry
loop — to exactly one namespace, instead of the caller's default visible set.
Combined with `memory.remember`'s existing `namespace` write override and
`memory.recall`'s `profile_id` serving-profile override (ADR-104 §4), this is
enough to run an isolated A/B measurement arm without touching production
data: write to a scratch namespace, read from that same namespace, and pin
the serving profile so scoring weights don't drift mid-measurement.

## Recipe

1. **Pick an arm namespace.** A valid namespace is one or more segments
separated by a single `:`, each segment matching `[a-zA-Z0-9\-_.]+` (no
spaces, no empty segments, no trailing `:`; `::` is rejected — it produces
an empty segment between the two colons). Prefix it so it is obviously
scratch data, e.g. `bench-arm-a`.

2. **Write the arm's corpus** with an explicit namespace override:

```text
memory.remember(content="...", namespace="bench-arm-a")
memory.remember(content="...", namespace="bench-arm-a")
```

Every memory written this way lands in `bench-arm-a` regardless of the
caller's actor or default namespace.

3. **(Optional) create and pin a serving profile** for the arm, so the same
posterior state serves every read in the measurement window:

```text
brain.create_profile(namespace="bench-arm-a", name="bench-arm-a-recall-v1", consumer_kind="recall")
```

4. **Read back through the same namespace**, with the profile pinned via the
ADR-104 `profile_id` override (bypasses binding resolution, so no
`brain.bind` is required for a scratch arm):

```text
memory.recall(query="...", namespace="bench-arm-a", profile_id="bench-arm-a-recall-v1")
```

`namespace` here is an exact match, not a widened visible set — the
candidate fetch never sees memories from any other namespace, including
`local`. An invalid namespace string is a hard per-op error, not a silent
fallback.

5. **Tear down** by deleting the arm's memories (`delete(id="...",
hard=true)` — one call per memory id; `delete` takes `id`/`kind`/`hard`,
not a `type=` param) once the measurement is done, or simply let the arm
namespace age out unread — it costs nothing beyond the storage of its own
rows.

## What is NOT yet isolated

- **Feedback events.** `brain.auto_feedback` / `brain.feedback` write into the
_profile's_ live posterior state, not the namespace. If a measurement arm
pins an existing (non-scratch) profile via `profile_id`, feedback recorded
during the arm still trains that profile's posteriors going forward — there
is no namespace-scoped posterior isolation. Use a freshly created,
arm-specific profile (step 3) to avoid contaminating a shared profile's
state. Tracked as issue #733 remainder.
- **`knowledge.compose`.** Compose does scope section loading to the caller's
token namespace (`WHERE namespace = ?1`) — it is not global. What it lacks
is _this feature_: there is no `namespace=` exact-match parameter on
compose analogous to the one this slice adds to `memory.recall`, and no
`profile_id`-style pinning either. A measurement arm cannot point compose
at a specific scratch namespace independent of the caller's token the way
it can for recall; compose calls in an arm are scoped by whatever namespace
the caller is already authorized for, not covered by recall's new
exact-match parameter.
- **The serve ledger.** `brain.record_serve` stamps the _effective_ namespace
used for the fetch (the arm namespace when `namespace=` was passed), so
ledger rows are attributable to the arm — but the ledger itself is a single
shared table, not partitioned per arm; querying it for arm-specific
analysis means filtering by namespace after the fact, not scoping the
write.
Loading