Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/reference/reward-memory-decision-consumption.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 17 additions & 7 deletions loopx/capabilities/reward_memory/decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
},
})

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"])
Expand Down
35 changes: 25 additions & 10 deletions loopx/control_plane/capabilities/reward_memory_decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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") {
Expand All @@ -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",
};
}
40 changes: 40 additions & 0 deletions tests/capabilities/test_reward_memory_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions tests/control_plane_ts/reward_memory_decision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading