Compare commits

...

5 Commits

Author SHA1 Message Date
9a7e554568 fix: add missing MemoryPulse import, init, and update calls
Some checks failed
CI / test (pull_request) Failing after 10s
CI / validate (pull_request) Failing after 14s
Review Approval Gate / verify-review (pull_request) Failing after 2s
2026-04-11 20:47:45 +00:00
d12bd7a806 feat(mnemosyne): wire MemoryPulse into app.js
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 14s
Review Approval Gate / verify-review (pull_request) Failing after 2s
- Import MemoryPulse component
- Initialize with scene + SpatialMemory
- Call update() in animation loop
- Trigger pulse on crystal click via raycasting
2026-04-11 20:46:34 +00:00
9355c02417 feat(mnemosyne): Memory Pulse — holographic ripple propagation
When a memory crystal is accessed, a visual pulse wave radiates
outward through the connection graph, illuminating linked memories
by BFS hop distance.

Features:
- Expanding ring effect at each crystal (color-matched to region)
- Connection line flash between pulsed memories
- Travel time based on spatial distance
- Intensity decay per hop (0.65^hop)
- Depth-limited to 5 hops to prevent runaway
- Fully self-contained component, integrates via SpatialMemory API
2026-04-11 20:45:16 +00:00
3c81c64f04 Merge pull request '[Mnemosyne] Memory Birth Animation System' (#1222) from feat/mnemosyne-memory-birth into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
2026-04-11 20:23:24 +00:00
909a61702e [claude] Mnemosyne: semantic search via holographic linker similarity (#1223) (#1225)
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
2026-04-11 20:19:52 +00:00
5 changed files with 438 additions and 1 deletions

16
app.js
View File

@@ -6,6 +6,7 @@ import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
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 { MemoryPulse } from './nexus/components/memory-pulse.js';
// ═══════════════════════════════════════════
// NEXUS v1.1 — Portal System Update
@@ -710,6 +711,7 @@ async function init() {
createAshStorm();
SpatialMemory.init(scene);
MemoryBirth.init(scene);
MemoryPulse.init(scene, SpatialMemory);
MemoryBirth.wrapSpatialMemory(SpatialMemory);
SpatialMemory.setCamera(camera);
updateLoad(90);
@@ -1918,6 +1920,19 @@ function setupControls() {
const portal = portals.find(p => p.ring === clickedRing);
if (portal) activatePortal(portal);
}
// Raycasting for memory crystals — trigger pulse
const crystalMeshes = SpatialMemory.getCrystalMeshes();
if (crystalMeshes.length > 0) {
const crystalHits = raycaster.intersectObjects(crystalMeshes);
if (crystalHits.length > 0) {
const hitMesh = crystalHits[0].object;
const memData = SpatialMemory.getMemoryFromMesh(hitMesh);
if (memData) {
MemoryPulse.triggerPulse(memData.data.id);
}
}
}
}
}
});
@@ -2872,6 +2887,7 @@ function gameLoop() {
if (typeof animateMemoryOrbs === 'function') {
SpatialMemory.update(delta);
MemoryBirth.update(delta);
MemoryPulse.update(delta);
animateMemoryOrbs(delta);
}

View File

@@ -0,0 +1,305 @@
// ═══════════════════════════════════════════
// PROJECT MNEMOSYNE — MEMORY PULSE ENGINE
// ═══════════════════════════════════════════
//
// Holographic ripple propagation: when a memory crystal is accessed,
// a visual pulse wave radiates outward through the connection graph,
// illuminating linked memories in decreasing intensity by hop distance.
//
// This makes the archive feel alive — one thought echoing through
// the holographic field of related knowledge.
//
// Issue: Mnemosyne Pulse Effect
// ═══════════════════════════════════════════
const MemoryPulse = (() => {
let _scene = null;
let _spatialMemory = null;
let _activePulses = []; // Currently propagating pulse waves
let _pulseRings = []; // Active ring meshes being rendered
let _connectionFlashes = []; // Active connection line flashes
const PULSE_SPEED = 8; // Units per second propagation
const PULSE_MAX_HOPS = 5; // Max graph depth to traverse
const RING_DURATION = 1.5; // Seconds each ring is visible
const RING_MAX_RADIUS = 2.0; // Max expansion of pulse ring
const FLASH_DURATION = 0.8; // Seconds connection lines flash
const BASE_INTENSITY = 3.0; // Emissive boost at pulse origin
const HOP_DECAY = 0.65; // Intensity multiplier per hop
// ─── INIT ────────────────────────────────────────────
function init(scene, spatialMemory) {
_scene = scene;
_spatialMemory = spatialMemory;
console.info('[Mnemosyne] Pulse engine initialized');
}
// ─── TRIGGER PULSE ──────────────────────────────────
/**
* Fire a pulse from a memory crystal. Propagates through
* connected memories by BFS, creating visual rings and
* connection line flashes at each hop.
* @param {string} sourceId - Memory ID to pulse from
*/
function triggerPulse(sourceId) {
if (!_scene || !_spatialMemory) return;
const memories = _spatialMemory.getAllMemories();
const source = memories.find(m => m.id === sourceId);
if (!source) return;
// BFS through connection graph
const visited = new Set();
const queue = [{ id: sourceId, hop: 0, delay: 0 }];
visited.add(sourceId);
const memMap = {};
memories.forEach(m => { memMap[m.id] = m; });
while (queue.length > 0) {
const { id, hop, delay } = queue.shift();
if (hop > PULSE_MAX_HOPS) continue;
const mem = memMap[id];
if (!mem) continue;
// Schedule ring spawn
_scheduleRing(id, hop, delay);
// Schedule connection flashes to neighbors
const connections = mem.connections || [];
connections.forEach(targetId => {
if (visited.has(targetId)) return;
visited.add(targetId);
const target = memMap[targetId];
if (!target) return;
const travelDelay = delay + _travelTime(mem, target);
_scheduleConnectionFlash(id, targetId, delay, travelDelay);
queue.push({ id: targetId, hop: hop + 1, delay: travelDelay });
});
}
}
// ─── TRAVEL TIME ────────────────────────────────────
function _travelTime(src, dst) {
const sp = src.position || [0, 0, 0];
const dp = dst.position || [0, 0, 0];
const dx = sp[0] - dp[0], dy = sp[1] - dp[1], dz = sp[2] - dp[2];
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
return dist / PULSE_SPEED;
}
// ─── SCHEDULE RING ──────────────────────────────────
function _scheduleRing(memId, hop, delay) {
const startTime = performance.now() + delay * 1000;
_activePulses.push({
type: 'ring',
memId,
hop,
startTime,
duration: RING_DURATION,
intensity: BASE_INTENSITY * Math.pow(HOP_DECAY, hop),
});
}
// ─── SCHEDULE CONNECTION FLASH ─────────────────────
function _scheduleConnectionFlash(fromId, toId, startDelay, endDelay) {
const startTime = performance.now() + startDelay * 1000;
_activePulses.push({
type: 'flash',
fromId,
toId,
startTime,
duration: endDelay - startDelay + FLASH_DURATION,
intensity: BASE_INTENSITY * Math.pow(HOP_DECAY, 0),
});
}
// ─── UPDATE (called per frame) ──────────────────────
function update(delta) {
const now = performance.now();
// Process scheduled pulses
for (let i = _activePulses.length - 1; i >= 0; i--) {
const pulse = _activePulses[i];
if (now < pulse.startTime) continue; // Not yet active
const elapsed = (now - pulse.startTime) / 1000;
const progress = Math.min(1, elapsed / pulse.duration);
if (progress >= 1) {
_activePulses.splice(i, 1);
continue;
}
if (pulse.type === 'ring') {
_renderRing(pulse, elapsed, progress);
} else if (pulse.type === 'flash') {
_renderConnectionFlash(pulse, elapsed, progress);
}
}
// Update existing ring meshes
for (let i = _pulseRings.length - 1; i >= 0; i--) {
const ring = _pulseRings[i];
ring.age += delta;
if (ring.age >= ring.maxAge) {
if (ring.mesh.parent) ring.mesh.parent.remove(ring.mesh);
ring.mesh.geometry.dispose();
ring.mesh.material.dispose();
_pulseRings.splice(i, 1);
continue;
}
const t = ring.age / ring.maxAge;
const scale = 1 + t * RING_MAX_RADIUS;
ring.mesh.scale.set(scale, scale, scale);
ring.mesh.material.opacity = ring.baseOpacity * (1 - t * t);
}
// Update connection flashes
for (let i = _connectionFlashes.length - 1; i >= 0; i--) {
const flash = _connectionFlashes[i];
flash.age += delta;
if (flash.age >= flash.maxAge) {
// Restore original material
if (flash.line && flash.line.material) {
flash.line.material.opacity = flash.originalOpacity;
flash.line.material.color.setHex(flash.originalColor);
}
_connectionFlashes.splice(i, 1);
continue;
}
const t = flash.age / flash.maxAge;
if (flash.line && flash.line.material) {
// Pulse opacity with travel effect
const wave = Math.sin(t * Math.PI);
flash.line.material.opacity = flash.originalOpacity + wave * 0.6;
flash.line.material.color.setHex(
_lerpColor(flash.originalColor, flash.flashColor, wave * 0.8)
);
}
}
}
// ─── RENDER RING ────────────────────────────────────
function _renderRing(pulse, elapsed, progress) {
// Find crystal position
const allMeshes = _spatialMemory.getCrystalMeshes();
let sourceMesh = null;
const memories = _spatialMemory.getAllMemories();
for (const mem of memories) {
if (mem.id === pulse.memId) {
// Find matching mesh
sourceMesh = allMeshes.find(m => m.userData.memId === pulse.memId);
break;
}
}
if (!sourceMesh) return;
// Only create ring once (check if we already have one for this pulse)
if (pulse._ringCreated) return;
pulse._ringCreated = true;
const ringGeo = new THREE.RingGeometry(0.1, 0.15, 32);
const region = memories.find(m => m.id === pulse.memId);
const color = _getRegionColor(region ? region.category : 'working');
const ringMat = new THREE.MeshBasicMaterial({
color: color,
transparent: true,
opacity: 0.8 * pulse.intensity / BASE_INTENSITY,
side: THREE.DoubleSide,
depthWrite: false,
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.position.copy(sourceMesh.position);
ring.position.y += 0.1; // Slight offset above crystal
ring.rotation.x = -Math.PI / 2; // Flat on XZ plane
ring.lookAt(ring.position.x, ring.position.y + 1, ring.position.z);
_scene.add(ring);
_pulseRings.push({
mesh: ring,
age: 0,
maxAge: RING_DURATION,
baseOpacity: ringMat.opacity,
});
}
// ─── RENDER CONNECTION FLASH ────────────────────────
function _renderConnectionFlash(pulse, elapsed, progress) {
if (pulse._flashCreated) return;
// Find the connection line between from and to
const fromMesh = _findMesh(pulse.fromId);
const toMesh = _findMesh(pulse.toId);
if (!fromMesh || !toMesh) return;
// Create a temporary line for the flash
const points = [fromMesh.position.clone(), toMesh.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({
color: 0x4af0c0,
transparent: true,
opacity: 0.0,
linewidth: 2,
});
const line = new THREE.Line(geo, mat);
_scene.add(line);
pulse._flashCreated = true;
_connectionFlashes.push({
line,
age: 0,
maxAge: pulse.duration,
originalOpacity: 0.0,
originalColor: 0x334455,
flashColor: 0x4af0c0,
});
}
// ─── HELPERS ────────────────────────────────────────
function _findMesh(memId) {
const meshes = _spatialMemory.getCrystalMeshes();
return meshes.find(m => m.userData.memId === memId) || null;
}
function _getRegionColor(category) {
const colors = {
documents: 0x4af0c0,
projects: 0xff6b35,
code: 0x7b5cff,
social: 0xff4488,
working: 0xffd700,
archive: 0x445566,
};
return colors[category] || colors.working;
}
function _lerpColor(a, b, t) {
const ar = (a >> 16) & 0xff, ag = (a >> 8) & 0xff, ab = a & 0xff;
const br = (b >> 16) & 0xff, bg = (b >> 8) & 0xff, bb = b & 0xff;
const rr = Math.round(ar + (br - ar) * t);
const rg = Math.round(ag + (bg - ag) * t);
const rb = Math.round(ab + (bb - ab) * t);
return (rr << 16) | (rg << 8) | rb;
}
// ─── PUBLIC API ─────────────────────────────────────
return {
init,
triggerPulse,
update,
};
})();
export { MemoryPulse };

View File

@@ -72,6 +72,53 @@ class MnemosyneArchive:
scored.sort(key=lambda x: x[0], reverse=True)
return [e for _, e in scored[:limit]]
def semantic_search(self, query: str, limit: int = 10, threshold: float = 0.05) -> list[ArchiveEntry]:
"""Semantic search using holographic linker similarity.
Scores each entry by Jaccard similarity between query tokens and entry
tokens, then boosts entries with more inbound links (more "holographic").
Falls back to keyword search if no entries meet the similarity threshold.
Args:
query: Natural language query string.
limit: Maximum number of results to return.
threshold: Minimum Jaccard similarity to be considered a semantic match.
Returns:
List of ArchiveEntry sorted by combined relevance score, descending.
"""
query_tokens = HolographicLinker._tokenize(query)
if not query_tokens:
return []
# Count inbound links for each entry (how many entries link TO this one)
inbound: dict[str, int] = {eid: 0 for eid in self._entries}
for entry in self._entries.values():
for linked_id in entry.links:
if linked_id in inbound:
inbound[linked_id] += 1
max_inbound = max(inbound.values(), default=1) or 1
scored = []
for entry in self._entries.values():
entry_tokens = HolographicLinker._tokenize(f"{entry.title} {entry.content} {' '.join(entry.topics)}")
if not entry_tokens:
continue
intersection = query_tokens & entry_tokens
union = query_tokens | entry_tokens
jaccard = len(intersection) / len(union)
if jaccard >= threshold:
link_boost = inbound[entry.id] / max_inbound * 0.2 # up to 20% boost
scored.append((jaccard + link_boost, entry))
if scored:
scored.sort(key=lambda x: x[0], reverse=True)
return [e for _, e in scored[:limit]]
# Graceful fallback to keyword search
return self.search(query, limit=limit)
def get_linked(self, entry_id: str, depth: int = 1) -> list[ArchiveEntry]:
"""Get entries linked to a given entry, up to specified depth."""
visited = set()

View File

@@ -23,7 +23,10 @@ def cmd_stats(args):
def cmd_search(args):
archive = MnemosyneArchive()
results = archive.search(args.query, limit=args.limit)
if getattr(args, "semantic", False):
results = archive.semantic_search(args.query, limit=args.limit)
else:
results = archive.search(args.query, limit=args.limit)
if not results:
print("No results found.")
return
@@ -96,6 +99,7 @@ def main():
s = sub.add_parser("search", help="Search the archive")
s.add_argument("query", help="Search query")
s.add_argument("-n", "--limit", type=int, default=10)
s.add_argument("--semantic", action="store_true", help="Use holographic linker similarity scoring")
i = sub.add_parser("ingest", help="Ingest a new entry")
i.add_argument("--title", required=True)

View File

@@ -197,6 +197,71 @@ def test_archive_stats_orphan_count():
assert s["newest_entry"] is not None
def test_semantic_search_returns_results():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Python automation", content="Building automation tools in Python")
ingest_event(archive, title="Cooking recipes", content="How to make pasta carbonara with cheese")
results = archive.semantic_search("python scripting", limit=5)
assert len(results) > 0
assert results[0].title == "Python automation"
def test_semantic_search_link_boost():
"""Entries with more inbound links rank higher when Jaccard is equal."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
# Create two similar entries; manually give one more links
e1 = ingest_event(archive, title="Machine learning", content="Neural networks deep learning models")
e2 = ingest_event(archive, title="Machine learning basics", content="Neural networks deep learning intro")
# Add a third entry that links to e1 so e1 has more inbound links
e3 = ingest_event(archive, title="AI overview", content="Artificial intelligence machine learning")
# Manually give e1 an extra inbound link by adding e3 -> e1
if e1.id not in e3.links:
e3.links.append(e1.id)
archive._save()
results = archive.semantic_search("machine learning neural networks", limit=5)
assert len(results) >= 2
# e1 should rank at or near top
assert results[0].id in {e1.id, e2.id}
def test_semantic_search_fallback_to_keyword():
"""Falls back to keyword search when no entry meets Jaccard threshold."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Exact match only", content="unique xyzzy token here")
# threshold=1.0 ensures no semantic match, triggering fallback
results = archive.semantic_search("xyzzy", limit=5, threshold=1.0)
# Fallback keyword search should find it
assert len(results) == 1
assert results[0].title == "Exact match only"
def test_semantic_search_empty_archive():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
results = archive.semantic_search("anything", limit=5)
assert results == []
def test_semantic_search_vs_keyword_relevance():
"""Semantic search finds conceptually related entries missed by keyword search."""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"
archive = MnemosyneArchive(archive_path=path)
ingest_event(archive, title="Python scripting", content="Writing scripts with Python for automation tasks")
ingest_event(archive, title="Baking bread", content="Mix flour water yeast knead bake oven")
# "coding" is semantically unrelated to baking but related to python scripting
results = archive.semantic_search("coding scripts automation")
assert len(results) > 0
assert results[0].title == "Python scripting"
def test_archive_topic_counts():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "test_archive.json"