logic-engine / tests /test_ace_core.py
ghostdrive1's picture
Upload folder using huggingface_hub
116524e verified
Raw
History Blame Contribute Delete
19.1 kB
"""Tests for ace core: Skillbook, SkillbookView, ACEStepContext."""
from __future__ import annotations
import json
import threading
from dataclasses import FrozenInstanceError
from unittest.mock import patch
import pytest
from ace.core.context import ACEStepContext, SkillbookView
from ace.core.insight_source import InsightSource
from ace.core.outputs import AgentOutput, ReflectorOutput
from ace.core.skillbook import (
Skill,
Skillbook,
UpdateBatch,
UpdateOperation,
)
# ------------------------------------------------------------------ #
# Skillbook CRUD
# ------------------------------------------------------------------ #
class TestSkillbookCRUD:
def test_add_and_get_skill(self):
sb = Skillbook()
skill = sb.add_skill("math", "Use division for fractions")
assert skill.section == "context"
assert skill.keywords == ["math"]
assert skill.issue == "Use division for fractions"
assert skill.insight == "Use division for fractions"
assert sb.get_skill(skill.id) is skill
def test_add_skill_custom_id(self):
sb = Skillbook()
skill = sb.add_skill("math", "issue", skill_id="custom-001")
assert skill.id == "custom-001"
assert sb.get_skill("custom-001") is skill
def test_update_skill(self):
sb = Skillbook()
skill = sb.add_skill("math", "old content")
updated = sb.update_skill(skill.id, insight="new content")
assert updated is not None
assert updated.insight == "new content"
def test_update_nonexistent_skill(self):
sb = Skillbook()
assert sb.update_skill("missing-id", insight="x") is None
def test_remove_skill_hard(self):
sb = Skillbook()
skill = sb.add_skill("math", "issue")
sb.remove_skill(skill.id, soft=False)
assert sb.get_skill(skill.id) is None
assert len(sb.skills()) == 0
def test_remove_skill_soft(self):
sb = Skillbook()
skill = sb.add_skill("math", "issue")
sb.remove_skill(skill.id)
assert sb.get_skill(skill.id) is not None
assert skill.active is False
assert len(sb.skills()) == 0 # active only
assert len(sb.skills(include_invalid=True)) == 1
def test_remove_nonexistent_skill(self):
sb = Skillbook()
sb.remove_skill("missing-id") # should not raise
def test_skills_list(self):
sb = Skillbook()
sb.add_skill("math", "a")
sb.add_skill("math", "b")
sb.add_skill("writing", "c")
assert len(sb.skills()) == 3
def test_generate_id_increments(self):
sb = Skillbook()
s1 = sb.add_skill("math", "a")
s2 = sb.add_skill("math", "b")
assert s1.id != s2.id
assert s1.id.startswith("context-")
assert s2.id.startswith("context-")
# ------------------------------------------------------------------ #
# Skillbook serialization
# ------------------------------------------------------------------ #
class TestSkillbookSerialization:
def test_round_trip(self):
sb = Skillbook()
sb.add_skill("math", "content A", skill_id="math-001")
sb.add_skill("writing", "content B", skill_id="writing-001")
data = sb.to_dict()
restored = Skillbook.from_dict(data)
assert len(restored.skills()) == 2
assert restored.get_skill("math-001").issue == "content A"
assert restored.get_skill("writing-001").issue == "content B"
def test_json_round_trip(self):
sb = Skillbook()
sb.add_skill("sec", "content", skill_id="sec-001")
json_str = sb.dumps()
restored = Skillbook.loads(json_str)
assert restored.get_skill("sec-001").issue == "content"
def test_file_round_trip(self, tmp_path):
sb = Skillbook()
sb.add_skill("sec", "content", skill_id="sec-001")
path = str(tmp_path / "sb.json")
sb.save_to_file(path)
restored = Skillbook.load_from_file(path)
assert restored.get_skill("sec-001").issue == "content"
def test_load_nonexistent_file(self):
with pytest.raises(FileNotFoundError):
Skillbook.load_from_file("/nonexistent/path.json")
def test_loads_invalid_json(self):
with pytest.raises((json.JSONDecodeError, ValueError)):
Skillbook.loads("not json")
def test_from_dict_malformed_sections(self):
"""v2 loads require an explicit schema version."""
payload = {
"skills": {},
"sections": {"bad": "not-a-list"},
"next_id": 0,
}
with pytest.raises(ValueError, match="Skillbook format v2 required"):
Skillbook.from_dict(payload)
def test_from_dict_missing_fields(self):
"""Missing optional fields should use defaults."""
payload = {
"schema_version": "2",
"skills": {
"s1": {
"id": "s1",
"section": "context",
"keywords": ["math"],
"issue": "x",
"insight": "x",
"created_at": "2025-01-01T00:00:00",
"updated_at": "2025-01-01T00:00:00",
}
},
"sections": {"context": ["s1"]},
}
sb = Skillbook.from_dict(payload)
skill = sb.get_skill("s1")
assert skill is not None
assert skill.embedding is None
assert skill.active is True
assert skill.occurrences == []
def test_sources_round_trip(self):
sb = Skillbook()
sb.add_skill(
"api",
"Check for a next-page token before stopping.",
skill_id="api-001",
insight_source=InsightSource(
trace_uid="kayba-hosted:conv-123",
source_system="kayba-hosted",
trace_id="conv-123",
display_name="checkout-failure.md",
sample_question="Why did pagination stop early?",
epoch=1,
),
)
restored = Skillbook.from_dict(sb.to_dict())
skill = restored.get_skill("api-001")
assert skill is not None
assert skill.occurrences[0].trace_id == "conv-123"
assert skill.occurrences[0].epoch == 1
assert skill.occurrences[0].sample_question == "Why did pagination stop early?"
def test_source_summary_and_filter_include_trace_identity(self):
sb = Skillbook()
sb.add_skill(
"api",
"Check for a next-page token before stopping.",
skill_id="api-001",
insight_source=InsightSource(
trace_uid="kayba-hosted:conv-123",
source_system="kayba-hosted",
trace_id="conv-123",
display_name="checkout-failure.md",
sample_question="Why did pagination stop early?",
epoch=2,
),
)
summary = sb.source_summary()
filtered = sb.source_filter(trace_uid="kayba-hosted:conv-123")
assert summary["source_systems"]["kayba-hosted"] == 1
assert summary["trace_uids"]["kayba-hosted:conv-123"] == 1
assert filtered["api-001"][0]["trace_id"] == "conv-123"
def test_update_skill_dedupes_identical_sources(self):
sb = Skillbook()
source = InsightSource(
trace_uid="synthetic:trace-001",
source_system="synthetic",
trace_id="trace-001",
display_name="trace-001",
)
sb.add_skill(
"api",
"Always check the continuation token.",
skill_id="api-001",
insight_source=source,
)
sb.update_skill("api-001", insight_source=source)
skill = sb.get_skill("api-001")
assert skill is not None
assert len(skill.occurrences) == 1
def test_add_skill_accepts_multiple_sources(self):
sb = Skillbook()
sb.add_skill(
"api",
"Generalize pagination handling across traces.",
skill_id="api-001",
insight_source=[
InsightSource(
trace_uid="synthetic:trace-001",
source_system="synthetic",
trace_id="trace-001",
display_name="trace-001",
),
InsightSource(
trace_uid="synthetic:trace-002",
source_system="synthetic",
trace_id="trace-002",
display_name="trace-002",
relation="supporting",
),
],
)
skill = sb.get_skill("api-001")
assert skill is not None
assert len(skill.occurrences) == 2
assert skill.occurrences[0].trace_id == "trace-001"
assert skill.occurrences[1].trace_id == "trace-002"
# ------------------------------------------------------------------ #
# Skillbook update operations
# ------------------------------------------------------------------ #
class TestSkillbookUpdates:
def test_apply_add(self):
sb = Skillbook()
batch = UpdateBatch(
reasoning="test",
operations=[UpdateOperation(type="ADD", section="math", issue="new skill")],
)
sb.apply_update(batch)
assert len(sb.skills()) == 1
assert sb.skills()[0].issue == "new skill"
def test_apply_update(self):
sb = Skillbook()
skill = sb.add_skill("math", "old", skill_id="math-001")
batch = UpdateBatch(
reasoning="test",
operations=[
UpdateOperation(
type="UPDATE",
section="math",
insight="new",
skill_id="math-001",
)
],
)
sb.apply_update(batch)
assert skill.insight == "new"
def test_apply_tag_is_noop(self):
"""TAG operations are accepted but no longer modify skills."""
sb = Skillbook()
sb.add_skill("math", "issue", skill_id="math-001")
batch = UpdateBatch(
reasoning="test",
operations=[
UpdateOperation(
type="TAG",
section="math",
skill_id="math-001",
metadata={"helpful": 1},
)
],
)
sb.apply_update(batch)
assert sb.get_skill("math-001") is not None
def test_apply_remove(self):
sb = Skillbook()
sb.add_skill("math", "issue", skill_id="math-001")
batch = UpdateBatch(
reasoning="test",
operations=[
UpdateOperation(type="REMOVE", section="math", skill_id="math-001")
],
)
sb.apply_update(batch)
skill = sb.get_skill("math-001")
assert skill is not None
assert skill.active is False
def test_apply_update_missing_skill_id(self):
"""UPDATE/TAG/REMOVE without skill_id should be skipped silently."""
sb = Skillbook()
batch = UpdateBatch(
reasoning="test",
operations=[
UpdateOperation(type="UPDATE", section="math", insight="x"),
UpdateOperation(type="TAG", section="math", metadata={"helpful": 1}),
UpdateOperation(type="REMOVE", section="math"),
],
)
sb.apply_update(batch) # should not raise
assert len(sb.skills()) == 0
# ------------------------------------------------------------------ #
# Skillbook thread safety
# ------------------------------------------------------------------ #
class TestSkillbookThreadSafety:
def test_concurrent_add_and_update(self):
"""Concurrent add_skill and update_skill should not corrupt state."""
sb = Skillbook()
errors = []
n_add = 50
n_update = 50
def adder():
try:
for i in range(n_add):
sb.add_skill("concurrent", f"skill-{i}")
except Exception as e:
errors.append(e)
def updater():
try:
for _ in range(n_update):
skills = sb.skills()
if skills:
sb.update_skill(skills[0].id, insight="updated")
except Exception as e:
errors.append(e)
threads = [
threading.Thread(target=adder),
threading.Thread(target=updater),
threading.Thread(target=adder),
threading.Thread(target=updater),
]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Thread safety errors: {errors}"
# All skills should be present (2 adders × 50 each)
assert len(sb.skills()) == n_add * 2
def test_lock_is_reentrant(self):
"""apply_update calls add_skill internally — lock must be reentrant."""
sb = Skillbook()
batch = UpdateBatch(
reasoning="test",
operations=[
UpdateOperation(type="ADD", section="sec", issue="a"),
UpdateOperation(type="ADD", section="sec", issue="b"),
],
)
sb.apply_update(batch)
assert len(sb.skills()) == 2
# ------------------------------------------------------------------ #
# SkillbookView
# ------------------------------------------------------------------ #
class TestSkillbookView:
def test_read_methods(self):
sb = Skillbook()
sb.add_skill("math", "content", skill_id="m-001")
view = SkillbookView(sb)
assert len(view) == 1
assert view.get_skill("m-001").issue == "content"
assert len(view.skills()) == 1
assert "skills" in view.stats()
def test_no_write_methods(self):
sb = Skillbook()
view = SkillbookView(sb)
assert not hasattr(view, "add_skill")
assert not hasattr(view, "update_skill")
assert not hasattr(view, "remove_skill")
assert not hasattr(view, "apply_update")
def test_iteration(self):
sb = Skillbook()
sb.add_skill("a", "x")
sb.add_skill("b", "y")
view = SkillbookView(sb)
skills = list(view)
assert len(skills) == 2
def test_repr(self):
sb = Skillbook()
sb.add_skill("a", "x")
view = SkillbookView(sb)
assert "1 skills" in repr(view)
# ------------------------------------------------------------------ #
# ACEStepContext
# ------------------------------------------------------------------ #
class TestACEStepContext:
def test_frozen(self):
ctx = ACEStepContext(sample="test")
with pytest.raises(FrozenInstanceError):
ctx.sample = "other"
def test_replace(self):
ctx = ACEStepContext(sample="test", epoch=1)
ctx2 = ctx.replace(epoch=2)
assert ctx.epoch == 1
assert ctx2.epoch == 2
def test_defaults(self):
ctx = ACEStepContext()
assert ctx.sample is None
assert ctx.skillbook is None
assert ctx.trace is None
assert ctx.agent_output is None
assert ctx.reflections == ()
assert ctx.skill_manager_output is None
assert ctx.epoch == 1
assert ctx.total_epochs == 1
assert ctx.step_index == 0
def test_replace_with_skillbook_view(self):
sb = Skillbook()
view = SkillbookView(sb)
ctx = ACEStepContext(skillbook=view)
assert ctx.skillbook is view
def test_replace_with_outputs(self):
agent_out = AgentOutput(reasoning="r", final_answer="a")
ctx = ACEStepContext()
ctx2 = ctx.replace(agent_output=agent_out)
assert ctx2.agent_output is agent_out
assert ctx.agent_output is None # original unchanged
# ------------------------------------------------------------------ #
# UpdateOperation / UpdateBatch parsing
# ------------------------------------------------------------------ #
class TestUpdateOperationParsing:
def test_from_json_add(self):
op = UpdateOperation.from_json(
{"type": "ADD", "section": "math", "issue": "skill content"}
)
assert op.type == "ADD"
assert op.section == "math"
assert op.issue == "skill content"
def test_from_json_parses_reflection_index(self):
op = UpdateOperation.from_json(
{
"type": "ADD",
"section": "math",
"issue": "skill content",
"learning_index": 1,
"reflection_index": 2,
"reflection_indices": [0, 2],
}
)
assert op.learning_index == 1
assert op.reflection_index == 2
assert op.reflection_indices == [0, 2]
assert op.to_json()["reflection_index"] == 2
assert op.to_json()["reflection_indices"] == [0, 2]
def test_from_json_tag_accepted(self):
"""TAG operations are parsed for backwards compatibility."""
op = UpdateOperation.from_json(
{
"type": "TAG",
"section": "math",
"skill_id": "m-001",
"metadata": {"helpful": 1},
}
)
assert op.type == "TAG"
assert op.metadata == {"helpful": 1}
def test_from_json_invalid_type(self):
with pytest.raises(ValueError, match="Invalid operation type"):
UpdateOperation.from_json({"type": "INVALID", "section": "x"})
def test_batch_from_json(self):
batch = UpdateBatch.from_json(
{
"reasoning": "test reasoning",
"operations": [
{"type": "ADD", "section": "a", "issue": "x"},
{"type": "ADD", "section": "b", "issue": "y"},
],
}
)
assert batch.reasoning == "test reasoning"
assert len(batch.operations) == 2
def test_batch_round_trip(self):
batch = UpdateBatch(
reasoning="r",
operations=[UpdateOperation(type="ADD", section="s", issue="c")],
)
data = batch.to_json()
restored = UpdateBatch.from_json(data)
assert restored.reasoning == "r"
assert len(restored.operations) == 1
assert restored.operations[0].type == "ADD"