diff --git a/.gitignore b/.gitignore index 8ec8fa1d95..9ba229d6b3 100644 --- a/.gitignore +++ b/.gitignore @@ -273,3 +273,6 @@ integrations/pi/plugins/powercontext/*.tgz # Local acceptance databases and redacted run artifacts .artifacts/ + +# pnpm package store is a local cache, never committed. +.pnpm-store/ diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 82cee3a518..66f6f16b80 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "7aa91ae9af78300e7bdbcc2e8b2135da5749469290a1aee67bc255c3e9a07b0c", + "contractSha256": "fdc752737cc446c96a1482debcde927860e1ce30539b7a647a78c8a64f94b112", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 1ed9b6b95a..1ac0bc081b 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -2218,6 +2218,7 @@ export interface components { }; AtomicMemorySearchHit: { memory: components["schemas"]["AtomicMemoryRecord"]; + /** @description Raw RRF score. Hybrid mode applies the deployment's normalized relative FTS/vector weights. */ score: number; matched_by: ("text" | "vector")[]; }; @@ -4091,6 +4092,7 @@ export interface components { title: string; summary: string; snippet: string | null; + /** @description Weighted RRF relevance normalized to the 0..100 range. */ score: number; matched_by: components["schemas"]["TopicMemoryMatchedBy"][]; }; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index ea299aa267..2fcb2939a4 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -15,7 +15,7 @@ */ // Generated from openapi/powercontext.yaml. Do not edit. -export const contractSha256 = "7aa91ae9af78300e7bdbcc2e8b2135da5749469290a1aee67bc255c3e9a07b0c"; +export const contractSha256 = "fdc752737cc446c96a1482debcde927860e1ce30539b7a647a78c8a64f94b112"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/operate/configuration.md b/docs/en/docs/operate/configuration.md index 5ef38ec13a..222b3b8d21 100644 --- a/docs/en/docs/operate/configuration.md +++ b/docs/en/docs/operate/configuration.md @@ -133,6 +133,8 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_RUNTIME_RECALL_GATE_ROUND1_MIN_SEMANTIC_SIMILARITY` | `0.15` | Semantic-similarity admission floor for the first expansion round; must not exceed the round-zero `0.3` | | `POWERCONTEXT_SERVER_RUNTIME_RECALL_GATE_ROUND2_MIN_SEMANTIC_SIMILARITY` | `0.10` | Semantic-similarity admission floor for the second expansion round; must not exceed the first round's value | | `POWERCONTEXT_SERVER_RUNTIME_RECALL_GATE_ALLOW_WITH_RERANK` | `false` | Whether expansion may still run when `MEMORY_RERANK_ENABLED` is set; by default reranking ends the expansion | +| `POWERCONTEXT_SERVER_RUNTIME_RECALL_FTS_WEIGHT` | `1.0` | Relative FTS contribution in hybrid Atomic Memory and Topic Memory RRF; `0` excludes FTS-only matches from hybrid results | +| `POWERCONTEXT_SERVER_RUNTIME_RECALL_VECTOR_WEIGHT` | `1.0` | Relative vector contribution in hybrid Atomic Memory and Topic Memory RRF; `0` excludes vector-only matches from hybrid results | | `POWERCONTEXT_SERVER_RUNTIME_MEMORY_SCHEDULE_SECONDS` | unset | Memory automatic admission interval; `SCHEDULE_SECONDS` remains a compatibility alias | | `POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_SCHEDULE_SECONDS` | unset | Topic Memory automatic admission interval; unset disables new automatic admission | | `POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_SOURCE_WINDOW_LIMIT` | `10` | Maximum Sources per Topic Memory Window, capped at 100; one Scope invocation can finish several Windows | @@ -191,6 +193,23 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` | unset | Experience automatic admission interval; unset preserves accepted work and stops new automatic admission | | `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | automatic local project targets | JSON override containing the host identity and explicit Agent Skill targets | +The recall channel weights are a ratio and apply to Atomic Memory and Topic Memory only after hybrid candidates pass +their channel admission floors. Single-channel searches remain unweighted; `auto` applies the weights only when it +selects hybrid search. Atomic Memory excludes zero-weight channels from `matched_by` and recall-gate relevance. +Topic Memory retains their evidence for Topics selected by a positive-weight channel. +Scaling both values equally has no effect. Setting one value to `0` excludes that channel from hybrid fusion, but a +higher weight cannot restore a candidate rejected before fusion. A zero weight does not disable that channel's +retrieval or admission work: vector embeddings may still be generated, and retrieval/admission counts still include +the zero-weight channel. A Topic Artifact search request that supplies its own `fusion` parameters replaces the +deployment recall weights entirely. The deployment weights apply only when the request does not specify `fusion` +parameters. After normalization, each non-zero channel weight must be at least the smallest normal IEEE 754 binary64 +value (`2.2250738585072014e-308`); smaller values are rejected at startup. Only an explicit `0` disables a channel's +fusion contribution. Evaluate weight changes together with recall-gate rounds, semantic admission floors, and +historical Topic candidate selection instead of treating the knobs independently. In particular, weighting FTS more +heavily can move a Topic's normalized score across +`POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_HISTORY_RRF_THRESHOLD` (default `70`) and change which historical Topics +are sent to the planner. + The recall-sufficiency gate is disabled by default. When enabled, the Runtime assesses the first recall's candidate count, family coverage, top-candidate score, and lexical coverage during `prepare_context`; when it judges the result insufficient, it may expand up to two more rounds and relaxes the semantic-similarity admission floor each round. The diff --git a/docs/en/rfcs/0014_memory_layer_design.md b/docs/en/rfcs/0014_memory_layer_design.md index 26a94ca627..cb79b1c892 100644 --- a/docs/en/rfcs/0014_memory_layer_design.md +++ b/docs/en/rfcs/0014_memory_layer_design.md @@ -775,12 +775,14 @@ Candidates below either threshold are not restored merely to fill `limit`. The a backend's internal order within each channel, and then use reciprocal rank fusion: ```text -rrf_score(candidate) = Σ 1 / (60 + rank_in_channel) +hybrid_rrf_score(candidate) = Σ channel_weight / (60 + rank_in_channel) ``` -`rank_in_channel` starts at 1. Single-channel `fts` and `vector` modes use the same one-channel formula to produce their -public score. A candidate rejected by or absent from one channel receives a contribution only from the other, and -`matched_by` contains only admitting channels. The OceanBase adapter +`rank_in_channel` starts at 1. The deployment's non-negative FTS/vector weights are relative: PowerContext normalizes +them to a mean of one, defaults both to `1.0`, and requires at least one to be positive. They apply only to `hybrid`; +single-channel `fts` and `vector` modes keep the unweighted `1 / (60 + rank)` public score. A zero-weight channel does +not contribute candidates or appear in `matched_by`. A candidate rejected by or absent from one channel receives a +contribution only from the other. The OceanBase adapter may use native `HYBRID_SEARCH` in version 4.6+ as an optimization, but it must preserve the same active-head filtering, RRF parameters, and stable tie-breaks externally. The 4.3.5 baseline performs RRF in the application. SQLite performs the same application-level RRF over FTS5 and Vec1 results. Equal RRF scores are broken by ascending diff --git a/docs/en/rfcs/1417_topic_memory.md b/docs/en/rfcs/1417_topic_memory.md index 78485b0b26..6e454a1de9 100644 --- a/docs/en/rfcs/1417_topic_memory.md +++ b/docs/en/rfcs/1417_topic_memory.md @@ -432,7 +432,12 @@ windows. Changing the Chunk policy requires rebuilding the corresponding retriev Each retrieval channel first collapses by Topic. When multiple Detail chunks from one Topic match, only the best position is retained for the snippet. The two or four channels enabled for the current deployment are fused by RRF rather than by comparing raw full-text scores with raw vector distances. A Topic occupies at most one final result -position, and `matched_by` records which channels matched it. The per-channel candidate limit applies only after this +position, and `matched_by` records every admitted channel that matched the selected Topic. A zero-weight channel makes +no ranking contribution and cannot select a Topic by itself, but its evidence remains in `matched_by` when another +positive-weight channel selects the same Topic. Hybrid mode applies the deployment's normalized relative FTS/vector +weights to both Topic and Detail channels of the corresponding kind. FTS-only and vector-only modes remain unweighted. +The weighted theoretical maximum for the enabled channels is the denominator that keeps the public score normalized +to `0..100`. The per-channel candidate limit applies only after this Topic collapse, so one Topic with many matching chunks cannot hide another Topic. FTS snippets center a bounded window on an Analyzer v1 query match; a vector-only detail hit uses a stable window from its matched chunk. The first-release search limit and historical candidate configuration are both bounded to 20. Search queries are diff --git a/docs/zh/docs/operate/configuration.md b/docs/zh/docs/operate/configuration.md index 53eedca403..413335e210 100644 --- a/docs/zh/docs/operate/configuration.md +++ b/docs/zh/docs/operate/configuration.md @@ -124,6 +124,8 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_RUNTIME_RECALL_GATE_ROUND1_MIN_SEMANTIC_SIMILARITY` | `0.15` | 第一轮追加搜索使用的语义相似度准入下限;启用时不得高于首轮的 `0.3` | | `POWERCONTEXT_SERVER_RUNTIME_RECALL_GATE_ROUND2_MIN_SEMANTIC_SIMILARITY` | `0.10` | 第二轮追加搜索使用的语义相似度准入下限;启用时不得高于第一轮的值 | | `POWERCONTEXT_SERVER_RUNTIME_RECALL_GATE_ALLOW_WITH_RERANK` | `false` | 已启用 `MEMORY_RERANK_ENABLED` 时是否仍允许追加搜索;默认在 rerank 之后不再追加 | +| `POWERCONTEXT_SERVER_RUNTIME_RECALL_FTS_WEIGHT` | `1.0` | hybrid Atomic Memory 和 Topic Memory RRF 中 FTS 的相对贡献;`0` 会从 hybrid 结果中排除仅命中 FTS 的候选 | +| `POWERCONTEXT_SERVER_RUNTIME_RECALL_VECTOR_WEIGHT` | `1.0` | hybrid Atomic Memory 和 Topic Memory RRF 中 vector 的相对贡献;`0` 会从 hybrid 结果中排除仅命中 vector 的候选 | | `POWERCONTEXT_SERVER_RUNTIME_MEMORY_SCHEDULE_SECONDS` | 未设置 | Memory 自动准入间隔;`SCHEDULE_SECONDS` 保留为兼容别名 | | `POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_SCHEDULE_SECONDS` | 未设置 | Topic Memory 自动准入间隔;未设置时不接纳新的自动调用 | | `POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_SOURCE_WINDOW_LIMIT` | `10` | 每个 Topic Memory Window 的 Source 数量上限,硬上限为 100;一次 Scope 调用可完成多个 Window | @@ -182,6 +184,17 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` | 未设置 | Experience 自动准入间隔;未设置时保留已接受工作,停止新的自动准入 | | `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | 自动生成本机项目 target | 覆盖默认值的 host identity 和显式 Agent Skill targets JSON object | +召回通道权重表示相对比例,应用于 Atomic Memory 和 Topic Memory,只在 hybrid 候选通过各通道准入下限后生效。 +单通道搜索保持无权重行为;`auto` 只有在选择 hybrid 搜索时才应用权重。Atomic Memory 的 `matched_by` 和 recall gate +相关度不包含零权重通道;Topic Memory 则为正权重通道选中的 Topic 保留零权重通道的证据。 +同比例缩放两个值不会改变结果。将其中一个设为 +`0` 会从 hybrid 融合中排除该通道,但提高权重不能恢复融合前已被拒绝的候选。零权重不会关闭该通道的检索和准入工作: +vector 查询仍可能生成 embedding,检索与准入计数也仍包含零权重通道。如果 Topic Artifact 搜索请求显式提供自己的 `fusion` +参数,它会完整取代部署级召回权重;只有请求未指定 `fusion` 时,部署级权重才生效。归一化后,每个非零通道权重必须不低于 IEEE 754 binary64 +最小正规数(`2.2250738585072014e-308`),更小的值会在启动时被拒绝。只有显式配置 `0` 才表示关闭该通道的融合贡献。调整权重时应同时评估 +recall gate 轮次、语义准入下限和历史 Topic 候选选择,不要将这些配置孤立调整。特别是,提高 FTS 权重可能使 Topic 的归一化分数跨过 +`POWERCONTEXT_SERVER_RUNTIME_TOPIC_MEMORY_HISTORY_RRF_THRESHOLD`(默认为 `70`),从而改变传给规划器的历史 Topic 集合。 + 召回充分性门控默认关闭。启用后,Runtime 在 `prepare_context` 阶段评估首轮候选的数量、来源家族覆盖、最优候选分数和词法 覆盖;判定为不足时最多追加两轮搜索,并在每轮放宽候选准入的语义相似度下限。门控判断本身不调用模型,追加轮次沿用同一请求的 Scope、家族、条数限制和上下文预算,并复用已经生成的查询向量。第一轮和第二轮的语义相似度下限必须保持递减顺序,违反该顺序 diff --git a/docs/zh/rfcs/0014_memory_layer_design.md b/docs/zh/rfcs/0014_memory_layer_design.md index 5019394209..903eb910b2 100644 --- a/docs/zh/rfcs/0014_memory_layer_design.md +++ b/docs/zh/rfcs/0014_memory_layer_design.md @@ -680,11 +680,13 @@ MVP 定义四种 mode: 低于阈值的候选不会为了填满 `limit` 而恢复。准入后的候选分别保持 backend 内部排序,再使用 reciprocal rank fusion: ```text -rrf_score(candidate) = Σ 1 / (60 + rank_in_channel) +hybrid_rrf_score(candidate) = Σ channel_weight / (60 + rank_in_channel) ``` -`rank_in_channel` 从 1 开始;单独的 `fts` 或 `vector` mode 也用单通道公式生成公开 score。被某通道拒绝或未被该通道 -召回的候选只贡献另一通道分数,`matched_by` 也只包含实际准入的通道。OceanBase adapter 可以使用 4.6+ 的原生 +`rank_in_channel` 从 1 开始。部署的 FTS/vector 权重为非负相对比例:PowerContext 会将其归一化到平均值 +为 1,默认均为 `1.0`,并要求至少一个为正数。权重只影响 `hybrid`;单独的 `fts` 或 `vector` mode 仍以 +`1 / (60 + rank)` 生成公开 score。零权重通道不产生候选,也不出现在 `matched_by` 中。被某通道拒绝或未被该通道召回的 +候选只贡献另一通道分数。OceanBase adapter 可以使用 4.6+ 的原生 `HYBRID_SEARCH` 优化,但对外必须保持 同样的 active-head 过滤、RRF 参数和稳定 tie-break;4.3.5 baseline 使用应用层 RRF。SQLite 使用 FTS5 与 Vec1 结果执行 相同的应用层 RRF。相同 RRF score 按 `memory_artifact_id`、UTF-8 `entry_id`、`entry_version_id` 升序打破平局。 diff --git a/docs/zh/rfcs/1417_topic_memory.md b/docs/zh/rfcs/1417_topic_memory.md index 92e0a9ef37..e6a5df3a4a 100644 --- a/docs/zh/rfcs/1417_topic_memory.md +++ b/docs/zh/rfcs/1417_topic_memory.md @@ -383,7 +383,10 @@ Chunk 策略变化需要重建对应检索投影。 每个检索通道先按 Topic collapse,同一 Topic 的多个 detail 命中只保留最佳位置用于 snippet。当前部署启用的 两个或四个通道通过 RRF 排名融合,不比较全文分数和向量距离的原始数值。最终一个 Topic 最多占一个结果位置,并保留 -`matched_by` 说明命中通道。每个通道都先按 Topic 折叠,再应用候选上限,因此单个 Topic 的大量命中 chunk 不会 +`matched_by` 说明被选中 Topic 通过准入的所有命中通道。零权重通道不贡献排名分数,也无法单独选中 Topic;但当同一 Topic +被其他正权重通道选中时,该通道的证据仍保留在 `matched_by` 中。`hybrid` mode 对对应类型的 Topic 和 Detail 通道同时应用部署级 +归一化 FTS/vector 相对权重,单独的 FTS 和 vector mode 仍不加权。开启通道的加权理论最大值作为归一化分母,使公开 score 保持在 +`0..100`。每个通道都先按 Topic 折叠,再应用候选上限,因此单个 Topic 的大量命中 chunk 不会 遮蔽其他 Topic。全文 snippet 以 Analyzer v1 查询命中为中心截取有界窗口;只有向量命中时,则稳定截取其命中 chunk 内部的窗口。 首版搜索 limit 与历史候选配置统一限制为 20。查询最多 8,192 个字符、64 个不同的 Analyzer term;构造后端 diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index 6ff7a30035..041e16d87a 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -1231,7 +1231,8 @@ "$ref": "#/components/schemas/AtomicMemoryRecord" }, "score": { - "type": "number" + "type": "number", + "description": "Raw RRF score. Hybrid mode applies the deployment's normalized relative FTS/vector weights." }, "matched_by": { "type": "array", diff --git a/integrations/dify/plugin/tools/pc_search.yaml b/integrations/dify/plugin/tools/pc_search.yaml index 4d116c5a7d..6419e3d818 100644 --- a/integrations/dify/plugin/tools/pc_search.yaml +++ b/integrations/dify/plugin/tools/pc_search.yaml @@ -162,6 +162,8 @@ output_schema: score: $ref: '#/definitions/Result/properties/hits/items/properties/score' type: number + description: Raw RRF score. Hybrid mode applies the deployment's normalized + relative FTS/vector weights. matched_by: $ref: '#/definitions/Result/properties/hits/items/properties/matched_by' type: array @@ -249,6 +251,8 @@ output_schema: - type: 'null' score: type: number + description: Raw RRF score. Hybrid mode applies the deployment's normalized + relative FTS/vector weights. matched_by: type: array items: diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index f5ece75347..e5e6427ced 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -5141,6 +5141,7 @@ components: $ref: '#/components/schemas/AtomicMemoryRecord' score: type: number + description: Raw RRF score. Hybrid mode applies the deployment's normalized relative FTS/vector weights. matched_by: type: array items: @@ -10205,6 +10206,8 @@ components: score: type: number minimum: 0 + maximum: 100 + description: Weighted RRF relevance normalized to the 0..100 range. matched_by: type: array minItems: 1 diff --git a/src/powercontext/builtin/artifacts/memory/fusion.py b/src/powercontext/builtin/artifacts/memory/fusion.py index bebe70d585..ccb0d8e0c0 100644 --- a/src/powercontext/builtin/artifacts/memory/fusion.py +++ b/src/powercontext/builtin/artifacts/memory/fusion.py @@ -23,7 +23,12 @@ MemoryHit, MemoryMatchedBy, ) -from powercontext.builtin.artifacts.search import AdmissionFloor, admits_fts_text, unit_l2_cosine_similarity +from powercontext.builtin.artifacts.search import ( + AdmissionFloor, + RecallChannelWeights, + admits_fts_text, + unit_l2_cosine_similarity, +) _RRF_CONSTANT = 60 _MIN_SEMANTIC_SIMILARITY = 0.3 @@ -65,8 +70,13 @@ def fuse_rankings( fts: Sequence[MemoryChannelHit], vector: Sequence[MemoryChannelHit], limit: int, + channel_weights: RecallChannelWeights | None = None, ) -> tuple[MemoryHit, ...]: - """Fuse ordered channel candidates with reciprocal rank fusion.""" + """Fuse ordered channel candidates with reciprocal rank fusion. + + ``channel_weights=None`` is the historical unweighted behavior used by the + single-channel modes. A caller supplies weights only for hybrid search. + """ if limit < 1: raise ValueError("memory search limit must be positive") # noqa: TRY003 @@ -76,7 +86,14 @@ def fuse_rankings( channels: dict[_HitIdentity, set[MemoryMatchedBy]] = {} relevance: dict[_HitIdentity, float] = {} + weights: dict[MemoryMatchedBy, float] = { + "fts": 1.0 if channel_weights is None else channel_weights.fts, + "vector": 1.0 if channel_weights is None else channel_weights.vector, + } for channel, ranking in (("fts", fts), ("vector", vector)): + weight = weights[channel] + if weight == 0.0: + continue seen: set[_HitIdentity] = set() for rank, candidate in enumerate(ranking, start=1): identity = _identity(candidate) @@ -84,7 +101,7 @@ def fuse_rankings( continue seen.add(identity) candidates.setdefault(identity, candidate) - scores[identity] = scores.get(identity, 0.0) + 1.0 / (_RRF_CONSTANT + rank) + scores[identity] = scores.get(identity, 0.0) + weight / (_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) @@ -107,6 +124,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]), + score_upper_bound=sum(weights[channel] for channel in channels[identity]) / (_RRF_CONSTANT + 1), relevance=relevance.get(identity), ) for identity in ordered diff --git a/src/powercontext/builtin/artifacts/memory/models.py b/src/powercontext/builtin/artifacts/memory/models.py index 9f97261715..323b529b21 100644 --- a/src/powercontext/builtin/artifacts/memory/models.py +++ b/src/powercontext/builtin/artifacts/memory/models.py @@ -159,7 +159,11 @@ class MemoryChannelHit(BaseModel): class MemoryHit(BaseModel): - """A fused retrieval result anchored to exact Memory content.""" + """A fused retrieval result anchored to exact Memory content. + + ``score_upper_bound`` is in-process fusion metadata used by the recall gate. It is + excluded from serialization and is not part of the public Memory hit contract. + """ memory_ref: ArtifactRef entry_id: str @@ -167,6 +171,7 @@ class MemoryHit(BaseModel): text: str score: float matched_by: tuple[MemoryMatchedBy, ...] + score_upper_bound: float | None = Field(default=None, gt=0.0, allow_inf_nan=False, exclude=True) relevance: float | None = Field(default=None, ge=-1.0, le=1.0, allow_inf_nan=False, exclude=True) diff --git a/src/powercontext/builtin/artifacts/search.py b/src/powercontext/builtin/artifacts/search.py index 6d80bc2524..2676a83251 100644 --- a/src/powercontext/builtin/artifacts/search.py +++ b/src/powercontext/builtin/artifacts/search.py @@ -18,6 +18,7 @@ import math import re +import sys import unicodedata from dataclasses import dataclass from itertools import pairwise @@ -30,6 +31,9 @@ _FTS_MIN_MATCHED_TERMS = 2 _FTS_SHORT_QUERY_MAX_TERMS = 2 _MIN_SEMANTIC_SIMILARITY = 0.3 +# Reject subnormal normalized weights. This conservative boundary leaves +# headroom for RRF division and prevents more extreme ratios from underflowing. +_MIN_NORMALIZED_RECALL_CHANNEL_WEIGHT = sys.float_info.min # Query-only normalization: persisted Analyzer v1 projections remain unchanged. Negations # are deliberately absent; domain constraints such as "without a backup" remain evidence. @@ -186,6 +190,49 @@ def unit_l2_cosine_similarity(distance: float) -> float: return max(-1.0, min(1.0, 1.0 - distance**2 / 2.0)) +@dataclass(frozen=True) +class RecallChannelWeights: + """Normalized relative weights for lexical and semantic recall channels. + + Inputs express a ratio. The stored weights have a mean of one, so scaling both + configured values by the same factor cannot change a public RRF score. + """ + + fts: float = 1.0 + vector: float = 1.0 + + def __post_init__(self) -> None: + if isinstance(self.fts, bool) or isinstance(self.vector, bool): + raise TypeError("recall channel weights must be numbers") # noqa: TRY003 + if not math.isfinite(self.fts) or not math.isfinite(self.vector): + raise ValueError("recall channel weights must be finite") # noqa: TRY003 + if self.fts < 0.0 or self.vector < 0.0: + raise ValueError("recall channel weights must be non-negative") # noqa: TRY003 + total = self.fts + self.vector + if not math.isfinite(total): + raise ValueError("recall channel weight total must be finite") # noqa: TRY003 + if total <= 0.0: + raise ValueError("at least one recall channel weight must be positive") # noqa: TRY003 + # Divide each input before scaling so a finite, positive subnormal total + # cannot overflow through an intermediate ``2.0 / total`` value. + normalized_fts = 2.0 * (self.fts / total) + normalized_vector = 2.0 * (self.vector / total) + if not math.isfinite(normalized_fts) or not math.isfinite(normalized_vector): + raise ValueError("normalized recall channel weights must be finite") # noqa: TRY003 + if (self.fts > 0.0 and normalized_fts < _MIN_NORMALIZED_RECALL_CHANNEL_WEIGHT) or ( + self.vector > 0.0 and normalized_vector < _MIN_NORMALIZED_RECALL_CHANNEL_WEIGHT + ): + raise ValueError( # noqa: TRY003 + "normalized non-zero recall channel weights must be at least " + "the smallest normal IEEE 754 binary64 value" + ) + object.__setattr__(self, "fts", normalized_fts) + object.__setattr__(self, "vector", normalized_vector) + + +DEFAULT_RECALL_CHANNEL_WEIGHTS = RecallChannelWeights() + + @dataclass(frozen=True) class AdmissionCounts: """Per-family, per-scope admission accounting for one search. diff --git a/src/powercontext/builtin/persistence/atomic_memory_index.py b/src/powercontext/builtin/persistence/atomic_memory_index.py index e452658620..4cb2cbb00a 100644 --- a/src/powercontext/builtin/persistence/atomic_memory_index.py +++ b/src/powercontext/builtin/persistence/atomic_memory_index.py @@ -32,6 +32,7 @@ from powercontext.builtin.artifacts.search import ( DEFAULT_ADMISSION_FLOOR, AdmissionFloor, + RecallChannelWeights, analyze_text, fts_query_requirements, ) @@ -41,6 +42,7 @@ from powercontext.builtin.tags import TagFilter AtomicMemorySearchMode = Literal["fts", "vector", "hybrid"] +ATOMIC_MEMORY_RRF_CONSTANT = 60 # Columns of unreleased development layouts; their tables are derived and rebuilt rather than migrated. _OBSOLETE_PROJECTION_COLUMNS = frozenset({"read_grants", "owner_type", "owner_id"}) @@ -402,7 +404,12 @@ def atomic_memory_channel_hits(rows: Any, /, *, vector: bool = False) -> tuple[A ) -def combine_atomic_memory_channels(channels: AtomicMemorySearchChannels, /) -> tuple[AtomicMemoryIndexHit, ...]: +def combine_atomic_memory_channels( + channels: AtomicMemorySearchChannels, + /, + *, + channel_weights: RecallChannelWeights | None = None, +) -> tuple[AtomicMemoryIndexHit, ...]: """Return exact-ref union with the RRF score used for its stable ordering. Vector distance remains independent of the fused ranking score. Revision or @@ -412,17 +419,24 @@ def combine_atomic_memory_channels(channels: AtomicMemorySearchChannels, /) -> t versions: dict[str, tuple[int, int]] = {} hits: dict[str, AtomicMemoryIndexHit] = {} ranks: dict[str, float] = {} + # Validate all retrieved identities, including evidence from a zero-weight channel. for channel in (channels.fts, channels.vector): - for rank, hit in enumerate(channel): + for hit in channel: artifact_id = hit.artifact_ref.artifact_id version = (hit.artifact_ref.revision, hit.state_version) if artifact_id in versions and versions[artifact_id] != version: raise AtomicMemoryIndexError("stale-recall", "Atomic Memory changed between retrieval channels") versions[artifact_id] = version + weights = (1.0, 1.0) if channel_weights is None else (channel_weights.fts, channel_weights.vector) + for channel, weight in zip((channels.fts, channels.vector), weights, strict=True): + if weight == 0.0: + continue + for rank, hit in enumerate(channel, start=1): + artifact_id = hit.artifact_ref.artifact_id previous = hits.setdefault(artifact_id, hit) if previous.distance is None and hit.distance is not None: hits[artifact_id] = replace(previous, distance=hit.distance) - ranks[artifact_id] = ranks.get(artifact_id, 0.0) + 1.0 / (60 + rank + 1) + ranks[artifact_id] = ranks.get(artifact_id, 0.0) + weight / (ATOMIC_MEMORY_RRF_CONSTANT + rank) return tuple( replace(hits[identity], score=ranks[identity]) for identity in sorted(hits, key=lambda identity: (-ranks[identity], identity)) @@ -613,6 +627,7 @@ def validate_request(request: AtomicMemorySearchRequest | AtomicMemoryRelatedReq __all__ = [ + "ATOMIC_MEMORY_RRF_CONSTANT", "AtomicMemoryIndex", "AtomicMemoryIndexCapabilities", "AtomicMemoryIndexError", diff --git a/src/powercontext/builtin/persistence/topic_memory.py b/src/powercontext/builtin/persistence/topic_memory.py index 504b6a1fdb..75a4969808 100644 --- a/src/powercontext/builtin/persistence/topic_memory.py +++ b/src/powercontext/builtin/persistence/topic_memory.py @@ -25,10 +25,18 @@ from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.artifacts import ArtifactAddress, ArtifactRef +from powercontext.artifacts.fusion import RrfParameters from powercontext.artifacts.search import ArtifactSearchContractError, ArtifactSearchMatch from powercontext.builtin.artifacts.memory import EmbeddingProfile from powercontext.builtin.artifacts.memory.canonical import canonical_embedding -from powercontext.builtin.artifacts.search import AdmissionCounts, AdmissionFloor, analyze_fts_query, analyze_text +from powercontext.builtin.artifacts.search import ( + DEFAULT_RECALL_CHANNEL_WEIGHTS, + AdmissionCounts, + AdmissionFloor, + RecallChannelWeights, + analyze_fts_query, + analyze_text, +) from powercontext.builtin.artifacts.topic_memory import ( MAX_TOPIC_MEMORY_CHANNEL_CANDIDATES, MAX_TOPIC_MEMORY_QUERY_LENGTH, @@ -80,9 +88,11 @@ def __init__( *, artifacts: ArtifactRepository | None = None, index: TopicMemoryIndex | None = None, + recall_channel_weights: RecallChannelWeights = DEFAULT_RECALL_CHANNEL_WEIGHTS, ) -> None: self.artifacts = ArtifactRepository((TopicMemory,)) if artifacts is None else artifacts self.index = NoTopicMemoryIndex() if index is None else index + self.recall_channel_weights = recall_channel_weights async def initialize(self, connection: AsyncConnection, /, *, configure_retrieval_shape: bool = True) -> None: """Initialize indexes and reject incomplete historical Topic projections. @@ -472,11 +482,9 @@ async def search( ) analyzed = analyze_text(query) used_mode = self._select_mode(mode, query_vector, embedding_profile) - fusion = None if artifact_request is not None: - fusion = topic_fusion_parameters(artifact_request.fusion) - validate_topic_weights(used_mode, fusion) admission = artifact_request.admission.as_floor() + fusion = self._search_fusion_parameters(used_mode, artifact_request) query_terms = tuple(sorted(set(analyzed.split()))) if used_mode in {"fts", "hybrid"} and len(query_terms) > MAX_TOPIC_MEMORY_QUERY_TERMS: raise InvalidRepositoryArgumentError( @@ -788,6 +796,29 @@ def _select_mode( raise TopicMemoryCapabilityError(selected) return selected + def _search_fusion_parameters( + self, + mode: TopicMemoryUsedSearchMode, + request: TopicArtifactSearchRequest | None, + ) -> RrfParameters | None: + """Use an explicit request policy or the deployment's hybrid default.""" + + if request is not None and request.fusion is not None: + params = topic_fusion_parameters(request.fusion) + elif mode == "hybrid": + params = RrfParameters( + weights={ + "topic_fts": self.recall_channel_weights.fts, + "detail_fts": self.recall_channel_weights.fts, + "topic_vector": self.recall_channel_weights.vector, + "detail_vector": self.recall_channel_weights.vector, + } + ) + else: + return None + validate_topic_weights(mode, params) + return params + def _canonical_query_vector( self, mode: TopicMemoryUsedSearchMode, diff --git a/src/powercontext/builtin/runtime/atomic_memory.py b/src/powercontext/builtin/runtime/atomic_memory.py index fbe12d3ecc..f79cb78fcb 100644 --- a/src/powercontext/builtin/runtime/atomic_memory.py +++ b/src/powercontext/builtin/runtime/atomic_memory.py @@ -42,8 +42,10 @@ from powercontext.builtin.artifacts.memory.models import MemoryQueryEmbedding from powercontext.builtin.artifacts.memory.reranking import MemoryReranker from powercontext.builtin.artifacts.search import ( + DEFAULT_RECALL_CHANNEL_WEIGHTS, AdmissionCounts, AdmissionFloor, + RecallChannelWeights, analyze_text, unit_l2_cosine_similarity, ) @@ -57,6 +59,7 @@ from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.atomic_memory import AtomicMemoryStateRepository, atomic_memory_state_expression from powercontext.builtin.persistence.atomic_memory_index import ( + ATOMIC_MEMORY_RRF_CONSTANT, AtomicMemoryIndex, AtomicMemoryIndexError, AtomicMemoryIndexFilter, @@ -105,8 +108,11 @@ class AtomicMemoryPage: @dataclass(frozen=True, slots=True) class AtomicMemorySearchHit: + """Exact retrieval evidence with an in-process bound for normalized RRF captures.""" + hit: AtomicMemoryIndexHit matched_by: tuple[str, ...] + score_upper_bound: float | None = None @property def text(self) -> str: @@ -116,7 +122,11 @@ def text(self) -> str: def relevance(self) -> float | None: """Cosine similarity of the vector channel, when this hit was vector-matched.""" - return None if self.hit.distance is None else unit_l2_cosine_similarity(self.hit.distance) + return ( + None + if "vector" not in self.matched_by or self.hit.distance is None + else unit_l2_cosine_similarity(self.hit.distance) + ) @dataclass(frozen=True, slots=True) @@ -156,12 +166,14 @@ def __init__( preview_signer: AtomicMemoryPreviewSigner | None = None, reranker: MemoryReranker | None = None, rerank_candidate_limit: int = 30, + recall_channel_weights: RecallChannelWeights = DEFAULT_RECALL_CHANNEL_WEIGHTS, prompt_context_factory=None, ) -> None: if reranker is not None and not getattr(reranker, "supports_atomic_memory", False): raise BaseOperationNotSupportedError("artifact_family", "atomic-memory", "Atomic text candidate contract") self.reranker = reranker self.rerank_candidate_limit = rerank_candidate_limit + self.recall_channel_weights = recall_channel_weights self.prompt_context_factory = prompt_context_factory self.database = database self.artifacts = artifacts @@ -325,6 +337,7 @@ async def search( mode, vector, query_embedding, embedding_calls = await self._resolve_query_embedding( query, mode, query_embedding, embedding_timeout_seconds, allow_embedding ) + channel_weights = application.recall_channel_weights if mode == "hybrid" else None profile = application.index.capabilities.embedding_profile if vector is not None else None # Inference finishes before the business snapshot opens. Builtin policy # reads share it; configured providers retain their own read boundary. @@ -351,28 +364,36 @@ async def search( admission=admission, ) channels = await application.index.search(connection, self.scope_id, request) - hits = combine_atomic_memory_channels(channels)[: request.limit] + hits = combine_atomic_memory_channels(channels, channel_weights=channel_weights)[: request.limit] await self._validate_search_hits(connection, hits, verify_vectors=vector is not None) recoverable = recovery_admission is not None and await application.index.probe_recoverable( connection, self.scope_id, request, recovery_admission ) fts = {_artifact_key(item.artifact_ref) for item in channels.fts} vectors = {_artifact_key(item.artifact_ref) for item in channels.vector} - candidates = tuple( - AtomicMemorySearchHit( - hit, - tuple( - channel - for channel, refs in (("text", fts), ("vector", vectors)) - if _artifact_key(hit.artifact_ref) in refs - ), + weights = { + "text": 1.0 if channel_weights is None else channel_weights.fts, + "vector": 1.0 if channel_weights is None else channel_weights.vector, + } + candidates: list[AtomicMemorySearchHit] = [] + for hit in hits: + matched_by = tuple( + channel + for channel, refs in (("text", fts), ("vector", vectors)) + if weights[channel] > 0.0 and _artifact_key(hit.artifact_ref) in refs + ) + candidates.append( + AtomicMemorySearchHit( + hit, + matched_by, + score_upper_bound=sum(weights[channel] for channel in matched_by) + / (ATOMIC_MEMORY_RRF_CONSTANT + 1), + ) ) - for hit in hits - ) page = await self._rerank( query, mode, - candidates, + tuple(candidates), limit, query_embedding if vector is not None else None, embedding_calls, diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index e251ea2557..b8015b896c 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -1048,6 +1048,7 @@ async def open_builtin_contexts( memory_write_gate=memory_write_gate, decision_observation_retention_days=config.runtime.decision_observation_retention_days, memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, + recall_channel_weights=config.runtime.recall_channel_weights, prompt_registry=prompt_registry, prompt_demonstrators=prompt_demonstrators, handoff_verification_keys=handoff_verification_keys, @@ -1121,6 +1122,7 @@ async def open_builtin_contexts( memory_reranker=memory_reranker, decision_model=decision_model, memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, + recall_channel_weights=config.runtime.recall_channel_weights, prompt_registry=prompt_registry, prompt_demonstrators=prompt_demonstrators, handoff_verification_keys=handoff_verification_keys, diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index 03b2795fdd..d2bb909eee 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -33,6 +33,7 @@ ) from powercontext.builtin.artifacts.memory.prompts import MemoryExtractionProfile +from powercontext.builtin.artifacts.search import RecallChannelWeights from powercontext.builtin.artifacts.skill import AgentSkillTarget, CodexSkillRoot from powercontext.builtin.artifacts.topic_memory import MAX_TOPIC_MEMORY_SEARCH_LIMIT from powercontext.builtin.artifacts.topic_memory.generation import ( @@ -176,6 +177,8 @@ def validate_memory_capacity_order(self) -> RuntimeConfig: recall_gate_round1_min_semantic_similarity: float = Field(default=0.15, ge=0.0, le=1.0) recall_gate_round2_min_semantic_similarity: float = Field(default=0.10, ge=0.0, le=1.0) recall_gate_allow_with_rerank: bool = False + recall_fts_weight: float = Field(default=1.0, ge=0.0, allow_inf_nan=False) + recall_vector_weight: float = Field(default=1.0, ge=0.0, allow_inf_nan=False) profile_schedule_enabled: bool = False profile_cron: str = "0 2 * * *" profile_timezone: str = "Asia/Shanghai" @@ -205,6 +208,24 @@ def validate_recall_gate_threshold_order(self): raise ValueError(_RECALL_GATE_ROUND2_ORDER_ERROR) return self + @field_validator("recall_fts_weight", "recall_vector_weight", mode="before") + @classmethod + def reject_boolean_recall_weight(cls, value: Any) -> Any: + if isinstance(value, bool): + raise ValueError("recall channel weights must be numbers") # noqa: TRY003, TRY004 - Pydantic validation + return value + + @model_validator(mode="after") + def validate_recall_channel_weights(self) -> RuntimeConfig: + RecallChannelWeights(fts=self.recall_fts_weight, vector=self.recall_vector_weight) + return self + + @property + def recall_channel_weights(self) -> RecallChannelWeights: + """Return the deployment's normalized FTS/vector recall ratio.""" + + return RecallChannelWeights(fts=self.recall_fts_weight, vector=self.recall_vector_weight) + schedule_seconds: float | None = Field(default=None, gt=0) memory_schedule_seconds: float | None = Field(default=None, gt=0) experience_schedule_seconds: float | None = Field(default=None, gt=0) diff --git a/src/powercontext/builtin/runtime/recall_sufficiency.py b/src/powercontext/builtin/runtime/recall_sufficiency.py index 395f9ac047..41522c7390 100644 --- a/src/powercontext/builtin/runtime/recall_sufficiency.py +++ b/src/powercontext/builtin/runtime/recall_sufficiency.py @@ -456,8 +456,13 @@ def build_recall_candidates( def _normalize_memory_score(hit: AtomicMemorySearchHit) -> float: """Normalize fused RRF ranking against its per-channel upper bound, not a probability.""" - channels = max(1, len(hit.matched_by)) - upper_bound = channels / (_RRF_CONSTANT + 1) + upper_bound = hit.score_upper_bound + if upper_bound is None: + # Compatibility fallback for unweighted or externally reconstructed hits. + # The internal weighted bound is excluded from serialization, so an in-process + # weighted fusion must supply it; the exact weighted scale cannot be recovered here. + channels = max(1, len(hit.matched_by)) + upper_bound = channels / (_RRF_CONSTANT + 1) if upper_bound <= 0.0: return 0.0 return max(0.0, min(1.0, hit.hit.score / upper_bound)) diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 6e0ab08457..73708495a6 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -80,7 +80,11 @@ current_prompt, prompt_operation, ) -from powercontext.builtin.artifacts.search import AdmissionFloor +from powercontext.builtin.artifacts.search import ( + DEFAULT_RECALL_CHANNEL_WEIGHTS, + AdmissionFloor, + RecallChannelWeights, +) from powercontext.builtin.artifacts.skill import ( ExternalSkillProvider, ExternalSkillRegistryUnavailableError, @@ -480,6 +484,7 @@ def __init__( memory_write_gate: MemoryWriteGate | None = None, decision_observation_retention_days: int | None = None, memory_rerank_candidate_limit: int = 30, + recall_channel_weights: RecallChannelWeights = DEFAULT_RECALL_CHANNEL_WEIGHTS, id_factory: IdFactory | None = None, handoff_artifact_id: str = "handoff", source_registry: SourceDefinitionRegistry | None = None, @@ -536,13 +541,18 @@ def __init__( embedding_model=embedding_model, reranker=memory_reranker, rerank_candidate_limit=memory_rerank_candidate_limit, + recall_channel_weights=recall_channel_weights, prompt_context_factory=lambda scope_id: ScopedPrompts(self.prompts, scope_id), cursor_secret=cursor_secret, id_factory=id_factory, preview_signer=preview_signer, restore_retry_budget=atomic_memory_restore_retry_budget, ) - topic_memory_repository = TopicMemoryRepository(artifacts=artifact_repository, index=self.topic_memory_index) + topic_memory_repository = TopicMemoryRepository( + artifacts=artifact_repository, + index=self.topic_memory_index, + recall_channel_weights=recall_channel_weights, + ) self.repositories = _Repositories( sources=source_repository, artifacts=artifact_repository, diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 503de9b187..07143c90c0 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -3286,7 +3286,12 @@ class AtomicMemorySearchHit(BaseModel): extra="forbid", ) memory: AtomicMemoryRecord - score: StrictFloat + score: Annotated[ + StrictFloat, + Field( + description="Raw RRF score. Hybrid mode applies the deployment's normalized relative FTS/vector weights." + ), + ] matched_by: list[MatchedByEnum] @@ -4131,7 +4136,9 @@ class SearchTopicMemoryHit(BaseModel): title: Annotated[StrictStr, Field(max_length=512, min_length=1)] summary: Annotated[StrictStr, Field(max_length=8000, min_length=1)] snippet: Annotated[StrictStr | None, Field(...)] - score: Annotated[StrictFloat, Field(ge=0.0)] + score: Annotated[ + StrictFloat, Field(description="Weighted RRF relevance normalized to the 0..100 range.", ge=0.0, le=100.0) + ] matched_by: Annotated[list[TopicMemoryMatchedBy], Field(min_length=1)] diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 8976d14280..181424e848 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -5185,7 +5185,21 @@ "AtomicMemorySearchHit": { "properties": { "memory": {"$ref": "#/components/schemas/AtomicMemoryRecord"}, - "score": {"type": "number"}, + "score": { + "type": "number", + "description": "Raw " + "RRF " + "score. " + "Hybrid " + "mode " + "applies " + "the " + "deployment's " + "normalized " + "relative " + "FTS/vector " + "weights.", + }, "matched_by": {"items": {"type": "string", "enum": ["text", "vector"]}, "type": "array"}, }, "additionalProperties": False, @@ -9568,7 +9582,12 @@ "title": {"type": "string", "maxLength": 512, "minLength": 1}, "summary": {"type": "string", "maxLength": 8000, "minLength": 1}, "snippet": {"type": "string", "nullable": True}, - "score": {"type": "number", "minimum": 0.0}, + "score": { + "type": "number", + "maximum": 100.0, + "minimum": 0.0, + "description": "Weighted RRF relevance normalized to the 0..100 range.", + }, "matched_by": { "items": {"$ref": "#/components/schemas/TopicMemoryMatchedBy"}, "type": "array", diff --git a/tests/builtin/artifacts/memory/test_fusion.py b/tests/builtin/artifacts/memory/test_fusion.py index a7a9f0a98e..ffc4230dc1 100644 --- a/tests/builtin/artifacts/memory/test_fusion.py +++ b/tests/builtin/artifacts/memory/test_fusion.py @@ -23,7 +23,7 @@ admit_vector_candidates, fuse_rankings, ) -from powercontext.builtin.artifacts.search import AdmissionFloor +from powercontext.builtin.artifacts.search import AdmissionFloor, RecallChannelWeights def channel_hit( @@ -82,6 +82,49 @@ def test_duplicate_channel_rows_contribute_only_the_first_rank() -> None: assert hits[0].score == pytest.approx(1 / 61) +@pytest.mark.parametrize( + ("fts_weight", "vector_weight", "expected_order"), + [ + (3.0, 1.0, ("lexical", "semantic")), + (1.0, 3.0, ("semantic", "lexical")), + ], +) +def test_hybrid_weights_prefer_the_higher_weight_channel( + fts_weight: float, + vector_weight: float, + expected_order: tuple[str, str], +) -> None: + lexical = channel_hit("lexical") + semantic = channel_hit("semantic") + + hits = fuse_rankings( + fts=(lexical, semantic), + vector=(semantic, lexical), + limit=2, + channel_weights=RecallChannelWeights(fts=fts_weight, vector=vector_weight), + ) + + assert tuple(hit.entry_id for hit in hits) == expected_order + assert hits[0].score_upper_bound == pytest.approx(2 / 61) + + +def test_zero_weight_excludes_only_that_channel_and_its_matches() -> None: + lexical_only = channel_hit("lexical-only") + semantic_only = channel_hit("semantic-only") + + hits = fuse_rankings( + fts=(lexical_only,), + vector=(semantic_only,), + limit=2, + channel_weights=RecallChannelWeights(fts=0.0, vector=1.0), + ) + + assert tuple(hit.entry_id for hit in hits) == ("semantic-only",) + assert hits[0].matched_by == ("vector",) + assert hits[0].score == pytest.approx(2 / 61) + assert hits[0].score_upper_bound == pytest.approx(2 / 61) + + def test_limit_must_be_positive() -> None: with pytest.raises(ValueError, match="positive"): fuse_rankings(fts=(), vector=(), limit=0) diff --git a/tests/builtin/artifacts/topic_memory/test_fusion.py b/tests/builtin/artifacts/topic_memory/test_fusion.py index de556943cb..f719c1085e 100644 --- a/tests/builtin/artifacts/topic_memory/test_fusion.py +++ b/tests/builtin/artifacts/topic_memory/test_fusion.py @@ -17,6 +17,7 @@ import pytest from powercontext.artifacts import ArtifactRef +from powercontext.artifacts.fusion import RrfParameters from powercontext.builtin.artifacts.topic_memory import ( TopicMemoryChannelHit, TopicMemoryMatchedBy, @@ -199,6 +200,85 @@ def test_rrf_scores_are_normalized_against_the_enabled_first_place_channels() -> assert all_channels[0].score == pytest.approx(100.0) +@pytest.mark.parametrize( + ("fts_weight", "vector_weight", "expected_order"), + [ + (3.0, 1.0, ("lexical", "semantic")), + (1.0, 3.0, ("semantic", "lexical")), + ], +) +def test_hybrid_weights_prefer_the_higher_weight_channel_without_saturating_scores( + fts_weight: float, + vector_weight: float, + expected_order: tuple[str, str], +) -> None: + lexical = TopicMemoryChannelHit( + artifact_ref=ArtifactRef(family="topic-memory", artifact_id="lexical", revision=1), + title="Lexical topic", + summary="needle", + channel="topic_fts", + ) + semantic = TopicMemoryChannelHit( + artifact_ref=ArtifactRef(family="topic-memory", artifact_id="semantic", revision=1), + title="Semantic topic", + summary="needle", + channel="topic_vector", + distance=0.1, + ) + + result = _fuse_topic_memory_rankings( + "needle", + TopicMemorySearchChannels(topic_fts=(lexical,), topic_vector=(semantic,)), + 2, + fusion=RrfParameters( + weights={ + "topic_fts": fts_weight, + "detail_fts": fts_weight, + "topic_vector": vector_weight, + "detail_vector": vector_weight, + } + ), + ).hits + + assert tuple(hit.artifact_ref.artifact_id for hit in result) == expected_order + assert result[0].score == pytest.approx(37.5) + assert result[1].score == pytest.approx(12.5) + assert all(0.0 < hit.score < 100.0 for hit in result) + + +def test_zero_weight_drops_topic_matches_from_only_that_channel() -> None: + lexical = TopicMemoryChannelHit( + artifact_ref=ArtifactRef(family="topic-memory", artifact_id="lexical", revision=1), + title="Lexical topic", + summary="needle", + channel="topic_fts", + ) + semantic = TopicMemoryChannelHit( + artifact_ref=ArtifactRef(family="topic-memory", artifact_id="semantic", revision=1), + title="Semantic topic", + summary="needle", + channel="topic_vector", + distance=0.1, + ) + + result = _fuse_topic_memory_rankings( + "needle", + TopicMemorySearchChannels(topic_fts=(lexical,), topic_vector=(semantic,)), + 2, + fusion=RrfParameters( + weights={ + "topic_fts": 0.0, + "detail_fts": 0.0, + "topic_vector": 1.0, + "detail_vector": 1.0, + } + ), + ).hits + + assert tuple(hit.artifact_ref.artifact_id for hit in result) == ("semantic",) + assert result[0].matched_by == ("topic_vector",) + + def test_fusion_outcome_counts_retrieved_and_admitted_over_enabled_channels() -> None: def hit( channel: TopicMemoryMatchedBy, diff --git a/tests/builtin/persistence/test_atomic_memory_fusion.py b/tests/builtin/persistence/test_atomic_memory_fusion.py new file mode 100644 index 0000000000..0a4c87e5e8 --- /dev/null +++ b/tests/builtin/persistence/test_atomic_memory_fusion.py @@ -0,0 +1,85 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from powercontext import ArtifactRef +from powercontext.builtin.artifacts.search import RecallChannelWeights +from powercontext.builtin.persistence.atomic_memory_index import ( + AtomicMemoryIndexError, + AtomicMemoryIndexHit, + AtomicMemorySearchChannels, + combine_atomic_memory_channels, +) + + +def _hit(artifact_id: str, *, distance: float | None = None) -> AtomicMemoryIndexHit: + return AtomicMemoryIndexHit( + ArtifactRef(family="atomic-memory", artifact_id=artifact_id, revision=1), + state_version=0, + kind="fact", + text=artifact_id, + score=0.0, + distance=distance, + ) + + +def test_default_atomic_weights_preserve_unweighted_results() -> None: + alpha, beta = _hit("alpha"), _hit("beta", distance=0.1) + channels = AtomicMemorySearchChannels(fts=(alpha, beta), vector=(beta, alpha)) + assert combine_atomic_memory_channels(channels, channel_weights=RecallChannelWeights()) == ( + combine_atomic_memory_channels(channels) + ) + + +@pytest.mark.parametrize(("fts", "vector", "first"), [(3.0, 1.0, "lexical"), (1.0, 3.0, "semantic")]) +def test_atomic_fusion_orders_disjoint_channels_by_weight(fts: float, vector: float, first: str) -> None: + hits = combine_atomic_memory_channels( + AtomicMemorySearchChannels(fts=(_hit("lexical"),), vector=(_hit("semantic", distance=0.1),)), + channel_weights=RecallChannelWeights(fts=fts, vector=vector), + ) + assert hits[0].artifact_ref.artifact_id == first + assert hits[0].score == pytest.approx(1.5 / 61) + assert hits[1].score == pytest.approx(0.5 / 61) + + +@pytest.mark.parametrize(("fts", "vector", "selected"), [(0.0, 1.0, "semantic"), (1.0, 0.0, "lexical")]) +def test_zero_weight_excludes_atomic_matches_from_only_that_channel(fts: float, vector: float, selected: str) -> None: + hits = combine_atomic_memory_channels( + AtomicMemorySearchChannels(fts=(_hit("lexical"),), vector=(_hit("semantic", distance=0.1),)), + channel_weights=RecallChannelWeights(fts=fts, vector=vector), + ) + assert len(hits) == 1 + assert hits[0].artifact_ref.artifact_id == selected + assert hits[0].score == pytest.approx(2 / 61) + + +@pytest.mark.parametrize("conflict", ["revision", "state_version"]) +@pytest.mark.parametrize(("fts", "vector"), [(1.0, 1.0), (0.0, 1.0), (1.0, 0.0)]) +def test_atomic_fusion_rejects_conflicting_identity_even_in_zero_weight_channels(conflict: str, fts, vector) -> None: + original = _hit("shared") + changed = ( + replace(original, artifact_ref=original.artifact_ref.model_copy(update={"revision": 2})) + if conflict == "revision" + else replace(original, state_version=1) + ) + with pytest.raises(AtomicMemoryIndexError, match="changed between retrieval channels"): + combine_atomic_memory_channels( + AtomicMemorySearchChannels(fts=(original,), vector=(changed,)), + channel_weights=RecallChannelWeights(fts=fts, vector=vector), + ) diff --git a/tests/builtin/persistence/test_topic_memory.py b/tests/builtin/persistence/test_topic_memory.py index d7c4c5433a..bbd4da84fa 100644 --- a/tests/builtin/persistence/test_topic_memory.py +++ b/tests/builtin/persistence/test_topic_memory.py @@ -21,19 +21,23 @@ from pathlib import Path import pytest -from sqlalchemy import delete, event, func, insert, select, text, update +from sqlalchemy import Table, delete, event, func, insert, select, text, update from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.artifacts import ArtifactRef +from powercontext.artifacts.fusion import FusionSelection from powercontext.builtin.artifacts.memory import EmbeddingProfile -from powercontext.builtin.artifacts.search import AdmissionFloor, fts_match_query +from powercontext.builtin.artifacts.search import AdmissionFloor, RecallChannelWeights, fts_match_query from powercontext.builtin.artifacts.topic_memory import ( MAX_TOPIC_MEMORY_QUERY_LENGTH, MAX_TOPIC_MEMORY_SEARCH_LIMIT, TOPIC_MEMORY_CHUNK_MAX_COUNT, + TopicArtifactSearchRequest, TopicMemory, TopicMemoryBrowseCursor, + TopicMemoryCapabilities, TopicMemoryCapabilityError, + TopicMemoryChannelHit, TopicMemoryChunk, TopicMemoryContent, TopicMemoryDraft, @@ -41,6 +45,7 @@ TopicMemoryProjectionError, TopicMemorySearchChannels, TopicMemorySearchRequest, + TopicMemorySearchResult, TopicMemoryStorageInvariantError, prepare_topic_memory_projection, ) @@ -140,8 +145,6 @@ def _fts_index() -> CompositeTopicMemoryIndex: def test_public_topic_search_keeps_signed_fts_metadata_and_exact_artifacts() -> None: - from powercontext.builtin.artifacts.topic_memory import TopicArtifactSearchRequest - async def scenario() -> None: index = _fts_index() repository = TopicMemoryRepository(index=index) @@ -185,6 +188,171 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_topic_search_deployment_weights_change_results_and_request_fusion_replaces_them( + monkeypatch: pytest.MonkeyPatch, +) -> None: + embedding_profile = EmbeddingProfile(profile_id="topic-test-v1", model="test", dimension=2) + lexical_ref = ArtifactRef(family="topic-memory", artifact_id="lexical", revision=1) + semantic_ref = ArtifactRef(family="topic-memory", artifact_id="semantic", revision=1) + channels = TopicMemorySearchChannels( + topic_fts=( + TopicMemoryChannelHit( + artifact_ref=lexical_ref, + title="Needle lexical topic", + summary="Needle lexical evidence", + channel="topic_fts", + ), + ), + topic_vector=( + TopicMemoryChannelHit( + artifact_ref=semantic_ref, + title="Semantic topic", + summary="Semantic evidence", + channel="topic_vector", + distance=0.1, + ), + ), + ) + + class StaticIndex: + capabilities = TopicMemoryCapabilities( + fts=True, + vector=True, + hybrid=True, + embedding_profile=embedding_profile, + ) + tables: tuple[Table, ...] = () + + async def initialize(self, _connection: AsyncConnection, /) -> None: + pass + + async def validate_current(self, _connection: AsyncConnection, /) -> None: + pass + + async def replace( + self, + _connection: AsyncConnection, + _scope_id: str, + _topic_ref: ArtifactRef, + _projection: TopicMemoryProjection, + /, + ) -> None: + pass + + async def search( + self, + _connection: AsyncConnection, + _scope_id: str, + _request: TopicMemorySearchRequest, + /, + ) -> TopicMemorySearchChannels: + return channels + + async def vector_complete( + self, + _connection: AsyncConnection, + _scope_id: str, + _topic_ref: ArtifactRef, + /, + ) -> bool: + return True + + topics = { + ref.artifact_id: TopicMemory( + artifact_id=ref.artifact_id, + revision=ref.revision, + content=TopicMemoryContent(title=ref.artifact_id, summary="Evidence", detail="Evidence detail"), + ) + for ref in (lexical_ref, semantic_ref) + } + default_repository = TopicMemoryRepository(index=StaticIndex()) + equal_repository = TopicMemoryRepository( + index=StaticIndex(), + recall_channel_weights=RecallChannelWeights(fts=1.0, vector=1.0), + ) + fts_weighted_repository = TopicMemoryRepository( + index=StaticIndex(), + recall_channel_weights=RecallChannelWeights(fts=3.0, vector=1.0), + ) + vector_weighted_repository = TopicMemoryRepository( + index=StaticIndex(), + recall_channel_weights=RecallChannelWeights(fts=1.0, vector=3.0), + ) + + async def get_topic(_connection: AsyncConnection, _scope_id: str, ref: ArtifactRef) -> TopicMemory: + return topics[ref.artifact_id] + + for repository in ( + default_repository, + equal_repository, + fts_weighted_repository, + vector_weighted_repository, + ): + monkeypatch.setattr(repository.artifacts, "get", get_topic) + + async def scenario() -> None: + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile, + profile.database.transaction() as connection, + ): + await default_repository.initialize(connection) + + async def search( + repository: TopicMemoryRepository, + *, + artifact_request: TopicArtifactSearchRequest | None = None, + ) -> TopicMemorySearchResult: + return await repository.search( + connection, + "scope-a", + "needle", + limit=2, + mode="hybrid", + query_vector=(1.0, 0.0), + embedding_profile=embedding_profile, + artifact_request=artifact_request, + ) + + default_result = await search(default_repository) + equal_result = await search(equal_repository) + fts_weighted_result = await search(fts_weighted_repository) + vector_weighted_result = await search(vector_weighted_repository) + request_weighted_result = await search( + fts_weighted_repository, + artifact_request=TopicArtifactSearchRequest( + query="needle", + mode="hybrid", + fusion=FusionSelection( + method="rrf", + params={ + "weights": { + "topic_vector": 3.0, + "detail_vector": 3.0, + } + }, + ), + ), + ) + + default_scores = {hit.artifact_ref.artifact_id: hit.score for hit in default_result.hits} + equal_scores = {hit.artifact_ref.artifact_id: hit.score for hit in equal_result.hits} + fts_weighted_scores = {hit.artifact_ref.artifact_id: hit.score for hit in fts_weighted_result.hits} + vector_weighted_scores = {hit.artifact_ref.artifact_id: hit.score for hit in vector_weighted_result.hits} + request_weighted_scores = {hit.artifact_ref.artifact_id: hit.score for hit in request_weighted_result.hits} + + assert default_result.hits == equal_result.hits + assert default_scores == {"lexical": 25.0, "semantic": 25.0} + assert equal_scores == default_scores + assert tuple(hit.artifact_ref for hit in fts_weighted_result.hits) == (lexical_ref, semantic_ref) + assert fts_weighted_scores == {"lexical": 37.5, "semantic": 12.5} + assert tuple(hit.artifact_ref for hit in vector_weighted_result.hits) == (semantic_ref, lexical_ref) + assert vector_weighted_scores == {"lexical": 12.5, "semantic": 37.5} + assert tuple(hit.artifact_ref for hit in request_weighted_result.hits) == (semantic_ref, lexical_ref) + assert request_weighted_scores == vector_weighted_scores + + asyncio.run(scenario()) + + def test_public_topic_missing_selected_exact_revision_is_a_contract_failure(monkeypatch) -> None: from powercontext.artifacts.search import ArtifactSearchContractError from powercontext.builtin.artifacts.topic_memory import TopicArtifactSearchRequest diff --git a/tests/builtin/runtime/test_recall_channel_weights.py b/tests/builtin/runtime/test_recall_channel_weights.py new file mode 100644 index 0000000000..1cf6663fa8 --- /dev/null +++ b/tests/builtin/runtime/test_recall_channel_weights.py @@ -0,0 +1,294 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import math +import sys +from pathlib import Path +from typing import Literal + +import pytest +from pydantic import ValidationError + +from powercontext import ArtifactRef +from powercontext.artifacts.fusion import RrfParameters +from powercontext.builtin.artifacts.search import RecallChannelWeights +from powercontext.builtin.artifacts.topic_memory import ( + TopicMemoryChannelHit, + TopicMemoryMatchedBy, + TopicMemorySearchChannels, +) +from powercontext.builtin.artifacts.topic_memory.fusion import _fuse_topic_memory_rankings +from powercontext.builtin.persistence.atomic_memory_index import ( + AtomicMemoryIndexHit, + AtomicMemorySearchChannels, + combine_atomic_memory_channels, +) +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import BuiltinConfig, RuntimeConfig, composition +from powercontext.server.settings import ServerSettings + + +def test_recall_channel_weights_default_to_the_historical_equal_ratio() -> None: + config = RuntimeConfig() + empty_config = RuntimeConfig.model_validate({}) + + assert config.recall_fts_weight == 1.0 + assert config.recall_vector_weight == 1.0 + assert config.recall_channel_weights.fts == 1.0 + assert config.recall_channel_weights.vector == 1.0 + assert empty_config.recall_channel_weights == config.recall_channel_weights + + +def test_recall_channel_weights_are_normalized_as_a_relative_ratio() -> None: + weights = RuntimeConfig(recall_fts_weight=3.0, recall_vector_weight=1.0).recall_channel_weights + scaled = RuntimeConfig(recall_fts_weight=300.0, recall_vector_weight=100.0).recall_channel_weights + + assert weights.fts == pytest.approx(1.5) + assert weights.vector == pytest.approx(0.5) + assert scaled == weights + + +def test_recall_channel_weights_support_the_reverse_ratio() -> None: + weights = RuntimeConfig(recall_fts_weight=1.0, recall_vector_weight=3.0).recall_channel_weights + + assert weights.fts == pytest.approx(0.5) + assert weights.vector == pytest.approx(1.5) + + +@pytest.mark.parametrize("configured_weight", [1e-310, 1e-320]) +def test_recall_channel_weights_normalize_equal_finite_subnormal_values(configured_weight: float) -> None: + weights = RuntimeConfig( + recall_fts_weight=configured_weight, + recall_vector_weight=configured_weight, + ).recall_channel_weights + + assert math.isfinite(weights.fts) + assert math.isfinite(weights.vector) + assert weights.fts == pytest.approx(1.0) + assert weights.vector == pytest.approx(1.0) + + +@pytest.mark.parametrize( + ("fts", "vector", "expected_fts", "expected_vector"), + [ + (0.0, 1.0, 0.0, 2.0), + (1.0, 0.0, 2.0, 0.0), + (1.0, 1.0, 1.0, 1.0), + (1e-300, 1.0, 2e-300, 2.0), + ], +) +def test_recall_channel_weights_preserve_valid_ratios( + fts: float, + vector: float, + expected_fts: float, + expected_vector: float, +) -> None: + weights = RuntimeConfig(recall_fts_weight=fts, recall_vector_weight=vector).recall_channel_weights + + assert weights.fts == pytest.approx(expected_fts, abs=0.0) + assert weights.vector == pytest.approx(expected_vector, abs=0.0) + + +def test_recall_channel_weights_accept_the_smallest_normalized_normal_weight() -> None: + weights = RuntimeConfig( + recall_fts_weight=sys.float_info.min, + recall_vector_weight=2.0, + ).recall_channel_weights + + assert weights.fts == sys.float_info.min + assert weights.vector == 2.0 + + +@pytest.mark.parametrize( + ("fts", "vector"), + [ + (1e-20, 1e303), + (1e303, 1e-20), + (1e-320, 1e303), + (1e303, 1e-320), + (sys.float_info.min / 2.0, 2.0), + (2.0, sys.float_info.min / 2.0), + ], +) +def test_extreme_nonzero_recall_weight_ratios_are_rejected_at_configuration_time( + fts: float, + vector: float, +) -> None: + with pytest.raises(ValidationError, match="must be at least the smallest normal IEEE 754 binary64 value"): + RuntimeConfig(recall_fts_weight=fts, recall_vector_weight=vector) + + +@pytest.mark.parametrize( + ("fts", "vector"), + [ + (1e-20, 1e303), + (1e303, 1e-20), + (1e-320, 1e303), + (1e303, 1e-320), + ], +) +def test_extreme_nonzero_recall_weight_ratios_are_rejected_by_the_domain_type( + fts: float, + vector: float, +) -> None: + with pytest.raises(ValueError, match="must be at least the smallest normal IEEE 754 binary64 value"): + RecallChannelWeights(fts=fts, vector=vector) + + +@pytest.mark.parametrize( + ("fts", "vector", "memory_channel", "topic_channel"), + [ + (sys.float_info.min, 2.0, "fts", "topic_fts"), + (2.0, sys.float_info.min, "vector", "topic_vector"), + ], +) +def test_smallest_accepted_channel_weight_remains_usable_in_memory_and_topic_fusion( + fts: float, + vector: float, + memory_channel: Literal["fts", "vector"], + topic_channel: TopicMemoryMatchedBy, +) -> None: + weights = RuntimeConfig(recall_fts_weight=fts, recall_vector_weight=vector).recall_channel_weights + memory_candidate = AtomicMemoryIndexHit( + artifact_ref=ArtifactRef(family="atomic-memory", artifact_id="memory", revision=1), + state_version=0, + kind="fact", + text="needle", + score=0.0, + distance=0.1 if memory_channel == "vector" else None, + ) + memory_hits = combine_atomic_memory_channels( + AtomicMemorySearchChannels( + fts=(memory_candidate,) if memory_channel == "fts" else (), + vector=(memory_candidate,) if memory_channel == "vector" else (), + ), + channel_weights=weights, + ) + topic_candidate = TopicMemoryChannelHit( + artifact_ref=ArtifactRef(family="topic-memory", artifact_id="topic", revision=1), + title="Needle topic", + summary="needle", + channel=topic_channel, + distance=0.1 if topic_channel == "topic_vector" else None, + ) + topic_hits = _fuse_topic_memory_rankings( + "needle", + TopicMemorySearchChannels( + topic_fts=(topic_candidate,) if topic_channel == "topic_fts" else (), + topic_vector=(topic_candidate,) if topic_channel == "topic_vector" else (), + ), + 1, + fusion=RrfParameters( + weights={ + "topic_fts": weights.fts, + "detail_fts": weights.fts, + "topic_vector": weights.vector, + "detail_vector": weights.vector, + } + ), + ).hits + + assert len(memory_hits) == 1 + assert memory_hits[0].score > 0.0 + assert len(topic_hits) == 1 + assert topic_hits[0].matched_by == (topic_channel,) + assert topic_hits[0].score > 0.0 + + +def test_recall_channel_weight_total_overflow_reports_the_actual_cause() -> None: + with pytest.raises(ValueError, match="weight total must be finite"): + RecallChannelWeights(fts=1e308, vector=1e308) + + with pytest.raises(ValidationError, match="weight total must be finite"): + RuntimeConfig(recall_fts_weight=1e308, recall_vector_weight=1e308) + + +def test_composition_passes_recall_weights_to_regular_and_topic_worker_contexts( + tmp_path: Path, +) -> None: + runtime_config = RuntimeConfig(recall_fts_weight=1.0, recall_vector_weight=3.0) + config = BuiltinConfig( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'recall-weights.db'}"), + runtime=runtime_config, + ) + expected = runtime_config.recall_channel_weights + + async def scenario() -> None: + async with composition.open_builtin_contexts(config) as contexts: + assert contexts.repositories.topic_memories.recall_channel_weights == expected + async with composition.open_builtin_contexts(config, _topic_memory_worker=True) as worker_contexts: + assert worker_contexts.repositories.topic_memories.recall_channel_weights == expected + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + ("fts", "vector"), + [ + (-1.0, 1.0), + (1.0, -1.0), + (math.nan, 1.0), + (math.inf, 1.0), + (-math.inf, 1.0), + (1.0, math.nan), + (1.0, math.inf), + (1.0, -math.inf), + (0.0, 0.0), + (1e308, 1e308), + (True, 1.0), + (1.0, False), + ], +) +def test_invalid_recall_channel_weights_are_rejected_at_configuration_time(fts: object, vector: object) -> None: + with pytest.raises(ValidationError): + RuntimeConfig.model_validate({"recall_fts_weight": fts, "recall_vector_weight": vector}) + + +@pytest.mark.parametrize("field", ["recall_fts_weight", "recall_vector_weight"]) +def test_none_recall_channel_weight_is_rejected_by_its_field(field: str) -> None: + with pytest.raises(ValidationError) as error: + RuntimeConfig.model_validate({field: None}) + + assert error.value.errors()[0]["loc"] == (field,) + + +def test_recall_channel_weights_load_from_the_server_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_RECALL_FTS_WEIGHT", "3") + monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_RECALL_VECTOR_WEIGHT", "1") + + runtime = ServerSettings().runtime + + assert runtime.recall_fts_weight == 3.0 + assert runtime.recall_vector_weight == 1.0 + assert runtime.recall_channel_weights.fts == pytest.approx(1.5) + assert runtime.recall_channel_weights.vector == pytest.approx(0.5) + + +@pytest.mark.parametrize( + ("fts_weight", "vector_weight"), + [("1e-20", "1e303"), ("1e303", "1e-20")], +) +def test_extreme_recall_channel_weight_ratio_from_server_environment_is_rejected( + monkeypatch: pytest.MonkeyPatch, + fts_weight: str, + vector_weight: str, +) -> None: + monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_RECALL_FTS_WEIGHT", fts_weight) + monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_RECALL_VECTOR_WEIGHT", vector_weight) + + with pytest.raises(ValidationError, match="must be at least the smallest normal IEEE 754 binary64 value"): + ServerSettings() diff --git a/tests/builtin/runtime/test_recall_sufficiency.py b/tests/builtin/runtime/test_recall_sufficiency.py index 4fb334913b..fea4bd689d 100644 --- a/tests/builtin/runtime/test_recall_sufficiency.py +++ b/tests/builtin/runtime/test_recall_sufficiency.py @@ -133,6 +133,7 @@ def _memory_hit( score: float, matched_by: tuple[MemoryMatchedBy, ...], text: str = "alpha beta", + score_upper_bound: float | None = None, artifact_ref: ArtifactRef = MEMORY_REF, relevance: float | None = None, ) -> AtomicMemorySearchHit: @@ -147,6 +148,7 @@ def _memory_hit( distance=None if relevance is None else math.sqrt(2 * (1 - relevance)), ), matched_by=matched_by, + score_upper_bound=score_upper_bound, ) @@ -697,6 +699,22 @@ def test_memory_two_channel_hit_normalizes_against_its_wider_upper_bound() -> No assert candidates[0].score == pytest.approx(0.5) +def test_memory_weighted_hit_normalizes_against_the_fusion_upper_bound() -> None: + candidates = build_recall_candidates( + memory_hits=( + _memory_hit( + score=1.5 / 61, + matched_by=("fts",), + score_upper_bound=1.5 / 61, + ), + ), + topic_memory_hits=(), + experience_hits=(), + ) + + assert candidates[0].score == pytest.approx(1.0) + + def test_memory_vector_relevance_reaches_gate_without_changing_rank_score() -> None: hits = ( _memory_hit(score=2 / 61, matched_by=("fts", "vector"), relevance=0.3), diff --git a/tests/e2e/test_atomic_memory_channel_weights.py b/tests/e2e/test_atomic_memory_channel_weights.py new file mode 100644 index 0000000000..82edb83079 --- /dev/null +++ b/tests/e2e/test_atomic_memory_channel_weights.py @@ -0,0 +1,209 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +import httpx +import pytest + +from powercontext.builtin.artifacts.memory import EmbeddingProfile, MemoryRerankDecision +from powercontext.builtin.inference import EmbeddingResult, InferenceUsage +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import ArtifactWrite +from powercontext.builtin.runtime import BuiltinConfig, RuntimeConfig, open_builtin_contexts +from powercontext.builtin.runtime.recall_sufficiency import build_recall_candidates +from powercontext.server.factory import create_server_app +from powercontext.server.settings import McpConfig, ServerSettings + + +class _Embedding: + profile = EmbeddingProfile(profile_id="channel-weights", model="fixture", dimension=3) + + async def embed(self, texts, /): + return EmbeddingResult( + vectors=tuple((0.0, 1.0, 0.0) if "lexical" in text else (1.0, 0.0, 0.0) for text in texts) + ) + + +class _SelectingReranker: + policy_id = "channel-weights-reranker" + supports_atomic_memory = True + + async def rerank(self, query, candidates, limit, /): + return MemoryRerankDecision(selected_ranks=(2, 1)[:limit], usage=InferenceUsage(requests=1)) + + +@pytest.mark.parametrize( + ("fts_weight", "vector_weight", "expected_texts", "expected_scores"), + [ + (1.0, 1.0, {"alpha lexical", "unrelated semantic"}, {"alpha lexical": 1 / 61, "unrelated semantic": 1 / 61}), + ( + 3.0, + 1.0, + {"alpha lexical", "unrelated semantic"}, + {"alpha lexical": 1.5 / 61, "unrelated semantic": 0.5 / 61}, + ), + ( + 1.0, + 3.0, + {"alpha lexical", "unrelated semantic"}, + {"alpha lexical": 0.5 / 61, "unrelated semantic": 1.5 / 61}, + ), + (0.0, 1.0, {"unrelated semantic"}, {"unrelated semantic": 2 / 61}), + (1.0, 0.0, {"alpha lexical"}, {"alpha lexical": 2 / 61}), + ( + 1e-320, + 1e-320, + {"alpha lexical", "unrelated semantic"}, + {"alpha lexical": 1 / 61, "unrelated semantic": 1 / 61}, + ), + ], +) +def test_http_atomic_search_applies_deployment_weights_only_to_hybrid( + tmp_path: Path, fts_weight, vector_weight, expected_texts, expected_scores +) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'weights.db'}"), + runtime=RuntimeConfig(recall_fts_weight=fts_weight, recall_vector_weight=vector_weight), + mcp=McpConfig(enabled=False), + ), + embedding_model=_Embedding(), + ) + + async def scenario(): + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client, + ): + scope_response = await client.get("/v1/scopes/default") + assert scope_response.status_code == 200, scope_response.text + scope_id = scope_response.json()["scope_id"] + for text in ("alpha lexical", "unrelated semantic"): + response = await client.post( + "/v1/memory/remember", json={"scope_id": scope_id, "kind": "fact", "text": text} + ) + assert response.status_code == 200, response.text + for mode in ("hybrid", "auto", "fts", "vector"): + response = await client.post( + "/v1/memory/search", json={"scope_id": scope_id, "query": "alpha", "mode": mode} + ) + assert response.status_code == 200, response.text + hits = response.json()["hits"] + actual = {hit["memory"]["text"]: hit["score"] for hit in hits} + if mode in {"hybrid", "auto"}: + assert response.json()["mode"] == "hybrid" + assert set(actual) == expected_texts + assert actual == pytest.approx(expected_scores) + if fts_weight != vector_weight: + assert hits[0]["memory"]["text"] == ( + "alpha lexical" if fts_weight > vector_weight else "unrelated semantic" + ) + else: + text = "alpha lexical" if mode == "fts" else "unrelated semantic" + assert actual == pytest.approx({text: 1 / 61}) + assert all("score_upper_bound" not in hit for hit in hits) + artifact_response = await client.post( + f"/v1/scopes/{scope_id}/artifacts/atomic-memory/search", json={"query": "alpha", "mode": "hybrid"} + ) + assert artifact_response.status_code == 200, artifact_response.text + results = artifact_response.json()["results"] + assert {item["content"]["text"] for item in results} == expected_texts + if fts_weight != vector_weight: + assert results[0]["content"]["text"] == ( + "alpha lexical" if fts_weight > vector_weight else "unrelated semantic" + ) + assert all("score_upper_bound" not in item for item in results) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("worker", [False, True]) +@pytest.mark.parametrize(("fts_weight", "vector_weight"), [(0.0, 1.0), (1.0, 0.0), (sys.float_info.min, 2.0)]) +def test_composed_atomic_search_preserves_active_channels_and_gate_metadata( + tmp_path: Path, worker: bool, fts_weight: float, vector_weight: float +) -> None: + async def scenario(): + config = BuiltinConfig( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'composed.db'}"), + runtime=RuntimeConfig(recall_fts_weight=fts_weight, recall_vector_weight=vector_weight), + ) + if worker: + # Workers consume the retrieval shape established by ordinary Runtime startup. + async with open_builtin_contexts(config, embedding_model=_Embedding()): + pass + async with open_builtin_contexts( + config, + embedding_model=_Embedding(), + _topic_memory_worker=worker, + ) as contexts: + await contexts.get("project") + for text in ("alpha lexical", "unrelated semantic", "alpha shared"): + await contexts.records.create_artifact( + "project", "atomic-memory", ArtifactWrite(content={"kind": "fact", "text": text}) + ) + page = await contexts.atomic_memory.for_scope("project").search("alpha", mode="hybrid") + assert page.embedding_calls == 1 + shared = next(hit for hit in page.hits if hit.text == "alpha shared") + expected_channels = tuple( + channel for channel, weight in (("text", fts_weight), ("vector", vector_weight)) if weight > 0 + ) + assert shared.matched_by == expected_channels + assert shared.score_upper_bound == pytest.approx(2 / 61) + assert shared.relevance == (pytest.approx(1.0) if vector_weight > 0 else None) + assert {hit.text for hit in page.hits} == { + "alpha shared", + *(("alpha lexical",) if fts_weight > 0 else ()), + *(("unrelated semantic",) if vector_weight > 0 else ()), + } + candidates = build_recall_candidates(memory_hits=page.hits, topic_memory_hits=(), experience_hits=()) + for hit, candidate in zip(page.hits, candidates, strict=True): + assert hit.score_upper_bound is not None and hit.score_upper_bound > 0 + assert candidate.score == pytest.approx(hit.hit.score / hit.score_upper_bound) + assert candidate.relevance == hit.relevance + if vector_weight == 0: + assert all(hit.relevance is None for hit in page.hits) + + asyncio.run(scenario()) + + +def test_atomic_rerank_preserves_weighted_gate_metadata(tmp_path: Path) -> None: + async def scenario(): + async with open_builtin_contexts( + BuiltinConfig( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'rerank.db'}"), + runtime=RuntimeConfig(recall_fts_weight=3.0, recall_vector_weight=1.0), + ), + embedding_model=_Embedding(), + memory_reranker=_SelectingReranker(), + ) as contexts: + await contexts.get("project") + for text in ("alpha lexical", "unrelated semantic"): + await contexts.records.create_artifact( + "project", "atomic-memory", ArtifactWrite(content={"kind": "fact", "text": text}) + ) + page = await contexts.atomic_memory.for_scope("project").search("alpha", mode="hybrid", limit=2) + assert tuple(hit.text for hit in page.hits) == ("unrelated semantic", "alpha lexical") + assert page.hits[0].score_upper_bound == pytest.approx(0.5 / 61) + assert page.hits[1].score_upper_bound == pytest.approx(1.5 / 61) + assert page.hits[0].relevance == pytest.approx(1.0) + assert page.hits[1].relevance is None + candidates = build_recall_candidates(memory_hits=page.hits, topic_memory_hits=(), experience_hits=()) + assert [candidate.score for candidate in candidates] == pytest.approx([1.0, 1.0]) + + asyncio.run(scenario())