diff --git a/docs/reference/reward-memory-decision-consumption.md b/docs/reference/reward-memory-decision-consumption.md index 9bd24fc16e..b1c2daab01 100644 --- a/docs/reference/reward-memory-decision-consumption.md +++ b/docs/reference/reward-memory-decision-consumption.md @@ -111,10 +111,25 @@ returns `replay_request_mismatch`. Reassessment uses retained qualified items an the original **cumulative** multi-corpus counters, not a second query. This is caller-retained replay, not automatic cross-process persistence or a new cache. +The private result retains the **original context-delivery receipt** separately +from the later semantic receipt. TypeScript revalidates its application, artifact, +surface and lesson attribution, so assessment (including an incomplete assessment) +does not erase a previously verified delivery. A direct semantic callback without +that receipt leaves `context_delivery_verified=false`; disposition and utility +are separate facts. This flag attests the caller's verified SDK context callback, +**not** frontend/Lark transport delivery or model utility. Public packets alone +cannot recreate that private lineage or upgrade historical receipts. + 仅 `public_packet` 用于展示,其余结果私有。通过既有执行上下文保留结果; `previous_result` 仅复用配置和输入均匹配的请求,变化则拒绝复用。后续判断使用 原条目和累计多 corpus 遥测,不重复查询。这不是自动跨进程存储或新的缓存。 +私有结果分别保留原上下文交付回执和后续语义回执,TS 对应用、产物、surface 与 +经验归因重新核验;评估成功或不完整均不抹掉此前已验证的交付。直接语义 callback +没有该回执时仍为 `context_delivery_verified=false`,语义判断与效果另行记录。 +该标记证明调用方已验证的 SDK 上下文 callback,不证明前端/飞书传输或模型收益; +仅凭公开 packet 不能重建这条私有链路,也不追溯升级历史回执。 + Empty/filtered/unavailable and invalid model/transport results preserve the base and allow ordinary research. Post-provider transport failure retains actual call/filter counts and the original private receipt. The existing route still diff --git a/loopx/capabilities/reward_memory/decision.py b/loopx/capabilities/reward_memory/decision.py index b176b04fdc..c76215fbe8 100644 --- a/loopx/capabilities/reward_memory/decision.py +++ b/loopx/capabilities/reward_memory/decision.py @@ -32,6 +32,7 @@ class RewardMemoryDecisionResult: recall_session: RewardMemoryRecallSession | None = None application_receipt: Mapping[str, Any] | None = None recall_telemetry: Mapping[str, Any] | None = None + context_delivery_receipt: Mapping[str, Any] | None = None def _transport_failure( @@ -64,21 +65,29 @@ def _recall_telemetry(hook: Mapping[str, Any]) -> dict[str, Any]: } +def _compact_application_receipt(receipt: Mapping[str, Any] | None) -> dict[str, Any]: + # Transport only: the TS owner verifies both receipts with the same binding rule. + return { + key: value for key, value in (receipt or {}).items() + if key in {"schema_version", "application_id", "artifact_ref", "surface_id", + "outcome", "memory_ref_digests", "current_artifact_verified", + "result_readback_verified"} + } + + def _project( request: dict[str, Any], status: str, telemetry: Mapping[str, Any], receipt: Mapping[str, Any] | None, + context_delivery_receipt: Mapping[str, Any] | None = None, ) -> dict[str, Any]: return effect_runtime_result("reward_memory.decision.project", { "request": request, "observation": { "status": status, **dict(telemetry), # No query, summary, lesson, model rationale or base artifact crosses into TS. - "application_receipt": { - key: value for key, value in (receipt or {}).items() - if key in {"schema_version", "application_id", "artifact_ref", "surface_id", - "outcome", "memory_ref_digests", "current_artifact_verified", - "result_readback_verified"} - }, + "application_receipt": _compact_application_receipt(receipt), + "context_delivery_receipt": _compact_application_receipt(context_delivery_receipt) + if context_delivery_receipt is not None else None, }, }) @@ -148,6 +157,7 @@ def apply(original: Any, items: tuple[RewardMemoryRecallItem, ...]) -> Mapping[s return RewardMemoryDecisionResult( packet, base if packet["preserve_base_output"] else hook["output"], base, digest, request, session, receipt, telemetry, + deepcopy(receipt) if packet["context_delivery_verified"] else None, ) except (KeyError, OSError, RuntimeError, TypeError, ValueError): return RewardMemoryDecisionResult( @@ -183,7 +193,7 @@ def assess_reward_memory_decision( receipt = application["receipt"] # Reassessment is not recall: retain every corpus's original cumulative counters. packet = _project(request, application["status"], delivered.recall_telemetry or {}, - application["receipt"]) + application["receipt"], delivered.context_delivery_receipt) return replace(delivered, public_packet=packet, request=request, output=delivered.base_output if packet["preserve_base_output"] else application["output"], application_receipt=application["receipt"]) diff --git a/loopx/control_plane/capabilities/reward_memory_decision.ts b/loopx/control_plane/capabilities/reward_memory_decision.ts index 244df8e24b..6b8606de65 100644 --- a/loopx/control_plane/capabilities/reward_memory_decision.ts +++ b/loopx/control_plane/capabilities/reward_memory_decision.ts @@ -26,6 +26,19 @@ function count(value: unknown, name: string): number { return value as number; } +function boundReceiptDigests(receipt: JsonObject, plan: JsonObject, outcome: unknown): string[] | null { + const digests = receipt.memory_ref_digests; + if (!Array.isArray(digests) || digests.length === 0 || digests.length > 8 || + !digests.every((item): item is string => typeof item === "string" && /^[0-9a-f]{16}$/.test(item))) { + return null; + } + return receipt.schema_version === "reward_memory_application_receipt_v0" && + receipt.application_id === plan.application_id && receipt.artifact_ref === plan.artifact_ref && + receipt.surface_id === plan.surface_id && receipt.outcome === outcome && + receipt.current_artifact_verified === true && receipt.result_readback_verified === true + ? digests : null; +} + /** Query-ready consumption policy; no config, provider content or model calls. */ export function planRewardMemoryDecision(params: JsonObject): JsonObject { const mode = requireStringLiteral(params.mode, MODES, "mode"); @@ -90,15 +103,17 @@ export function projectRewardMemoryDecision(params: JsonObject): JsonObject { ? "all_provider_items_filtered" : "provider_returned_no_items"}; if (plan.mode === "recall_only") return {...packet, status: "recalled"}; const receipt = requireJsonObject(observation.application_receipt, "application_receipt"); - const digests = receipt.memory_ref_digests; - const attributed = Array.isArray(digests) && digests.length > 0 && digests.length <= 8 && - digests.every((item) => typeof item === "string" && /^[0-9a-f]{16}$/.test(item)); - const bound = receipt.schema_version === "reward_memory_application_receipt_v0" && - receipt.application_id === plan.application_id && receipt.artifact_ref === plan.artifact_ref && - receipt.surface_id === plan.surface_id && receipt.outcome === hookStatus && - receipt.current_artifact_verified === true && receipt.result_readback_verified === true && attributed; - if (!bound || hookStatus === "failed" || hookStatus === "available_not_applied") { - return {...packet, status: "incomplete", reason_code: "application_evidence_incomplete"}; + const digests = boundReceiptDigests(receipt, plan, hookStatus); + const priorDelivery = observation.context_delivery_receipt == null ? null + : boundReceiptDigests(requireJsonObject(observation.context_delivery_receipt, + "context_delivery_receipt"), plan, "applied"); + // Delivery and disposition are independent facts, bound to the same artifact and items. + // A direct semantic callback does not retroactively establish context delivery. + const assessedPacket = {...packet, context_delivery_verified: + plan.application_kind === "semantic_application" && priorDelivery !== null && + (digests === null || digests.every((digest) => priorDelivery.includes(digest)))}; + if (!digests || hookStatus === "failed" || hookStatus === "available_not_applied") { + return {...assessedPacket, status: "incomplete", reason_code: "application_evidence_incomplete"}; } // A delivered context is available for reasoning; it is not the reasoning disposition. if (plan.application_kind === "context_delivery") { @@ -111,7 +126,7 @@ export function projectRewardMemoryDecision(params: JsonObject): JsonObject { return {...packet, status: "incomplete", reason_code: "semantic_disposition_required"}; } return { - ...packet, status: hookStatus, semantic_disposition: hookStatus, memory_ref_digests: digests, + ...assessedPacket, status: hookStatus, semantic_disposition: hookStatus, memory_ref_digests: digests, decision_consumption_complete: true, preserve_base_output: hookStatus !== "applied", }; } diff --git a/tests/capabilities/test_reward_memory_decision.py b/tests/capabilities/test_reward_memory_decision.py index 8f9d9d32b0..5bc597cb83 100644 --- a/tests/capabilities/test_reward_memory_decision.py +++ b/tests/capabilities/test_reward_memory_decision.py @@ -188,9 +188,12 @@ def judge(base, items): assessed = assess_reward_memory_decision(delivered, apply_memory=judge) assert assessed.public_packet["decision_consumption_complete"] + assert assessed.public_packet["context_delivery_verified"] is True assert assessed.public_packet["semantic_disposition"] == outcome assert assessed.public_packet["provider_call_count"] == provider.calls == 2 assert assessed.public_packet["filtered_count"] == 1 + assert assessed.context_delivery_receipt == delivered.application_receipt + assert assessed.context_delivery_receipt["outcome"] == "applied" assert len(assessments) == 1 assert assess_reward_memory_decision(assessed, apply_memory=judge) is assessed assert run_reward_memory_decision(config, query_ready=True, application_kind="context_delivery", @@ -207,6 +210,43 @@ def judge(base, items): assert assessed.public_packet[flag] is False +@pytest.mark.parametrize("outcome", ["applied", "ignored", "refuted"]) +def test_direct_semantic_assessment_does_not_invent_context_delivery(tmp_path, outcome): + config, arguments, records = context(tmp_path) + provider = Provider(records) + result = run_reward_memory_decision( + config, query_ready=True, application_kind="semantic_application", provider=provider, + apply_memory=lambda base, items: {"outcome": outcome, "output": base, + "memory_refs": [item.memory_ref for item in items], "current_artifact_verified": True, + "reasoning_summary": "Direct comparison with the current artifact."}, **arguments, + ) + assert result.public_packet["decision_consumption_complete"] is True + assert result.public_packet["context_delivery_verified"] is False + assert result.context_delivery_receipt is None + assert provider.calls == 1 + + +def test_failed_assessment_retains_delivery_for_retry_without_recall(tmp_path): + config, arguments, records = context(tmp_path) + provider = Provider(records) + delivered = run_reward_memory_decision(config, query_ready=True, application_kind="context_delivery", + apply_memory=delivery, provider=provider, **arguments) + failed = assess_reward_memory_decision(delivered, apply_memory=lambda base, items: { + "outcome": "ignored", "output": base, "memory_refs": [], + "current_artifact_verified": False, "reasoning_summary": "Assessment not yet verified."}) + assert failed.public_packet["context_delivery_verified"] is True + assert failed.public_packet["decision_consumption_complete"] is False + assert failed.output == arguments["base_output"] + assert failed.context_delivery_receipt == delivered.application_receipt + recovered = assess_reward_memory_decision(failed, apply_memory=lambda base, items: { + "outcome": "ignored", "output": base, "memory_refs": [item.memory_ref for item in items], + "current_artifact_verified": True, "reasoning_summary": "Current evidence already covers this lesson."}) + assert recovered.public_packet["context_delivery_verified"] is True + assert recovered.public_packet["decision_consumption_complete"] is True + assert recovered.public_packet["utility_verified"] is False + assert provider.calls == recovered.public_packet["provider_call_count"] == 1 + + @pytest.mark.parametrize("bad", ["foreign_ref", "unverified_artifact", "unattributed", "throws"]) def test_invalid_semantic_evidence_is_not_adoption(tmp_path, bad): config, arguments, records = context(tmp_path) diff --git a/tests/control_plane_ts/reward_memory_decision.test.ts b/tests/control_plane_ts/reward_memory_decision.test.ts index d82a012b0c..05a6929b69 100644 --- a/tests/control_plane_ts/reward_memory_decision.test.ts +++ b/tests/control_plane_ts/reward_memory_decision.test.ts @@ -64,3 +64,50 @@ test("project only the original hook's safe typed boundary reason", () => { assert.equal(rejected.boundary_reason_code, "exact_corpus_request_invalid"); assert.equal(rejected.provider_call_count, 0); }); + +test("semantic assessment preserves a separately bound context delivery, not utility", () => { + const contextReceipt = {...observation.application_receipt, outcome: "applied"}; + for (const outcome of ["applied", "ignored", "refuted"]) { + const result = projectRewardMemoryDecision({request, observation: {...observation, status: outcome, + application_receipt: {...observation.application_receipt, outcome}, + context_delivery_receipt: contextReceipt}}); + assert.equal(result.context_delivery_verified, true); + assert.equal(result.decision_consumption_complete, true); + assert.equal(result.semantic_disposition, outcome); + assert.equal(result.provider_call_count, 2); + assert.equal(result.filtered_count, 1); + assert.equal(result.utility_verified, false); + assert.equal(result.grants_new_action_authority, false); + } + assert.equal(projectRewardMemoryDecision({request, observation}).context_delivery_verified, false); +}); + +test("delivery evidence cannot transfer across identity, artifact, surface or lesson", () => { + const contextReceipt = {...observation.application_receipt, outcome: "applied"}; + for (const patch of [{application_id: "other"}, {artifact_ref: "artifact:other"}, {surface_id: "other"}, + {schema_version: "other"}, {outcome: "ignored"}, {current_artifact_verified: false}, + {result_readback_verified: false}, {memory_ref_digests: []}, {memory_ref_digests: ["b".repeat(16)]}]) { + const result = projectRewardMemoryDecision({request, observation: {...observation, + context_delivery_receipt: {...contextReceipt, ...patch}}}); + assert.equal(result.context_delivery_verified, false); + // A legitimate direct semantic assessment remains valid, independent of delivery. + assert.equal(result.decision_consumption_complete, true); + } + assert.throws(() => projectRewardMemoryDecision({request, observation: {...observation, + context_delivery_receipt: "not a receipt"}})); + const recalled = projectRewardMemoryDecision({request: {...request, mode: "recall_only"}, + observation: {...observation, context_delivery_receipt: contextReceipt}}); + assert.equal(recalled.context_delivery_verified, false); + assert.equal(recalled.decision_consumption_complete, false); +}); + +test("failed assessment preserves verified delivery without completing the decision", () => { + const result = projectRewardMemoryDecision({request, observation: {...observation, status: "failed", + application_receipt: {...observation.application_receipt, outcome: "failed", memory_ref_digests: [], + current_artifact_verified: false}, + context_delivery_receipt: {...observation.application_receipt, outcome: "applied"}}}); + assert.equal(result.context_delivery_verified, true); + assert.equal(result.status, "incomplete"); + assert.equal(result.decision_consumption_complete, false); + assert.equal(result.preserve_base_output, true); +});