Compare commits
16 Commits
feat/mnemo
...
fix/mnemos
| Author | SHA1 | Date | |
|---|---|---|---|
| 3361a0e259 | |||
| 8fb0a50b91 | |||
| 99e4baf54b | |||
| b0e24af7fe | |||
| 65cef9d9c0 | |||
| 267505a68f | |||
| e8312d91f7 | |||
| 446ec370c8 | |||
| 76e62fe43f | |||
| b52c7281f0 | |||
| af1221fb80 | |||
| 42a4169940 | |||
| 3f7c037562 | |||
| 17e714c9d2 | |||
| 653c20862c | |||
| 89e19dbaa2 |
4
app.js
4
app.js
@@ -7,6 +7,7 @@ import { SpatialMemory } from './nexus/components/spatial-memory.js';
|
||||
import { MemoryBirth } from './nexus/components/memory-birth.js';
|
||||
import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
|
||||
import { MemoryInspect } from './nexus/components/memory-inspect.js';
|
||||
import { MemoryPulse } from './nexus/components/memory-pulse.js';
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NEXUS v1.1 — Portal System Update
|
||||
@@ -715,6 +716,7 @@ async function init() {
|
||||
MemoryBirth.wrapSpatialMemory(SpatialMemory);
|
||||
SpatialMemory.setCamera(camera);
|
||||
MemoryInspect.init({ onNavigate: _navigateToMemory });
|
||||
MemoryPulse.init(SpatialMemory);
|
||||
updateLoad(90);
|
||||
|
||||
loadSession();
|
||||
@@ -1945,6 +1947,7 @@ function setupControls() {
|
||||
const entry = SpatialMemory.getMemoryFromMesh(hits[0].object);
|
||||
if (entry) {
|
||||
SpatialMemory.highlightMemory(entry.data.id);
|
||||
MemoryPulse.triggerPulse(entry.data.id);
|
||||
const regionDef = SpatialMemory.REGIONS[entry.region] || SpatialMemory.REGIONS.working;
|
||||
MemoryInspect.show(entry.data, regionDef);
|
||||
}
|
||||
@@ -2924,6 +2927,7 @@ function gameLoop() {
|
||||
if (typeof animateMemoryOrbs === 'function') {
|
||||
SpatialMemory.update(delta);
|
||||
MemoryBirth.update(delta);
|
||||
MemoryPulse.update();
|
||||
animateMemoryOrbs(delta);
|
||||
}
|
||||
|
||||
|
||||
160
nexus/components/memory-pulse.js
Normal file
160
nexus/components/memory-pulse.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// ═══════════════════════════════════════════════════
|
||||
// PROJECT MNEMOSYNE — MEMORY PULSE
|
||||
// ═══════════════════════════════════════════════════
|
||||
//
|
||||
// BFS wave animation triggered on crystal click.
|
||||
// When a memory crystal is clicked, a visual pulse
|
||||
// radiates through the connection graph — illuminating
|
||||
// linked memories hop-by-hop with a glow that rises
|
||||
// sharply and then fades.
|
||||
//
|
||||
// Usage:
|
||||
// MemoryPulse.init(SpatialMemory);
|
||||
// MemoryPulse.triggerPulse(memId);
|
||||
// MemoryPulse.update(); // called each frame
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
const MemoryPulse = (() => {
|
||||
|
||||
let _sm = null;
|
||||
|
||||
// [{mesh, startTime, delay, duration, peakIntensity, baseIntensity}]
|
||||
const _activeEffects = [];
|
||||
|
||||
// ── Config ───────────────────────────────────────
|
||||
const HOP_DELAY_MS = 180; // ms between hops
|
||||
const PULSE_DURATION = 650; // ms for glow rise + fade per node
|
||||
const PEAK_INTENSITY = 5.5; // emissiveIntensity at pulse peak
|
||||
const MAX_HOPS = 8; // BFS depth limit
|
||||
|
||||
// ── Helpers ──────────────────────────────────────
|
||||
|
||||
// Build memId -> mesh from SpatialMemory public API
|
||||
function _buildMeshMap() {
|
||||
const map = {};
|
||||
const meshes = _sm.getCrystalMeshes();
|
||||
for (const mesh of meshes) {
|
||||
const entry = _sm.getMemoryFromMesh(mesh);
|
||||
if (entry) map[entry.data.id] = mesh;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Build bidirectional adjacency graph from memory connection data
|
||||
function _buildGraph() {
|
||||
const graph = {};
|
||||
const memories = _sm.getAllMemories();
|
||||
for (const mem of memories) {
|
||||
if (!graph[mem.id]) graph[mem.id] = [];
|
||||
if (mem.connections) {
|
||||
for (const targetId of mem.connections) {
|
||||
graph[mem.id].push(targetId);
|
||||
if (!graph[targetId]) graph[targetId] = [];
|
||||
graph[targetId].push(mem.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────
|
||||
|
||||
function init(spatialMemory) {
|
||||
_sm = spatialMemory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a BFS pulse wave originating from memId.
|
||||
* Each hop level illuminates after HOP_DELAY_MS * hop ms.
|
||||
* @param {string} memId - ID of the clicked memory crystal
|
||||
*/
|
||||
function triggerPulse(memId) {
|
||||
if (!_sm) return;
|
||||
|
||||
const meshMap = _buildMeshMap();
|
||||
const graph = _buildGraph();
|
||||
|
||||
if (!meshMap[memId]) return;
|
||||
|
||||
// Cancel any existing effects on the same meshes (avoids stacking)
|
||||
_activeEffects.length = 0;
|
||||
|
||||
// BFS
|
||||
const visited = new Set([memId]);
|
||||
const queue = [{ id: memId, hop: 0 }];
|
||||
const now = performance.now();
|
||||
const scheduled = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { id, hop } = queue.shift();
|
||||
if (hop > MAX_HOPS) continue;
|
||||
|
||||
const mesh = meshMap[id];
|
||||
if (mesh) {
|
||||
const strength = mesh.userData.strength || 0.7;
|
||||
const baseIntensity = 1.0 + Math.sin(mesh.userData.pulse || 0) * 0.5 * strength;
|
||||
|
||||
scheduled.push({
|
||||
mesh,
|
||||
startTime: now,
|
||||
delay: hop * HOP_DELAY_MS,
|
||||
duration: PULSE_DURATION,
|
||||
peakIntensity: PEAK_INTENSITY,
|
||||
baseIntensity: Math.max(0.5, baseIntensity)
|
||||
});
|
||||
}
|
||||
|
||||
for (const neighborId of (graph[id] || [])) {
|
||||
if (!visited.has(neighborId)) {
|
||||
visited.add(neighborId);
|
||||
queue.push({ id: neighborId, hop: hop + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const effect of scheduled) {
|
||||
_activeEffects.push(effect);
|
||||
}
|
||||
|
||||
console.info('[MemoryPulse] Pulse triggered from', memId, '—', scheduled.length, 'nodes in wave');
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance all active pulse animations. Call once per frame.
|
||||
*/
|
||||
function update() {
|
||||
if (_activeEffects.length === 0) return;
|
||||
|
||||
const now = performance.now();
|
||||
|
||||
for (let i = _activeEffects.length - 1; i >= 0; i--) {
|
||||
const e = _activeEffects[i];
|
||||
const elapsed = now - e.startTime - e.delay;
|
||||
|
||||
if (elapsed < 0) continue; // waiting for its hop delay
|
||||
|
||||
if (elapsed >= e.duration) {
|
||||
// Animation complete — restore base intensity
|
||||
if (e.mesh.material) {
|
||||
e.mesh.material.emissiveIntensity = e.baseIntensity;
|
||||
}
|
||||
_activeEffects.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// t: 0 → 1 over duration
|
||||
const t = elapsed / e.duration;
|
||||
// sin curve over [0, π]: smooth rise then fall
|
||||
const glow = Math.sin(t * Math.PI);
|
||||
|
||||
if (e.mesh.material) {
|
||||
e.mesh.material.emissiveIntensity =
|
||||
e.baseIntensity + glow * (e.peakIntensity - e.baseIntensity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { init, triggerPulse, update };
|
||||
})();
|
||||
|
||||
export { MemoryPulse };
|
||||
@@ -67,7 +67,7 @@ modules:
|
||||
cli:
|
||||
status: shipped
|
||||
files: [cli.py]
|
||||
description: CLI interface — stats, search, ingest, link, topics, remove, export, clusters, hubs, bridges, rebuild, tag/untag/retag, timeline, neighbors
|
||||
description: CLI interface — stats, search, ingest, link, topics, remove, export, clusters, hubs, bridges, rebuild, tag/untag/retag, timeline, neighbors, consolidate, path, touch, decay, vitality, fading, vibrant
|
||||
|
||||
tests:
|
||||
status: shipped
|
||||
@@ -151,21 +151,27 @@ frontend:
|
||||
planned:
|
||||
|
||||
memory_decay:
|
||||
status: planned
|
||||
status: shipped
|
||||
files: [entry.py, archive.py]
|
||||
description: >
|
||||
Memories have living energy that fades with neglect and
|
||||
brightens with access. Vitality score based on access
|
||||
frequency and recency. Was attempted in PR #1221 but
|
||||
went stale — needs fresh implementation against current main.
|
||||
frequency and recency. Exponential decay with 30-day half-life.
|
||||
Touch boost with diminishing returns.
|
||||
priority: medium
|
||||
merged_prs:
|
||||
- "#TBD" # Will be filled when PR is created
|
||||
|
||||
memory_pulse:
|
||||
status: planned
|
||||
status: shipped
|
||||
files: [nexus/components/memory-pulse.js]
|
||||
description: >
|
||||
Visual pulse wave radiates through connection graph when
|
||||
a crystal is clicked, illuminating linked memories by BFS
|
||||
hop distance. Was attempted in PR #1226 — needs rebasing.
|
||||
hop distance.
|
||||
priority: medium
|
||||
merged_prs:
|
||||
- "#1263"
|
||||
|
||||
embedding_backend:
|
||||
status: shipped
|
||||
@@ -178,10 +184,26 @@ planned:
|
||||
merged_prs:
|
||||
- "#TBD" # Will be filled when PR is created
|
||||
|
||||
|
||||
memory_path:
|
||||
status: shipped
|
||||
files: [archive.py, cli.py, tests/test_path.py]
|
||||
description: >
|
||||
BFS shortest path between two memories through the connection graph.
|
||||
Answers "how is memory X related to memory Y?" by finding the chain
|
||||
of connections. Includes path_explanation for human-readable output.
|
||||
CLI command: mnemosyne path <start_id> <end_id>
|
||||
priority: medium
|
||||
merged_prs:
|
||||
- "#TBD"
|
||||
|
||||
memory_consolidation:
|
||||
status: planned
|
||||
status: shipped
|
||||
files: [archive.py, cli.py, tests/test_consolidation.py]
|
||||
description: >
|
||||
Automatic merging of duplicate/near-duplicate memories
|
||||
using content_hash and semantic similarity. Periodic
|
||||
consolidation pass.
|
||||
priority: low
|
||||
merged_prs:
|
||||
- "#1260"
|
||||
|
||||
@@ -389,6 +389,17 @@ class MnemosyneArchive:
|
||||
oldest_entry = timestamps[0] if timestamps else None
|
||||
newest_entry = timestamps[-1] if timestamps else None
|
||||
|
||||
# Vitality summary
|
||||
if n > 0:
|
||||
vitalities = [self._compute_vitality(e) for e in entries]
|
||||
avg_vitality = round(sum(vitalities) / n, 4)
|
||||
fading_count = sum(1 for v in vitalities if v < 0.3)
|
||||
vibrant_count = sum(1 for v in vitalities if v > 0.7)
|
||||
else:
|
||||
avg_vitality = 0.0
|
||||
fading_count = 0
|
||||
vibrant_count = 0
|
||||
|
||||
return {
|
||||
"entries": n,
|
||||
"total_links": total_links,
|
||||
@@ -398,6 +409,9 @@ class MnemosyneArchive:
|
||||
"link_density": link_density,
|
||||
"oldest_entry": oldest_entry,
|
||||
"newest_entry": newest_entry,
|
||||
"avg_vitality": avg_vitality,
|
||||
"fading_count": fading_count,
|
||||
"vibrant_count": vibrant_count,
|
||||
}
|
||||
|
||||
def _build_adjacency(self) -> dict[str, set[str]]:
|
||||
@@ -742,6 +756,355 @@ class MnemosyneArchive:
|
||||
results.sort(key=lambda e: e.created_at)
|
||||
return results
|
||||
|
||||
# ─── Memory Decay ─────────────────────────────────────────
|
||||
|
||||
# Decay parameters
|
||||
_DECAY_HALF_LIFE_DAYS: float = 30.0 # Half-life for exponential decay
|
||||
_TOUCH_BOOST_FACTOR: float = 0.1 # Base boost on access (diminishes as vitality → 1.0)
|
||||
|
||||
def touch(self, entry_id: str) -> ArchiveEntry:
|
||||
"""Record an access to an entry, boosting its vitality.
|
||||
|
||||
The boost is ``_TOUCH_BOOST_FACTOR * (1 - current_vitality)`` —
|
||||
diminishing returns as vitality approaches 1.0 ensures entries
|
||||
can never exceed 1.0 through touch alone.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the entry to touch.
|
||||
|
||||
Returns:
|
||||
The updated ArchiveEntry.
|
||||
|
||||
Raises:
|
||||
KeyError: If entry_id does not exist.
|
||||
"""
|
||||
entry = self._entries.get(entry_id)
|
||||
if entry is None:
|
||||
raise KeyError(entry_id)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Compute current decayed vitality before boosting
|
||||
current = self._compute_vitality(entry)
|
||||
boost = self._TOUCH_BOOST_FACTOR * (1.0 - current)
|
||||
entry.vitality = min(1.0, current + boost)
|
||||
entry.last_accessed = now
|
||||
self._save()
|
||||
return entry
|
||||
|
||||
def _compute_vitality(self, entry: ArchiveEntry) -> float:
|
||||
"""Compute the current vitality of an entry based on time decay.
|
||||
|
||||
Uses exponential decay: ``v = base * 0.5 ^ (hours_since_access / half_life_hours)``
|
||||
|
||||
If the entry has never been accessed, uses ``created_at`` as the
|
||||
reference point. New entries with no access start at full vitality.
|
||||
|
||||
Args:
|
||||
entry: The archive entry.
|
||||
|
||||
Returns:
|
||||
Current vitality as a float in [0.0, 1.0].
|
||||
"""
|
||||
if entry.last_accessed is None:
|
||||
# Never accessed — check age from creation
|
||||
created = self._parse_dt(entry.created_at)
|
||||
hours_elapsed = (datetime.now(timezone.utc) - created).total_seconds() / 3600
|
||||
else:
|
||||
last = self._parse_dt(entry.last_accessed)
|
||||
hours_elapsed = (datetime.now(timezone.utc) - last).total_seconds() / 3600
|
||||
|
||||
half_life_hours = self._DECAY_HALF_LIFE_DAYS * 24
|
||||
if hours_elapsed <= 0 or half_life_hours <= 0:
|
||||
return entry.vitality
|
||||
|
||||
decayed = entry.vitality * (0.5 ** (hours_elapsed / half_life_hours))
|
||||
return max(0.0, min(1.0, decayed))
|
||||
|
||||
def get_vitality(self, entry_id: str) -> dict:
|
||||
"""Get the current vitality status of an entry.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the entry.
|
||||
|
||||
Returns:
|
||||
Dict with keys: entry_id, title, vitality, last_accessed, age_days
|
||||
|
||||
Raises:
|
||||
KeyError: If entry_id does not exist.
|
||||
"""
|
||||
entry = self._entries.get(entry_id)
|
||||
if entry is None:
|
||||
raise KeyError(entry_id)
|
||||
|
||||
current_vitality = self._compute_vitality(entry)
|
||||
created = self._parse_dt(entry.created_at)
|
||||
age_days = (datetime.now(timezone.utc) - created).days
|
||||
|
||||
return {
|
||||
"entry_id": entry.id,
|
||||
"title": entry.title,
|
||||
"vitality": round(current_vitality, 4),
|
||||
"last_accessed": entry.last_accessed,
|
||||
"age_days": age_days,
|
||||
}
|
||||
|
||||
def fading(self, limit: int = 10) -> list[dict]:
|
||||
"""Return entries with the lowest vitality (most neglected).
|
||||
|
||||
Args:
|
||||
limit: Maximum number of entries to return.
|
||||
|
||||
Returns:
|
||||
List of dicts sorted by vitality ascending (most faded first).
|
||||
Each dict has keys: entry_id, title, vitality, last_accessed, age_days
|
||||
"""
|
||||
scored = []
|
||||
for entry in self._entries.values():
|
||||
v = self._compute_vitality(entry)
|
||||
created = self._parse_dt(entry.created_at)
|
||||
age_days = (datetime.now(timezone.utc) - created).days
|
||||
scored.append({
|
||||
"entry_id": entry.id,
|
||||
"title": entry.title,
|
||||
"vitality": round(v, 4),
|
||||
"last_accessed": entry.last_accessed,
|
||||
"age_days": age_days,
|
||||
})
|
||||
scored.sort(key=lambda x: x["vitality"])
|
||||
return scored[:limit]
|
||||
|
||||
def vibrant(self, limit: int = 10) -> list[dict]:
|
||||
"""Return entries with the highest vitality (most alive).
|
||||
|
||||
Args:
|
||||
limit: Maximum number of entries to return.
|
||||
|
||||
Returns:
|
||||
List of dicts sorted by vitality descending (most vibrant first).
|
||||
Each dict has keys: entry_id, title, vitality, last_accessed, age_days
|
||||
"""
|
||||
scored = []
|
||||
for entry in self._entries.values():
|
||||
v = self._compute_vitality(entry)
|
||||
created = self._parse_dt(entry.created_at)
|
||||
age_days = (datetime.now(timezone.utc) - created).days
|
||||
scored.append({
|
||||
"entry_id": entry.id,
|
||||
"title": entry.title,
|
||||
"vitality": round(v, 4),
|
||||
"last_accessed": entry.last_accessed,
|
||||
"age_days": age_days,
|
||||
})
|
||||
scored.sort(key=lambda x: x["vitality"], reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
def apply_decay(self) -> dict:
|
||||
"""Apply time-based decay to all entries and persist.
|
||||
|
||||
Recomputes each entry's vitality based on elapsed time since
|
||||
its last access (or creation if never accessed). Saves the
|
||||
archive after updating.
|
||||
|
||||
Returns:
|
||||
Dict with keys: total_entries, decayed_count, avg_vitality,
|
||||
fading_count (entries below 0.3), vibrant_count (entries above 0.7)
|
||||
"""
|
||||
decayed = 0
|
||||
total_vitality = 0.0
|
||||
fading_count = 0
|
||||
vibrant_count = 0
|
||||
|
||||
for entry in self._entries.values():
|
||||
old_v = entry.vitality
|
||||
new_v = self._compute_vitality(entry)
|
||||
if abs(new_v - old_v) > 1e-6:
|
||||
entry.vitality = new_v
|
||||
decayed += 1
|
||||
total_vitality += entry.vitality
|
||||
if entry.vitality < 0.3:
|
||||
fading_count += 1
|
||||
if entry.vitality > 0.7:
|
||||
vibrant_count += 1
|
||||
|
||||
n = len(self._entries)
|
||||
self._save()
|
||||
|
||||
return {
|
||||
"total_entries": n,
|
||||
"decayed_count": decayed,
|
||||
"avg_vitality": round(total_vitality / n, 4) if n else 0.0,
|
||||
"fading_count": fading_count,
|
||||
"vibrant_count": vibrant_count,
|
||||
}
|
||||
|
||||
def consolidate(
|
||||
self,
|
||||
threshold: float = 0.9,
|
||||
dry_run: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Scan the archive and merge duplicate/near-duplicate entries.
|
||||
|
||||
Two entries are considered duplicates if:
|
||||
- They share the same ``content_hash`` (exact duplicate), or
|
||||
- Their similarity score (via HolographicLinker) exceeds ``threshold``
|
||||
(near-duplicate when an embedding backend is available or Jaccard is
|
||||
high enough at the given threshold).
|
||||
|
||||
Merge strategy:
|
||||
- Keep the *older* entry (earlier ``created_at``).
|
||||
- Union topics from both entries (case-deduped).
|
||||
- Merge metadata from newer into older (older values win on conflicts).
|
||||
- Transfer all links from the newer entry to the older entry.
|
||||
- Delete the newer entry.
|
||||
|
||||
Args:
|
||||
threshold: Similarity threshold for near-duplicate detection (0.0–1.0).
|
||||
Default 0.9 is intentionally conservative.
|
||||
dry_run: If True, return the list of would-be merges without mutating
|
||||
the archive.
|
||||
|
||||
Returns:
|
||||
List of dicts, one per merged pair::
|
||||
|
||||
{
|
||||
"kept": <entry_id of survivor>,
|
||||
"removed": <entry_id of duplicate>,
|
||||
"reason": "exact_hash" | "semantic_similarity",
|
||||
"score": float, # 1.0 for exact hash matches
|
||||
"dry_run": bool,
|
||||
}
|
||||
"""
|
||||
merges: list[dict] = []
|
||||
entries = list(self._entries.values())
|
||||
removed_ids: set[str] = set()
|
||||
|
||||
for i, entry_a in enumerate(entries):
|
||||
if entry_a.id in removed_ids:
|
||||
continue
|
||||
for entry_b in entries[i + 1:]:
|
||||
if entry_b.id in removed_ids:
|
||||
continue
|
||||
|
||||
# Determine if they are duplicates
|
||||
reason: Optional[str] = None
|
||||
score: float = 0.0
|
||||
|
||||
if (
|
||||
entry_a.content_hash is not None
|
||||
and entry_b.content_hash is not None
|
||||
and entry_a.content_hash == entry_b.content_hash
|
||||
):
|
||||
reason = "exact_hash"
|
||||
score = 1.0
|
||||
else:
|
||||
sim = self.linker.compute_similarity(entry_a, entry_b)
|
||||
if sim >= threshold:
|
||||
reason = "semantic_similarity"
|
||||
score = sim
|
||||
|
||||
if reason is None:
|
||||
continue
|
||||
|
||||
# Decide which entry to keep (older survives)
|
||||
if entry_a.created_at <= entry_b.created_at:
|
||||
kept, removed = entry_a, entry_b
|
||||
else:
|
||||
kept, removed = entry_b, entry_a
|
||||
|
||||
merges.append({
|
||||
"kept": kept.id,
|
||||
"removed": removed.id,
|
||||
"reason": reason,
|
||||
"score": round(score, 4),
|
||||
"dry_run": dry_run,
|
||||
})
|
||||
|
||||
if not dry_run:
|
||||
# Merge topics (case-deduped)
|
||||
existing_lower = {t.lower() for t in kept.topics}
|
||||
for tag in removed.topics:
|
||||
if tag.lower() not in existing_lower:
|
||||
kept.topics.append(tag)
|
||||
existing_lower.add(tag.lower())
|
||||
|
||||
# Merge metadata (kept wins on key conflicts)
|
||||
for k, v in removed.metadata.items():
|
||||
if k not in kept.metadata:
|
||||
kept.metadata[k] = v
|
||||
|
||||
# Transfer links: add removed's links to kept
|
||||
kept_links_set = set(kept.links)
|
||||
for lid in removed.links:
|
||||
if lid != kept.id and lid not in kept_links_set and lid not in removed_ids:
|
||||
kept.links.append(lid)
|
||||
kept_links_set.add(lid)
|
||||
# Update the other entry's back-link
|
||||
other = self._entries.get(lid)
|
||||
if other and kept.id not in other.links:
|
||||
other.links.append(kept.id)
|
||||
|
||||
# Remove back-links pointing at the removed entry
|
||||
for other in self._entries.values():
|
||||
if removed.id in other.links:
|
||||
other.links.remove(removed.id)
|
||||
if other.id != kept.id and kept.id not in other.links:
|
||||
other.links.append(kept.id)
|
||||
|
||||
del self._entries[removed.id]
|
||||
removed_ids.add(removed.id)
|
||||
|
||||
if not dry_run and merges:
|
||||
self._save()
|
||||
|
||||
return merges
|
||||
|
||||
|
||||
def shortest_path(self, start_id: str, end_id: str) -> list[str] | None:
|
||||
"""Find shortest path between two entries through the connection graph.
|
||||
|
||||
Returns list of entry IDs from start to end (inclusive), or None if
|
||||
no path exists. Uses BFS for unweighted shortest path.
|
||||
"""
|
||||
if start_id == end_id:
|
||||
return [start_id] if start_id in self._entries else None
|
||||
if start_id not in self._entries or end_id not in self._entries:
|
||||
return None
|
||||
|
||||
adj = self._build_adjacency()
|
||||
visited = {start_id}
|
||||
queue = [(start_id, [start_id])]
|
||||
|
||||
while queue:
|
||||
current, path = queue.pop(0)
|
||||
for neighbor in adj.get(current, []):
|
||||
if neighbor == end_id:
|
||||
return path + [neighbor]
|
||||
if neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
queue.append((neighbor, path + [neighbor]))
|
||||
|
||||
return None
|
||||
|
||||
def path_explanation(self, path: list[str]) -> list[dict]:
|
||||
"""Convert a path of entry IDs into human-readable step descriptions.
|
||||
|
||||
Returns list of dicts with 'id', 'title', and 'topics' for each step.
|
||||
"""
|
||||
steps = []
|
||||
for entry_id in path:
|
||||
entry = self._entries.get(entry_id)
|
||||
if entry:
|
||||
steps.append({
|
||||
"id": entry.id,
|
||||
"title": entry.title,
|
||||
"topics": entry.topics,
|
||||
"content_preview": entry.content[:120] + "..." if len(entry.content) > 120 else entry.content,
|
||||
})
|
||||
else:
|
||||
steps.append({"id": entry_id, "title": "[unknown]", "topics": []})
|
||||
return steps
|
||||
|
||||
def rebuild_links(self, threshold: Optional[float] = None) -> int:
|
||||
"""Recompute all links from scratch.
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ Provides: mnemosyne ingest, mnemosyne search, mnemosyne link, mnemosyne stats,
|
||||
mnemosyne topics, mnemosyne remove, mnemosyne export,
|
||||
mnemosyne clusters, mnemosyne hubs, mnemosyne bridges, mnemosyne rebuild,
|
||||
mnemosyne tag, mnemosyne untag, mnemosyne retag,
|
||||
mnemosyne timeline, mnemosyne neighbors
|
||||
mnemosyne timeline, mnemosyne neighbors, mnemosyne path,
|
||||
mnemosyne touch, mnemosyne decay, mnemosyne vitality,
|
||||
mnemosyne fading, mnemosyne vibrant
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -206,6 +208,38 @@ def cmd_timeline(args):
|
||||
print()
|
||||
|
||||
|
||||
|
||||
def cmd_path(args):
|
||||
archive = MnemosyneArchive(archive_path=args.archive) if args.archive else MnemosyneArchive()
|
||||
path = archive.shortest_path(args.start, args.end)
|
||||
if path is None:
|
||||
print(f"No path found between {args.start} and {args.end}")
|
||||
return
|
||||
steps = archive.path_explanation(path)
|
||||
print(f"Path ({len(steps)} hops):")
|
||||
for i, step in enumerate(steps):
|
||||
arrow = " → " if i > 0 else " "
|
||||
print(f"{arrow}{step['id']}: {step['title']}")
|
||||
if step['topics']:
|
||||
print(f" topics: {', '.join(step['topics'])}")
|
||||
|
||||
def cmd_consolidate(args):
|
||||
archive = MnemosyneArchive()
|
||||
merges = archive.consolidate(threshold=args.threshold, dry_run=args.dry_run)
|
||||
if not merges:
|
||||
print("No duplicates found.")
|
||||
return
|
||||
label = "[DRY RUN] " if args.dry_run else ""
|
||||
for m in merges:
|
||||
print(f"{label}Merge ({m['reason']}, score={m['score']:.4f}):")
|
||||
print(f" kept: {m['kept'][:8]}")
|
||||
print(f" removed: {m['removed'][:8]}")
|
||||
if args.dry_run:
|
||||
print(f"\n{len(merges)} pair(s) would be merged. Re-run without --dry-run to apply.")
|
||||
else:
|
||||
print(f"\nMerged {len(merges)} duplicate pair(s).")
|
||||
|
||||
|
||||
def cmd_neighbors(args):
|
||||
archive = MnemosyneArchive()
|
||||
try:
|
||||
@@ -222,6 +256,65 @@ def cmd_neighbors(args):
|
||||
print()
|
||||
|
||||
|
||||
def cmd_touch(args):
|
||||
archive = MnemosyneArchive()
|
||||
try:
|
||||
entry = archive.touch(args.entry_id)
|
||||
except KeyError:
|
||||
print(f"Entry not found: {args.entry_id}")
|
||||
sys.exit(1)
|
||||
v = archive.get_vitality(entry.id)
|
||||
print(f"[{entry.id[:8]}] {entry.title}")
|
||||
print(f" Vitality: {v['vitality']:.4f} (boosted)")
|
||||
|
||||
|
||||
def cmd_decay(args):
|
||||
archive = MnemosyneArchive()
|
||||
result = archive.apply_decay()
|
||||
print(f"Applied decay to {result['total_entries']} entries")
|
||||
print(f" Decayed: {result['decayed_count']}")
|
||||
print(f" Avg vitality: {result['avg_vitality']:.4f}")
|
||||
print(f" Fading (<0.3): {result['fading_count']}")
|
||||
print(f" Vibrant (>0.7): {result['vibrant_count']}")
|
||||
|
||||
|
||||
def cmd_vitality(args):
|
||||
archive = MnemosyneArchive()
|
||||
try:
|
||||
v = archive.get_vitality(args.entry_id)
|
||||
except KeyError:
|
||||
print(f"Entry not found: {args.entry_id}")
|
||||
sys.exit(1)
|
||||
print(f"[{v['entry_id'][:8]}] {v['title']}")
|
||||
print(f" Vitality: {v['vitality']:.4f}")
|
||||
print(f" Last accessed: {v['last_accessed'] or 'never'}")
|
||||
print(f" Age: {v['age_days']} days")
|
||||
|
||||
|
||||
def cmd_fading(args):
|
||||
archive = MnemosyneArchive()
|
||||
results = archive.fading(limit=args.limit)
|
||||
if not results:
|
||||
print("Archive is empty.")
|
||||
return
|
||||
for v in results:
|
||||
print(f"[{v['entry_id'][:8]}] {v['title']}")
|
||||
print(f" Vitality: {v['vitality']:.4f} | Age: {v['age_days']}d | Last: {v['last_accessed'] or 'never'}")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_vibrant(args):
|
||||
archive = MnemosyneArchive()
|
||||
results = archive.vibrant(limit=args.limit)
|
||||
if not results:
|
||||
print("Archive is empty.")
|
||||
return
|
||||
for v in results:
|
||||
print(f"[{v['entry_id'][:8]}] {v['title']}")
|
||||
print(f" Vitality: {v['vitality']:.4f} | Age: {v['age_days']}d | Last: {v['last_accessed'] or 'never'}")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(prog="mnemosyne", description="The Living Holographic Archive")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
@@ -283,6 +376,31 @@ def main():
|
||||
nb.add_argument("entry_id", help="Anchor entry ID")
|
||||
nb.add_argument("--days", type=int, default=7, help="Window in days (default: 7)")
|
||||
|
||||
|
||||
pa = sub.add_parser("path", help="Find shortest path between two memories")
|
||||
pa.add_argument("start", help="Starting entry ID")
|
||||
pa.add_argument("end", help="Target entry ID")
|
||||
pa.add_argument("--archive", default=None, help="Archive path")
|
||||
|
||||
co = sub.add_parser("consolidate", help="Merge duplicate/near-duplicate entries")
|
||||
co.add_argument("--dry-run", action="store_true", help="Show what would be merged without applying")
|
||||
co.add_argument("--threshold", type=float, default=0.9, help="Similarity threshold (default: 0.9)")
|
||||
|
||||
|
||||
tc = sub.add_parser("touch", help="Boost an entry's vitality by accessing it")
|
||||
tc.add_argument("entry_id", help="Entry ID to touch")
|
||||
|
||||
dc = sub.add_parser("decay", help="Apply time-based decay to all entries")
|
||||
|
||||
vy = sub.add_parser("vitality", help="Show an entry's vitality status")
|
||||
vy.add_argument("entry_id", help="Entry ID to check")
|
||||
|
||||
fg = sub.add_parser("fading", help="Show most neglected entries (lowest vitality)")
|
||||
fg.add_argument("-n", "--limit", type=int, default=10, help="Max entries to show")
|
||||
|
||||
vb = sub.add_parser("vibrant", help="Show most alive entries (highest vitality)")
|
||||
vb.add_argument("-n", "--limit", type=int, default=10, help="Max entries to show")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
@@ -305,6 +423,13 @@ def main():
|
||||
"retag": cmd_retag,
|
||||
"timeline": cmd_timeline,
|
||||
"neighbors": cmd_neighbors,
|
||||
"consolidate": cmd_consolidate,
|
||||
"path": cmd_path,
|
||||
"touch": cmd_touch,
|
||||
"decay": cmd_decay,
|
||||
"vitality": cmd_vitality,
|
||||
"fading": cmd_fading,
|
||||
"vibrant": cmd_vibrant,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ class ArchiveEntry:
|
||||
updated_at: Optional[str] = None # Set on mutation; None means same as created_at
|
||||
links: list[str] = field(default_factory=list) # IDs of related entries
|
||||
content_hash: Optional[str] = None # SHA-256 of title+content for dedup
|
||||
vitality: float = 1.0 # 0.0 (dead) to 1.0 (fully alive)
|
||||
last_accessed: Optional[str] = None # ISO datetime of last access; None = never accessed
|
||||
|
||||
def __post_init__(self):
|
||||
if self.content_hash is None:
|
||||
@@ -52,6 +54,8 @@ class ArchiveEntry:
|
||||
"updated_at": self.updated_at,
|
||||
"links": self.links,
|
||||
"content_hash": self.content_hash,
|
||||
"vitality": self.vitality,
|
||||
"last_accessed": self.last_accessed,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
138
nexus/mnemosyne/tests/test_cli_commands.py
Normal file
138
nexus/mnemosyne/tests/test_cli_commands.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Tests for Mnemosyne CLI commands — path, touch, decay, vitality, fading, vibrant."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
import sys
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def archive(tmp_path):
|
||||
path = tmp_path / "test_archive.json"
|
||||
return MnemosyneArchive(archive_path=path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def linked_archive(tmp_path):
|
||||
"""Archive with entries linked to each other for path testing."""
|
||||
path = tmp_path / "test_archive.json"
|
||||
arch = MnemosyneArchive(archive_path=path, auto_embed=False)
|
||||
e1 = arch.add(ArchiveEntry(title="Alpha", content="first entry about python", topics=["code"]))
|
||||
e2 = arch.add(ArchiveEntry(title="Beta", content="second entry about python coding", topics=["code"]))
|
||||
e3 = arch.add(ArchiveEntry(title="Gamma", content="third entry about cooking recipes", topics=["food"]))
|
||||
return arch, e1, e2, e3
|
||||
|
||||
|
||||
class TestPathCommand:
|
||||
def test_shortest_path_exists(self, linked_archive):
|
||||
arch, e1, e2, e3 = linked_archive
|
||||
path = arch.shortest_path(e1.id, e2.id)
|
||||
assert path is not None
|
||||
assert path[0] == e1.id
|
||||
assert path[-1] == e2.id
|
||||
|
||||
def test_shortest_path_no_connection(self, linked_archive):
|
||||
arch, e1, e2, e3 = linked_archive
|
||||
# e3 (cooking) likely not linked to e1 (python coding)
|
||||
path = arch.shortest_path(e1.id, e3.id)
|
||||
# Path may or may not exist depending on linking threshold
|
||||
# Either None or a list is valid
|
||||
|
||||
def test_shortest_path_same_entry(self, linked_archive):
|
||||
arch, e1, _, _ = linked_archive
|
||||
path = arch.shortest_path(e1.id, e1.id)
|
||||
assert path == [e1.id]
|
||||
|
||||
def test_shortest_path_missing_entry(self, linked_archive):
|
||||
arch, e1, _, _ = linked_archive
|
||||
path = arch.shortest_path(e1.id, "nonexistent-id")
|
||||
assert path is None
|
||||
|
||||
|
||||
class TestTouchCommand:
|
||||
def test_touch_boosts_vitality(self, archive):
|
||||
entry = archive.add(ArchiveEntry(title="Test", content="Content"))
|
||||
# Simulate time passing by setting old last_accessed
|
||||
old_time = "2020-01-01T00:00:00+00:00"
|
||||
entry.last_accessed = old_time
|
||||
entry.vitality = 0.5
|
||||
archive._save()
|
||||
|
||||
touched = archive.touch(entry.id)
|
||||
assert touched.vitality > 0.5
|
||||
assert touched.last_accessed != old_time
|
||||
|
||||
def test_touch_missing_entry(self, archive):
|
||||
with pytest.raises(KeyError):
|
||||
archive.touch("nonexistent-id")
|
||||
|
||||
|
||||
class TestDecayCommand:
|
||||
def test_apply_decay_returns_stats(self, archive):
|
||||
archive.add(ArchiveEntry(title="Test", content="Content"))
|
||||
result = archive.apply_decay()
|
||||
assert result["total_entries"] == 1
|
||||
assert "avg_vitality" in result
|
||||
assert "fading_count" in result
|
||||
assert "vibrant_count" in result
|
||||
|
||||
def test_decay_on_empty_archive(self, archive):
|
||||
result = archive.apply_decay()
|
||||
assert result["total_entries"] == 0
|
||||
assert result["avg_vitality"] == 0.0
|
||||
|
||||
|
||||
class TestVitalityCommand:
|
||||
def test_get_vitality(self, archive):
|
||||
entry = archive.add(ArchiveEntry(title="Test", content="Content"))
|
||||
v = archive.get_vitality(entry.id)
|
||||
assert v["entry_id"] == entry.id
|
||||
assert v["title"] == "Test"
|
||||
assert 0.0 <= v["vitality"] <= 1.0
|
||||
assert v["age_days"] >= 0
|
||||
|
||||
def test_get_vitality_missing(self, archive):
|
||||
with pytest.raises(KeyError):
|
||||
archive.get_vitality("nonexistent-id")
|
||||
|
||||
|
||||
class TestFadingVibrant:
|
||||
def test_fading_returns_sorted_ascending(self, archive):
|
||||
# Add entries with different vitalities
|
||||
e1 = archive.add(ArchiveEntry(title="Vibrant", content="High energy"))
|
||||
e2 = archive.add(ArchiveEntry(title="Fading", content="Low energy"))
|
||||
e2.vitality = 0.1
|
||||
e2.last_accessed = "2020-01-01T00:00:00+00:00"
|
||||
archive._save()
|
||||
|
||||
results = archive.fading(limit=10)
|
||||
assert len(results) == 2
|
||||
assert results[0]["vitality"] <= results[1]["vitality"]
|
||||
|
||||
def test_vibrant_returns_sorted_descending(self, archive):
|
||||
e1 = archive.add(ArchiveEntry(title="Fresh", content="New"))
|
||||
e2 = archive.add(ArchiveEntry(title="Old", content="Ancient"))
|
||||
e2.vitality = 0.1
|
||||
e2.last_accessed = "2020-01-01T00:00:00+00:00"
|
||||
archive._save()
|
||||
|
||||
results = archive.vibrant(limit=10)
|
||||
assert len(results) == 2
|
||||
assert results[0]["vitality"] >= results[1]["vitality"]
|
||||
|
||||
def test_fading_limit(self, archive):
|
||||
for i in range(15):
|
||||
archive.add(ArchiveEntry(title=f"Entry {i}", content=f"Content {i}"))
|
||||
results = archive.fading(limit=5)
|
||||
assert len(results) == 5
|
||||
|
||||
def test_vibrant_empty(self, archive):
|
||||
results = archive.vibrant()
|
||||
assert results == []
|
||||
176
nexus/mnemosyne/tests/test_consolidation.py
Normal file
176
nexus/mnemosyne/tests/test_consolidation.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Tests for MnemosyneArchive.consolidate() — duplicate/near-duplicate merging."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
from nexus.mnemosyne.ingest import ingest_event
|
||||
|
||||
|
||||
def _archive(tmp: str) -> MnemosyneArchive:
|
||||
return MnemosyneArchive(archive_path=Path(tmp) / "archive.json", auto_embed=False)
|
||||
|
||||
|
||||
def test_consolidate_exact_duplicate_removed():
|
||||
"""Two entries with identical content_hash are merged; only one survives."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _archive(tmp)
|
||||
e1 = ingest_event(archive, title="Hello world", content="Exactly the same content", topics=["a"])
|
||||
# Manually add a second entry with the same hash to simulate a duplicate
|
||||
e2 = ArchiveEntry(title="Hello world", content="Exactly the same content", topics=["b"])
|
||||
# Bypass dedup guard so we can test consolidate() rather than add()
|
||||
archive._entries[e2.id] = e2
|
||||
archive._save()
|
||||
|
||||
assert archive.count == 2
|
||||
merges = archive.consolidate(dry_run=False)
|
||||
assert len(merges) == 1
|
||||
assert merges[0]["reason"] == "exact_hash"
|
||||
assert merges[0]["score"] == 1.0
|
||||
assert archive.count == 1
|
||||
|
||||
|
||||
def test_consolidate_keeps_older_entry():
|
||||
"""The older entry (earlier created_at) is kept, the newer is removed."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _archive(tmp)
|
||||
e1 = ingest_event(archive, title="Hello world", content="Same content here", topics=[])
|
||||
e2 = ArchiveEntry(title="Hello world", content="Same content here", topics=[])
|
||||
# Make e2 clearly newer
|
||||
e2.created_at = "2099-01-01T00:00:00+00:00"
|
||||
archive._entries[e2.id] = e2
|
||||
archive._save()
|
||||
|
||||
merges = archive.consolidate(dry_run=False)
|
||||
assert len(merges) == 1
|
||||
assert merges[0]["kept"] == e1.id
|
||||
assert merges[0]["removed"] == e2.id
|
||||
|
||||
|
||||
def test_consolidate_merges_topics():
|
||||
"""Topics from the removed entry are merged (unioned) into the kept entry."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _archive(tmp)
|
||||
e1 = ingest_event(archive, title="Memory item", content="Shared content body", topics=["alpha"])
|
||||
e2 = ArchiveEntry(title="Memory item", content="Shared content body", topics=["beta", "gamma"])
|
||||
e2.created_at = "2099-01-01T00:00:00+00:00"
|
||||
archive._entries[e2.id] = e2
|
||||
archive._save()
|
||||
|
||||
archive.consolidate(dry_run=False)
|
||||
survivor = archive.get(e1.id)
|
||||
assert survivor is not None
|
||||
topic_lower = {t.lower() for t in survivor.topics}
|
||||
assert "alpha" in topic_lower
|
||||
assert "beta" in topic_lower
|
||||
assert "gamma" in topic_lower
|
||||
|
||||
|
||||
def test_consolidate_merges_metadata():
|
||||
"""Metadata from the removed entry is merged into the kept entry; kept values win."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _archive(tmp)
|
||||
e1 = ArchiveEntry(
|
||||
title="Shared", content="Identical body here", topics=[], metadata={"k1": "v1", "shared": "kept"}
|
||||
)
|
||||
archive._entries[e1.id] = e1
|
||||
e2 = ArchiveEntry(
|
||||
title="Shared", content="Identical body here", topics=[], metadata={"k2": "v2", "shared": "removed"}
|
||||
)
|
||||
e2.created_at = "2099-01-01T00:00:00+00:00"
|
||||
archive._entries[e2.id] = e2
|
||||
archive._save()
|
||||
|
||||
archive.consolidate(dry_run=False)
|
||||
survivor = archive.get(e1.id)
|
||||
assert survivor.metadata["k1"] == "v1"
|
||||
assert survivor.metadata["k2"] == "v2"
|
||||
assert survivor.metadata["shared"] == "kept" # kept entry wins
|
||||
|
||||
|
||||
def test_consolidate_dry_run_no_mutation():
|
||||
"""Dry-run mode returns merge plan but does not alter the archive."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _archive(tmp)
|
||||
ingest_event(archive, title="Same", content="Identical content to dedup", topics=[])
|
||||
e2 = ArchiveEntry(title="Same", content="Identical content to dedup", topics=[])
|
||||
e2.created_at = "2099-01-01T00:00:00+00:00"
|
||||
archive._entries[e2.id] = e2
|
||||
archive._save()
|
||||
|
||||
merges = archive.consolidate(dry_run=True)
|
||||
assert len(merges) == 1
|
||||
assert merges[0]["dry_run"] is True
|
||||
# Archive must be unchanged
|
||||
assert archive.count == 2
|
||||
|
||||
|
||||
def test_consolidate_no_duplicates():
|
||||
"""When no duplicates exist, consolidate returns an empty list."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _archive(tmp)
|
||||
ingest_event(archive, title="Unique A", content="This is completely unique content for A")
|
||||
ingest_event(archive, title="Unique B", content="Totally different words here for B")
|
||||
merges = archive.consolidate(threshold=0.9)
|
||||
assert merges == []
|
||||
|
||||
|
||||
def test_consolidate_transfers_links():
|
||||
"""Links from the removed entry are inherited by the kept entry."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _archive(tmp)
|
||||
# Create a third entry to act as a link target
|
||||
target = ingest_event(archive, title="Target", content="The link target entry", topics=[])
|
||||
|
||||
e1 = ArchiveEntry(title="Dup", content="Exact duplicate body text", topics=[], links=[target.id])
|
||||
archive._entries[e1.id] = e1
|
||||
target.links.append(e1.id)
|
||||
|
||||
e2 = ArchiveEntry(title="Dup", content="Exact duplicate body text", topics=[])
|
||||
e2.created_at = "2099-01-01T00:00:00+00:00"
|
||||
archive._entries[e2.id] = e2
|
||||
archive._save()
|
||||
|
||||
archive.consolidate(dry_run=False)
|
||||
survivor = archive.get(e1.id)
|
||||
assert survivor is not None
|
||||
assert target.id in survivor.links
|
||||
|
||||
|
||||
def test_consolidate_near_duplicate_semantic():
|
||||
"""Near-duplicate entries above the similarity threshold are merged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = _archive(tmp)
|
||||
# Entries with very high Jaccard overlap
|
||||
text_a = "python automation scripting building tools workflows"
|
||||
text_b = "python automation scripting building tools workflows tasks"
|
||||
e1 = ArchiveEntry(title="Automator", content=text_a, topics=[])
|
||||
e2 = ArchiveEntry(title="Automator", content=text_b, topics=[])
|
||||
e2.created_at = "2099-01-01T00:00:00+00:00"
|
||||
archive._entries[e1.id] = e1
|
||||
archive._entries[e2.id] = e2
|
||||
archive._save()
|
||||
|
||||
# Use a low threshold to ensure these very similar entries match
|
||||
merges = archive.consolidate(threshold=0.7, dry_run=False)
|
||||
assert len(merges) >= 1
|
||||
assert merges[0]["reason"] == "semantic_similarity"
|
||||
|
||||
|
||||
def test_consolidate_persists_after_reload():
|
||||
"""After consolidation, the reduced archive survives a save/reload cycle."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path, auto_embed=False)
|
||||
ingest_event(archive, title="Persist test", content="Body to dedup and persist", topics=[])
|
||||
e2 = ArchiveEntry(title="Persist test", content="Body to dedup and persist", topics=[])
|
||||
e2.created_at = "2099-01-01T00:00:00+00:00"
|
||||
archive._entries[e2.id] = e2
|
||||
archive._save()
|
||||
|
||||
archive.consolidate(dry_run=False)
|
||||
assert archive.count == 1
|
||||
|
||||
reloaded = MnemosyneArchive(archive_path=path, auto_embed=False)
|
||||
assert reloaded.count == 1
|
||||
278
nexus/mnemosyne/tests/test_memory_decay.py
Normal file
278
nexus/mnemosyne/tests/test_memory_decay.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Tests for Mnemosyne memory decay system."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def archive(tmp_path):
|
||||
"""Create a fresh archive for testing."""
|
||||
path = tmp_path / "test_archive.json"
|
||||
return MnemosyneArchive(archive_path=path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def populated_archive(tmp_path):
|
||||
"""Create an archive with some entries."""
|
||||
path = tmp_path / "test_archive.json"
|
||||
arch = MnemosyneArchive(archive_path=path)
|
||||
arch.add(ArchiveEntry(title="Fresh Entry", content="Just added", topics=["test"]))
|
||||
arch.add(ArchiveEntry(title="Old Entry", content="Been here a while", topics=["test"]))
|
||||
arch.add(ArchiveEntry(title="Another Entry", content="Some content", topics=["other"]))
|
||||
return arch
|
||||
|
||||
|
||||
class TestVitalityFields:
|
||||
"""Test that vitality fields exist on entries."""
|
||||
|
||||
def test_entry_has_vitality_default(self):
|
||||
entry = ArchiveEntry(title="Test", content="Content")
|
||||
assert entry.vitality == 1.0
|
||||
|
||||
def test_entry_has_last_accessed_default(self):
|
||||
entry = ArchiveEntry(title="Test", content="Content")
|
||||
assert entry.last_accessed is None
|
||||
|
||||
def test_entry_roundtrip_with_vitality(self):
|
||||
entry = ArchiveEntry(
|
||||
title="Test", content="Content",
|
||||
vitality=0.75,
|
||||
last_accessed="2024-01-01T00:00:00+00:00"
|
||||
)
|
||||
d = entry.to_dict()
|
||||
assert d["vitality"] == 0.75
|
||||
assert d["last_accessed"] == "2024-01-01T00:00:00+00:00"
|
||||
restored = ArchiveEntry.from_dict(d)
|
||||
assert restored.vitality == 0.75
|
||||
assert restored.last_accessed == "2024-01-01T00:00:00+00:00"
|
||||
|
||||
|
||||
class TestTouch:
|
||||
"""Test touch() access recording and vitality boost."""
|
||||
|
||||
def test_touch_sets_last_accessed(self, archive):
|
||||
entry = archive.add(ArchiveEntry(title="Test", content="Content"))
|
||||
assert entry.last_accessed is None
|
||||
touched = archive.touch(entry.id)
|
||||
assert touched.last_accessed is not None
|
||||
|
||||
def test_touch_boosts_vitality(self, archive):
|
||||
entry = archive.add(ArchiveEntry(title="Test", content="Content", vitality=0.5))
|
||||
touched = archive.touch(entry.id)
|
||||
# Boost = 0.1 * (1 - 0.5) = 0.05, so vitality should be ~0.55
|
||||
# (assuming no time decay in test — instantaneous)
|
||||
assert touched.vitality > 0.5
|
||||
assert touched.vitality <= 1.0
|
||||
|
||||
def test_touch_diminishing_returns(self, archive):
|
||||
entry = archive.add(ArchiveEntry(title="Test", content="Content", vitality=0.9))
|
||||
touched = archive.touch(entry.id)
|
||||
# Boost = 0.1 * (1 - 0.9) = 0.01, so vitality should be ~0.91
|
||||
assert touched.vitality < 0.92
|
||||
assert touched.vitality > 0.9
|
||||
|
||||
def test_touch_never_exceeds_one(self, archive):
|
||||
entry = archive.add(ArchiveEntry(title="Test", content="Content", vitality=0.99))
|
||||
for _ in range(10):
|
||||
entry = archive.touch(entry.id)
|
||||
assert entry.vitality <= 1.0
|
||||
|
||||
def test_touch_missing_entry_raises(self, archive):
|
||||
with pytest.raises(KeyError):
|
||||
archive.touch("nonexistent-id")
|
||||
|
||||
def test_touch_persists(self, archive):
|
||||
entry = archive.add(ArchiveEntry(title="Test", content="Content"))
|
||||
archive.touch(entry.id)
|
||||
# Reload archive
|
||||
arch2 = MnemosyneArchive(archive_path=archive._path)
|
||||
loaded = arch2.get(entry.id)
|
||||
assert loaded.last_accessed is not None
|
||||
|
||||
|
||||
class TestGetVitality:
|
||||
"""Test get_vitality() status reporting."""
|
||||
|
||||
def test_get_vitality_basic(self, archive):
|
||||
entry = archive.add(ArchiveEntry(title="Test", content="Content"))
|
||||
status = archive.get_vitality(entry.id)
|
||||
assert status["entry_id"] == entry.id
|
||||
assert status["title"] == "Test"
|
||||
assert 0.0 <= status["vitality"] <= 1.0
|
||||
assert status["age_days"] == 0
|
||||
|
||||
def test_get_vitality_missing_raises(self, archive):
|
||||
with pytest.raises(KeyError):
|
||||
archive.get_vitality("nonexistent-id")
|
||||
|
||||
|
||||
class TestComputeVitality:
|
||||
"""Test the decay computation."""
|
||||
|
||||
def test_new_entry_full_vitality(self, archive):
|
||||
entry = archive.add(ArchiveEntry(title="Test", content="Content"))
|
||||
v = archive._compute_vitality(entry)
|
||||
assert v == 1.0
|
||||
|
||||
def test_recently_touched_high_vitality(self, archive):
|
||||
entry = archive.add(ArchiveEntry(title="Test", content="Content"))
|
||||
archive.touch(entry.id)
|
||||
v = archive._compute_vitality(entry)
|
||||
assert v > 0.99 # Should be essentially 1.0 since just touched
|
||||
|
||||
def test_old_entry_decays(self, archive):
|
||||
entry = archive.add(ArchiveEntry(title="Test", content="Content"))
|
||||
# Simulate old access — set last_accessed to 60 days ago
|
||||
old_date = (datetime.now(timezone.utc) - timedelta(days=60)).isoformat()
|
||||
entry.last_accessed = old_date
|
||||
entry.vitality = 1.0
|
||||
archive._save()
|
||||
v = archive._compute_vitality(entry)
|
||||
# 60 days with 30-day half-life: v = 1.0 * 0.5^(60/30) = 0.25
|
||||
assert v < 0.3
|
||||
assert v > 0.2
|
||||
|
||||
def test_very_old_entry_nearly_zero(self, archive):
|
||||
entry = archive.add(ArchiveEntry(title="Test", content="Content"))
|
||||
old_date = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat()
|
||||
entry.last_accessed = old_date
|
||||
entry.vitality = 1.0
|
||||
archive._save()
|
||||
v = archive._compute_vitality(entry)
|
||||
# 365 days / 30 half-life = ~12 half-lives -> ~0.0002
|
||||
assert v < 0.01
|
||||
|
||||
|
||||
class TestFading:
|
||||
"""Test fading() — most neglected entries."""
|
||||
|
||||
def test_fading_returns_lowest_first(self, populated_archive):
|
||||
entries = list(populated_archive._entries.values())
|
||||
# Make one entry very old
|
||||
old_entry = entries[1]
|
||||
old_date = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat()
|
||||
old_entry.last_accessed = old_date
|
||||
old_entry.vitality = 1.0
|
||||
populated_archive._save()
|
||||
|
||||
fading = populated_archive.fading(limit=3)
|
||||
assert len(fading) <= 3
|
||||
# First result should be the oldest
|
||||
assert fading[0]["entry_id"] == old_entry.id
|
||||
# Should be in ascending order
|
||||
for i in range(len(fading) - 1):
|
||||
assert fading[i]["vitality"] <= fading[i + 1]["vitality"]
|
||||
|
||||
def test_fading_empty_archive(self, archive):
|
||||
fading = archive.fading()
|
||||
assert fading == []
|
||||
|
||||
def test_fading_limit(self, populated_archive):
|
||||
fading = populated_archive.fading(limit=2)
|
||||
assert len(fading) == 2
|
||||
|
||||
|
||||
class TestVibrant:
|
||||
"""Test vibrant() — most alive entries."""
|
||||
|
||||
def test_vibrant_returns_highest_first(self, populated_archive):
|
||||
entries = list(populated_archive._entries.values())
|
||||
# Make one entry very old
|
||||
old_entry = entries[1]
|
||||
old_date = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat()
|
||||
old_entry.last_accessed = old_date
|
||||
old_entry.vitality = 1.0
|
||||
populated_archive._save()
|
||||
|
||||
vibrant = populated_archive.vibrant(limit=3)
|
||||
# Should be in descending order
|
||||
for i in range(len(vibrant) - 1):
|
||||
assert vibrant[i]["vitality"] >= vibrant[i + 1]["vitality"]
|
||||
# First result should NOT be the old entry
|
||||
assert vibrant[0]["entry_id"] != old_entry.id
|
||||
|
||||
def test_vibrant_empty_archive(self, archive):
|
||||
vibrant = archive.vibrant()
|
||||
assert vibrant == []
|
||||
|
||||
|
||||
class TestApplyDecay:
|
||||
"""Test apply_decay() bulk decay operation."""
|
||||
|
||||
def test_apply_decay_returns_stats(self, populated_archive):
|
||||
result = populated_archive.apply_decay()
|
||||
assert result["total_entries"] == 3
|
||||
assert "decayed_count" in result
|
||||
assert "avg_vitality" in result
|
||||
assert "fading_count" in result
|
||||
assert "vibrant_count" in result
|
||||
|
||||
def test_apply_decay_persists(self, populated_archive):
|
||||
populated_archive.apply_decay()
|
||||
# Reload
|
||||
arch2 = MnemosyneArchive(archive_path=populated_archive._path)
|
||||
result2 = arch2.apply_decay()
|
||||
# Should show same entries
|
||||
assert result2["total_entries"] == 3
|
||||
|
||||
def test_apply_decay_on_empty(self, archive):
|
||||
result = archive.apply_decay()
|
||||
assert result["total_entries"] == 0
|
||||
assert result["avg_vitality"] == 0.0
|
||||
|
||||
|
||||
class TestStatsVitality:
|
||||
"""Test that stats() includes vitality summary."""
|
||||
|
||||
def test_stats_includes_vitality(self, populated_archive):
|
||||
stats = populated_archive.stats()
|
||||
assert "avg_vitality" in stats
|
||||
assert "fading_count" in stats
|
||||
assert "vibrant_count" in stats
|
||||
assert 0.0 <= stats["avg_vitality"] <= 1.0
|
||||
|
||||
def test_stats_empty_archive(self, archive):
|
||||
stats = archive.stats()
|
||||
assert stats["avg_vitality"] == 0.0
|
||||
assert stats["fading_count"] == 0
|
||||
assert stats["vibrant_count"] == 0
|
||||
|
||||
|
||||
class TestDecayLifecycle:
|
||||
"""Integration test: full lifecycle from creation to fading."""
|
||||
|
||||
def test_entry_lifecycle(self, archive):
|
||||
# Create
|
||||
entry = archive.add(ArchiveEntry(title="Memory", content="A thing happened"))
|
||||
assert entry.vitality == 1.0
|
||||
|
||||
# Touch a few times
|
||||
for _ in range(5):
|
||||
archive.touch(entry.id)
|
||||
|
||||
# Check it's vibrant
|
||||
vibrant = archive.vibrant(limit=1)
|
||||
assert len(vibrant) == 1
|
||||
assert vibrant[0]["entry_id"] == entry.id
|
||||
|
||||
# Simulate time passing
|
||||
entry.last_accessed = (datetime.now(timezone.utc) - timedelta(days=45)).isoformat()
|
||||
entry.vitality = 0.8
|
||||
archive._save()
|
||||
|
||||
# Apply decay
|
||||
result = archive.apply_decay()
|
||||
assert result["total_entries"] == 1
|
||||
|
||||
# Check it's now fading
|
||||
fading = archive.fading(limit=1)
|
||||
assert fading[0]["entry_id"] == entry.id
|
||||
assert fading[0]["vitality"] < 0.5
|
||||
106
nexus/mnemosyne/tests/test_path.py
Normal file
106
nexus/mnemosyne/tests/test_path.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Tests for MnemosyneArchive.shortest_path and path_explanation."""
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
|
||||
|
||||
def _make_archive(tmp_path):
|
||||
archive = MnemosyneArchive(str(tmp_path / "test_archive.json"))
|
||||
return archive
|
||||
|
||||
|
||||
class TestShortestPath:
|
||||
def test_direct_connection(self, tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
a = archive.add("Alpha", "first entry", topics=["start"])
|
||||
b = archive.add("Beta", "second entry", topics=["end"])
|
||||
# Manually link
|
||||
a.links.append(b.id)
|
||||
b.links.append(a.id)
|
||||
archive._entries[a.id] = a
|
||||
archive._entries[b.id] = b
|
||||
archive._save()
|
||||
|
||||
path = archive.shortest_path(a.id, b.id)
|
||||
assert path == [a.id, b.id]
|
||||
|
||||
def test_multi_hop_path(self, tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
a = archive.add("A", "alpha", topics=["x"])
|
||||
b = archive.add("B", "beta", topics=["y"])
|
||||
c = archive.add("C", "gamma", topics=["z"])
|
||||
# Chain: A -> B -> C
|
||||
a.links.append(b.id)
|
||||
b.links.extend([a.id, c.id])
|
||||
c.links.append(b.id)
|
||||
archive._entries[a.id] = a
|
||||
archive._entries[b.id] = b
|
||||
archive._entries[c.id] = c
|
||||
archive._save()
|
||||
|
||||
path = archive.shortest_path(a.id, c.id)
|
||||
assert path == [a.id, b.id, c.id]
|
||||
|
||||
def test_no_path(self, tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
a = archive.add("A", "isolated", topics=[])
|
||||
b = archive.add("B", "also isolated", topics=[])
|
||||
path = archive.shortest_path(a.id, b.id)
|
||||
assert path is None
|
||||
|
||||
def test_same_entry(self, tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
a = archive.add("A", "lonely", topics=[])
|
||||
path = archive.shortest_path(a.id, a.id)
|
||||
assert path == [a.id]
|
||||
|
||||
def test_nonexistent_entry(self, tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
a = archive.add("A", "exists", topics=[])
|
||||
path = archive.shortest_path("fake-id", a.id)
|
||||
assert path is None
|
||||
|
||||
def test_shortest_of_multiple(self, tmp_path):
|
||||
"""When multiple paths exist, BFS returns shortest."""
|
||||
archive = _make_archive(tmp_path)
|
||||
a = archive.add("A", "a", topics=[])
|
||||
b = archive.add("B", "b", topics=[])
|
||||
c = archive.add("C", "c", topics=[])
|
||||
d = archive.add("D", "d", topics=[])
|
||||
# A -> B -> D (short)
|
||||
# A -> C -> B -> D (long)
|
||||
a.links.extend([b.id, c.id])
|
||||
b.links.extend([a.id, d.id, c.id])
|
||||
c.links.extend([a.id, b.id])
|
||||
d.links.append(b.id)
|
||||
for e in [a, b, c, d]:
|
||||
archive._entries[e.id] = e
|
||||
archive._save()
|
||||
|
||||
path = archive.shortest_path(a.id, d.id)
|
||||
assert len(path) == 3 # A -> B -> D, not A -> C -> B -> D
|
||||
|
||||
|
||||
class TestPathExplanation:
|
||||
def test_returns_step_details(self, tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
a = archive.add("Alpha", "the beginning", topics=["origin"])
|
||||
b = archive.add("Beta", "the middle", topics=["process"])
|
||||
a.links.append(b.id)
|
||||
b.links.append(a.id)
|
||||
archive._entries[a.id] = a
|
||||
archive._entries[b.id] = b
|
||||
archive._save()
|
||||
|
||||
path = [a.id, b.id]
|
||||
steps = archive.path_explanation(path)
|
||||
assert len(steps) == 2
|
||||
assert steps[0]["title"] == "Alpha"
|
||||
assert steps[1]["title"] == "Beta"
|
||||
assert "origin" in steps[0]["topics"]
|
||||
|
||||
def test_content_preview_truncation(self, tmp_path):
|
||||
archive = _make_archive(tmp_path)
|
||||
a = archive.add("A", "x" * 200, topics=[])
|
||||
steps = archive.path_explanation([a.id])
|
||||
assert len(steps[0]["content_preview"]) <= 123 # 120 + "..."
|
||||
Reference in New Issue
Block a user