get-claudia 1.29.2 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/memory-daemon/claudia_memory/database.py +44 -0
- package/memory-daemon/claudia_memory/mcp/server.py +81 -0
- package/memory-daemon/claudia_memory/schema.sql +4 -0
- package/memory-daemon/claudia_memory/services/guards.py +32 -1
- package/memory-daemon/claudia_memory/services/recall.py +2 -1
- package/memory-daemon/claudia_memory/services/remember.py +180 -30
- package/memory-daemon/tests/test_batch_parallel.py +119 -0
- package/memory-daemon/tests/test_bitemporal.py +202 -16
- package/memory-daemon/tests/test_guards.py +3 -2
- package/memory-daemon/tests/test_relationship_guards.py +79 -0
- package/package.json +1 -1
- package/template-v2/.claude/skills/map-connections/SKILL.md +23 -21
|
@@ -138,6 +138,21 @@ class Database:
|
|
|
138
138
|
else:
|
|
139
139
|
conn.commit()
|
|
140
140
|
|
|
141
|
+
@contextmanager
|
|
142
|
+
def transaction(self) -> Generator[sqlite3.Connection, None, None]:
|
|
143
|
+
"""Explicit multi-step transaction. Commits on success, rolls back on error.
|
|
144
|
+
|
|
145
|
+
Use this when multiple operations must succeed or fail atomically.
|
|
146
|
+
Unlike connection(), the caller uses conn.execute() directly.
|
|
147
|
+
"""
|
|
148
|
+
conn = self._get_connection()
|
|
149
|
+
try:
|
|
150
|
+
yield conn
|
|
151
|
+
conn.commit()
|
|
152
|
+
except Exception:
|
|
153
|
+
conn.rollback()
|
|
154
|
+
raise
|
|
155
|
+
|
|
141
156
|
@contextmanager
|
|
142
157
|
def cursor(self) -> Generator[sqlite3.Cursor, None, None]:
|
|
143
158
|
"""Context manager for database cursor"""
|
|
@@ -724,6 +739,30 @@ class Database:
|
|
|
724
739
|
conn.commit()
|
|
725
740
|
logger.info("Applied migration 14: dispatch_tier for native agent teams")
|
|
726
741
|
|
|
742
|
+
if current_version < 15:
|
|
743
|
+
# Migration 15: Add origin_type to relationships for organic trust model
|
|
744
|
+
try:
|
|
745
|
+
conn.execute(
|
|
746
|
+
"ALTER TABLE relationships ADD COLUMN origin_type TEXT DEFAULT 'extracted'"
|
|
747
|
+
)
|
|
748
|
+
except sqlite3.OperationalError as e:
|
|
749
|
+
if "duplicate column" not in str(e).lower():
|
|
750
|
+
logger.warning(f"Migration 15 statement failed: {e}")
|
|
751
|
+
|
|
752
|
+
# Grandfather existing relationships: all get 'extracted' (the safe default)
|
|
753
|
+
try:
|
|
754
|
+
conn.execute(
|
|
755
|
+
"UPDATE relationships SET origin_type = 'extracted' WHERE origin_type IS NULL"
|
|
756
|
+
)
|
|
757
|
+
except sqlite3.OperationalError as e:
|
|
758
|
+
logger.warning(f"Migration 15 grandfather failed: {e}")
|
|
759
|
+
|
|
760
|
+
conn.execute(
|
|
761
|
+
"INSERT OR IGNORE INTO schema_migrations (version, description) VALUES (15, 'Add origin_type to relationships for organic trust model')"
|
|
762
|
+
)
|
|
763
|
+
conn.commit()
|
|
764
|
+
logger.info("Applied migration 15: relationship origin_type for organic trust model")
|
|
765
|
+
|
|
727
766
|
# FTS5 setup: ensure memories_fts exists regardless of migration path.
|
|
728
767
|
# The FTS5 virtual table + triggers contain internal semicolons that the
|
|
729
768
|
# schema.sql line-based parser can't handle, so we always check here.
|
|
@@ -852,6 +891,11 @@ class Database:
|
|
|
852
891
|
logger.warning("Migration 14 incomplete: agent_dispatches missing dispatch_tier column")
|
|
853
892
|
return 13
|
|
854
893
|
|
|
894
|
+
# Migration 15 added origin_type to relationships
|
|
895
|
+
if "origin_type" not in rel_cols:
|
|
896
|
+
logger.warning("Migration 15 incomplete: relationships missing origin_type column")
|
|
897
|
+
return 14
|
|
898
|
+
|
|
855
899
|
return None # All good
|
|
856
900
|
|
|
857
901
|
def _store_workspace_path(self, conn: sqlite3.Connection) -> None:
|
|
@@ -57,6 +57,7 @@ from ..services.remember import (
|
|
|
57
57
|
get_remember_service,
|
|
58
58
|
get_unsummarized_turns,
|
|
59
59
|
invalidate_memory,
|
|
60
|
+
invalidate_relationship,
|
|
60
61
|
merge_entities,
|
|
61
62
|
relate_entities,
|
|
62
63
|
store_reflection,
|
|
@@ -240,6 +241,16 @@ async def list_tools() -> ListToolsResult:
|
|
|
240
241
|
"description": "If true, invalidate existing relationship of same type between same entities and create new one",
|
|
241
242
|
"default": False,
|
|
242
243
|
},
|
|
244
|
+
"origin_type": {
|
|
245
|
+
"type": "string",
|
|
246
|
+
"description": "How this was learned: user_stated, extracted, inferred, corrected",
|
|
247
|
+
"default": "extracted",
|
|
248
|
+
},
|
|
249
|
+
"direction": {
|
|
250
|
+
"type": "string",
|
|
251
|
+
"description": "Relationship direction: forward, backward, or bidirectional",
|
|
252
|
+
"default": "bidirectional",
|
|
253
|
+
},
|
|
243
254
|
},
|
|
244
255
|
"required": ["source", "target", "relationship"],
|
|
245
256
|
},
|
|
@@ -639,6 +650,23 @@ async def list_tools() -> ListToolsResult:
|
|
|
639
650
|
"type": "number",
|
|
640
651
|
"description": "Relationship strength 0.0-1.0 (for 'relate' op)",
|
|
641
652
|
},
|
|
653
|
+
"origin_type": {
|
|
654
|
+
"type": "string",
|
|
655
|
+
"description": "How this was learned: user_stated, extracted, inferred (for 'relate' op)",
|
|
656
|
+
},
|
|
657
|
+
"supersedes": {
|
|
658
|
+
"type": "boolean",
|
|
659
|
+
"description": "Invalidate existing relationship of same type (for 'relate' op)",
|
|
660
|
+
"default": False,
|
|
661
|
+
},
|
|
662
|
+
"valid_at": {
|
|
663
|
+
"type": "string",
|
|
664
|
+
"description": "When this relationship became true (for 'relate' op)",
|
|
665
|
+
},
|
|
666
|
+
"direction": {
|
|
667
|
+
"type": "string",
|
|
668
|
+
"description": "Relationship direction (for 'relate' op)",
|
|
669
|
+
},
|
|
642
670
|
},
|
|
643
671
|
"required": ["op"],
|
|
644
672
|
},
|
|
@@ -1060,6 +1088,37 @@ async def list_tools() -> ListToolsResult:
|
|
|
1060
1088
|
"required": ["memory_id"],
|
|
1061
1089
|
},
|
|
1062
1090
|
),
|
|
1091
|
+
Tool(
|
|
1092
|
+
name="memory.invalidate_relationship",
|
|
1093
|
+
description=(
|
|
1094
|
+
"Mark a relationship as incorrect or ended without creating a replacement. "
|
|
1095
|
+
"Use when the user says a relationship is wrong, or when someone leaves a "
|
|
1096
|
+
"company, ends a partnership, etc. The relationship is preserved for history "
|
|
1097
|
+
"but excluded from active queries."
|
|
1098
|
+
),
|
|
1099
|
+
inputSchema={
|
|
1100
|
+
"type": "object",
|
|
1101
|
+
"properties": {
|
|
1102
|
+
"source": {
|
|
1103
|
+
"type": "string",
|
|
1104
|
+
"description": "Source entity name",
|
|
1105
|
+
},
|
|
1106
|
+
"target": {
|
|
1107
|
+
"type": "string",
|
|
1108
|
+
"description": "Target entity name",
|
|
1109
|
+
},
|
|
1110
|
+
"relationship": {
|
|
1111
|
+
"type": "string",
|
|
1112
|
+
"description": "Relationship type to invalidate (works_with, manages, etc.)",
|
|
1113
|
+
},
|
|
1114
|
+
"reason": {
|
|
1115
|
+
"type": "string",
|
|
1116
|
+
"description": "Why this relationship is being invalidated",
|
|
1117
|
+
},
|
|
1118
|
+
},
|
|
1119
|
+
"required": ["source", "target", "relationship"],
|
|
1120
|
+
},
|
|
1121
|
+
),
|
|
1063
1122
|
Tool(
|
|
1064
1123
|
name="memory.audit_history",
|
|
1065
1124
|
description=(
|
|
@@ -1281,6 +1340,8 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult:
|
|
|
1281
1340
|
strength=arguments.get("strength", 1.0),
|
|
1282
1341
|
valid_at=arguments.get("valid_at"),
|
|
1283
1342
|
supersedes=arguments.get("supersedes", False),
|
|
1343
|
+
origin_type=arguments.get("origin_type", "extracted"),
|
|
1344
|
+
direction=arguments.get("direction", "bidirectional"),
|
|
1284
1345
|
)
|
|
1285
1346
|
return CallToolResult(
|
|
1286
1347
|
content=[
|
|
@@ -1604,6 +1665,10 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult:
|
|
|
1604
1665
|
target=op["target"],
|
|
1605
1666
|
relationship=op["relationship"],
|
|
1606
1667
|
strength=op.get("strength", 1.0),
|
|
1668
|
+
supersedes=op.get("supersedes", False),
|
|
1669
|
+
valid_at=op.get("valid_at"),
|
|
1670
|
+
direction=op.get("direction", "bidirectional"),
|
|
1671
|
+
origin_type=op.get("origin_type", "extracted"),
|
|
1607
1672
|
)
|
|
1608
1673
|
op_result["success"] = True
|
|
1609
1674
|
op_result["relationship_id"] = relationship_id
|
|
@@ -1873,6 +1938,22 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult:
|
|
|
1873
1938
|
]
|
|
1874
1939
|
)
|
|
1875
1940
|
|
|
1941
|
+
elif name == "memory.invalidate_relationship":
|
|
1942
|
+
result = invalidate_relationship(
|
|
1943
|
+
source=arguments["source"],
|
|
1944
|
+
target=arguments["target"],
|
|
1945
|
+
relationship=arguments["relationship"],
|
|
1946
|
+
reason=arguments.get("reason"),
|
|
1947
|
+
)
|
|
1948
|
+
return CallToolResult(
|
|
1949
|
+
content=[
|
|
1950
|
+
TextContent(
|
|
1951
|
+
type="text",
|
|
1952
|
+
text=json.dumps(result),
|
|
1953
|
+
)
|
|
1954
|
+
]
|
|
1955
|
+
)
|
|
1956
|
+
|
|
1876
1957
|
elif name == "memory.audit_history":
|
|
1877
1958
|
# Get audit history for entity or memory
|
|
1878
1959
|
entity_id = arguments.get("entity_id")
|
|
@@ -89,6 +89,7 @@ CREATE TABLE IF NOT EXISTS relationships (
|
|
|
89
89
|
target_entity_id INTEGER NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
|
90
90
|
relationship_type TEXT NOT NULL, -- works_with, manages, client_of, etc.
|
|
91
91
|
strength REAL DEFAULT 1.0, -- Relationship strength (decays/grows)
|
|
92
|
+
origin_type TEXT DEFAULT 'extracted', -- user_stated, extracted, inferred, corrected
|
|
92
93
|
direction TEXT DEFAULT 'bidirectional' CHECK (direction IN ('forward', 'backward', 'bidirectional')),
|
|
93
94
|
valid_at TEXT, -- When this relationship became true in the real world
|
|
94
95
|
invalid_at TEXT, -- When this relationship was superseded (NULL = current)
|
|
@@ -408,6 +409,9 @@ VALUES (13, 'Add origin_type to memories, agent_dispatches table for Trust North
|
|
|
408
409
|
INSERT OR IGNORE INTO schema_migrations (version, description)
|
|
409
410
|
VALUES (14, 'Add dispatch_tier to agent_dispatches for native agent team support');
|
|
410
411
|
|
|
412
|
+
INSERT OR IGNORE INTO schema_migrations (version, description)
|
|
413
|
+
VALUES (15, 'Add origin_type to relationships for organic trust model');
|
|
414
|
+
|
|
411
415
|
-- ============================================================================
|
|
412
416
|
-- AGENT DISPATCHES: Track delegated tasks to sub-agents
|
|
413
417
|
-- ============================================================================
|
|
@@ -112,15 +112,37 @@ def validate_entity(
|
|
|
112
112
|
return result
|
|
113
113
|
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
# Origin-aware strength ceilings: relationships can't exceed their evidence level
|
|
116
|
+
ORIGIN_STRENGTH_CEILING = {
|
|
117
|
+
"user_stated": 1.0, # User said it directly -- full trust
|
|
118
|
+
"extracted": 0.8, # Evidence from a document -- high but not absolute
|
|
119
|
+
"inferred": 0.5, # Co-occurrence guess -- must earn trust through repetition
|
|
120
|
+
"corrected": 1.0, # User corrected it -- full trust
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Origin-scaled reinforcement increments (Hebbian: stronger signals potentiate more)
|
|
124
|
+
REINFORCEMENT_BY_ORIGIN = {
|
|
125
|
+
"user_stated": 0.2,
|
|
126
|
+
"extracted": 0.1,
|
|
127
|
+
"inferred": 0.05,
|
|
128
|
+
"corrected": 0.2,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def validate_relationship(
|
|
133
|
+
strength: float = 1.0,
|
|
134
|
+
origin_type: str = "extracted",
|
|
135
|
+
) -> ValidationResult:
|
|
116
136
|
"""
|
|
117
137
|
Validate a relationship before storage.
|
|
118
138
|
|
|
119
139
|
Checks:
|
|
120
140
|
- Strength clamped to [0, 1]
|
|
141
|
+
- Strength capped by origin authority ceiling
|
|
121
142
|
"""
|
|
122
143
|
result = ValidationResult()
|
|
123
144
|
|
|
145
|
+
# Clamp to [0, 1]
|
|
124
146
|
if strength < 0:
|
|
125
147
|
result.warnings.append(f"Relationship strength {strength} clamped to 0.0")
|
|
126
148
|
result.adjustments["strength"] = 0.0
|
|
@@ -128,4 +150,13 @@ def validate_relationship(strength: float = 1.0) -> ValidationResult:
|
|
|
128
150
|
result.warnings.append(f"Relationship strength {strength} clamped to 1.0")
|
|
129
151
|
result.adjustments["strength"] = 1.0
|
|
130
152
|
|
|
153
|
+
# Cap by origin authority
|
|
154
|
+
ceiling = ORIGIN_STRENGTH_CEILING.get(origin_type, 0.5)
|
|
155
|
+
effective = result.adjustments.get("strength", strength)
|
|
156
|
+
if effective > ceiling:
|
|
157
|
+
result.warnings.append(
|
|
158
|
+
f"Strength {effective} exceeds ceiling {ceiling} for origin '{origin_type}', capped"
|
|
159
|
+
)
|
|
160
|
+
result.adjustments["strength"] = ceiling
|
|
161
|
+
|
|
131
162
|
return result
|
|
@@ -696,10 +696,12 @@ class RecallService:
|
|
|
696
696
|
|
|
697
697
|
relationships = []
|
|
698
698
|
for row in rel_rows:
|
|
699
|
+
row_keys = row.keys()
|
|
699
700
|
rel_dict = {
|
|
700
701
|
"type": row["relationship_type"],
|
|
701
702
|
"direction": row["direction"],
|
|
702
703
|
"strength": row["strength"],
|
|
704
|
+
"origin_type": row["origin_type"] if "origin_type" in row_keys else "extracted",
|
|
703
705
|
"other_entity": (
|
|
704
706
|
row["target_name"]
|
|
705
707
|
if row["source_entity_id"] == entity["id"]
|
|
@@ -713,7 +715,6 @@ class RecallService:
|
|
|
713
715
|
}
|
|
714
716
|
# Include temporal fields when showing historical data
|
|
715
717
|
if include_historical:
|
|
716
|
-
row_keys = row.keys()
|
|
717
718
|
rel_dict["valid_at"] = row["valid_at"] if "valid_at" in row_keys else None
|
|
718
719
|
rel_dict["invalid_at"] = row["invalid_at"] if "invalid_at" in row_keys else None
|
|
719
720
|
relationships.append(rel_dict)
|
|
@@ -376,6 +376,7 @@ class RememberService:
|
|
|
376
376
|
metadata: Optional[Dict] = None,
|
|
377
377
|
valid_at: Optional[str] = None,
|
|
378
378
|
supersedes: bool = False,
|
|
379
|
+
origin_type: str = "extracted",
|
|
379
380
|
) -> Optional[int]:
|
|
380
381
|
"""
|
|
381
382
|
Create or strengthen a relationship between entities.
|
|
@@ -390,12 +391,15 @@ class RememberService:
|
|
|
390
391
|
valid_at: When this relationship became true (ISO string, defaults to now)
|
|
391
392
|
supersedes: If True, invalidate existing relationship of same type
|
|
392
393
|
between same entities before creating a new one
|
|
394
|
+
origin_type: How this was learned: user_stated, extracted, inferred, corrected
|
|
393
395
|
|
|
394
396
|
Returns:
|
|
395
397
|
Relationship ID or None
|
|
396
398
|
"""
|
|
397
|
-
|
|
398
|
-
|
|
399
|
+
from .guards import ORIGIN_STRENGTH_CEILING, REINFORCEMENT_BY_ORIGIN
|
|
400
|
+
|
|
401
|
+
# Run deterministic guards (origin-aware)
|
|
402
|
+
guard_result = validate_relationship(strength, origin_type=origin_type)
|
|
399
403
|
if guard_result.warnings:
|
|
400
404
|
for w in guard_result.warnings:
|
|
401
405
|
logger.warning(f"Relationship guard: {w}")
|
|
@@ -412,44 +416,56 @@ class RememberService:
|
|
|
412
416
|
effective_valid_at = valid_at or now
|
|
413
417
|
|
|
414
418
|
if supersedes:
|
|
415
|
-
# Invalidate
|
|
419
|
+
# Invalidate existing relationship of same type between same entities (atomic)
|
|
416
420
|
existing_to_supersede = self.db.get_one(
|
|
417
421
|
"relationships",
|
|
418
|
-
where="source_entity_id = ? AND relationship_type = ? AND invalid_at IS NULL",
|
|
419
|
-
where_params=(source_id, relationship_type),
|
|
422
|
+
where="source_entity_id = ? AND target_entity_id = ? AND relationship_type = ? AND invalid_at IS NULL",
|
|
423
|
+
where_params=(source_id, target_id, relationship_type),
|
|
420
424
|
)
|
|
421
425
|
if existing_to_supersede:
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
426
|
+
with self.db.transaction() as conn:
|
|
427
|
+
# Invalidate the old relationship (mark when it ended)
|
|
428
|
+
conn.execute(
|
|
429
|
+
"UPDATE relationships SET invalid_at = ?, updated_at = ? WHERE id = ?",
|
|
430
|
+
(now, now, existing_to_supersede["id"]),
|
|
431
|
+
)
|
|
432
|
+
# Rename the type to free the UNIQUE constraint slot
|
|
433
|
+
old_meta = json.loads(existing_to_supersede["metadata"] or "{}")
|
|
434
|
+
old_meta["superseded_by_at"] = now
|
|
435
|
+
conn.execute(
|
|
436
|
+
"UPDATE relationships SET relationship_type = ?, metadata = ? WHERE id = ?",
|
|
437
|
+
(
|
|
438
|
+
f"{relationship_type}__superseded_{existing_to_supersede['id']}",
|
|
439
|
+
json.dumps(old_meta),
|
|
440
|
+
existing_to_supersede["id"],
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Audit log for supersede
|
|
445
|
+
_audit_log(
|
|
446
|
+
"relationship_supersede",
|
|
447
|
+
details={
|
|
448
|
+
"old_id": existing_to_supersede["id"],
|
|
449
|
+
"source": source_name,
|
|
450
|
+
"target": target_name,
|
|
451
|
+
"type": relationship_type,
|
|
440
452
|
},
|
|
441
|
-
"id = ?",
|
|
442
|
-
(existing_to_supersede["id"],),
|
|
443
453
|
)
|
|
444
454
|
|
|
455
|
+
# Supersede always sets origin_type to 'corrected' (user is correcting the record)
|
|
456
|
+
supersede_origin = "corrected"
|
|
457
|
+
ceiling = ORIGIN_STRENGTH_CEILING.get(supersede_origin, 0.5)
|
|
458
|
+
capped_strength = min(strength, ceiling)
|
|
459
|
+
|
|
445
460
|
# Create new relationship
|
|
446
|
-
|
|
461
|
+
new_id = self.db.insert(
|
|
447
462
|
"relationships",
|
|
448
463
|
{
|
|
449
464
|
"source_entity_id": source_id,
|
|
450
465
|
"target_entity_id": target_id,
|
|
451
466
|
"relationship_type": relationship_type,
|
|
452
|
-
"strength":
|
|
467
|
+
"strength": capped_strength,
|
|
468
|
+
"origin_type": supersede_origin,
|
|
453
469
|
"direction": direction,
|
|
454
470
|
"valid_at": effective_valid_at,
|
|
455
471
|
"created_at": now,
|
|
@@ -458,6 +474,21 @@ class RememberService:
|
|
|
458
474
|
},
|
|
459
475
|
)
|
|
460
476
|
|
|
477
|
+
# Audit log for create
|
|
478
|
+
_audit_log(
|
|
479
|
+
"relationship_create",
|
|
480
|
+
details={
|
|
481
|
+
"id": new_id,
|
|
482
|
+
"source": source_name,
|
|
483
|
+
"target": target_name,
|
|
484
|
+
"type": relationship_type,
|
|
485
|
+
"origin_type": supersede_origin,
|
|
486
|
+
"strength": capped_strength,
|
|
487
|
+
},
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
return new_id
|
|
491
|
+
|
|
461
492
|
# Check for existing current relationship (non-supersede path)
|
|
462
493
|
existing = self.db.get_one(
|
|
463
494
|
"relationships",
|
|
@@ -466,11 +497,23 @@ class RememberService:
|
|
|
466
497
|
)
|
|
467
498
|
|
|
468
499
|
if existing:
|
|
469
|
-
#
|
|
470
|
-
|
|
500
|
+
# Determine ceiling: if new origin is higher-authority, upgrade
|
|
501
|
+
existing_origin = existing["origin_type"] if "origin_type" in existing.keys() else "extracted"
|
|
502
|
+
effective_origin = existing_origin
|
|
503
|
+
|
|
504
|
+
# Origin upgrade: user_stated/corrected outrank extracted, which outranks inferred
|
|
505
|
+
origin_rank = {"inferred": 0, "extracted": 1, "user_stated": 2, "corrected": 2}
|
|
506
|
+
if origin_rank.get(origin_type, 0) > origin_rank.get(existing_origin, 0):
|
|
507
|
+
effective_origin = origin_type
|
|
508
|
+
|
|
509
|
+
ceiling = ORIGIN_STRENGTH_CEILING.get(effective_origin, 0.5)
|
|
510
|
+
increment = REINFORCEMENT_BY_ORIGIN.get(origin_type, 0.1)
|
|
511
|
+
new_strength = min(ceiling, existing["strength"] + increment)
|
|
512
|
+
|
|
471
513
|
update_data = {
|
|
472
514
|
"strength": new_strength,
|
|
473
515
|
"updated_at": now,
|
|
516
|
+
"origin_type": effective_origin,
|
|
474
517
|
}
|
|
475
518
|
# Ensure valid_at is set on existing relationships
|
|
476
519
|
row_keys = existing.keys()
|
|
@@ -485,13 +528,14 @@ class RememberService:
|
|
|
485
528
|
return existing["id"]
|
|
486
529
|
else:
|
|
487
530
|
# Create new relationship
|
|
488
|
-
|
|
531
|
+
new_id = self.db.insert(
|
|
489
532
|
"relationships",
|
|
490
533
|
{
|
|
491
534
|
"source_entity_id": source_id,
|
|
492
535
|
"target_entity_id": target_id,
|
|
493
536
|
"relationship_type": relationship_type,
|
|
494
537
|
"strength": strength,
|
|
538
|
+
"origin_type": origin_type,
|
|
495
539
|
"direction": direction,
|
|
496
540
|
"valid_at": effective_valid_at,
|
|
497
541
|
"created_at": now,
|
|
@@ -500,6 +544,105 @@ class RememberService:
|
|
|
500
544
|
},
|
|
501
545
|
)
|
|
502
546
|
|
|
547
|
+
# Audit log
|
|
548
|
+
_audit_log(
|
|
549
|
+
"relationship_create",
|
|
550
|
+
details={
|
|
551
|
+
"id": new_id,
|
|
552
|
+
"source": source_name,
|
|
553
|
+
"target": target_name,
|
|
554
|
+
"type": relationship_type,
|
|
555
|
+
"origin_type": origin_type,
|
|
556
|
+
"strength": strength,
|
|
557
|
+
},
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
return new_id
|
|
561
|
+
|
|
562
|
+
def invalidate_relationship(
|
|
563
|
+
self,
|
|
564
|
+
source_name: str,
|
|
565
|
+
target_name: str,
|
|
566
|
+
relationship_type: str,
|
|
567
|
+
reason: Optional[str] = None,
|
|
568
|
+
) -> Optional[Dict[str, Any]]:
|
|
569
|
+
"""
|
|
570
|
+
Invalidate a relationship without creating a replacement.
|
|
571
|
+
|
|
572
|
+
Finds the active relationship by source + target + type, marks it with
|
|
573
|
+
invalid_at, and renames the type to free the UNIQUE constraint. Atomic.
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
source_name: Source entity name
|
|
577
|
+
target_name: Target entity name
|
|
578
|
+
relationship_type: Type of relationship to invalidate
|
|
579
|
+
reason: Why this relationship is being invalidated
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
Dict with invalidated relationship info, or None if not found
|
|
583
|
+
"""
|
|
584
|
+
source_id = self._find_or_create_entity(source_name)
|
|
585
|
+
target_id = self._find_or_create_entity(target_name)
|
|
586
|
+
|
|
587
|
+
if not source_id or not target_id:
|
|
588
|
+
return None
|
|
589
|
+
|
|
590
|
+
existing = self.db.get_one(
|
|
591
|
+
"relationships",
|
|
592
|
+
where="source_entity_id = ? AND target_entity_id = ? AND relationship_type = ? AND invalid_at IS NULL",
|
|
593
|
+
where_params=(source_id, target_id, relationship_type),
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
if not existing:
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
now = datetime.utcnow().isoformat()
|
|
600
|
+
|
|
601
|
+
with self.db.transaction() as conn:
|
|
602
|
+
# Invalidate and rename type atomically
|
|
603
|
+
old_meta = json.loads(existing["metadata"] or "{}")
|
|
604
|
+
old_meta["invalidated_reason"] = reason
|
|
605
|
+
old_meta["invalidated_at"] = now
|
|
606
|
+
|
|
607
|
+
conn.execute(
|
|
608
|
+
"UPDATE relationships SET invalid_at = ?, updated_at = ?, "
|
|
609
|
+
"relationship_type = ?, metadata = ? WHERE id = ?",
|
|
610
|
+
(
|
|
611
|
+
now,
|
|
612
|
+
now,
|
|
613
|
+
f"{relationship_type}__invalidated_{existing['id']}",
|
|
614
|
+
json.dumps(old_meta),
|
|
615
|
+
existing["id"],
|
|
616
|
+
),
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# Audit log
|
|
620
|
+
_audit_log(
|
|
621
|
+
"relationship_invalidate",
|
|
622
|
+
details={
|
|
623
|
+
"id": existing["id"],
|
|
624
|
+
"source": source_name,
|
|
625
|
+
"target": target_name,
|
|
626
|
+
"type": relationship_type,
|
|
627
|
+
"reason": reason,
|
|
628
|
+
},
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
logger.info(
|
|
632
|
+
f"Invalidated relationship {existing['id']}: "
|
|
633
|
+
f"{source_name} -> {relationship_type} -> {target_name}"
|
|
634
|
+
+ (f" ({reason})" if reason else "")
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
"relationship_id": existing["id"],
|
|
639
|
+
"source": source_name,
|
|
640
|
+
"target": target_name,
|
|
641
|
+
"relationship_type": relationship_type,
|
|
642
|
+
"invalidated_at": now,
|
|
643
|
+
"reason": reason,
|
|
644
|
+
}
|
|
645
|
+
|
|
503
646
|
def merge_entities(
|
|
504
647
|
self,
|
|
505
648
|
source_id: int,
|
|
@@ -1576,3 +1719,10 @@ def correct_memory(memory_id: int, correction: str, reason: Optional[str] = None
|
|
|
1576
1719
|
def invalidate_memory(memory_id: int, reason: Optional[str] = None) -> Dict[str, Any]:
|
|
1577
1720
|
"""Mark a memory as no longer true"""
|
|
1578
1721
|
return get_remember_service().invalidate_memory(memory_id, reason)
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
def invalidate_relationship(
|
|
1725
|
+
source: str, target: str, relationship: str, reason: Optional[str] = None
|
|
1726
|
+
) -> Optional[Dict[str, Any]]:
|
|
1727
|
+
"""Invalidate a relationship without creating a replacement"""
|
|
1728
|
+
return get_remember_service().invalidate_relationship(source, target, relationship, reason)
|
|
@@ -346,3 +346,122 @@ class TestBatchWithParallelEmbeddings:
|
|
|
346
346
|
|
|
347
347
|
mem2 = db.get_one("memories", where="content = ?", where_params=("Meeting scheduled for Friday",))
|
|
348
348
|
assert mem2 is not None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class TestBatchRelateForwarding:
|
|
352
|
+
"""Test that batch relate operations forward origin_type and other parameters."""
|
|
353
|
+
|
|
354
|
+
def test_batch_relate_forwards_origin_type(self, db):
|
|
355
|
+
"""origin_type is stored in DB when passed through batch relate."""
|
|
356
|
+
svc = _get_remember_service(db)
|
|
357
|
+
|
|
358
|
+
# Create entities first
|
|
359
|
+
svc.remember_entity(
|
|
360
|
+
name="Ford Perry",
|
|
361
|
+
entity_type="person",
|
|
362
|
+
_precomputed_embedding=_fake_embedding("Ford Perry"),
|
|
363
|
+
)
|
|
364
|
+
svc.remember_entity(
|
|
365
|
+
name="Beemok Capital",
|
|
366
|
+
entity_type="organization",
|
|
367
|
+
_precomputed_embedding=_fake_embedding("Beemok Capital"),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# Relate with inferred origin
|
|
371
|
+
rel_id = svc.relate_entities(
|
|
372
|
+
source_name="Ford Perry",
|
|
373
|
+
target_name="Beemok Capital",
|
|
374
|
+
relationship_type="works_at",
|
|
375
|
+
origin_type="inferred",
|
|
376
|
+
)
|
|
377
|
+
assert rel_id is not None
|
|
378
|
+
|
|
379
|
+
row = db.get_one("relationships", where="id = ?", where_params=(rel_id,))
|
|
380
|
+
assert row["origin_type"] == "inferred"
|
|
381
|
+
# Inferred ceiling is 0.5, so strength should be capped
|
|
382
|
+
assert row["strength"] <= 0.5
|
|
383
|
+
|
|
384
|
+
def test_batch_relate_forwards_supersedes(self, db):
|
|
385
|
+
"""Batch supersede works: old relationship invalidated, new one created."""
|
|
386
|
+
svc = _get_remember_service(db)
|
|
387
|
+
|
|
388
|
+
svc.remember_entity(
|
|
389
|
+
name="Ford Perry",
|
|
390
|
+
entity_type="person",
|
|
391
|
+
_precomputed_embedding=_fake_embedding("Ford Perry"),
|
|
392
|
+
)
|
|
393
|
+
svc.remember_entity(
|
|
394
|
+
name="Acme Corp",
|
|
395
|
+
entity_type="organization",
|
|
396
|
+
_precomputed_embedding=_fake_embedding("Acme Corp"),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# Create initial relationship
|
|
400
|
+
rel1 = svc.relate_entities(
|
|
401
|
+
source_name="Ford Perry",
|
|
402
|
+
target_name="Acme Corp",
|
|
403
|
+
relationship_type="works_at",
|
|
404
|
+
origin_type="extracted",
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Supersede it
|
|
408
|
+
rel2 = svc.relate_entities(
|
|
409
|
+
source_name="Ford Perry",
|
|
410
|
+
target_name="Acme Corp",
|
|
411
|
+
relationship_type="works_at",
|
|
412
|
+
supersedes=True,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
assert rel2 != rel1
|
|
416
|
+
|
|
417
|
+
# Old should be invalidated
|
|
418
|
+
old_row = db.get_one("relationships", where="id = ?", where_params=(rel1,))
|
|
419
|
+
assert old_row["invalid_at"] is not None
|
|
420
|
+
|
|
421
|
+
# New should be active with origin_type='corrected'
|
|
422
|
+
new_row = db.get_one("relationships", where="id = ?", where_params=(rel2,))
|
|
423
|
+
assert new_row["invalid_at"] is None
|
|
424
|
+
assert new_row["origin_type"] == "corrected"
|
|
425
|
+
|
|
426
|
+
def test_origin_upgrade_lifts_ceiling(self, db):
|
|
427
|
+
"""Re-encountering with higher-authority origin upgrades origin_type and lifts ceiling."""
|
|
428
|
+
svc = _get_remember_service(db)
|
|
429
|
+
|
|
430
|
+
svc.remember_entity(
|
|
431
|
+
name="Ford Perry",
|
|
432
|
+
entity_type="person",
|
|
433
|
+
_precomputed_embedding=_fake_embedding("Ford Perry"),
|
|
434
|
+
)
|
|
435
|
+
svc.remember_entity(
|
|
436
|
+
name="Beemok Capital",
|
|
437
|
+
entity_type="organization",
|
|
438
|
+
_precomputed_embedding=_fake_embedding("Beemok Capital"),
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# Create with inferred origin (ceiling 0.5)
|
|
442
|
+
rel_id = svc.relate_entities(
|
|
443
|
+
source_name="Ford Perry",
|
|
444
|
+
target_name="Beemok Capital",
|
|
445
|
+
relationship_type="works_at",
|
|
446
|
+
origin_type="inferred",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
row1 = db.get_one("relationships", where="id = ?", where_params=(rel_id,))
|
|
450
|
+
assert row1["origin_type"] == "inferred"
|
|
451
|
+
assert row1["strength"] <= 0.5
|
|
452
|
+
|
|
453
|
+
# Re-encounter with user_stated (should upgrade origin and lift ceiling)
|
|
454
|
+
rel_id2 = svc.relate_entities(
|
|
455
|
+
source_name="Ford Perry",
|
|
456
|
+
target_name="Beemok Capital",
|
|
457
|
+
relationship_type="works_at",
|
|
458
|
+
origin_type="user_stated",
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Same relationship ID (strengthened, not replaced)
|
|
462
|
+
assert rel_id2 == rel_id
|
|
463
|
+
|
|
464
|
+
row2 = db.get_one("relationships", where="id = ?", where_params=(rel_id,))
|
|
465
|
+
assert row2["origin_type"] == "user_stated"
|
|
466
|
+
# With user_stated ceiling of 1.0 and +0.2 increment, strength should increase
|
|
467
|
+
assert row2["strength"] > row1["strength"]
|
|
@@ -18,7 +18,7 @@ def _setup_db():
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _create_entities(db):
|
|
21
|
-
"""Create Sarah, Acme, and
|
|
21
|
+
"""Create Sarah, Acme, Beta, and Casey entities."""
|
|
22
22
|
sarah_id = db.insert("entities", {
|
|
23
23
|
"name": "Sarah Chen",
|
|
24
24
|
"type": "person",
|
|
@@ -37,7 +37,13 @@ def _create_entities(db):
|
|
|
37
37
|
"canonical_name": "beta corp",
|
|
38
38
|
"importance": 1.0,
|
|
39
39
|
})
|
|
40
|
-
|
|
40
|
+
casey_id = db.insert("entities", {
|
|
41
|
+
"name": "Casey Potenzone",
|
|
42
|
+
"type": "person",
|
|
43
|
+
"canonical_name": "casey potenzone",
|
|
44
|
+
"importance": 1.0,
|
|
45
|
+
})
|
|
46
|
+
return sarah_id, acme_id, beta_id, casey_id
|
|
41
47
|
|
|
42
48
|
|
|
43
49
|
class TestBitemporalRelationships:
|
|
@@ -46,7 +52,7 @@ class TestBitemporalRelationships:
|
|
|
46
52
|
def test_new_relationship_gets_valid_at(self):
|
|
47
53
|
"""New relationship gets valid_at set automatically."""
|
|
48
54
|
db, tmpdir = _setup_db()
|
|
49
|
-
sarah_id, acme_id, _ = _create_entities(db)
|
|
55
|
+
sarah_id, acme_id, _, _ = _create_entities(db)
|
|
50
56
|
|
|
51
57
|
import claudia_memory.database as db_mod
|
|
52
58
|
import claudia_memory.services.remember as rem_mod
|
|
@@ -70,9 +76,14 @@ class TestBitemporalRelationships:
|
|
|
70
76
|
rem_mod._service = old_svc
|
|
71
77
|
|
|
72
78
|
def test_supersedes_invalidates_old_creates_new(self):
|
|
73
|
-
"""supersedes=True invalidates old relationship and creates new one.
|
|
79
|
+
"""supersedes=True invalidates old relationship of same triple and creates new one.
|
|
80
|
+
|
|
81
|
+
Note: supersede now correctly matches source + target + type (not just source + type).
|
|
82
|
+
To replace Sarah->Acme with Sarah->Beta, use invalidate+create, not supersede.
|
|
83
|
+
Supersede is for correcting the SAME relationship (same source, target, type).
|
|
84
|
+
"""
|
|
74
85
|
db, tmpdir = _setup_db()
|
|
75
|
-
sarah_id, acme_id, beta_id = _create_entities(db)
|
|
86
|
+
sarah_id, acme_id, beta_id, _ = _create_entities(db)
|
|
76
87
|
|
|
77
88
|
import claudia_memory.database as db_mod
|
|
78
89
|
import claudia_memory.services.remember as rem_mod
|
|
@@ -89,9 +100,9 @@ class TestBitemporalRelationships:
|
|
|
89
100
|
rel1_id = svc.relate_entities("Sarah Chen", "Acme Corp", "works_at")
|
|
90
101
|
assert rel1_id is not None
|
|
91
102
|
|
|
92
|
-
# Supersede
|
|
103
|
+
# Supersede same triple (correction to same relationship)
|
|
93
104
|
rel2_id = svc.relate_entities(
|
|
94
|
-
"Sarah Chen", "
|
|
105
|
+
"Sarah Chen", "Acme Corp", "works_at",
|
|
95
106
|
supersedes=True,
|
|
96
107
|
)
|
|
97
108
|
assert rel2_id is not None
|
|
@@ -102,11 +113,12 @@ class TestBitemporalRelationships:
|
|
|
102
113
|
assert old_row["invalid_at"] is not None
|
|
103
114
|
assert "__superseded_" in old_row["relationship_type"]
|
|
104
115
|
|
|
105
|
-
# New relationship should be current
|
|
116
|
+
# New relationship should be current with origin_type='corrected'
|
|
106
117
|
new_row = db.get_one("relationships", where="id = ?", where_params=(rel2_id,))
|
|
107
118
|
assert new_row["invalid_at"] is None
|
|
108
119
|
assert new_row["valid_at"] is not None
|
|
109
120
|
assert new_row["relationship_type"] == "works_at"
|
|
121
|
+
assert new_row["origin_type"] == "corrected"
|
|
110
122
|
finally:
|
|
111
123
|
db_mod._db = old_db
|
|
112
124
|
rem_mod._service = old_svc
|
|
@@ -114,7 +126,7 @@ class TestBitemporalRelationships:
|
|
|
114
126
|
def test_default_recall_shows_current_only(self):
|
|
115
127
|
"""Default recall_about only shows current (non-invalidated) relationships."""
|
|
116
128
|
db, tmpdir = _setup_db()
|
|
117
|
-
sarah_id, acme_id, beta_id = _create_entities(db)
|
|
129
|
+
sarah_id, acme_id, beta_id, _ = _create_entities(db)
|
|
118
130
|
|
|
119
131
|
import claudia_memory.database as db_mod
|
|
120
132
|
import claudia_memory.services.remember as rem_mod
|
|
@@ -132,9 +144,11 @@ class TestBitemporalRelationships:
|
|
|
132
144
|
rem_svc = RememberService()
|
|
133
145
|
rec_svc = RecallService()
|
|
134
146
|
|
|
135
|
-
# Create and
|
|
147
|
+
# Create Acme relationship, then invalidate and create Beta
|
|
136
148
|
rem_svc.relate_entities("Sarah Chen", "Acme Corp", "works_at")
|
|
137
|
-
rem_svc.
|
|
149
|
+
rem_svc.invalidate_relationship("Sarah Chen", "Acme Corp", "works_at",
|
|
150
|
+
reason="Left company")
|
|
151
|
+
rem_svc.relate_entities("Sarah Chen", "Beta Corp", "works_at")
|
|
138
152
|
|
|
139
153
|
# Default: only current
|
|
140
154
|
result = rec_svc.recall_about("Sarah Chen")
|
|
@@ -152,7 +166,7 @@ class TestBitemporalRelationships:
|
|
|
152
166
|
def test_include_historical_shows_all(self):
|
|
153
167
|
"""include_historical=True shows all relationships including invalidated."""
|
|
154
168
|
db, tmpdir = _setup_db()
|
|
155
|
-
sarah_id, acme_id, beta_id = _create_entities(db)
|
|
169
|
+
sarah_id, acme_id, beta_id, _ = _create_entities(db)
|
|
156
170
|
|
|
157
171
|
import claudia_memory.database as db_mod
|
|
158
172
|
import claudia_memory.services.remember as rem_mod
|
|
@@ -170,11 +184,13 @@ class TestBitemporalRelationships:
|
|
|
170
184
|
rem_svc = RememberService()
|
|
171
185
|
rec_svc = RecallService()
|
|
172
186
|
|
|
173
|
-
# Create and
|
|
187
|
+
# Create Acme relationship, then invalidate and create Beta
|
|
174
188
|
rem_svc.relate_entities("Sarah Chen", "Acme Corp", "works_at")
|
|
175
|
-
rem_svc.
|
|
189
|
+
rem_svc.invalidate_relationship("Sarah Chen", "Acme Corp", "works_at",
|
|
190
|
+
reason="Left company")
|
|
191
|
+
rem_svc.relate_entities("Sarah Chen", "Beta Corp", "works_at")
|
|
176
192
|
|
|
177
|
-
# Historical: shows both
|
|
193
|
+
# Historical: shows both (Acme invalidated + Beta current)
|
|
178
194
|
result = rec_svc.recall_about("Sarah Chen", include_historical=True)
|
|
179
195
|
assert len(result["relationships"]) >= 2
|
|
180
196
|
|
|
@@ -190,7 +206,7 @@ class TestBitemporalRelationships:
|
|
|
190
206
|
def test_migration_grandfathers_existing(self):
|
|
191
207
|
"""Migration sets valid_at = created_at for existing relationships."""
|
|
192
208
|
db, tmpdir = _setup_db()
|
|
193
|
-
sarah_id, acme_id, _ = _create_entities(db)
|
|
209
|
+
sarah_id, acme_id, _, _ = _create_entities(db)
|
|
194
210
|
|
|
195
211
|
# Insert a relationship without valid_at (simulating pre-migration)
|
|
196
212
|
db.execute(
|
|
@@ -251,3 +267,173 @@ class TestBitemporalRelationships:
|
|
|
251
267
|
finally:
|
|
252
268
|
db_mod._db = old_db
|
|
253
269
|
rem_mod._service = old_svc
|
|
270
|
+
|
|
271
|
+
def test_supersede_targets_correct_relationship(self):
|
|
272
|
+
"""Superseding A->X only affects X, not A->Y of the same type.
|
|
273
|
+
|
|
274
|
+
This was a real bug: the old supersede query only matched source + type,
|
|
275
|
+
not target. When Sarah had works_with->Acme AND works_with->Beta,
|
|
276
|
+
superseding works_with->Acme could invalidate works_with->Beta instead.
|
|
277
|
+
"""
|
|
278
|
+
db, tmpdir = _setup_db()
|
|
279
|
+
sarah_id, acme_id, beta_id, _ = _create_entities(db)
|
|
280
|
+
|
|
281
|
+
import claudia_memory.database as db_mod
|
|
282
|
+
import claudia_memory.services.remember as rem_mod
|
|
283
|
+
old_db = db_mod._db
|
|
284
|
+
old_svc = rem_mod._service
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
db_mod._db = db
|
|
288
|
+
rem_mod._service = None
|
|
289
|
+
from claudia_memory.services.remember import RememberService
|
|
290
|
+
svc = RememberService()
|
|
291
|
+
|
|
292
|
+
# Create two relationships of the same type to different targets
|
|
293
|
+
rel_acme = svc.relate_entities("Sarah Chen", "Acme Corp", "works_at")
|
|
294
|
+
rel_beta = svc.relate_entities("Sarah Chen", "Beta Corp", "works_at")
|
|
295
|
+
assert rel_acme is not None
|
|
296
|
+
assert rel_beta is not None
|
|
297
|
+
assert rel_acme != rel_beta
|
|
298
|
+
|
|
299
|
+
# Supersede the Acme relationship specifically
|
|
300
|
+
rel_acme_new = svc.relate_entities(
|
|
301
|
+
"Sarah Chen", "Acme Corp", "works_at",
|
|
302
|
+
supersedes=True,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# The Beta relationship should be UNTOUCHED
|
|
306
|
+
beta_row = db.get_one("relationships", where="id = ?", where_params=(rel_beta,))
|
|
307
|
+
assert beta_row["invalid_at"] is None
|
|
308
|
+
assert beta_row["relationship_type"] == "works_at"
|
|
309
|
+
|
|
310
|
+
# The old Acme relationship should be invalidated
|
|
311
|
+
acme_old = db.get_one("relationships", where="id = ?", where_params=(rel_acme,))
|
|
312
|
+
assert acme_old["invalid_at"] is not None
|
|
313
|
+
finally:
|
|
314
|
+
db_mod._db = old_db
|
|
315
|
+
rem_mod._service = old_svc
|
|
316
|
+
|
|
317
|
+
def test_supersede_same_triple_correction(self):
|
|
318
|
+
"""Superseding A->X with a new A->X (same triple) works as a correction."""
|
|
319
|
+
db, tmpdir = _setup_db()
|
|
320
|
+
_create_entities(db)
|
|
321
|
+
|
|
322
|
+
import claudia_memory.database as db_mod
|
|
323
|
+
import claudia_memory.services.remember as rem_mod
|
|
324
|
+
old_db = db_mod._db
|
|
325
|
+
old_svc = rem_mod._service
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
db_mod._db = db
|
|
329
|
+
rem_mod._service = None
|
|
330
|
+
from claudia_memory.services.remember import RememberService
|
|
331
|
+
svc = RememberService()
|
|
332
|
+
|
|
333
|
+
# Create initial relationship
|
|
334
|
+
rel1 = svc.relate_entities("Sarah Chen", "Acme Corp", "works_at",
|
|
335
|
+
strength=0.5, origin_type="inferred")
|
|
336
|
+
|
|
337
|
+
# Supersede with a correction (same source, target, type)
|
|
338
|
+
rel2 = svc.relate_entities("Sarah Chen", "Acme Corp", "works_at",
|
|
339
|
+
supersedes=True, origin_type="user_stated")
|
|
340
|
+
|
|
341
|
+
assert rel2 is not None
|
|
342
|
+
assert rel2 != rel1
|
|
343
|
+
|
|
344
|
+
# Old should be invalidated
|
|
345
|
+
old_row = db.get_one("relationships", where="id = ?", where_params=(rel1,))
|
|
346
|
+
assert old_row["invalid_at"] is not None
|
|
347
|
+
|
|
348
|
+
# New should have origin_type='corrected' (supersede always sets this)
|
|
349
|
+
new_row = db.get_one("relationships", where="id = ?", where_params=(rel2,))
|
|
350
|
+
assert new_row["origin_type"] == "corrected"
|
|
351
|
+
assert new_row["invalid_at"] is None
|
|
352
|
+
finally:
|
|
353
|
+
db_mod._db = old_db
|
|
354
|
+
rem_mod._service = old_svc
|
|
355
|
+
|
|
356
|
+
def test_invalidate_relationship(self):
|
|
357
|
+
"""Invalidation sets invalid_at without creating a replacement."""
|
|
358
|
+
db, tmpdir = _setup_db()
|
|
359
|
+
_create_entities(db)
|
|
360
|
+
|
|
361
|
+
import claudia_memory.database as db_mod
|
|
362
|
+
import claudia_memory.services.remember as rem_mod
|
|
363
|
+
old_db = db_mod._db
|
|
364
|
+
old_svc = rem_mod._service
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
db_mod._db = db
|
|
368
|
+
rem_mod._service = None
|
|
369
|
+
from claudia_memory.services.remember import RememberService
|
|
370
|
+
svc = RememberService()
|
|
371
|
+
|
|
372
|
+
# Create a relationship
|
|
373
|
+
rel_id = svc.relate_entities("Sarah Chen", "Acme Corp", "works_at")
|
|
374
|
+
assert rel_id is not None
|
|
375
|
+
|
|
376
|
+
# Invalidate it
|
|
377
|
+
result = svc.invalidate_relationship(
|
|
378
|
+
"Sarah Chen", "Acme Corp", "works_at",
|
|
379
|
+
reason="She left the company",
|
|
380
|
+
)
|
|
381
|
+
assert result is not None
|
|
382
|
+
assert result["relationship_id"] == rel_id
|
|
383
|
+
assert result["reason"] == "She left the company"
|
|
384
|
+
|
|
385
|
+
# The row should be invalidated with renamed type
|
|
386
|
+
row = db.get_one("relationships", where="id = ?", where_params=(rel_id,))
|
|
387
|
+
assert row["invalid_at"] is not None
|
|
388
|
+
assert "__invalidated_" in row["relationship_type"]
|
|
389
|
+
|
|
390
|
+
# No new relationship should have been created
|
|
391
|
+
active = db.execute(
|
|
392
|
+
"SELECT COUNT(*) as cnt FROM relationships WHERE invalid_at IS NULL",
|
|
393
|
+
fetch=True,
|
|
394
|
+
)
|
|
395
|
+
assert active[0]["cnt"] == 0
|
|
396
|
+
finally:
|
|
397
|
+
db_mod._db = old_db
|
|
398
|
+
rem_mod._service = old_svc
|
|
399
|
+
|
|
400
|
+
def test_invalidate_logs_audit(self):
|
|
401
|
+
"""Invalidation logs to the audit trail."""
|
|
402
|
+
db, tmpdir = _setup_db()
|
|
403
|
+
_create_entities(db)
|
|
404
|
+
|
|
405
|
+
import claudia_memory.database as db_mod
|
|
406
|
+
import claudia_memory.services.remember as rem_mod
|
|
407
|
+
import claudia_memory.services.audit as audit_mod
|
|
408
|
+
old_db = db_mod._db
|
|
409
|
+
old_svc = rem_mod._service
|
|
410
|
+
old_audit_svc = audit_mod._service
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
db_mod._db = db
|
|
414
|
+
rem_mod._service = None
|
|
415
|
+
audit_mod._service = None # Reset audit singleton to use test DB
|
|
416
|
+
from claudia_memory.services.remember import RememberService
|
|
417
|
+
svc = RememberService()
|
|
418
|
+
|
|
419
|
+
rel_id = svc.relate_entities("Sarah Chen", "Acme Corp", "works_at")
|
|
420
|
+
svc.invalidate_relationship(
|
|
421
|
+
"Sarah Chen", "Acme Corp", "works_at",
|
|
422
|
+
reason="Incorrect data",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Check audit log for invalidation entry
|
|
426
|
+
audit_rows = db.execute(
|
|
427
|
+
"SELECT * FROM audit_log WHERE operation = 'relationship_invalidate'",
|
|
428
|
+
fetch=True,
|
|
429
|
+
) or []
|
|
430
|
+
assert len(audit_rows) >= 1
|
|
431
|
+
# The audit should reference the correct relationship
|
|
432
|
+
import json
|
|
433
|
+
details = json.loads(audit_rows[0]["details"])
|
|
434
|
+
assert details["id"] == rel_id
|
|
435
|
+
assert details["reason"] == "Incorrect data"
|
|
436
|
+
finally:
|
|
437
|
+
db_mod._db = old_db
|
|
438
|
+
rem_mod._service = old_svc
|
|
439
|
+
audit_mod._service = old_audit_svc
|
|
@@ -68,8 +68,9 @@ def test_validate_entity_empty_name():
|
|
|
68
68
|
|
|
69
69
|
def test_validate_relationship_strength_clamped():
|
|
70
70
|
"""Strength outside [0, 1] gets clamped"""
|
|
71
|
-
|
|
71
|
+
# Use user_stated (ceiling=1.0) to isolate the clamping logic from origin ceilings
|
|
72
|
+
result = validate_relationship(1.5, origin_type="user_stated")
|
|
72
73
|
assert result.adjustments["strength"] == 1.0
|
|
73
74
|
|
|
74
|
-
result2 = validate_relationship(-0.2)
|
|
75
|
+
result2 = validate_relationship(-0.2, origin_type="user_stated")
|
|
75
76
|
assert result2.adjustments["strength"] == 0.0
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tests for origin-aware relationship strength guards."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from claudia_memory.services.guards import (
|
|
6
|
+
ORIGIN_STRENGTH_CEILING,
|
|
7
|
+
REINFORCEMENT_BY_ORIGIN,
|
|
8
|
+
validate_relationship,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestOriginStrengthCeilings:
|
|
13
|
+
"""Test that relationship strength is capped by origin authority."""
|
|
14
|
+
|
|
15
|
+
def test_inferred_capped_at_half(self):
|
|
16
|
+
"""Inferred relationships are capped at 0.5 strength."""
|
|
17
|
+
result = validate_relationship(strength=1.0, origin_type="inferred")
|
|
18
|
+
assert "strength" in result.adjustments
|
|
19
|
+
assert result.adjustments["strength"] == 0.5
|
|
20
|
+
assert any("ceiling" in w for w in result.warnings)
|
|
21
|
+
|
|
22
|
+
def test_user_stated_uncapped(self):
|
|
23
|
+
"""User-stated relationships allow full 1.0 strength."""
|
|
24
|
+
result = validate_relationship(strength=1.0, origin_type="user_stated")
|
|
25
|
+
# No cap needed -- user_stated ceiling is 1.0
|
|
26
|
+
assert result.adjustments.get("strength", 1.0) == 1.0
|
|
27
|
+
assert not any("ceiling" in w for w in result.warnings)
|
|
28
|
+
|
|
29
|
+
def test_extracted_capped_at_0_8(self):
|
|
30
|
+
"""Extracted relationships are capped at 0.8 strength."""
|
|
31
|
+
result = validate_relationship(strength=1.0, origin_type="extracted")
|
|
32
|
+
assert "strength" in result.adjustments
|
|
33
|
+
assert result.adjustments["strength"] == 0.8
|
|
34
|
+
assert any("ceiling" in w for w in result.warnings)
|
|
35
|
+
|
|
36
|
+
def test_corrected_uncapped(self):
|
|
37
|
+
"""Corrected relationships allow full 1.0 strength (same as user_stated)."""
|
|
38
|
+
result = validate_relationship(strength=1.0, origin_type="corrected")
|
|
39
|
+
assert result.adjustments.get("strength", 1.0) == 1.0
|
|
40
|
+
assert not any("ceiling" in w for w in result.warnings)
|
|
41
|
+
|
|
42
|
+
def test_unknown_origin_defaults_to_0_5(self):
|
|
43
|
+
"""Unknown origin types default to 0.5 ceiling."""
|
|
44
|
+
result = validate_relationship(strength=0.9, origin_type="mystery")
|
|
45
|
+
assert result.adjustments["strength"] == 0.5
|
|
46
|
+
|
|
47
|
+
def test_strength_below_ceiling_untouched(self):
|
|
48
|
+
"""Strength already below ceiling is not adjusted."""
|
|
49
|
+
result = validate_relationship(strength=0.3, origin_type="inferred")
|
|
50
|
+
# 0.3 < 0.5 ceiling, so no adjustment for ceiling
|
|
51
|
+
assert "strength" not in result.adjustments or result.adjustments.get("strength") == 0.3
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestReinforcementScaling:
|
|
55
|
+
"""Test that reinforcement increments scale by origin."""
|
|
56
|
+
|
|
57
|
+
def test_inferred_increment_is_0_05(self):
|
|
58
|
+
assert REINFORCEMENT_BY_ORIGIN["inferred"] == 0.05
|
|
59
|
+
|
|
60
|
+
def test_extracted_increment_is_0_1(self):
|
|
61
|
+
assert REINFORCEMENT_BY_ORIGIN["extracted"] == 0.1
|
|
62
|
+
|
|
63
|
+
def test_user_stated_increment_is_0_2(self):
|
|
64
|
+
assert REINFORCEMENT_BY_ORIGIN["user_stated"] == 0.2
|
|
65
|
+
|
|
66
|
+
def test_corrected_increment_is_0_2(self):
|
|
67
|
+
assert REINFORCEMENT_BY_ORIGIN["corrected"] == 0.2
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestCeilingValues:
|
|
71
|
+
"""Verify the ceiling constants are correct."""
|
|
72
|
+
|
|
73
|
+
def test_ceiling_values(self):
|
|
74
|
+
assert ORIGIN_STRENGTH_CEILING == {
|
|
75
|
+
"user_stated": 1.0,
|
|
76
|
+
"extracted": 0.8,
|
|
77
|
+
"inferred": 0.5,
|
|
78
|
+
"corrected": 1.0,
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -60,9 +60,10 @@ For each file, extract:
|
|
|
60
60
|
|
|
61
61
|
### 3. Extract Relationships
|
|
62
62
|
|
|
63
|
-
Identify explicit and implicit relationships:
|
|
63
|
+
Identify explicit and implicit relationships. For each relationship, set `origin_type` honestly based on how you know it. The system automatically caps strength based on origin, so always use `strength: 1.0` and let the guards enforce the ceiling.
|
|
64
64
|
|
|
65
|
-
**
|
|
65
|
+
**Extracted Relationships** (origin_type: "extracted", ceiling: 0.8)
|
|
66
|
+
Explicitly stated in the file:
|
|
66
67
|
- "works with [Name]" -> `works_with`
|
|
67
68
|
- "client of [Name]" -> `client_of`
|
|
68
69
|
- "reports to [Name]" -> `reports_to`
|
|
@@ -71,11 +72,10 @@ Identify explicit and implicit relationships:
|
|
|
71
72
|
- "partner at [Org]" -> `partner_at`
|
|
72
73
|
- "advisor to [Name/Org]" -> `advisor_to`
|
|
73
74
|
|
|
74
|
-
**
|
|
75
|
+
**Inferred Relationships** (origin_type: "inferred", ceiling: 0.5)
|
|
76
|
+
Co-mentioned or contextually implied:
|
|
75
77
|
- Two people mentioned in the same file -> `mentioned_with`
|
|
76
78
|
- People in the same project file -> `collaborates_on`
|
|
77
|
-
|
|
78
|
-
**Inferred Relationships (Low Confidence: 0.3)**
|
|
79
79
|
- Same city + same industry -> `likely_connected`
|
|
80
80
|
- Same organization -> `colleagues`
|
|
81
81
|
- Same community group -> `community_connection`
|
|
@@ -96,18 +96,20 @@ Use `memory.batch` for efficiency:
|
|
|
96
96
|
memory.batch operations=[
|
|
97
97
|
{op: "entity", name: "Sarah Chen", type: "person", description: "CEO at Acme Corp"},
|
|
98
98
|
{op: "entity", name: "Acme Corp", type: "organization"},
|
|
99
|
-
{op: "relate", source: "Sarah Chen", target: "Acme Corp", relationship: "works_at", strength: 0
|
|
100
|
-
{op: "relate", source: "Sarah Chen", target: "Tom Miller", relationship: "works_with", strength: 0
|
|
99
|
+
{op: "relate", source: "Sarah Chen", target: "Acme Corp", relationship: "works_at", strength: 1.0, origin_type: "extracted"},
|
|
100
|
+
{op: "relate", source: "Sarah Chen", target: "Tom Miller", relationship: "works_with", strength: 1.0, origin_type: "inferred"},
|
|
101
101
|
...
|
|
102
102
|
]
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
For relationship
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
-
|
|
105
|
+
For relationship `origin_type`:
|
|
106
|
+
- Explicitly stated in the file ("Sarah is CEO of Acme"): `origin_type: "extracted"`
|
|
107
|
+
- Co-mentioned or contextually implied: `origin_type: "inferred"`
|
|
108
|
+
- User told you directly: `origin_type: "user_stated"`
|
|
109
|
+
|
|
110
|
+
The system automatically caps strength based on origin. You don't need to manually calibrate. Just be honest about how you know, and always use `strength: 1.0`.
|
|
109
111
|
|
|
110
|
-
When
|
|
112
|
+
When re-encountering existing relationships, the system strengthens them incrementally (scaled by origin). Repeated evidence builds trust organically.
|
|
111
113
|
|
|
112
114
|
### 6. Report Results
|
|
113
115
|
|
|
@@ -129,23 +131,23 @@ Output format:
|
|
|
129
131
|
|
|
130
132
|
### New Relationships ([count])
|
|
131
133
|
|
|
132
|
-
| Source | Relationship | Target |
|
|
133
|
-
|
|
134
|
-
| Sarah Chen | works_at | Acme Corp |
|
|
135
|
-
| Sarah Chen | collaborates_on | Website Redesign |
|
|
136
|
-
| Sarah Chen | mentioned_with | Tom Miller |
|
|
134
|
+
| Source | Relationship | Target | Origin |
|
|
135
|
+
|--------|--------------|--------|--------|
|
|
136
|
+
| Sarah Chen | works_at | Acme Corp | extracted |
|
|
137
|
+
| Sarah Chen | collaborates_on | Website Redesign | extracted |
|
|
138
|
+
| Sarah Chen | mentioned_with | Tom Miller | inferred |
|
|
137
139
|
|
|
138
140
|
### Inferred Connections ([count])
|
|
139
141
|
|
|
140
|
-
| Entity A | Entity B | Reason |
|
|
141
|
-
|
|
142
|
-
| Sarah Chen | Jane Doe | Same city (Palm Beach) + industry (real estate) |
|
|
142
|
+
| Entity A | Entity B | Reason | Origin |
|
|
143
|
+
|----------|----------|--------|--------|
|
|
144
|
+
| Sarah Chen | Jane Doe | Same city (Palm Beach) + industry (real estate) | inferred |
|
|
143
145
|
|
|
144
146
|
### Updated Relationships ([count])
|
|
145
147
|
|
|
146
148
|
| Relationship | Change |
|
|
147
149
|
|--------------|--------|
|
|
148
|
-
| Sarah Chen -> client_of -> Beta Inc |
|
|
150
|
+
| Sarah Chen -> client_of -> Beta Inc | strengthened (re-encountered, extracted) |
|
|
149
151
|
|
|
150
152
|
### Summary
|
|
151
153
|
|