The population move + the safety net + the universal consumer hookup,
all shipped together. This is where the engineering graph becomes
genuinely useful against the real 262-memory corpus.
5F: Memory → Entity graduation (THE population move)
- src/atocore/engineering/_graduation_prompt.py: stdlib-only shared
prompt module mirroring _llm_prompt.py pattern (container + host
use same system prompt, no drift)
- scripts/graduate_memories.py: host-side batch driver that asks
claude-p "does this memory describe a typed entity?" and creates
entity candidates with source_refs pointing back to the memory
- promote_entity() now scans source_refs for memory:* prefix; if
found, flips source memory to status='graduated' with
graduated_to_entity_id forward pointer + writes memory_audit row
- GET /admin/graduation/stats exposes graduation rate for dashboard
5G: Sync conflict detection on entity promote
- src/atocore/engineering/conflicts.py: detect_conflicts_for_entity()
runs on every active promote. V1 checks 3 slot kinds narrowly to
avoid false positives:
* component.material (multiple USES_MATERIAL edges)
* component.part_of (multiple PART_OF edges)
* requirement.name (duplicate active Requirements in same project)
- Conflicts + members persist via the tables built in 5A
- Fires a "warning" alert via Phase 4 framework
- Deduplicates: same (slot_kind, slot_key) won't get a new row
- resolve_conflict(action="dismiss|supersede_others|no_action"):
supersede_others marks non-winner members as status='superseded'
- GET /admin/conflicts + POST /admin/conflicts/{id}/resolve
5H: MCP + context pack integration
- scripts/atocore_mcp.py: 7 new engineering tools exposed to every
MCP-aware client (Claude Desktop, Claude Code, Cursor, Zed):
* atocore_engineering_map (Q-001/004 system tree)
* atocore_engineering_gaps (Q-006/009/011 killer queries — THE
director's question surfaced as a built-in tool)
* atocore_engineering_requirements_for_component (Q-005)
* atocore_engineering_decisions (Q-008)
* atocore_engineering_changes (Q-013 — reads entity audit log)
* atocore_engineering_impact (Q-016 BFS downstream)
* atocore_engineering_evidence (Q-017 inbound provenance)
- MCP tools total: 14 (7 memory/state/health + 7 engineering)
- context/builder.py _build_engineering_context now appends a compact
gaps summary ("Gaps: N orphan reqs, M risky decisions, K unsupported
claims") so every project-scoped LLM call sees "what we're missing"
Tests: 341 → 356 (15 new):
- 5F: graduation prompt parses positive/negative decisions, rejects
unknown entity types, tolerates markdown fences; promote_entity
marks source memory graduated with forward pointer; entity without
memory refs promotes cleanly
- 5G: component.material + component.part_of + requirement.name
conflicts detected; clean component triggers nothing; dedup works;
supersede_others resolution marks losers; dismiss leaves both
active; end-to-end promote triggers detection
- 5H: graduation user message includes project + type + content
No regressions across the 341 prior tests. The MCP server now answers
"which p05 requirements aren't satisfied?" directly from any Claude
session — no user prompt engineering, no context hacks.
Next to kick off from user: run graduation script on Dalidou to
populate the graph from 262 existing memories:
ssh papa@dalidou 'cd /srv/storage/atocore/app && PYTHONPATH=src \
python3 scripts/graduate_memories.py --project p05-interferometer --limit 30 --dry-run'
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
247 lines
8.7 KiB
Python
247 lines
8.7 KiB
Python
"""Phase 5F + 5G + 5H tests — graduation, conflicts, MCP tools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from atocore.engineering.conflicts import (
|
|
detect_conflicts_for_entity,
|
|
list_open_conflicts,
|
|
resolve_conflict,
|
|
)
|
|
from atocore.engineering._graduation_prompt import (
|
|
build_user_message,
|
|
parse_graduation_output,
|
|
)
|
|
from atocore.engineering.service import (
|
|
create_entity,
|
|
create_relationship,
|
|
get_entity,
|
|
init_engineering_schema,
|
|
promote_entity,
|
|
)
|
|
from atocore.memory.service import create_memory
|
|
from atocore.models.database import get_connection, init_db
|
|
|
|
|
|
# --- 5F Memory graduation ---
|
|
|
|
|
|
def test_graduation_prompt_parses_positive_decision():
|
|
raw = """
|
|
{"graduate": true, "entity_type": "component", "name": "Primary Mirror",
|
|
"description": "The 1.2m primary mirror for p04", "confidence": 0.85,
|
|
"relationships": [{"rel_type": "part_of", "target_hint": "Optics Subsystem"}]}
|
|
"""
|
|
decision = parse_graduation_output(raw)
|
|
assert decision is not None
|
|
assert decision["graduate"] is True
|
|
assert decision["entity_type"] == "component"
|
|
assert decision["name"] == "Primary Mirror"
|
|
assert decision["confidence"] == 0.85
|
|
assert decision["relationships"] == [
|
|
{"rel_type": "part_of", "target_hint": "Optics Subsystem"}
|
|
]
|
|
|
|
|
|
def test_graduation_prompt_parses_negative_decision():
|
|
raw = '{"graduate": false, "reason": "conversational filler, no typed entity"}'
|
|
decision = parse_graduation_output(raw)
|
|
assert decision is not None
|
|
assert decision["graduate"] is False
|
|
assert "filler" in decision["reason"]
|
|
|
|
|
|
def test_graduation_prompt_rejects_unknown_entity_type():
|
|
raw = '{"graduate": true, "entity_type": "quantum_thing", "name": "x"}'
|
|
assert parse_graduation_output(raw) is None
|
|
|
|
|
|
def test_graduation_prompt_tolerates_markdown_fences():
|
|
raw = '```json\n{"graduate": false, "reason": "ok"}\n```'
|
|
d = parse_graduation_output(raw)
|
|
assert d is not None
|
|
assert d["graduate"] is False
|
|
|
|
|
|
def test_promote_entity_marks_source_memory_graduated(tmp_data_dir):
|
|
init_db()
|
|
init_engineering_schema()
|
|
mem = create_memory("knowledge", "The Primary Mirror is 1.2m Zerodur",
|
|
project="p-test", status="active")
|
|
# Create entity candidate pointing back to the memory
|
|
ent = create_entity(
|
|
"component",
|
|
"Primary Mirror",
|
|
project="p-test",
|
|
status="candidate",
|
|
source_refs=[f"memory:{mem.id}"],
|
|
)
|
|
# Promote
|
|
assert promote_entity(ent.id, actor="test-triage")
|
|
|
|
# Memory should now be graduated with forward pointer
|
|
with get_connection() as conn:
|
|
row = conn.execute(
|
|
"SELECT status, graduated_to_entity_id FROM memories WHERE id = ?",
|
|
(mem.id,),
|
|
).fetchone()
|
|
assert row["status"] == "graduated"
|
|
assert row["graduated_to_entity_id"] == ent.id
|
|
|
|
|
|
def test_promote_entity_without_memory_refs_no_graduation(tmp_data_dir):
|
|
"""Entity not backed by any memory — promote still works, no graduation."""
|
|
init_db()
|
|
init_engineering_schema()
|
|
ent = create_entity("component", "Orphan", project="p-test", status="candidate")
|
|
assert promote_entity(ent.id)
|
|
assert get_entity(ent.id).status == "active"
|
|
|
|
|
|
# --- 5G Conflict detection ---
|
|
|
|
|
|
def test_component_material_conflict_detected(tmp_data_dir):
|
|
init_db()
|
|
init_engineering_schema()
|
|
c = create_entity("component", "Mirror", project="p-test")
|
|
m1 = create_entity("material", "Zerodur", project="p-test")
|
|
m2 = create_entity("material", "ULE", project="p-test")
|
|
create_relationship(c.id, m1.id, "uses_material")
|
|
create_relationship(c.id, m2.id, "uses_material")
|
|
|
|
detected = detect_conflicts_for_entity(c.id)
|
|
assert len(detected) == 1
|
|
|
|
conflicts = list_open_conflicts(project="p-test")
|
|
assert any(c["slot_kind"] == "component.material" for c in conflicts)
|
|
conflict = next(c for c in conflicts if c["slot_kind"] == "component.material")
|
|
assert len(conflict["members"]) == 2
|
|
|
|
|
|
def test_component_part_of_conflict_detected(tmp_data_dir):
|
|
init_db()
|
|
init_engineering_schema()
|
|
c = create_entity("component", "MultiPart", project="p-test")
|
|
s1 = create_entity("subsystem", "Mechanical", project="p-test")
|
|
s2 = create_entity("subsystem", "Optical", project="p-test")
|
|
create_relationship(c.id, s1.id, "part_of")
|
|
create_relationship(c.id, s2.id, "part_of")
|
|
|
|
detected = detect_conflicts_for_entity(c.id)
|
|
assert len(detected) == 1
|
|
conflicts = list_open_conflicts(project="p-test")
|
|
assert any(c["slot_kind"] == "component.part_of" for c in conflicts)
|
|
|
|
|
|
def test_requirement_name_conflict_detected(tmp_data_dir):
|
|
init_db()
|
|
init_engineering_schema()
|
|
r1 = create_entity("requirement", "Surface figure < 25nm",
|
|
project="p-test", description="Primary mirror spec")
|
|
r2 = create_entity("requirement", "Surface figure < 25nm",
|
|
project="p-test", description="Different interpretation")
|
|
|
|
detected = detect_conflicts_for_entity(r2.id)
|
|
assert len(detected) == 1
|
|
conflicts = list_open_conflicts(project="p-test")
|
|
assert any(c["slot_kind"] == "requirement.name" for c in conflicts)
|
|
|
|
|
|
def test_conflict_not_detected_for_clean_component(tmp_data_dir):
|
|
init_db()
|
|
init_engineering_schema()
|
|
c = create_entity("component", "Clean", project="p-test")
|
|
m = create_entity("material", "Zerodur", project="p-test")
|
|
create_relationship(c.id, m.id, "uses_material")
|
|
|
|
detected = detect_conflicts_for_entity(c.id)
|
|
assert detected == []
|
|
|
|
|
|
def test_conflict_resolution_supersedes_losers(tmp_data_dir):
|
|
init_db()
|
|
init_engineering_schema()
|
|
c = create_entity("component", "Mirror2", project="p-test")
|
|
m1 = create_entity("material", "Zerodur2", project="p-test")
|
|
m2 = create_entity("material", "ULE2", project="p-test")
|
|
create_relationship(c.id, m1.id, "uses_material")
|
|
create_relationship(c.id, m2.id, "uses_material")
|
|
|
|
detected = detect_conflicts_for_entity(c.id)
|
|
conflict_id = detected[0]
|
|
|
|
# Resolve by picking m1 as the winner
|
|
assert resolve_conflict(conflict_id, "supersede_others", winner_id=m1.id)
|
|
|
|
# m2 should now be superseded; m1 stays active
|
|
assert get_entity(m1.id).status == "active"
|
|
assert get_entity(m2.id).status == "superseded"
|
|
|
|
# Conflict should be marked resolved
|
|
open_conflicts = list_open_conflicts(project="p-test")
|
|
assert not any(c["id"] == conflict_id for c in open_conflicts)
|
|
|
|
|
|
def test_conflict_resolution_dismiss_leaves_entities_alone(tmp_data_dir):
|
|
init_db()
|
|
init_engineering_schema()
|
|
r1 = create_entity("requirement", "Dup req", project="p-test",
|
|
description="first meaning")
|
|
r2 = create_entity("requirement", "Dup req", project="p-test",
|
|
description="second meaning")
|
|
detected = detect_conflicts_for_entity(r2.id)
|
|
conflict_id = detected[0]
|
|
|
|
assert resolve_conflict(conflict_id, "dismiss")
|
|
# Both still active — dismiss just clears the conflict marker
|
|
assert get_entity(r1.id).status == "active"
|
|
assert get_entity(r2.id).status == "active"
|
|
|
|
|
|
def test_deduplicate_conflicts_for_same_slot(tmp_data_dir):
|
|
"""Running detection twice on the same entity shouldn't dup the conflict row."""
|
|
init_db()
|
|
init_engineering_schema()
|
|
c = create_entity("component", "Dup", project="p-test")
|
|
m1 = create_entity("material", "A", project="p-test")
|
|
m2 = create_entity("material", "B", project="p-test")
|
|
create_relationship(c.id, m1.id, "uses_material")
|
|
create_relationship(c.id, m2.id, "uses_material")
|
|
|
|
detect_conflicts_for_entity(c.id)
|
|
detect_conflicts_for_entity(c.id) # should be a no-op
|
|
|
|
conflicts = list_open_conflicts(project="p-test")
|
|
mat_conflicts = [c for c in conflicts if c["slot_kind"] == "component.material"]
|
|
assert len(mat_conflicts) == 1
|
|
|
|
|
|
def test_promote_triggers_conflict_detection(tmp_data_dir):
|
|
"""End-to-end: promoting a candidate component with 2 active material edges
|
|
triggers conflict detection."""
|
|
init_db()
|
|
init_engineering_schema()
|
|
|
|
c = create_entity("component", "AutoFlag", project="p-test", status="candidate")
|
|
m1 = create_entity("material", "X1", project="p-test")
|
|
m2 = create_entity("material", "X2", project="p-test")
|
|
create_relationship(c.id, m1.id, "uses_material")
|
|
create_relationship(c.id, m2.id, "uses_material")
|
|
|
|
promote_entity(c.id, actor="test")
|
|
|
|
conflicts = list_open_conflicts(project="p-test")
|
|
assert any(c["slot_kind"] == "component.material" for c in conflicts)
|
|
|
|
|
|
# --- 5H MCP tool shape checks (via build_user_message) ---
|
|
|
|
|
|
def test_graduation_user_message_includes_project_and_type():
|
|
msg = build_user_message("some content", "p04-gigabit", "project")
|
|
assert "p04-gigabit" in msg
|
|
assert "project" in msg
|
|
assert "some content" in msg
|