659 lines
23 KiB
Python
659 lines
23 KiB
Python
import asyncio
|
|
from datetime import datetime
|
|
import json
|
|
|
|
from hy_memory.models.memory import (
|
|
MemoryLayer,
|
|
MemoryNode,
|
|
MemoryStatus,
|
|
SourceType,
|
|
)
|
|
from hy_memory.pipelines.balanced_catalog import (
|
|
CATALOG_MAX_PER_DOMAIN,
|
|
CATALOG_MAX_TOTAL,
|
|
CATALOG_VERSION,
|
|
CATALOG_SCHEMA_KIND,
|
|
attach_balanced_catalog_to_l0,
|
|
catalog_reject_reason,
|
|
collect_forget_targets,
|
|
compact_verified_catalog_entries,
|
|
eligible_catalog_facts,
|
|
normalize_catalog_selection,
|
|
prepare_catalog_entries,
|
|
run_balanced_catalog_generation,
|
|
suppress_forget_targets,
|
|
source_bank_fingerprint,
|
|
)
|
|
from hy_memory.pipelines._retrieval.profile_evidence import reverse_lookup_l6
|
|
from hy_memory.pipelines.system2_tools import run_existing_schema_compaction
|
|
|
|
|
|
def run(value):
|
|
return asyncio.run(value)
|
|
|
|
|
|
def fact(node_id, content, *, owner="user", status=MemoryStatus.ACTIVE, latest=True):
|
|
return MemoryNode(
|
|
node_id=node_id,
|
|
user_id="u1",
|
|
agent_id="a1",
|
|
layer=MemoryLayer.L2_FACT,
|
|
content=content,
|
|
owner=owner,
|
|
status=status,
|
|
is_latest=latest,
|
|
tags=["tag"],
|
|
)
|
|
|
|
|
|
def catalog(node_id="catalog", content="System2 verified persona catalog:\n- [habits_items] X"):
|
|
return MemoryNode(
|
|
node_id=node_id,
|
|
user_id="u1",
|
|
agent_id="",
|
|
layer=MemoryLayer.L6_SCHEMA,
|
|
content=content,
|
|
status=MemoryStatus.ACTIVE,
|
|
source_type=SourceType.COMPOSITE,
|
|
custom={
|
|
"schema_kind": CATALOG_SCHEMA_KIND,
|
|
"balanced_catalog_version": CATALOG_VERSION,
|
|
"source_bank_fingerprint": "fp",
|
|
"selected_count": 1,
|
|
},
|
|
)
|
|
|
|
|
|
def test_normalize_accepts_wrapped_and_unwrapped_domains():
|
|
wrapped = {"domains": {"habits_items": [2, 1], "health_accessibility": [3]}}
|
|
unwrapped = {"habits_items": [2, 1], "health_accessibility": [3]}
|
|
assert normalize_catalog_selection(wrapped, candidate_count=3) == normalize_catalog_selection(
|
|
unwrapped, candidate_count=3
|
|
)
|
|
|
|
|
|
def test_verifier_rejects_forget_and_generic_questions():
|
|
assert catalog_reject_reason(
|
|
"The user asked the assistant to forget that they like jazz."
|
|
) == "forget"
|
|
assert catalog_reject_reason(
|
|
"The user asked why jazz harmony sounds unusual."
|
|
) == "generic_question"
|
|
assert catalog_reject_reason(
|
|
"The user collects first-edition jazz biographies."
|
|
) == ""
|
|
|
|
|
|
def test_collect_forget_targets_accepts_only_explicit_markers():
|
|
nodes = [
|
|
fact("marker", "The user asked the assistant to forget that they enjoy iced coffee."),
|
|
fact("ordinary", "The user sometimes forgets to bring an umbrella."),
|
|
]
|
|
assert collect_forget_targets(nodes) == ["they enjoy iced coffee"]
|
|
|
|
|
|
def test_full_anchor_suppression_removes_target_not_weak_overlap():
|
|
entries = [
|
|
{"node_id": "drop", "domain": "preferences_collections",
|
|
"content": "The user enjoys iced coffee."},
|
|
{"node_id": "keep", "domain": "work_education",
|
|
"content": "The user works in a coffee shop supply company."},
|
|
]
|
|
kept, suppressed = suppress_forget_targets(entries, ["they enjoy iced coffee"])
|
|
assert [row["node_id"] for row in kept] == ["keep"]
|
|
assert suppressed == [{"node_id": "drop", "domain": "preferences_collections"}]
|
|
|
|
|
|
def test_prepare_catalog_entries_keeps_exact_l2_text():
|
|
facts = [
|
|
fact("f1", "The user carries a steel bottle every day."),
|
|
fact("f2", "The user asked the assistant to forget a preference."),
|
|
]
|
|
entries, rejected = prepare_catalog_entries(
|
|
{"habits_items": [1, 2]}, facts
|
|
)
|
|
assert [row["node_id"] for row in entries] == ["f1"]
|
|
assert entries[0]["content"] == facts[0].content
|
|
assert rejected[0]["reason"] == "forget"
|
|
|
|
|
|
def test_verified_catalog_keeps_four_per_domain_and_thirty_two_total():
|
|
entries = []
|
|
for domain_index, domain in enumerate((
|
|
"identity_relationships",
|
|
"health_accessibility",
|
|
"preferences_collections",
|
|
"habits_items",
|
|
"work_education",
|
|
"culture_creative_experience",
|
|
"values_coping",
|
|
"current_constraints_goals",
|
|
)):
|
|
for item_index in range(4):
|
|
entries.append({
|
|
"domain": domain,
|
|
"node_id": f"d{domain_index}-{item_index}",
|
|
"content": f"fact {domain_index}-{item_index}",
|
|
})
|
|
compacted = compact_verified_catalog_entries(entries)
|
|
assert len(compacted) == CATALOG_MAX_TOTAL == 32
|
|
for domain in {entry["domain"] for entry in compacted}:
|
|
assert sum(entry["domain"] == domain for entry in compacted) \
|
|
== CATALOG_MAX_PER_DOMAIN == 4
|
|
assert [entry["node_id"] for entry in compacted[:4]] == [
|
|
"d0-0", "d0-1", "d0-2", "d0-3",
|
|
]
|
|
|
|
|
|
def test_verified_compaction_backfills_after_rejected_nominee():
|
|
facts = [
|
|
fact("question", "The user asked why steel bottles are recyclable."),
|
|
fact("keep1", "The user carries a steel bottle every day."),
|
|
fact("keep2", "The user packs a canvas lunch bag for work."),
|
|
fact("keep3", "The user keeps a paper planner on the desk."),
|
|
]
|
|
selected, rejected = prepare_catalog_entries(
|
|
{"habits_items": [1, 2, 3, 4]}, facts,
|
|
)
|
|
compacted = compact_verified_catalog_entries(selected)
|
|
assert rejected[0]["node_id"] == "question"
|
|
assert [entry["node_id"] for entry in compacted] == [
|
|
"keep1", "keep2", "keep3",
|
|
]
|
|
|
|
|
|
def test_eligible_facts_are_active_latest_user_l2_only():
|
|
nodes = [
|
|
fact("keep", "The user likes tea."),
|
|
fact("old", "The user liked coffee.", latest=False),
|
|
fact("shadow", "The user likes milk.", status=MemoryStatus.SHADOW),
|
|
fact("agent", "The user likes cake.", owner="assistant"),
|
|
MemoryNode(node_id="l3", layer=MemoryLayer.L3_SUMMARY, content="The user summary"),
|
|
]
|
|
assert [node.node_id for node in eligible_catalog_facts(nodes)] == ["keep"]
|
|
|
|
|
|
def test_attach_catalog_changes_only_one_l0():
|
|
l0_node = MemoryNode(
|
|
node_id="l0", user_id="u1", agent_id="", layer=MemoryLayer.L0_BASIC_INFO,
|
|
content="The user's name is Alice.",
|
|
)
|
|
original = [{"node_id": "l0", "node": l0_node, "content": l0_node.content, "score": 1.0}]
|
|
updated, info = attach_balanced_catalog_to_l0(original, catalog())
|
|
assert info["attached"] is True
|
|
assert len(updated) == 1
|
|
assert updated[0]["content"] == l0_node.content
|
|
assert "System2 verified persona catalog" in updated[0]["_balanced_catalog_suffix"]
|
|
assert original[0]["content"] == l0_node.content
|
|
|
|
|
|
def test_attach_catalog_builds_synthetic_l0_when_basic_profile_is_missing():
|
|
cat = catalog()
|
|
rows, info = attach_balanced_catalog_to_l0([], cat)
|
|
assert info["attached"] is True
|
|
assert info["synthetic_l0"] is True
|
|
assert len(rows) == 1
|
|
assert rows[0]["node"] is None
|
|
assert rows[0]["layer"] == MemoryLayer.L0_BASIC_INFO.value
|
|
assert rows[0]["content"] == cat.content
|
|
assert rows[0]["source"] == "balanced_catalog_synthetic_l0"
|
|
|
|
|
|
def test_attach_catalog_refuses_multiple_l0():
|
|
cat = catalog()
|
|
rows = [{"node_id": "1"}, {"node_id": "2"}]
|
|
assert attach_balanced_catalog_to_l0(rows, cat)[1]["attached"] is False
|
|
|
|
|
|
class FakeVectorStore:
|
|
def __init__(self, facts):
|
|
self.facts = facts
|
|
self.calls = []
|
|
|
|
async def list_by_user(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
return self.facts
|
|
|
|
async def get_by_id(self, node_id):
|
|
return next((node for node in self.facts if node.node_id == node_id), None)
|
|
|
|
|
|
class FakeGraphStore:
|
|
def __init__(self, nodes=None):
|
|
self.nodes = {node.node_id: node for node in (nodes or [])}
|
|
self.upserts = []
|
|
self.links = []
|
|
self.updates = []
|
|
self.fail_update_ids = set()
|
|
|
|
async def get_all_nodes(self, isolation_key, layer=None, status=None, limit=100):
|
|
return [
|
|
node for node in self.nodes.values()
|
|
if (layer is None or node.layer == layer)
|
|
and (status is None or node.status == status)
|
|
][:limit]
|
|
|
|
async def get_nodes_by_user(self, user_id, layer=None, status=None, limit=100):
|
|
return [
|
|
node for node in self.nodes.values()
|
|
if node.user_id == user_id
|
|
and (layer is None or node.layer == layer)
|
|
and (status is None or node.status == status)
|
|
][:limit]
|
|
|
|
async def get_node(self, node_id):
|
|
return self.nodes.get(node_id)
|
|
|
|
async def upsert_memory_node(self, node):
|
|
self.nodes[node.node_id] = node
|
|
self.upserts.append(node.node_id)
|
|
return node.node_id
|
|
|
|
async def ensure_vdbref(self, node_id, layer):
|
|
return None
|
|
|
|
async def add_derived_from(self, memory_node_id, vdbref_node_id):
|
|
self.links.append((memory_node_id, vdbref_node_id))
|
|
return True
|
|
|
|
async def update_node(self, node_id, updates):
|
|
self.updates.append((node_id, updates))
|
|
if node_id in self.fail_update_ids:
|
|
return False
|
|
node = self.nodes[node_id]
|
|
if "status" in updates:
|
|
node.status = MemoryStatus(updates["status"])
|
|
return True
|
|
|
|
async def delete_node(self, node_id):
|
|
self.nodes.pop(node_id, None)
|
|
return True
|
|
|
|
|
|
def test_same_bank_catalog_is_zero_llm_zero_write(monkeypatch):
|
|
facts = [fact("f1", "The user carries a steel bottle every day.")]
|
|
fingerprint = source_bank_fingerprint(facts)
|
|
current = catalog()
|
|
current.custom["source_bank_fingerprint"] = fingerprint
|
|
graph = FakeGraphStore([current])
|
|
monkeypatch.setenv("MEMORY_EVOLING_BALANCED_CATALOG_ENABLED", "true")
|
|
|
|
class FailProvider:
|
|
def __init__(self, config):
|
|
raise AssertionError("LLM must not be constructed")
|
|
|
|
monkeypatch.setattr(
|
|
"hy_memory.pipelines.balanced_catalog.LLMProvider", FailProvider
|
|
)
|
|
result = run(run_balanced_catalog_generation(
|
|
user_id="u1", vector_store=FakeVectorStore(facts),
|
|
graph_store=graph, config=object(),
|
|
))
|
|
assert result["reason"] == "current_catalog_exists"
|
|
assert result["prompt_tokens"] == 0
|
|
assert graph.upserts == []
|
|
assert graph.links == []
|
|
assert graph.updates == []
|
|
|
|
|
|
def test_same_bank_legacy_catalog_is_rebuilt_at_current_version(monkeypatch):
|
|
facts = [fact("f1", "The user carries a steel bottle every day.")]
|
|
fingerprint = source_bank_fingerprint(facts)
|
|
current = catalog("old-v2")
|
|
current.custom["balanced_catalog_version"] = 2
|
|
current.custom["source_bank_fingerprint"] = fingerprint
|
|
graph = FakeGraphStore([current])
|
|
FakeProvider.response = json.dumps({"domains": {"habits_items": [1]}})
|
|
FakeProvider.calls = 0
|
|
monkeypatch.setenv("MEMORY_EVOLING_BALANCED_CATALOG_ENABLED", "true")
|
|
monkeypatch.setattr(
|
|
"hy_memory.pipelines.balanced_catalog.LLMProvider", FakeProvider
|
|
)
|
|
result = run(run_balanced_catalog_generation(
|
|
user_id="u1", vector_store=FakeVectorStore(facts),
|
|
graph_store=graph, config=object(),
|
|
))
|
|
assert result["success"] is True
|
|
assert result["catalog_node_id"] != "old-v2"
|
|
assert graph.nodes["old-v2"].status == MemoryStatus.SUPERSEDED
|
|
assert graph.nodes[result["catalog_node_id"]].custom[
|
|
"balanced_catalog_version"
|
|
] == CATALOG_VERSION == 4
|
|
assert FakeProvider.calls == 1
|
|
|
|
|
|
def test_cloned_catalog_is_adopted_by_exact_content_without_llm(monkeypatch):
|
|
facts = [
|
|
fact("new-f1", "The user carries a steel bottle every day."),
|
|
fact("new-f2", "The user collects European design books."),
|
|
]
|
|
old = catalog(
|
|
"cloned-old",
|
|
content=(
|
|
"System2 verified persona catalog:\n"
|
|
"- [preferences_collections] The user collects European design books.\n"
|
|
"- [habits_items] The user carries a steel bottle every day."
|
|
),
|
|
)
|
|
old.custom.update({
|
|
"balanced_catalog_version": CATALOG_VERSION,
|
|
"source_bank_fingerprint": "source-namespace-fingerprint",
|
|
"selected_count": 2,
|
|
"domains": {
|
|
"preferences_collections": ["source-f2"],
|
|
"habits_items": ["source-f1"],
|
|
},
|
|
})
|
|
graph = FakeGraphStore([old])
|
|
monkeypatch.setenv("MEMORY_EVOLING_BALANCED_CATALOG_ENABLED", "true")
|
|
|
|
class FailProvider:
|
|
def __init__(self, config):
|
|
raise AssertionError("clone adoption must not construct the LLM")
|
|
|
|
monkeypatch.setattr(
|
|
"hy_memory.pipelines.balanced_catalog.LLMProvider", FailProvider
|
|
)
|
|
result = run(run_balanced_catalog_generation(
|
|
user_id="u1", vector_store=FakeVectorStore(facts),
|
|
graph_store=graph, config=object(),
|
|
))
|
|
assert result["success"] is True
|
|
assert result["reason"] == "cloned_catalog_adopted"
|
|
assert result["selected_count"] == 2
|
|
assert result["prompt_tokens"] == 0
|
|
adopted = graph.nodes[result["catalog_node_id"]]
|
|
assert adopted.custom["clone_adoption_version"] == "exact-content-remap-v1"
|
|
assert adopted.agent_id == ""
|
|
assert adopted.evidence_chain == ["new-f2", "new-f1"]
|
|
assert adopted.custom["domains"]["preferences_collections"] == ["new-f2"]
|
|
assert graph.nodes["cloned-old"].status == MemoryStatus.SUPERSEDED
|
|
|
|
|
|
def test_agent_scoped_catalog_migrates_to_user_scope_without_llm(monkeypatch):
|
|
facts = [fact("f1", "The user carries a steel bottle every day.")]
|
|
old = catalog(
|
|
"agent-scoped-old",
|
|
content=(
|
|
"System2 verified persona catalog:\n"
|
|
"- [habits_items] The user carries a steel bottle every day."
|
|
),
|
|
)
|
|
old.agent_id = "agent-a"
|
|
old.evidence_chain = ["f1"]
|
|
old.custom.update({
|
|
"source_bank_fingerprint": source_bank_fingerprint(facts),
|
|
"selected_count": 1,
|
|
"domains": {"habits_items": ["f1"]},
|
|
})
|
|
graph = FakeGraphStore([old])
|
|
monkeypatch.setenv("MEMORY_EVOLING_BALANCED_CATALOG_ENABLED", "true")
|
|
|
|
class FailProvider:
|
|
def __init__(self, config):
|
|
raise AssertionError("agent-scope migration must not call the LLM")
|
|
|
|
monkeypatch.setattr(
|
|
"hy_memory.pipelines.balanced_catalog.LLMProvider", FailProvider,
|
|
)
|
|
result = run(run_balanced_catalog_generation(
|
|
user_id="u1", vector_store=FakeVectorStore(facts),
|
|
graph_store=graph, config=object(),
|
|
))
|
|
|
|
assert result["reason"] == "cloned_catalog_adopted"
|
|
migrated = graph.nodes[result["catalog_node_id"]]
|
|
assert migrated.agent_id == ""
|
|
assert migrated.custom["profile_scope"] == "user"
|
|
assert graph.nodes["agent-scoped-old"].status == MemoryStatus.SUPERSEDED
|
|
|
|
|
|
def test_catalog_with_any_still_valid_custom_id_is_not_clone_adopted(monkeypatch):
|
|
facts = [fact("f1", "The user carries a steel bottle every day.")]
|
|
old = catalog(
|
|
"same-namespace-old",
|
|
content=(
|
|
"System2 verified persona catalog:\n"
|
|
"- [habits_items] The user carries a steel bottle every day."
|
|
),
|
|
)
|
|
old.custom.update({
|
|
"balanced_catalog_version": CATALOG_VERSION,
|
|
"source_bank_fingerprint": "old-bank",
|
|
"selected_count": 1,
|
|
"domains": {"habits_items": ["f1"]},
|
|
})
|
|
graph = FakeGraphStore([old])
|
|
FakeProvider.response = json.dumps({"domains": {"habits_items": [1]}})
|
|
FakeProvider.calls = 0
|
|
monkeypatch.setenv("MEMORY_EVOLING_BALANCED_CATALOG_ENABLED", "true")
|
|
monkeypatch.setattr(
|
|
"hy_memory.pipelines.balanced_catalog.LLMProvider", FakeProvider
|
|
)
|
|
result = run(run_balanced_catalog_generation(
|
|
user_id="u1", vector_store=FakeVectorStore(facts),
|
|
graph_store=graph, config=object(),
|
|
))
|
|
assert result["success"] is True
|
|
assert result.get("reason") != "cloned_catalog_adopted"
|
|
assert FakeProvider.calls == 1
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, content):
|
|
self.content = content
|
|
self.prompt_tokens = 100
|
|
self.completion_tokens = 20
|
|
|
|
|
|
class FakeProvider:
|
|
response = ""
|
|
calls = 0
|
|
|
|
def __init__(self, config):
|
|
pass
|
|
|
|
async def complete_messages(self, messages, max_tokens):
|
|
type(self).calls += 1
|
|
return FakeResponse(type(self).response)
|
|
|
|
|
|
def test_generation_creates_exact_catalog_and_evidence_links(monkeypatch):
|
|
facts = [
|
|
fact("f1", "The user carries a steel bottle every day."),
|
|
fact("f2", "The user asked why steel bottles are recyclable."),
|
|
fact("f3", "The user collects European design books."),
|
|
]
|
|
graph = FakeGraphStore()
|
|
FakeProvider.response = json.dumps({
|
|
"domains": {
|
|
"habits_items": [1, 2],
|
|
"preferences_collections": [3],
|
|
}
|
|
})
|
|
FakeProvider.calls = 0
|
|
monkeypatch.setenv("MEMORY_EVOLING_BALANCED_CATALOG_ENABLED", "true")
|
|
monkeypatch.setattr(
|
|
"hy_memory.pipelines.balanced_catalog.LLMProvider", FakeProvider
|
|
)
|
|
result = run(run_balanced_catalog_generation(
|
|
user_id="u1", vector_store=FakeVectorStore(facts),
|
|
graph_store=graph, config=object(),
|
|
))
|
|
assert result["success"] is True
|
|
assert result["selected_count"] == 2
|
|
assert result["rejected"][0]["node_id"] == "f2"
|
|
assert result["rejected"][0]["reason"] == "generic_question"
|
|
assert FakeProvider.calls == 1
|
|
node = graph.nodes[result["catalog_node_id"]]
|
|
assert node.agent_id == ""
|
|
assert node.custom["schema_kind"] == CATALOG_SCHEMA_KIND
|
|
assert node.custom["balanced_catalog_version"] == 4
|
|
assert node.custom["catalog_max_per_domain"] == 4
|
|
assert node.custom["catalog_max_total"] == 32
|
|
assert node.embedding is None
|
|
assert "The user carries a steel bottle every day." in node.content
|
|
assert "The user collects European design books." in node.content
|
|
# Rendering/evidence order is the frozen domain order, not source index.
|
|
assert [edge[1] for edge in graph.links] == ["f3", "f1"]
|
|
|
|
|
|
def test_generation_uses_cross_agent_user_fact_bank(monkeypatch):
|
|
facts = [
|
|
fact("f-a", "The user carries a steel bottle every day."),
|
|
fact("f-b", "The user collects European design books."),
|
|
]
|
|
facts[0].agent_id = "agent-a"
|
|
facts[1].agent_id = "agent-b"
|
|
store = FakeVectorStore(facts)
|
|
graph = FakeGraphStore()
|
|
FakeProvider.response = json.dumps({
|
|
"domains": {
|
|
"habits_items": [1],
|
|
"preferences_collections": [2],
|
|
},
|
|
})
|
|
FakeProvider.calls = 0
|
|
monkeypatch.setenv("MEMORY_EVOLING_BALANCED_CATALOG_ENABLED", "true")
|
|
monkeypatch.setattr(
|
|
"hy_memory.pipelines.balanced_catalog.LLMProvider", FakeProvider,
|
|
)
|
|
|
|
result = run(run_balanced_catalog_generation(
|
|
user_id="u1", vector_store=store, graph_store=graph, config=object(),
|
|
))
|
|
|
|
assert result["success"] is True
|
|
node = graph.nodes[result["catalog_node_id"]]
|
|
assert node.agent_id == ""
|
|
assert set(node.evidence_chain) == {"f-a", "f-b"}
|
|
assert "agent_id" not in store.calls[0]
|
|
|
|
|
|
def test_generation_suppresses_selected_forget_target(monkeypatch):
|
|
facts = [
|
|
fact("target", "The user enjoys iced coffee."),
|
|
fact("safe", "The user collects European design books."),
|
|
fact(
|
|
"marker",
|
|
"The user asked the assistant to forget that they enjoy iced coffee.",
|
|
),
|
|
]
|
|
graph = FakeGraphStore()
|
|
FakeProvider.response = json.dumps({
|
|
"domains": {
|
|
# Candidate order is deterministic by node_id: marker, safe, target.
|
|
"preferences_collections": [2, 3],
|
|
}
|
|
})
|
|
monkeypatch.setenv("MEMORY_EVOLING_BALANCED_CATALOG_ENABLED", "true")
|
|
monkeypatch.setattr(
|
|
"hy_memory.pipelines.balanced_catalog.LLMProvider", FakeProvider
|
|
)
|
|
result = run(run_balanced_catalog_generation(
|
|
user_id="u1", vector_store=FakeVectorStore(facts),
|
|
graph_store=graph, config=object(),
|
|
))
|
|
assert result["success"] is True
|
|
assert result["selected_count"] == 1
|
|
assert result["suppressed_forget_target_entries"] == [
|
|
{"node_id": "target", "domain": "preferences_collections"}
|
|
]
|
|
node = graph.nodes[result["catalog_node_id"]]
|
|
assert "iced coffee" not in node.content
|
|
assert "European design books" in node.content
|
|
assert node.custom["suppressed_forget_target_count"] == 1
|
|
assert [edge[1] for edge in graph.links] == ["safe"]
|
|
|
|
|
|
def test_changed_bank_creates_new_version_and_retires_old(monkeypatch):
|
|
old = catalog("old")
|
|
old.custom["source_bank_fingerprint"] = "old-bank"
|
|
graph = FakeGraphStore([old])
|
|
facts = [fact("f1", "The user carries a steel bottle every day.")]
|
|
FakeProvider.response = json.dumps({"domains": {"habits_items": [1]}})
|
|
FakeProvider.calls = 0
|
|
monkeypatch.setenv("MEMORY_EVOLING_BALANCED_CATALOG_ENABLED", "true")
|
|
monkeypatch.setattr(
|
|
"hy_memory.pipelines.balanced_catalog.LLMProvider", FakeProvider
|
|
)
|
|
result = run(run_balanced_catalog_generation(
|
|
user_id="u1", vector_store=FakeVectorStore(facts),
|
|
graph_store=graph, config=object(),
|
|
))
|
|
assert result["success"] is True
|
|
assert result["catalog_node_id"] != "old"
|
|
assert graph.nodes["old"].status == MemoryStatus.SUPERSEDED
|
|
assert result["retired_catalog_ids"] == ["old"]
|
|
|
|
|
|
def test_failed_retirement_rolls_back_new_catalog(monkeypatch):
|
|
old = catalog("old")
|
|
old.custom["source_bank_fingerprint"] = "old-bank"
|
|
graph = FakeGraphStore([old])
|
|
graph.fail_update_ids.add("old")
|
|
facts = [fact("f1", "The user carries a steel bottle every day.")]
|
|
FakeProvider.response = json.dumps({"domains": {"habits_items": [1]}})
|
|
monkeypatch.setenv("MEMORY_EVOLING_BALANCED_CATALOG_ENABLED", "true")
|
|
monkeypatch.setattr(
|
|
"hy_memory.pipelines.balanced_catalog.LLMProvider", FakeProvider
|
|
)
|
|
result = run(run_balanced_catalog_generation(
|
|
user_id="u1", vector_store=FakeVectorStore(facts),
|
|
graph_store=graph, config=object(),
|
|
))
|
|
assert result["success"] is False
|
|
assert set(graph.nodes) == {"old"}
|
|
assert graph.nodes["old"].status == MemoryStatus.ACTIVE
|
|
|
|
|
|
class ReverseGraph:
|
|
async def find_referencing_memories(self, ids, limit=50):
|
|
return [
|
|
{
|
|
"node_id": "catalog", "content": "catalog", "layer": "l6_schema",
|
|
"custom_json": json.dumps({"schema_kind": CATALOG_SCHEMA_KIND}),
|
|
"evidence_vdb_id": "f1",
|
|
},
|
|
{
|
|
"node_id": "catalog_supplement_x", "content": "supplement",
|
|
"layer": "l6_schema",
|
|
"custom_json": json.dumps({
|
|
"schema_kind": "specific_persona_supplement",
|
|
}),
|
|
"evidence_vdb_id": "f1",
|
|
},
|
|
{
|
|
"node_id": "schema", "content": "schema", "layer": "l6_schema",
|
|
"custom_json": json.dumps({"schema_kind": "behavior_pattern"}),
|
|
"evidence_vdb_id": "f1",
|
|
},
|
|
]
|
|
|
|
|
|
def test_reverse_lookup_excludes_catalog():
|
|
hits = run(reverse_lookup_l6(ReverseGraph(), ["f1"]))
|
|
assert [hit["node_id"] for hit in hits] == ["schema"]
|
|
|
|
|
|
class NoopExecutor:
|
|
pass
|
|
|
|
|
|
class NoopEmbed:
|
|
async def embed_batch(self, texts):
|
|
raise AssertionError("catalog should be excluded before embedding")
|
|
|
|
|
|
def test_schema_compaction_excludes_catalog(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hy_memory.pipelines.system2_tools._S2_SCHEMA_COMPACTION_ENABLED", True
|
|
)
|
|
graph = FakeGraphStore([catalog("c1"), catalog("c2")])
|
|
result = run(run_existing_schema_compaction(
|
|
graph_store=graph, tool_executor=NoopExecutor(), embed_service=NoopEmbed(),
|
|
isolation_key="u1::a1::default_session",
|
|
))
|
|
assert result["scanned"] == 0
|
|
assert result["merged"] == 0
|