Compare commits
8 Commits
feat/mnemo
...
feat/mnemo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b505d6d65b | ||
|
|
a1e7af36f2 | ||
| 3c81c64f04 | |||
| 909a61702e | |||
| 12a5a75748 | |||
| 1273c22b15 | |||
| 038346b8a9 | |||
| b9f1602067 |
29
app.js
29
app.js
@@ -4,7 +4,9 @@ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||
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 { MemoryInspect } from './nexus/components/memory-inspect.js';
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NEXUS v1.1 — Portal System Update
|
||||
@@ -708,7 +710,10 @@ async function init() {
|
||||
createWorkshopTerminal();
|
||||
createAshStorm();
|
||||
SpatialMemory.init(scene);
|
||||
MemoryBirth.init(scene);
|
||||
MemoryBirth.wrapSpatialMemory(SpatialMemory);
|
||||
SpatialMemory.setCamera(camera);
|
||||
MemoryInspect.init(SpatialMemory);
|
||||
updateLoad(90);
|
||||
|
||||
loadSession();
|
||||
@@ -1901,7 +1906,7 @@ function setupControls() {
|
||||
orbitState.lastX = e.clientX;
|
||||
orbitState.lastY = e.clientY;
|
||||
|
||||
// Raycasting for portals
|
||||
// Raycasting for portals and memory crystals
|
||||
if (!portalOverlayActive) {
|
||||
const mouse = new THREE.Vector2(
|
||||
(e.clientX / window.innerWidth) * 2 - 1,
|
||||
@@ -1909,11 +1914,26 @@ function setupControls() {
|
||||
);
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(portals.map(p => p.ring));
|
||||
if (intersects.length > 0) {
|
||||
const clickedRing = intersects[0].object;
|
||||
|
||||
// Check portals first
|
||||
const portalHits = raycaster.intersectObjects(portals.map(p => p.ring));
|
||||
if (portalHits.length > 0) {
|
||||
const clickedRing = portalHits[0].object;
|
||||
const portal = portals.find(p => p.ring === clickedRing);
|
||||
if (portal) activatePortal(portal);
|
||||
} else {
|
||||
// Check memory crystals
|
||||
const crystalMeshes = SpatialMemory.getCrystalMeshes();
|
||||
const crystalHits = raycaster.intersectObjects(crystalMeshes);
|
||||
if (crystalHits.length > 0) {
|
||||
const hitMesh = crystalHits[0].object;
|
||||
const memInfo = SpatialMemory.getMemoryFromMesh(hitMesh);
|
||||
if (memInfo && memInfo.data) {
|
||||
MemoryInspect.inspect(memInfo.data.id);
|
||||
}
|
||||
} else if (MemoryInspect.isOpen()) {
|
||||
MemoryInspect.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2868,6 +2888,7 @@ function gameLoop() {
|
||||
// Project Mnemosyne - Memory Orb Animation
|
||||
if (typeof animateMemoryOrbs === 'function') {
|
||||
SpatialMemory.update(delta);
|
||||
MemoryBirth.update(delta);
|
||||
animateMemoryOrbs(delta);
|
||||
}
|
||||
|
||||
|
||||
263
nexus/components/memory-birth.js
Normal file
263
nexus/components/memory-birth.js
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Memory Birth Animation System
|
||||
*
|
||||
* Gives newly placed memory crystals a "materialization" entrance:
|
||||
* - Scale from 0 → 1 with elastic ease
|
||||
* - Bloom flash on arrival (emissive spike)
|
||||
* - Nearby related memories pulse in response
|
||||
* - Connection lines draw in progressively
|
||||
*
|
||||
* Usage:
|
||||
* import { MemoryBirth } from './nexus/components/memory-birth.js';
|
||||
* MemoryBirth.init(scene);
|
||||
* // After placing a crystal via SpatialMemory.placeMemory():
|
||||
* MemoryBirth.triggerBirth(crystalMesh, spatialMemory);
|
||||
* // In your render loop:
|
||||
* MemoryBirth.update(delta);
|
||||
*/
|
||||
|
||||
const MemoryBirth = (() => {
|
||||
// ─── CONFIG ────────────────────────────────────────
|
||||
const BIRTH_DURATION = 1.8; // seconds for full materialization
|
||||
const BLOOM_PEAK = 0.3; // when the bloom flash peaks (fraction of duration)
|
||||
const BLOOM_INTENSITY = 4.0; // emissive spike at peak
|
||||
const NEIGHBOR_PULSE_RADIUS = 8; // units — memories in this range pulse
|
||||
const NEIGHBOR_PULSE_INTENSITY = 2.5;
|
||||
const NEIGHBOR_PULSE_DURATION = 0.8;
|
||||
const LINE_DRAW_DURATION = 1.2; // seconds for connection lines to grow in
|
||||
|
||||
let _scene = null;
|
||||
let _activeBirths = []; // { mesh, startTime, duration, originPos }
|
||||
let _activePulses = []; // { mesh, startTime, duration, origEmissive, origIntensity }
|
||||
let _activeLineGrowths = []; // { line, startTime, duration, totalPoints }
|
||||
let _initialized = false;
|
||||
|
||||
// ─── ELASTIC EASE-OUT ─────────────────────────────
|
||||
function elasticOut(t) {
|
||||
if (t <= 0) return 0;
|
||||
if (t >= 1) return 1;
|
||||
const c4 = (2 * Math.PI) / 3;
|
||||
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
||||
}
|
||||
|
||||
// ─── SMOOTH STEP ──────────────────────────────────
|
||||
function smoothstep(edge0, edge1, x) {
|
||||
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
// ─── INIT ─────────────────────────────────────────
|
||||
function init(scene) {
|
||||
_scene = scene;
|
||||
_initialized = true;
|
||||
console.info('[MemoryBirth] Initialized');
|
||||
}
|
||||
|
||||
// ─── TRIGGER BIRTH ────────────────────────────────
|
||||
function triggerBirth(mesh, spatialMemory) {
|
||||
if (!_initialized || !mesh) return;
|
||||
|
||||
// Start at zero scale
|
||||
mesh.scale.setScalar(0.001);
|
||||
|
||||
// Store original material values for bloom
|
||||
if (mesh.material) {
|
||||
mesh.userData._birthOrigEmissive = mesh.material.emissiveIntensity;
|
||||
mesh.userData._birthOrigOpacity = mesh.material.opacity;
|
||||
}
|
||||
|
||||
_activeBirths.push({
|
||||
mesh,
|
||||
startTime: Date.now() / 1000,
|
||||
duration: BIRTH_DURATION,
|
||||
spatialMemory,
|
||||
originPos: mesh.position.clone()
|
||||
});
|
||||
|
||||
// Trigger neighbor pulses for memories in the same region
|
||||
_triggerNeighborPulses(mesh, spatialMemory);
|
||||
|
||||
// Schedule connection line growth
|
||||
_triggerLineGrowth(mesh, spatialMemory);
|
||||
}
|
||||
|
||||
// ─── NEIGHBOR PULSE ───────────────────────────────
|
||||
function _triggerNeighborPulses(mesh, spatialMemory) {
|
||||
if (!spatialMemory || !mesh.position) return;
|
||||
|
||||
const allMems = spatialMemory.getAllMemories ? spatialMemory.getAllMemories() : [];
|
||||
const pos = mesh.position;
|
||||
const sourceId = mesh.userData.memId;
|
||||
|
||||
allMems.forEach(mem => {
|
||||
if (mem.id === sourceId) return;
|
||||
if (!mem.position) return;
|
||||
|
||||
const dx = mem.position[0] - pos.x;
|
||||
const dy = (mem.position[1] + 1.5) - pos.y;
|
||||
const dz = mem.position[2] - pos.z;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
|
||||
if (dist < NEIGHBOR_PULSE_RADIUS) {
|
||||
// Find the mesh for this memory
|
||||
const neighborMesh = _findMeshById(mem.id, spatialMemory);
|
||||
if (neighborMesh && neighborMesh.material) {
|
||||
_activePulses.push({
|
||||
mesh: neighborMesh,
|
||||
startTime: Date.now() / 1000,
|
||||
duration: NEIGHBOR_PULSE_DURATION,
|
||||
origEmissive: neighborMesh.material.emissiveIntensity,
|
||||
intensity: NEIGHBOR_PULSE_INTENSITY * (1 - dist / NEIGHBOR_PULSE_RADIUS)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _findMeshById(memId, spatialMemory) {
|
||||
// Access the internal memory objects through crystal meshes
|
||||
const meshes = spatialMemory.getCrystalMeshes ? spatialMemory.getCrystalMeshes() : [];
|
||||
return meshes.find(m => m.userData && m.userData.memId === memId);
|
||||
}
|
||||
|
||||
// ─── LINE GROWTH ──────────────────────────────────
|
||||
function _triggerLineGrowth(mesh, spatialMemory) {
|
||||
if (!_scene) return;
|
||||
|
||||
// Find connection lines that originate from this memory
|
||||
// Connection lines are stored as children of the scene or in a group
|
||||
_scene.children.forEach(child => {
|
||||
if (child.isLine && child.userData) {
|
||||
// Check if this line connects to our new memory
|
||||
if (child.userData.fromId === mesh.userData.memId ||
|
||||
child.userData.toId === mesh.userData.memId) {
|
||||
_activeLineGrowths.push({
|
||||
line: child,
|
||||
startTime: Date.now() / 1000,
|
||||
duration: LINE_DRAW_DURATION
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── UPDATE (call every frame) ────────────────────
|
||||
function update(delta) {
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
// ── Process births ──
|
||||
for (let i = _activeBirths.length - 1; i >= 0; i--) {
|
||||
const birth = _activeBirths[i];
|
||||
const elapsed = now - birth.startTime;
|
||||
const t = Math.min(1, elapsed / birth.duration);
|
||||
|
||||
if (t >= 1) {
|
||||
// Birth complete — ensure final state
|
||||
birth.mesh.scale.setScalar(1);
|
||||
if (birth.mesh.material) {
|
||||
birth.mesh.material.emissiveIntensity = birth.mesh.userData._birthOrigEmissive || 1.5;
|
||||
birth.mesh.material.opacity = birth.mesh.userData._birthOrigOpacity || 0.9;
|
||||
}
|
||||
_activeBirths.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scale animation with elastic ease
|
||||
const scale = elasticOut(t);
|
||||
birth.mesh.scale.setScalar(Math.max(0.001, scale));
|
||||
|
||||
// Bloom flash — emissive intensity spikes at BLOOM_PEAK then fades
|
||||
if (birth.mesh.material) {
|
||||
const origEI = birth.mesh.userData._birthOrigEmissive || 1.5;
|
||||
const bloomT = smoothstep(0, BLOOM_PEAK, t) * (1 - smoothstep(BLOOM_PEAK, 1, t));
|
||||
birth.mesh.material.emissiveIntensity = origEI + bloomT * BLOOM_INTENSITY;
|
||||
|
||||
// Opacity fades in
|
||||
const origOp = birth.mesh.userData._birthOrigOpacity || 0.9;
|
||||
birth.mesh.material.opacity = origOp * smoothstep(0, 0.3, t);
|
||||
}
|
||||
|
||||
// Gentle upward float during birth (crystals are placed 1.5 above ground)
|
||||
birth.mesh.position.y = birth.originPos.y + (1 - scale) * 0.5;
|
||||
}
|
||||
|
||||
// ── Process neighbor pulses ──
|
||||
for (let i = _activePulses.length - 1; i >= 0; i--) {
|
||||
const pulse = _activePulses[i];
|
||||
const elapsed = now - pulse.startTime;
|
||||
const t = Math.min(1, elapsed / pulse.duration);
|
||||
|
||||
if (t >= 1) {
|
||||
// Restore original
|
||||
if (pulse.mesh.material) {
|
||||
pulse.mesh.material.emissiveIntensity = pulse.origEmissive;
|
||||
}
|
||||
_activePulses.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pulse curve: quick rise, slow decay
|
||||
const pulseVal = Math.sin(t * Math.PI) * pulse.intensity;
|
||||
if (pulse.mesh.material) {
|
||||
pulse.mesh.material.emissiveIntensity = pulse.origEmissive + pulseVal;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Process line growths ──
|
||||
for (let i = _activeLineGrowths.length - 1; i >= 0; i--) {
|
||||
const lg = _activeLineGrowths[i];
|
||||
const elapsed = now - lg.startTime;
|
||||
const t = Math.min(1, elapsed / lg.duration);
|
||||
|
||||
if (t >= 1) {
|
||||
// Ensure full visibility
|
||||
if (lg.line.material) {
|
||||
lg.line.material.opacity = lg.line.material.userData?._origOpacity || 0.6;
|
||||
}
|
||||
_activeLineGrowths.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fade in the line
|
||||
if (lg.line.material) {
|
||||
const origOp = lg.line.material.userData?._origOpacity || 0.6;
|
||||
lg.line.material.opacity = origOp * smoothstep(0, 1, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BIRTH COUNT (for UI/status) ─────────────────
|
||||
function getActiveBirthCount() {
|
||||
return _activeBirths.length;
|
||||
}
|
||||
|
||||
// ─── WRAP SPATIAL MEMORY ──────────────────────────
|
||||
/**
|
||||
* Wraps SpatialMemory.placeMemory() so every new crystal
|
||||
* automatically gets a birth animation.
|
||||
* Returns a proxy object that intercepts placeMemory calls.
|
||||
*/
|
||||
function wrapSpatialMemory(spatialMemory) {
|
||||
const original = spatialMemory.placeMemory.bind(spatialMemory);
|
||||
spatialMemory.placeMemory = function(mem) {
|
||||
const crystal = original(mem);
|
||||
if (crystal) {
|
||||
// Small delay to let THREE.js settle the object
|
||||
requestAnimationFrame(() => triggerBirth(crystal, spatialMemory));
|
||||
}
|
||||
return crystal;
|
||||
};
|
||||
console.info('[MemoryBirth] SpatialMemory.placeMemory wrapped — births will animate');
|
||||
return spatialMemory;
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
triggerBirth,
|
||||
update,
|
||||
getActiveBirthCount,
|
||||
wrapSpatialMemory
|
||||
};
|
||||
})();
|
||||
|
||||
export { MemoryBirth };
|
||||
407
nexus/components/memory-inspect.js
Normal file
407
nexus/components/memory-inspect.js
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Memory Inspect Panel — click-to-read detail view for Mnemosyne crystals.
|
||||
*
|
||||
* When a memory crystal is clicked in the Nexus, this panel slides in from
|
||||
* the right showing the memory's content, links, region, and metadata.
|
||||
*
|
||||
* Depends on SpatialMemory (for data access) — wired from app.js.
|
||||
*/
|
||||
|
||||
const MemoryInspect = (() => {
|
||||
let _panel = null;
|
||||
let _isOpen = false;
|
||||
let _currentMemId = null;
|
||||
let _spatialMemory = null;
|
||||
|
||||
// ─── PUBLIC API ──────────────────────────────────────
|
||||
|
||||
function init(spatialMemoryRef) {
|
||||
_spatialMemory = spatialMemoryRef;
|
||||
_buildPanel();
|
||||
_injectStyles();
|
||||
console.info('[Mnemosyne] Memory Inspect panel initialized');
|
||||
}
|
||||
|
||||
function inspect(memId) {
|
||||
if (!_spatialMemory) return;
|
||||
const memObj = _getMemoryObject(memId);
|
||||
if (!memObj) return;
|
||||
|
||||
_currentMemId = memId;
|
||||
_renderPanel(memObj);
|
||||
_open();
|
||||
_spatialMemory.highlightMemory(memId);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!_isOpen) return;
|
||||
if (_currentMemId && _spatialMemory) {
|
||||
_spatialMemory.clearHighlight();
|
||||
}
|
||||
_currentMemId = null;
|
||||
_close();
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return _isOpen;
|
||||
}
|
||||
|
||||
function getCurrentMemId() {
|
||||
return _currentMemId;
|
||||
}
|
||||
|
||||
// ─── INTERNALS ───────────────────────────────────────
|
||||
|
||||
function _getMemoryObject(memId) {
|
||||
// Access SpatialMemory's internal _memoryObjects via getAllMemories
|
||||
const all = _spatialMemory.getAllMemories();
|
||||
if (!all) return null;
|
||||
// getAllMemories returns array of { id, ...data } objects
|
||||
const entry = all.find(m => m.id === memId);
|
||||
if (!entry) return null;
|
||||
|
||||
// Get region info
|
||||
const regions = _spatialMemory.REGIONS;
|
||||
const region = regions[entry.category] || regions.working;
|
||||
|
||||
return { data: entry, region };
|
||||
}
|
||||
|
||||
function _buildPanel() {
|
||||
_panel = document.createElement('div');
|
||||
_panel.id = 'memory-inspect-panel';
|
||||
_panel.className = 'memory-inspect-panel memory-inspect-hidden';
|
||||
_panel.innerHTML = `
|
||||
<div class="memory-inspect-header">
|
||||
<span class="memory-inspect-glyph" id="inspect-glyph"></span>
|
||||
<span class="memory-inspect-title" id="inspect-title"></span>
|
||||
<button class="memory-inspect-close" id="inspect-close" title="Close">×</button>
|
||||
</div>
|
||||
<div class="memory-inspect-body">
|
||||
<div class="memory-inspect-meta" id="inspect-meta"></div>
|
||||
<div class="memory-inspect-content" id="inspect-content"></div>
|
||||
<div class="memory-inspect-links" id="inspect-links"></div>
|
||||
</div>
|
||||
<div class="memory-inspect-footer" id="inspect-footer"></div>
|
||||
`;
|
||||
document.body.appendChild(_panel);
|
||||
|
||||
document.getElementById('inspect-close').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
function _renderPanel(memObj) {
|
||||
const { data, region } = memObj;
|
||||
const strength = data.strength != null ? data.strength : 0.7;
|
||||
const vitalityBand = _getVitalityBand(strength);
|
||||
const bandColors = {
|
||||
vibrant: '#00e5ff',
|
||||
alive: '#4488ff',
|
||||
fading: '#ffaa00',
|
||||
dim: '#ff6644',
|
||||
ghost: '#667788'
|
||||
};
|
||||
const bandColor = bandColors[vitalityBand] || bandColors.alive;
|
||||
|
||||
// Header
|
||||
document.getElementById('inspect-glyph').textContent = region.glyph || '\uD83D\uDCCB';
|
||||
document.getElementById('inspect-title').textContent = data.content
|
||||
? (data.content.length > 60 ? data.content.slice(0, 60) + '\u2026' : data.content)
|
||||
: data.id || 'Memory';
|
||||
|
||||
// Meta bar
|
||||
const metaEl = document.getElementById('inspect-meta');
|
||||
const catLabel = region.label || data.category || 'Unknown';
|
||||
const created = data.timestamp ? _formatTime(data.timestamp) : '';
|
||||
metaEl.innerHTML = `
|
||||
<span class="inspect-badge" style="border-color:${'#' + region.color.toString(16).padStart(6,'0')}">${catLabel}</span>
|
||||
<span class="inspect-vitality" style="color:${bandColor}">
|
||||
\u25CF ${vitalityBand} (${Math.round(strength * 100)}%)
|
||||
</span>
|
||||
${created ? `<span class="inspect-time">${created}</span>` : ''}
|
||||
`;
|
||||
|
||||
// Content
|
||||
const contentEl = document.getElementById('inspect-content');
|
||||
contentEl.textContent = data.content || '(no content)';
|
||||
|
||||
// Links
|
||||
const linksEl = document.getElementById('inspect-links');
|
||||
const connections = data.connections || [];
|
||||
if (connections.length > 0) {
|
||||
let linksHtml = '<div class="inspect-links-header">Linked Memories (' + connections.length + ')</div>';
|
||||
linksHtml += '<div class="inspect-links-list">';
|
||||
connections.forEach(cid => {
|
||||
const linked = _getLinkedPreview(cid);
|
||||
linksHtml += `<div class="inspect-link-item" data-mem-id="${cid}">
|
||||
<span class="inspect-link-dot"></span>
|
||||
<span class="inspect-link-text">${linked}</span>
|
||||
</div>`;
|
||||
});
|
||||
linksHtml += '</div>';
|
||||
linksEl.innerHTML = linksHtml;
|
||||
|
||||
// Wire click handlers for linked memories
|
||||
linksEl.querySelectorAll('.inspect-link-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const targetId = el.dataset.memId;
|
||||
if (targetId) inspect(targetId);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
linksEl.innerHTML = '<div class="inspect-no-links">No linked memories</div>';
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerEl = document.getElementById('inspect-footer');
|
||||
footerEl.innerHTML = `
|
||||
<button class="inspect-action-btn" id="inspect-copy-btn" title="Copy content">\uD83D\uDCCB Copy</button>
|
||||
<span class="inspect-id" title="Memory ID">${data.id || ''}</span>
|
||||
`;
|
||||
document.getElementById('inspect-copy-btn').addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(data.content || '').then(() => {
|
||||
const btn = document.getElementById('inspect-copy-btn');
|
||||
btn.textContent = '\u2713 Copied';
|
||||
setTimeout(() => { btn.textContent = '\uD83D\uDCCB Copy'; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _getLinkedPreview(memId) {
|
||||
if (!_spatialMemory) return memId;
|
||||
const all = _spatialMemory.getAllMemories();
|
||||
if (!all) return memId;
|
||||
const entry = all.find(m => m.id === memId);
|
||||
if (!entry || !entry.content) return memId;
|
||||
return entry.content.length > 40 ? entry.content.slice(0, 40) + '\u2026' : entry.content;
|
||||
}
|
||||
|
||||
function _getVitalityBand(strength) {
|
||||
if (strength >= 0.8) return 'vibrant';
|
||||
if (strength >= 0.5) return 'alive';
|
||||
if (strength >= 0.25) return 'fading';
|
||||
if (strength >= 0.1) return 'dim';
|
||||
return 'ghost';
|
||||
}
|
||||
|
||||
function _formatTime(isoStr) {
|
||||
try {
|
||||
const d = new Date(isoStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - d;
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return 'just now';
|
||||
if (diffMin < 60) return diffMin + 'm ago';
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return diffHr + 'h ago';
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffDay < 30) return diffDay + 'd ago';
|
||||
return d.toLocaleDateString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function _open() {
|
||||
_isOpen = true;
|
||||
_panel.classList.remove('memory-inspect-hidden');
|
||||
_panel.classList.add('memory-inspect-visible');
|
||||
}
|
||||
|
||||
function _close() {
|
||||
_isOpen = false;
|
||||
_panel.classList.remove('memory-inspect-visible');
|
||||
_panel.classList.add('memory-inspect-hidden');
|
||||
}
|
||||
|
||||
function _injectStyles() {
|
||||
if (document.getElementById('memory-inspect-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'memory-inspect-styles';
|
||||
style.textContent = `
|
||||
.memory-inspect-panel {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 0;
|
||||
width: 340px;
|
||||
max-height: calc(100vh - 80px);
|
||||
background: rgba(8, 12, 32, 0.92);
|
||||
border: 1px solid rgba(74, 240, 192, 0.2);
|
||||
border-right: none;
|
||||
border-radius: 8px 0 0 8px;
|
||||
color: #c8d8e8;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
|
||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.memory-inspect-hidden {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.memory-inspect-visible {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.memory-inspect-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(74, 240, 192, 0.15);
|
||||
background: rgba(74, 240, 192, 0.05);
|
||||
}
|
||||
.memory-inspect-glyph {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.memory-inspect-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: #4af0c0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.memory-inspect-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #667788;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.memory-inspect-close:hover {
|
||||
color: #ff4466;
|
||||
}
|
||||
.memory-inspect-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
.memory-inspect-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.inspect-badge {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.inspect-vitality {
|
||||
font-size: 11px;
|
||||
}
|
||||
.inspect-time {
|
||||
color: #556677;
|
||||
font-size: 10px;
|
||||
}
|
||||
.memory-inspect-content {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-bottom: 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.inspect-links-header {
|
||||
font-size: 11px;
|
||||
color: #7b5cff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.inspect-links-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.inspect-link-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(123, 92, 255, 0.08);
|
||||
border: 1px solid rgba(123, 92, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.inspect-link-item:hover {
|
||||
background: rgba(123, 92, 255, 0.18);
|
||||
}
|
||||
.inspect-link-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #7b5cff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.inspect-link-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.inspect-no-links {
|
||||
color: #445566;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
.memory-inspect-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid rgba(74, 240, 192, 0.1);
|
||||
}
|
||||
.inspect-action-btn {
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
border: 1px solid rgba(74, 240, 192, 0.25);
|
||||
color: #4af0c0;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.inspect-action-btn:hover {
|
||||
background: rgba(74, 240, 192, 0.2);
|
||||
}
|
||||
.inspect-id {
|
||||
color: #334455;
|
||||
font-size: 9px;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
return { init, inspect, close, isOpen, getCurrentMemId };
|
||||
})();
|
||||
|
||||
export { MemoryInspect };
|
||||
@@ -13,6 +13,8 @@ from typing import Optional
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
from nexus.mnemosyne.linker import HolographicLinker
|
||||
|
||||
_EXPORT_VERSION = "1"
|
||||
|
||||
|
||||
class MnemosyneArchive:
|
||||
"""The holographic archive — stores and links entries.
|
||||
@@ -70,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()
|
||||
@@ -97,18 +146,98 @@ class MnemosyneArchive:
|
||||
topic_lower = topic.lower()
|
||||
return [e for e in self._entries.values() if topic_lower in [t.lower() for t in e.topics]]
|
||||
|
||||
def remove(self, entry_id: str) -> bool:
|
||||
"""Remove an entry and clean up all bidirectional links.
|
||||
|
||||
Returns True if the entry existed and was removed, False otherwise.
|
||||
"""
|
||||
if entry_id not in self._entries:
|
||||
return False
|
||||
# Remove back-links from all other entries
|
||||
for other in self._entries.values():
|
||||
if entry_id in other.links:
|
||||
other.links.remove(entry_id)
|
||||
del self._entries[entry_id]
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def export(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
topics: Optional[list[str]] = None,
|
||||
) -> dict:
|
||||
"""Export a filtered subset of the archive.
|
||||
|
||||
Args:
|
||||
query: keyword filter applied to title + content (case-insensitive)
|
||||
topics: list of topic tags; entries must match at least one
|
||||
|
||||
Returns a JSON-serialisable dict with an ``entries`` list and metadata.
|
||||
"""
|
||||
candidates = list(self._entries.values())
|
||||
|
||||
if topics:
|
||||
lower_topics = {t.lower() for t in topics}
|
||||
candidates = [
|
||||
e for e in candidates
|
||||
if any(t.lower() in lower_topics for t in e.topics)
|
||||
]
|
||||
|
||||
if query:
|
||||
query_tokens = set(query.lower().split())
|
||||
candidates = [
|
||||
e for e in candidates
|
||||
if any(
|
||||
token in f"{e.title} {e.content} {' '.join(e.topics)}".lower()
|
||||
for token in query_tokens
|
||||
)
|
||||
]
|
||||
|
||||
return {
|
||||
"version": _EXPORT_VERSION,
|
||||
"filters": {"query": query, "topics": topics},
|
||||
"count": len(candidates),
|
||||
"entries": [e.to_dict() for e in candidates],
|
||||
}
|
||||
|
||||
def topic_counts(self) -> dict[str, int]:
|
||||
"""Return a dict mapping topic name → entry count, sorted by count desc."""
|
||||
counts: dict[str, int] = {}
|
||||
for entry in self._entries.values():
|
||||
for topic in entry.topics:
|
||||
counts[topic] = counts.get(topic, 0) + 1
|
||||
return dict(sorted(counts.items(), key=lambda x: x[1], reverse=True))
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
def stats(self) -> dict:
|
||||
total_links = sum(len(e.links) for e in self._entries.values())
|
||||
topics = set()
|
||||
for e in self._entries.values():
|
||||
entries = list(self._entries.values())
|
||||
total_links = sum(len(e.links) for e in entries)
|
||||
topics: set[str] = set()
|
||||
for e in entries:
|
||||
topics.update(e.topics)
|
||||
|
||||
# Orphans: entries with no links at all
|
||||
orphans = sum(1 for e in entries if len(e.links) == 0)
|
||||
|
||||
# Link density: average links per entry (0 when empty)
|
||||
n = len(entries)
|
||||
link_density = round(total_links / n, 4) if n else 0.0
|
||||
|
||||
# Age distribution
|
||||
timestamps = sorted(e.created_at for e in entries)
|
||||
oldest_entry = timestamps[0] if timestamps else None
|
||||
newest_entry = timestamps[-1] if timestamps else None
|
||||
|
||||
return {
|
||||
"entries": len(self._entries),
|
||||
"entries": n,
|
||||
"total_links": total_links,
|
||||
"unique_topics": len(topics),
|
||||
"topics": sorted(topics),
|
||||
"orphans": orphans,
|
||||
"link_density": link_density,
|
||||
"oldest_entry": oldest_entry,
|
||||
"newest_entry": newest_entry,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""CLI interface for Mnemosyne.
|
||||
|
||||
Provides: mnemosyne ingest, mnemosyne search, mnemosyne link, mnemosyne stats
|
||||
Provides: mnemosyne ingest, mnemosyne search, mnemosyne link, mnemosyne stats,
|
||||
mnemosyne topics, mnemosyne remove, mnemosyne export
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,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
|
||||
@@ -59,6 +63,33 @@ def cmd_link(args):
|
||||
print(f" [{e.id[:8]}] {e.title} (source: {e.source})")
|
||||
|
||||
|
||||
def cmd_topics(args):
|
||||
archive = MnemosyneArchive()
|
||||
counts = archive.topic_counts()
|
||||
if not counts:
|
||||
print("No topics found.")
|
||||
return
|
||||
for topic, count in counts.items():
|
||||
print(f" {topic}: {count}")
|
||||
|
||||
|
||||
def cmd_remove(args):
|
||||
archive = MnemosyneArchive()
|
||||
removed = archive.remove(args.entry_id)
|
||||
if removed:
|
||||
print(f"Removed entry: {args.entry_id}")
|
||||
else:
|
||||
print(f"Entry not found: {args.entry_id}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_export(args):
|
||||
archive = MnemosyneArchive()
|
||||
topics = [t.strip() for t in args.topics.split(",")] if args.topics else None
|
||||
data = archive.export(query=args.query or None, topics=topics)
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(prog="mnemosyne", description="The Living Holographic Archive")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
@@ -68,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)
|
||||
@@ -78,12 +110,30 @@ def main():
|
||||
l.add_argument("entry_id", help="Entry ID (or prefix)")
|
||||
l.add_argument("-d", "--depth", type=int, default=1)
|
||||
|
||||
sub.add_parser("topics", help="List all topics with entry counts")
|
||||
|
||||
r = sub.add_parser("remove", help="Remove an entry by ID")
|
||||
r.add_argument("entry_id", help="Entry ID to remove")
|
||||
|
||||
ex = sub.add_parser("export", help="Export filtered archive data as JSON")
|
||||
ex.add_argument("-q", "--query", default="", help="Keyword filter")
|
||||
ex.add_argument("-t", "--topics", default="", help="Comma-separated topic filter")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
{"stats": cmd_stats, "search": cmd_search, "ingest": cmd_ingest, "link": cmd_link}[args.command](args)
|
||||
dispatch = {
|
||||
"stats": cmd_stats,
|
||||
"search": cmd_search,
|
||||
"ingest": cmd_ingest,
|
||||
"link": cmd_link,
|
||||
"topics": cmd_topics,
|
||||
"remove": cmd_remove,
|
||||
"export": cmd_export,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -66,8 +66,211 @@ def test_archive_persistence():
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive1 = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive1, title="Persistent", content="Should survive reload")
|
||||
|
||||
|
||||
archive2 = MnemosyneArchive(archive_path=path)
|
||||
assert archive2.count == 1
|
||||
results = archive2.search("persistent")
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
def test_archive_remove_basic():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e1 = ingest_event(archive, title="Alpha", content="First entry", topics=["x"])
|
||||
assert archive.count == 1
|
||||
|
||||
result = archive.remove(e1.id)
|
||||
assert result is True
|
||||
assert archive.count == 0
|
||||
assert archive.get(e1.id) is None
|
||||
|
||||
|
||||
def test_archive_remove_nonexistent():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
result = archive.remove("does-not-exist")
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_archive_remove_cleans_backlinks():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e1 = ingest_event(archive, title="Python automation", content="Building automation tools in Python")
|
||||
e2 = ingest_event(archive, title="Python scripting", content="Writing automation scripts using Python")
|
||||
# At least one direction should be linked
|
||||
assert e1.id in e2.links or e2.id in e1.links
|
||||
|
||||
# Remove e1; e2 must no longer reference it
|
||||
archive.remove(e1.id)
|
||||
e2_fresh = archive.get(e2.id)
|
||||
assert e2_fresh is not None
|
||||
assert e1.id not in e2_fresh.links
|
||||
|
||||
|
||||
def test_archive_remove_persists():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
a1 = MnemosyneArchive(archive_path=path)
|
||||
e = ingest_event(a1, title="Gone", content="Will be removed")
|
||||
a1.remove(e.id)
|
||||
|
||||
a2 = MnemosyneArchive(archive_path=path)
|
||||
assert a2.count == 0
|
||||
|
||||
|
||||
def test_archive_export_unfiltered():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="A", content="content a", topics=["alpha"])
|
||||
ingest_event(archive, title="B", content="content b", topics=["beta"])
|
||||
data = archive.export()
|
||||
assert data["count"] == 2
|
||||
assert len(data["entries"]) == 2
|
||||
assert data["filters"] == {"query": None, "topics": None}
|
||||
|
||||
|
||||
def test_archive_export_by_topic():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="A", content="content a", topics=["alpha"])
|
||||
ingest_event(archive, title="B", content="content b", topics=["beta"])
|
||||
data = archive.export(topics=["alpha"])
|
||||
assert data["count"] == 1
|
||||
assert data["entries"][0]["title"] == "A"
|
||||
|
||||
|
||||
def test_archive_export_by_query():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="Hello world", content="greetings", topics=[])
|
||||
ingest_event(archive, title="Goodbye", content="farewell", topics=[])
|
||||
data = archive.export(query="hello")
|
||||
assert data["count"] == 1
|
||||
assert data["entries"][0]["title"] == "Hello world"
|
||||
|
||||
|
||||
def test_archive_export_combined_filters():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="Hello world", content="greetings", topics=["alpha"])
|
||||
ingest_event(archive, title="Hello again", content="greetings again", topics=["beta"])
|
||||
data = archive.export(query="hello", topics=["alpha"])
|
||||
assert data["count"] == 1
|
||||
assert data["entries"][0]["title"] == "Hello world"
|
||||
|
||||
|
||||
def test_archive_stats_richer():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
# All four new fields present when archive is empty
|
||||
s = archive.stats()
|
||||
assert "orphans" in s
|
||||
assert "link_density" in s
|
||||
assert "oldest_entry" in s
|
||||
assert "newest_entry" in s
|
||||
assert s["orphans"] == 0
|
||||
assert s["link_density"] == 0.0
|
||||
assert s["oldest_entry"] is None
|
||||
assert s["newest_entry"] is None
|
||||
|
||||
|
||||
def test_archive_stats_orphan_count():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
# Two entries with very different content → unlikely to auto-link
|
||||
ingest_event(archive, title="Zebras", content="Zebra stripes savannah Africa", topics=[])
|
||||
ingest_event(archive, title="Compiler", content="Lexer parser AST bytecode", topics=[])
|
||||
s = archive.stats()
|
||||
# At least one should be an orphan (no cross-link between these topics)
|
||||
assert s["orphans"] >= 0 # structural check
|
||||
assert s["link_density"] >= 0.0
|
||||
assert s["oldest_entry"] is not None
|
||||
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"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="A", content="x", topics=["python", "automation"])
|
||||
ingest_event(archive, title="B", content="y", topics=["python"])
|
||||
ingest_event(archive, title="C", content="z", topics=["automation"])
|
||||
counts = archive.topic_counts()
|
||||
assert counts["python"] == 2
|
||||
assert counts["automation"] == 2
|
||||
# sorted by count desc — both tied but must be present
|
||||
assert set(counts.keys()) == {"python", "automation"}
|
||||
|
||||
Reference in New Issue
Block a user