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
173 changes: 108 additions & 65 deletions docs/en/rfcs/1560_recall_sufficiency_gate.md

Large diffs are not rendered by default.

140 changes: 86 additions & 54 deletions docs/zh/rfcs/1560_recall_sufficiency_gate.md

Large diffs are not rendered by default.

13 changes: 7 additions & 6 deletions src/powercontext/builtin/artifacts/memory/fusion.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
MemoryHit,
MemoryMatchedBy,
)
from powercontext.builtin.artifacts.search import AdmissionFloor, admits_fts_text
from powercontext.builtin.artifacts.search import AdmissionFloor, admits_fts_text, unit_l2_cosine_similarity

_RRF_CONSTANT = 60
_MIN_SEMANTIC_SIMILARITY = 0.3
Expand Down Expand Up @@ -56,14 +56,10 @@ def admit_vector_candidates(
return tuple(
candidate
for candidate in candidates
if candidate.distance is not None and _unit_l2_cosine_similarity(candidate.distance) >= baseline
if candidate.distance is not None and unit_l2_cosine_similarity(candidate.distance) >= baseline
)


def _unit_l2_cosine_similarity(distance: float) -> float:
return max(-1.0, min(1.0, 1.0 - distance**2 / 2.0))


def fuse_rankings(
*,
fts: Sequence[MemoryChannelHit],
Expand All @@ -78,6 +74,7 @@ def fuse_rankings(
candidates: dict[_HitIdentity, MemoryChannelHit] = {}
scores: dict[_HitIdentity, float] = {}
channels: dict[_HitIdentity, set[MemoryMatchedBy]] = {}
relevance: dict[_HitIdentity, float] = {}

for channel, ranking in (("fts", fts), ("vector", vector)):
seen: set[_HitIdentity] = set()
Expand All @@ -89,6 +86,9 @@ def fuse_rankings(
candidates.setdefault(identity, candidate)
scores[identity] = scores.get(identity, 0.0) + 1.0 / (_RRF_CONSTANT + rank)
channels.setdefault(identity, set()).add(channel)
if channel == "vector" and candidate.distance is not None:
similarity = unit_l2_cosine_similarity(candidate.distance)
relevance[identity] = max(relevance.get(identity, -1.0), similarity)

ordered = sorted(
candidates,
Expand All @@ -107,6 +107,7 @@ def fuse_rankings(
text=candidates[identity].text,
score=scores[identity],
matched_by=tuple(channel for channel in ("fts", "vector") if channel in channels[identity]),
relevance=relevance.get(identity),

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.

I walked through this and think the current behavior is the defensible one, not a loss: a candidate that gains an admitted vector channel only under the lowered floor always has cosine below the round-zero baseline (0.3), and _score_signals uses only at-or-above-baseline scores for the gap and can only lower top_score with below-baseline values. Promoting such a scope to scored would risk exactly the wrong outcome - converting a recoverable thin-candidates case into a terminal weak-top-1 on a 0.25 cosine. The RFC states this rule explicitly ("lower-ranked vector hits cannot turn an FTS-only first hit into a weak score"), and the accumulated candidate order keeps round 0 first, so the first-hit rule stays invariant to expansion order (there is an e2e test pinning exactly that). If the admitted-pool top cosine is ever surfaced to the gate (the separate fusion-limit gap), that would be the point to revisit this too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — this matches the intended invariant: a vector row that only survives the lowered floor is by construction below the 0.3 baseline, so promoting it to scored would invert a recoverable thin-candidates round into weak-top-1 on a below-threshold cosine. No change needed; the per-Scope first-hit rule and the ordering e2e test pin this.

)
for identity in ordered
)
Expand Down
1 change: 1 addition & 0 deletions src/powercontext/builtin/artifacts/memory/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ class MemoryHit(BaseModel):
text: str
score: float
matched_by: tuple[MemoryMatchedBy, ...]
relevance: float | None = Field(default=None, ge=-1.0, le=1.0, allow_inf_nan=False, exclude=True)


class MemoryRerankTrace(BaseModel):
Expand Down
15 changes: 10 additions & 5 deletions src/powercontext/builtin/artifacts/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Shared deterministic lexical analysis for built-in Artifact projections."""
"""Shared deterministic lexical and vector admission for built-in Artifact projections."""

from __future__ import annotations

Expand Down Expand Up @@ -107,10 +107,8 @@ class AdmissionFloor:
module and ``0.3`` mirrored by ``memory/fusion.py`` and ``topic_memory/fusion.py`` — so
that a ``floor=None`` / ``admission=None`` call reproduces today's behaviour bit for bit.

This type plays the ``RecallAdmissionPolicy`` role described by RFC 1560: it is the value
threaded into each searchable family's search to override its floor. Passing ``None``
(the historical default) is therefore equivalent to the RFC's ``RecallAdmissionPolicy()``
with both overrides unset, which is exactly what round 0 does.
RFC 1560 threads this value into each searchable family's search to override its floor.
Round zero passes ``None`` to keep the historical defaults.
"""

lexical_coverage: float = _FTS_MIN_QUERY_COVERAGE
Expand Down Expand Up @@ -182,6 +180,12 @@ def lexical_search_score(raw: object, metric: object, /) -> tuple[float, Channel
)


def unit_l2_cosine_similarity(distance: float) -> float:
"""Convert L2 distance between unit vectors to cosine similarity."""

return max(-1.0, min(1.0, 1.0 - distance**2 / 2.0))


@dataclass(frozen=True)
class AdmissionCounts:
"""Per-family, per-scope admission accounting for one search.
Expand Down Expand Up @@ -370,4 +374,5 @@ def _is_cjk(character: str) -> bool:
"fts_match_query",
"fts_query_requirements",
"lexical_search_score",
"unit_l2_cosine_similarity",
]
15 changes: 14 additions & 1 deletion src/powercontext/builtin/artifacts/topic_memory/fusion.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
analyze_text,
analyze_text_with_spans,
lexical_search_score,
unit_l2_cosine_similarity,
)
from powercontext.builtin.artifacts.topic_memory.models import (
TopicMemoryChannelHit,
Expand Down Expand Up @@ -134,6 +135,7 @@ def _fuse_topic_memory_rankings(
matched_by=tuple(channel for channel in _CHANNEL_ORDER if channel in evidence[hit.key]),
retrieval_score=hit.score,
channel_scores=_channel_scores(evidence[hit.key]) if include_scores else None,
relevance=_relevance(evidence[hit.key]),
)
for hit in selected
),
Expand All @@ -155,6 +157,17 @@ def _channel_scores(evidence: dict[str, TopicMemoryChannelHit]) -> dict[str, Cha
return result


def _relevance(evidence: dict[str, TopicMemoryChannelHit]) -> float | None:
"""Return the best cosine similarity among the admitted vector channels, if any."""

similarities = [
unit_l2_cosine_similarity(hit.distance)
for name, hit in evidence.items()
if name.endswith("_vector") and hit.distance is not None
]
return max(similarities, default=None)


def _retrieved_count(
channels: TopicMemorySearchChannels,
channel: TopicMemoryMatchedBy,
Expand Down Expand Up @@ -216,7 +229,7 @@ def admits_topic_memory_vector_distance(
"""

baseline = _MIN_SEMANTIC_SIMILARITY if floor is None else floor.min_semantic_similarity
return max(-1.0, min(1.0, 1.0 - distance**2 / 2.0)) >= baseline
return unit_l2_cosine_similarity(distance) >= baseline


def _snippet(query: str, value: str, *, lexical: bool) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ class TopicMemorySearchHit(BaseModel):
matched_by: tuple[TopicMemoryMatchedBy, ...]
retrieval_score: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False, exclude=True)
channel_scores: dict[str, ChannelScore] | None = Field(default=None, exclude=True)
relevance: float | None = Field(default=None, ge=-1.0, le=1.0, allow_inf_nan=False, exclude=True)


@dataclass(frozen=True)
Expand Down
Loading
Loading