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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ memory_ops 的人工正文编辑与删除都核对预期修订号,并在同一

## M2 生命周期与确定性维护

迁移 008 添加 maintenance_runs(阶段游标与固定大小的汇总计数)/maintenance_items(仅实际变更与失败)、memory_dependency_losses(每对来源的一次性扣减)、maintenance_batch_retries(每批一次)、batch_message_refs(冻结 JSON 段的引用索引),扩展 admin_operations 的对象字段,并在 memories 保存中等重要度的衰减余数和 purged_at。升级回填批次引用、当前失效依据及人工修订操作标识;在事务内延迟外键检查并原样重建 messages 为 AUTOINCREMENT,防止已清理消息的 ID 被复用。原始记忆与检索排序保持原样。
迁移 008 添加 maintenance_runs(阶段游标与固定大小的汇总计数)/maintenance_items(仅实际变更与失败)、memory_dependency_losses(每对来源的一次性扣减)、maintenance_batch_retries(迁移 036 起每批在第 1、3、7 天各一次)、batch_message_refs(冻结 JSON 段的引用索引),扩展 admin_operations 的对象字段,并在 memories 保存中等重要度的衰减余数和 purged_at。升级回填批次引用、当前失效依据及人工修订操作标识;在事务内延迟外键检查并原样重建 messages 为 AUTOINCREMENT,防止已清理消息的 ID 被复用。原始记忆与检索排序保持原样。

`adjust_retention(store, id, delta=0, *, value=None, floor=None, expected_revision=None, current=None, settings=None, _conn=None)` 接受原子增量、绝对值或下限;有 _conn 时加入调用者的短事务,否则自行开启 Store.write。缺失/已删除/修订不符返回 None;成功返回最新行。截断、双阈值与 forgotten_at 同事务,不加修订号,不写日常修订。`confirm_retention(..., _conn=conn)` 读取 confirmation_increment 并更新 last_confirmed_at;学习已在 confirm_once 中调用,confirmed_once 保证每批一次,默认 +5;显式确认与写入去重共用同一事务和集合。在途记忆仅转为遗忘且修订号不变时仍可确认,修正/反驳继续要求有效状态。反馈、管理员强度、维护衰减及 M11 已共用此接口。

Expand Down
11 changes: 8 additions & 3 deletions src/iris/learning.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ def _validate_output_shape(output: Any, raw: str) -> None:
raise ValueError("invalid learning output shape (" + "; ".join(errors) + ")")


def _dropped_memory(item: dict[str, Any], reason: str) -> dict[str, Any]:
# The batch result is JSON; the runtime vector attached before _apply is not.
return {"section": "memories", "reason": reason,
"item": {key: value for key, value in item.items() if key != "_embedding"}}


def _score(value: Any, default: int) -> int:
try:
return max(0, min(100, round(float(value))))
Expand Down Expand Up @@ -781,7 +787,7 @@ def self_output_only(memory: Any, evidence: list[int]) -> bool:
"SELECT 1 FROM memories WHERE id=? AND revision=? AND lifecycle='active'", (m['id'],m['revision'] + (m['id'] in updated))).fetchone()
for m in derived)
if stale:
dropped.append({'section':'memories','item':item,'reason':'derived memory changed or invisible'})
dropped.append(_dropped_memory(item, 'derived memory changed or invisible'))
continue
incoming_scope = visibility.evidence(item['evidence'], [m['id'] for m in derived])
speaker_id = canonical_subject(conn, item["speaker_id"])
Expand Down Expand Up @@ -818,8 +824,7 @@ def same_meaning(memory: Any) -> bool:
if duplicate:
if duplicate["id"] not in created:
if self_output_only(duplicate, item["evidence"]):
dropped.append({"section": "memories", "reason": SELF_ONLY_CONFIRMATION,
"item": {k: v for k, v in item.items() if k != "_embedding"}})
dropped.append(_dropped_memory(item, SELF_ONLY_CONFIRMATION))
else:
confirm_once(duplicate["id"])
for message_id in item["evidence"]:
Expand Down
62 changes: 62 additions & 0 deletions tests/test_learning_dropped_serialization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Dropped items are saved in the batch result, so runtime vectors must stay out of them."""
import json

from conftest import FakeGateway, msg
from iris.learning import LearningEngine, PROMPT_VERSION
from iris.models import ModelConfig
from iris.queue import form_batch
from test_batches import memory


def run(store, output, hook=None):
formed = form_batch(store, "A", PROMPT_VERSION, target_count=1, future_count=2, history_count=2)
gateway = FakeGateway(output, hook=hook)
gateway.configs["embedding"] = ModelConfig("fake", "", "fake-embedding")
return formed, LearningEngine(store, gateway).run_batch(formed.id, force=True)


def stored_result(store, batch_id):
with store.read() as conn:
row = conn.execute("SELECT state,result_json FROM batches WHERE id=?", (batch_id,)).fetchone()
return row["state"], json.loads(row["result_json"])


def assert_no_runtime_fields(result):
assert all("_embedding" not in item["item"] for item in result["dropped"] if isinstance(item["item"], dict))


def test_stale_derived_memory_drop_does_not_fail_the_batch(store):
msg(store, 1, "小林养了一只猫")
_, seeded = run(store, {"memories": [memory()]})
source = seeded["created"][0]
msg(store, 2, "小林最近常去宠物店")

def forget_source(_):
with store.write() as conn:
conn.execute("UPDATE memories SET lifecycle='forgotten' WHERE id=?", (source,))

output = {"memories": [{**memory("小林常去宠物店", [2]), "derived_from": ["M1"]}]}
# The material must offer M1 for the derived reference to pass validation.
formed, result = run(store, output, hook=forget_source)

assert result["created"] == []
assert [item["reason"] for item in result["dropped"]] == ["derived memory changed or invisible"]
assert_no_runtime_fields(result)
state, stored = stored_result(store, formed.id)
assert state == "succeeded"
assert stored["dropped"][0]["item"]["content"] == "小林常去宠物店"
assert_no_runtime_fields(stored)


def test_batch_limit_drops_stay_serializable_with_embeddings(store):
msg(store, 1, "小林列了很多爱好")
output = {"memories": [memory(f"小林喜欢第{i}种爱好") for i in range(22)]}

formed, result = run(store, output)

assert len(result["created"]) == 20
assert [item["reason"] for item in result["dropped"]] == ["batch memory limit 20"] * 2
assert_no_runtime_fields(result)
state, stored = stored_result(store, formed.id)
assert state == "succeeded"
assert_no_runtime_fields(stored)