Skip to content

feat(stella-context): domain overlap admits again, against a scope the query actually narrowed (#2333) - #2356

Merged
macanderson merged 5 commits into
mainfrom
fix/2333-domain-evidence-scope
Aug 8, 2026
Merged

feat(stella-context): domain overlap admits again, against a scope the query actually narrowed (#2333)#2356
macanderson merged 5 commits into
mainfrom
fix/2333-domain-evidence-scope

Conversation

@macanderson

@macanderson macanderson commented Aug 8, 2026

Copy link
Copy Markdown
Owner

What

Domain overlap becomes an admission channel again — this time against a scope the query itself narrowed, which is the property that was missing when it shipped and the reason it was removed.

A recall now carries two domain scopes (RecallScope):

scope source filters ranks admits
session the workspace vocabulary (Domains::names())
query the domains owning the files this goal named ✅, conditionally

Admission requires query to be a non-empty proper subset of session (evidence::scope_is_query_conditional).

Why the proper-subset condition is the whole fix

If the two scopes are equal, "shares a domain with the query" and "is in scope at all" are the same predicate — and the second is already the filter every candidate passed to get here. The rung would admit the entire in-scope corpus, which is precisely what it did: the session hands recall its whole vocabulary and nothing narrowed it, so the gate admitted every tagged node on every prompt, and reflection write-back tags every episode whose turn touched files. That was #2289 reopened at full width, and it is why PR #2298 removed the rung outright rather than patching it.

A scope must narrow to discriminate. Narrowing session instead was the tempting one-value version and is strictly worse: session also drives node_ids_excluded_by_scope, so a narrower value silently starts suppressing memories rather than merely declining to admit them. Hence two fields, not one.

How

  • RecallScope (crates/stella-context/src/retrieval/scope.rs) — the two-scope type. recall_scoped_excluding takes it; recall and recall_scoped stay as conveniences and document that they can never admit on domain, so a caller with no per-query scope gets the conservative behavior by construction rather than by remembering to ask.
  • Derivation (ScopedStore::query, crates/stella-cli/src/contextgraph.rs) — query_domain_scope maps the goal's anchors through Domains::domains_for_path. Anchors are already workspace-relative paths (goal_path_anchors), which is exactly what that function consumes, so no URI parsing is involved. An anchor in any other spelling matches no prefix and drops out — narrowing the scope less, which can only make the gate more reluctant to admit. That is the safe direction. ScopedStore now holds the Domains taxonomy rather than just its names; ContextQuery stays workspace-agnostic, so the wire contract is untouched.
  • No extra I/O. A query scope that narrows a non-empty session scope implies the corpus tag map was already loaded for the overlap ranking, so the evidence projection is a filter over rows in hand, never a second scan.

Witness

In the store — one corpus, one prompt, a single variable changed:

test query scope result
a_full_vocabulary_domain_scope_is_not_evidence the whole vocabulary 0 frames, no_evidence_cut 5
a_narrowed_domain_scope_is_evidence one domain of it exactly that domain's note-0, note-3; other three refused

No note shares a distinctive term with the prompt, so domain overlap is the only channel that can fire — these read the rung directly. The narrowed case is a true fail→pass witness: with the channel disabled it returns [] against the expected ["note-0", "note-3"], verified by neutering scope_is_query_conditional and re-running.

recall_scoped_alone_never_admits_on_domain pins the convenience API's posture. Four unit tests pin the predicate itself, including that repeats cannot fake a narrowing (set semantics, not list length) and that a scope reaching outside the vocabulary is not a subset.

In the CLI — six tests pin the derivation (query_domain_scope_derivation), including the sweeping goal that names a file in every domain and therefore narrows nothing. That degenerate case is pinned on both sides of the boundary deliberately: the two halves have to agree on it, and it is the exact predicate that shipped inverted.

File-size ratchet

RecallScope pushed retrieval.rs to 1519 lines, past the 1500 limit, and the file is not grandfathered — so it was split, not baselined (AGENTS.md § God files). 1444 now.

The split follows a real seam rather than the line count: retrieval::scope owns domain scoping entire — the type plus the two projections the corpus tag map feeds, overlap_ranking (ranks, against session) and evidence_ids (admits, against query). Putting them side by side is the point. They read the same map through different scopes, that asymmetry is the subtlest thing in retrieval, and it has already been got wrong once. Both are pure functions over loaded rows, matching the evidence and ranking siblings; the parent keeps the query that loads the map.

Docs

The three prose sites that describe the channels move together, as they must: the evidence.rs module doc, the require_evidence doc comment in crates/stella-cli/src/settings/context.rs, and docs/spec/adaptive-context/adaptive-context.md §6.1 — which now states the general rule rather than just this instance: admission is strictly narrower than ranking, and a predicate the query does not vary cannot be an admission rung.

Verification

  • cargo test -p stella-context — 175 passed, 0 failed.
  • cargo test -p stella-cli — 1500 unit + 12 integration binaries, 0 failed.
  • cargo clippy -p stella-context -p stella-cli --all-targets -- -D warnings — clean.
  • RUSTDOCFLAGS="-D warnings" cargo doc — clean (exit 0).
  • cargo fmt --all --check — clean.
  • make guards-fast — all guards green, check-file-size and check-module-reachability included.

Notes on CI

main is red independently of this branch. shellcheck fails on scripts/test-arena-scripts.sh (arrived via #2328/#2351), which reds every open PR whatever it changed. Filed as #2355 with the fix; deliberately not folded in here, since a shared red gate that several PRs each "helpfully" repair is its own failure mode. This diff touches no shell script.

Forward-checked against #2354. That PR (open) tightens doc-warnings to --document-private-items, which surfaces doc links inside pub(crate) items. Ran this branch under the stricter flag: zero errors in any code this PR adds or touchesscope.rs, evidence.rs, evidence_tests.rs, contextgraph.rs, and every added line of retrieval.rs are clean. The 9 that do fire are all pre-existing on main in files this PR does not touch (candidates.rs, ranking.rs, ann.rs, store/domain.rs, store/schema.rs, and retrieval.rs's own module doc), and are exactly what #2354 repairs. So this merges green in either order.

Closes #2333
Refs #2289

…e query actually narrowed

The evidence gate's domain rung was removed because the caller could not make
it query-conditional: a session hands recall its whole vocabulary, so
"overlaps the query's domains" degenerated to "carries any tag at all". This
restores the channel with the narrowing that makes it real.

A recall now carries two domain scopes (`RecallScope`), because they answer
different questions and cannot share a value without one becoming wrong. The
SESSION scope is the vocabulary: it filters out-of-scope nodes and ranks
overlap in the RRF, both correct uses of a value that does not vary per turn.
The QUERY scope is what this goal selected — the domains owning the workspace
files the goal named, derived in `ScopedStore::query` from the anchors
`goal_path_anchors` already computes. Only the query scope may admit.

Admission requires it to be a non-empty PROPER SUBSET of the session scope
(`evidence::scope_is_query_conditional`). That condition is the whole fix: if
the two scopes are equal, "shares a domain with the query" and "is in scope at
all" are the same predicate — and the second is already the filter every
candidate passed to get here, so the rung would admit the entire in-scope
corpus. A scope must narrow to discriminate.

Narrowing the session scope instead was the tempting one-value version and is
strictly worse: it also drives `node_ids_excluded_by_scope`, so a narrower
value silently starts SUPPRESSING memories rather than merely declining to
admit them. Hence two fields.

Witnessed on both sides of the boundary. In the store, one corpus and one
prompt with a single variable changed: `a_full_vocabulary_domain_scope_is_not_evidence`
(scope = vocabulary → 0 frames) against `a_narrowed_domain_scope_is_evidence`
(scope = one domain → exactly that domain's two notes, the other three
refused). The narrowed case returns [] with the channel disabled, so it is a
true fail→pass witness. In the CLI, six tests pin the derivation itself,
including the sweeping goal that reaches every domain and therefore narrows
nothing.

`recall_scoped` keeps taking a bare session scope and documents that it can
never admit on domain — a caller with no per-query scope gets the conservative
behavior by construction rather than by remembering to ask for it.

Closes #2333
Refs #2289
`RecallScope` is public and `scope_is_query_conditional` is `pub(crate)`, so
the link failed the rustdoc gate under `-D warnings`. Name it in backticks and
state the rule inline, which is what a reader of the public doc needs anyway.
`RecallScope` pushed `retrieval.rs` to 1519 lines, past the 1500-line ratchet,
and the file is not grandfathered — so it gets split, not baselined
(AGENTS.md § "God files"). 1444 lines now, with room.

The split is along a real seam rather than wherever the line count fell.
`retrieval::scope` owns everything about domain scoping: the two-scope type
and the two projections the corpus tag map feeds — `overlap_ranking` (which
ranks, against the session scope) and `evidence_ids` (which admits, against
the query scope). Putting them side by side is the point: they read the same
map through different scopes, the asymmetry is the subtlest thing in
retrieval, and it has already been got wrong once (#2289).

Both are pure functions over already-loaded rows, matching the idiom of the
sibling modules `evidence` and `ranking`; the parent keeps the one query that
loads the map.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
stella-cli-docs Ignored Ignored Preview Aug 8, 2026 10:20pm

@sourcery-ai

sourcery-ai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

This PR reintroduces domain-overlap as an admission channel by introducing a two-part recall scope (session vs per-query) and using the query-narrowed scope to derive domain evidence, while refactoring retrieval scoping into a new module, wiring the scope derivation through the CLI, and updating docs and tests accordingly.

Sequence diagram for domain-overlap ranking vs admission with RecallScope

sequenceDiagram
    actor User
    participant ScopedStore
    participant ContextStore
    participant recall_blocking
    participant scope
    participant evidence

    User->>ScopedStore: query()
    ScopedStore->>ScopedStore: query_domain_scope(domains, anchors)
    ScopedStore->>ScopedStore: RecallScope { session, query }
    ScopedStore->>ContextStore: recall_scoped_excluding(query, &RecallScope, &excluded_ids)

    ContextStore->>recall_blocking: recall_blocking(conn, q, excluded)

    recall_blocking->>scope: overlap_ranking(&metas, &scoped_domains, &query_domains)
    scope-->>recall_blocking: domain_ranked

    recall_blocking->>evidence: scope_is_query_conditional(&q.query_scope, &q.domains)
    alt [scope_is_query_conditional == true]
        recall_blocking->>scope: evidence_ids(&metas, &scoped_domains, &q.query_scope)
        scope-->>recall_blocking: domain_evidence
    else [scope_is_query_conditional == false]
        recall_blocking->>recall_blocking: domain_evidence = []
    end

    recall_blocking->>evidence: admissible_ids(&anchor_ids, &anchor_adjacent, &pass.distinctive_matchers(), &domain_evidence, &semantic_hits)
    evidence-->>recall_blocking: admissible

    recall_blocking-->>ContextStore: RecallResult
    ContextStore-->>ScopedStore: RecallResult
    ScopedStore-->>User: ContextUsage
Loading

File-Level Changes

Change Details Files
Introduce two-scope domain model for recall and use it to drive ranking vs admission separately.
  • Add RecallScope struct with separate session and query domain scopes and helper constructor
  • Export RecallScope from stella-context crate and update recall APIs to accept it
  • Extend RecallInputs with query_scope alongside domains to keep both scopes together
crates/stella-context/src/retrieval/scope.rs
crates/stella-context/src/retrieval.rs
crates/stella-context/src/lib.rs
Implement domain-overlap ranking and admission using shared tag map with proper subset guard.
  • Move domain-overlap ranking logic into scope::overlap_ranking over NodeMeta + tag map
  • Add scope::evidence_ids to compute domain-based evidence IDs from query scope
  • Introduce scope_is_query_conditional to require non-empty proper subset of session scope for domain evidence
  • Include domain_evidence in admissible_ids union and thread query_scope/domains into recall_blocking
crates/stella-context/src/retrieval/scope.rs
crates/stella-context/src/retrieval/evidence.rs
crates/stella-context/src/retrieval.rs
Derive per-query domain scope on the CLI side from goal anchors and pass full taxonomy to the store.
  • Change ScopedStore to hold Domains taxonomy instead of just domain names
  • Implement query_domain_scope to map workspace-relative anchors to domain names (sorted, deduped)
  • Use RecallScope{session: domains.names(), query: query_domain_scope(...)} in ScopedStore::query
  • Adjust memory_plane and session_host signatures and call sites to pass Domains instead of Vec
  • Update SessionMemory to clone Domains into session_host
crates/stella-cli/src/contextgraph.rs
crates/stella-cli/src/contextgraph/tests.rs
crates/stella-cli/src/memory.rs
Update tests to cover domain evidence behavior, query-scope derivation, and new RecallScope API.
  • Refactor domain-tagging test setup into reusable domain_tagged_store and vocabulary helpers
  • Add tests for full-vocabulary vs narrowed domain scopes and convenience recall_scoped behavior
  • Add unit tests for scope_is_query_conditional semantics (empty, proper subset, repeats, out-of-vocabulary)
  • Add CLI-side tests for query_domain_scope derivation including degenerate full-vocabulary case
  • Update existing retrieval and contextgraph tests to use RecallScope and Domains defaults where needed
crates/stella-context/src/retrieval/evidence_tests.rs
crates/stella-context/src/retrieval/evidence.rs
crates/stella-context/src/retrieval/tests.rs
crates/stella-cli/src/contextgraph/tests.rs
Document the domain-overlap channel, two-scope model, and evidence requirements across modules and spec.
  • Revise evidence.rs module docs to explain restored domain-overlap channel and two-scope behavior
  • Clarify comments around recall_scoped vs recall_scoped_excluding about ranking vs admission
  • Update RetrievalSettings.require_evidence docs to include domain-overlap as a possible evidence channel
  • Extend adaptive-context spec to describe session vs query domain scopes and proper-subset condition
crates/stella-context/src/retrieval/evidence.rs
crates/stella-context/src/retrieval.rs
crates/stella-cli/src/settings/context.rs
docs/spec/adaptive-context/adaptive-context.md

Assessment against linked issues

Issue Objective Addressed Explanation
#2333 Reintroduce domain overlap as an admission evidence channel using a per-query domain scope that fires only when the query scope is a non-empty proper subset of the session vocabulary, while keeping domain overlap as a ranking signal against the session scope.
#2333 Derive the per-query domain scope in the CLI from the goal’s anchors via Domains::domains_for_path, keep the session scope as the full workspace domain vocabulary, and pass both scopes to the store without changing the external ContextQuery wire type.
#2333 Maintain engine invariants (no extra I/O in evidence computation, refusals counted via no_evidence_cut) and update the documentation sites (evidence.rs module doc, require_evidence doc comment, and adaptive-context spec §6.1) to describe the new domain overlap admission behavior.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@macanderson
macanderson merged commit ad92643 into main Aug 8, 2026
11 of 12 checks passed
@macanderson
macanderson deleted the fix/2333-domain-evidence-scope branch August 8, 2026 22:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

context: reintroduce domain overlap as recall evidence, behind a real per-query scope

1 participant