Files
CyberGril/tests/test_semantic.py
T
zonggongandmultica-agent 78b6267d3a HEL-885 rework: per-row progress matches what was really sent, cross-day and unknown-outcome accounting stay conservative, positive caps require the ledger authority, and the release dependency-fill command actually exists - every Memory.add now carries an anchor for exactly its own row (the HEL-882 whole-batch anchor let the first stored vector mark unsent rows complete; the manager's real-SDK partial reproduction showed the user's original words never leaving the process), a row finishing with a legitimately EMPTY extraction gets a durable sem_row_done record (r4.sql additive table) written inside the worker thread the moment its add completes, resume skips = vector-store anchors ∪ sem_row_done so no row is lost, none falsely complete, and finished work is never re-sent across budget-refusal/crash/restart/lease/concurrency, entry_ids merge across partial attempts, and the swallowed-failure detection is per-row; PersistentBudget.settle/release book on hold.day (the old write to state.day manufactured -80 used on the new day and re-opened headroom the reservation day had already spent; token_usage/request_usage refund the paying day, budget_total stays global) and the empty-budget_total TypeError crash in total_requests_used/total_tokens_used is fixed - the old one-shot-total test passed only because that crash surfaced as chat.error, it now books the total honestly; _atomic_reserve no longer purges aged reserve: rows (a crashed worker's hold is an UNKNOWN outcome - an hour-old timeout is not proof the call never happened; unknown consumption stays billed across restarts, only a provably-unsent release() ever removes a hold, embed_ops and llm_ops share the fix); Mem0Backend refuses construction before importing mem0 when any cap>0 lacks a (non-empty dict) authority block - ledger_authority_required - or when the prefix check fails, closing the fresh-empty-ledger bypass the manager reproduced live, config/semantic.example.yaml ships the authority block as REQUIRED with the explicit cumulative formula (embed_cap=500+approved-increment e.g. 680, embed_reserve=2 frozen untouched, min_used=498; never write 180 as the cumulative cap of a 498-used account) mirrored into docs/budget-plan-hel874.md §2 with the HEL-885 accounting rules; deploy/hel874-release.md §4 replaces the nonexistent uv pip download (rc=2, re-verified) with python3 -m pip download --only-binary :all: --platform manylinux2014_x86_64 --python-version 3.11 --implementation cp --abi cp311 + sha256 manifest + uv pip install --no-index --find-links offline merge + same-process co-import verdict, honest missing-materials list unchanged (no 3.11 wheels cached, zero downloads this card, no version swaps), and tools/check_release_offline_hel882.py gains R7 probing both command surfaces offline; verification: 281 python tests pass on 3.11.16 venv + requirements.lock offline (9 new tests/test_rework_hel885.py cases: partial batch claims only the sent row / empty-extraction row completes and is never resent / process restart sends only the missing row / concurrent ticks never double-send / cross-day settle and release book on the reservation day / aged unknown llm+embed reservations never refunded / missing-empty-malformed-wrong authority all fail closed; test_resume_never_loses_or_duplicates_rows rewritten to drive the REAL product path instead of hand-planting single-row anchors the product never wrote), ruff clean (src/tests/tools), web tsc + 42 vitest + build via npm ci --offline, pinned mem0 2.2.1 install on the deterministic boundary: contract re-run 12/12 (harness now hands a synthetic authority prefix - production defaults not relaxed), new tools/verify_semantic_recovery_hel885.py 7/7 (S0 authority fail-closed, S1 per-row partial with exactly 1 HTTP, S2 restart sends only the missing row proven by HTTP count since mem0 prompts legitimately carry old rows in rolling history, S3 lease recovery + concurrent ticks single-send, S4a/S4b unknown reservations kept), both manager review scripts re-run VERBATIM against this head (partial: completed=[2] only, resume done with 2 entries, HTTP=2, user words actually sent; extra: new-day used=0, over-cap request refused, aged reservation second_allowed=false, uv probe still rc=2), release offline check R1-R7 green, dual-target requirement checks 4/4 with handoff-HEL-885.json; zero real LLM, zero real embeddings, zero non-loopback outbound, authoritative ledgers and freezes untouched, not deployed, PR not merged, no visual changes
Co-authored-by: multica-agent <github@multica.ai>
2026-10-27 00:01:44 +08:00

168 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""R4 semantic wiring: pre-grouped extraction, fail-closed provenance gate, restarts.
All deterministic with the stub backend — zero model calls, zero embeddings.
The gate negative cases mirror the audited HEL-859–867 rejection forms ported
onto the product's own tables (main archive + PublicView share state).
"""
import json
import pytest
from cybergril.memory.semantic import (
SemanticMemory,
StubBackend,
anchor_fingerprint,
build_semantic,
)
from cybergril.memory.store import Store
@pytest.fixture
def store(tmp_path):
s = Store(tmp_path / "sem.db")
yield s
s.close()
def add_turn(store, turn, user_text, companion_text=None, *, ts=1700000000000):
with store.conn:
store.execute("INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,'user',?,?,'sent')",
(ts, user_text, turn))
if companion_text is not None:
store.execute(
"INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,'companion',?,?,'sent')",
(ts + 1, companion_text, turn))
def make_public_companion(store, turn):
"""Mark this turn's companion row public: judged+acked attempt, all-told candidates."""
row = store.query_one("SELECT id FROM chat_message WHERE turn_id=? AND role='companion'", (turn,))
event = store.execute(
"INSERT INTO life_event(ts,kind,content,place,valence,salience,source,told,day_key) "
"VALUES(?,?,?,?,?,?,'generated',1,?)",
(1700000000000, "thought", "已讲的生活", "卧室", 0.2, 0.6, "2026-10-26"),
).lastrowid
with store.conn:
store.execute(
"INSERT INTO sharing_attempt(message_id,turn_id,epoch,token,text_hash,expires_at,candidates,"
"state,selected,ack_at) VALUES(?,?,1,'t','h',9223372036854775806,?,'judged',?,1700000001000)",
(row["id"], turn, json.dumps([event]), json.dumps([event])),
)
return row["id"]
@pytest.fixture
def sem(store):
return SemanticMemory(store, StubBackend())
async def test_only_public_rows_enter_batches(store, sem):
add_turn(store, "t1", "我说了蓝色", "我听到了蓝色") # companion row not shared yet
sem.enqueue_uncovered()
job = store.query_one("SELECT * FROM sem_batch")
# user rows are the user's own speech and always public; the unshared
# companion row stays out of every outbound batch.
anchor = json.loads(job["anchor_json"])
assert [r[1] for r in anchor["turn_roles"]] == ["user"]
rows = sem.public_rows_for_turn("t1")
assert {r["role"] for r in rows} == {"user"}
assert anchor["input_sha256"] == anchor_fingerprint(rows)
# once shared, the companion row gets its own deterministic batch
make_public_companion(store, "t1")
sem.enqueue_uncovered()
jobs = store.query("SELECT * FROM sem_batch ORDER BY id")
assert len(jobs) == 2
assert {r[1] for r in json.loads(jobs[1]["anchor_json"])["turn_roles"]} == {"companion"}
# one archive row enters at most one batch, ever
sem.enqueue_uncovered()
assert store.query_one("SELECT COUNT(*) n FROM sem_batch")["n"] == 2
async def test_gate_rejects_mismatched_fingerprint_and_missing_rows(store, sem):
add_turn(store, "t2", "钥匙放在抽屉里", "好,我记住抽屉了")
make_public_companion(store, "t2")
sem.enqueue_uncovered()
await sem.tick()
assert store.query_one("SELECT state FROM sem_batch")["state"] == "done"
hits = await sem.backend.search("钥匙")
assert hits
# archive row rewritten after the fact -> fingerprint no longer matches
with store.conn:
store.execute("UPDATE chat_message SET text='被改写过的原话' WHERE turn_id='t2' AND role='user'")
chain = sem.chain_resolve(hits[0])
assert not chain["resolved"] and chain["reject_reason"] == "source_mismatch"
block = await sem.context("钥匙放在哪里", [], 600)
assert block == ""
async def test_gate_rejects_fabricated_and_foreign_hits(store, sem):
add_turn(store, "t3", "我最爱海雾的颜色", "海边的感觉很好")
make_public_companion(store, "t3")
sem.enqueue_uncovered()
await sem.tick()
good = (await sem.backend.search("海雾"))[0]
assert sem.chain_resolve(good)["resolved"]
# a hit whose payload anchor is missing entirely
assert sem.chain_resolve({"id": "stub-999", "memory": "任意文本"})["reject_reason"] == "no_payload"
# a real entry but text swapped in flight (hit_text_mismatch form)
forged = dict(good)
forged["memory"] = good["memory"] + "捏造的尾巴"
chain = sem.chain_resolve(forged)
assert chain["reject_reason"] == "hit_text_mismatch"
async def test_gate_rejects_unshared_companion_row(store, sem):
add_turn(store, "t4", "今天面试结束了", "辛苦了,等你好消息")
make_public_companion(store, "t4")
sem.enqueue_uncovered()
await sem.tick()
# per-row anchors (HEL-885): the companion's own entry anchors only her row
hits = await sem.backend.search("辛苦")
assert sem.chain_resolve(hits[0])["resolved"]
# share state revoked at read time (attempt no longer acked) -> fail closed
with store.conn:
store.execute("UPDATE sharing_attempt SET ack_at=NULL WHERE turn_id='t4'")
chain = sem.chain_resolve(hits[0])
assert chain["reject_reason"] == "companion_unshared"
block = await sem.context("辛苦了等什么", [], 600)
assert "辛苦" not in block
# her row's rejection never touches the user row's own entry
user_hit = (await sem.backend.search("面试"))[0]
assert sem.chain_resolve(user_hit)["resolved"]
async def test_context_respects_budget_and_excludes_turns(store, sem):
add_turn(store, "t5", "窗台的多肉冒了新芽", "新芽很可爱")
make_public_companion(store, "t5")
sem.enqueue_uncovered()
await sem.tick()
block = await sem.context("多肉", [], 600)
assert block.startswith("她确实记得的更早的事")
assert "多肉" in block
# the current turn's own rows are never recalled as "earlier" memory
assert await sem.context("多肉", ["t5"], 600) == ""
assert await sem.context("多肉", [], 10) == ""
async def test_pending_batch_restart_recovery_never_resends(store, sem):
add_turn(store, "t6", "备忘:周五看牙医", "记住了")
make_public_companion(store, "t6")
sem.enqueue_uncovered()
with store.conn:
store.execute("UPDATE sem_batch SET state='pending',lease_until=1,owner='dead'")
sem.recover()
job = store.query_one("SELECT state,reason FROM sem_batch")
assert job["state"] == "failed" and job["reason"] == "lease_expired"
# failed batches retry within attempts bound; coverage rows prevent duplicates
await sem.tick()
assert store.query_one("SELECT state FROM sem_batch")["state"] == "done"
assert store.query_one("SELECT COUNT(*) n FROM sem_covered")["n"] == 2
def test_build_semantic_modes(store):
assert build_semantic(store, mode="off") is None
stub = build_semantic(store, mode="stub")
assert stub.backend.backend == "stub"
# mem0 without an approved embed cap fails closed to disabled
assert build_semantic(store, mode="mem0", config={}) is None