feat(drizzle-duckdb-wasm): recall & emotion

This commit is contained in:
Neko Ayaka
2025-03-30 01:12:15 +08:00
parent c8e5501cc2
commit fa7c41710f
12 changed files with 2673 additions and 58 deletions
@@ -101,31 +101,31 @@ function jumpAhead(amount, unit) {
<template>
<div class="rounded-lg bg-neutral-100 p-4 dark:bg-neutral-800/50">
<h2 class="mb-2 text-lg font-semibold">
Time Simulation
</h2>
<div class="flex flex-wrap items-center gap-4">
<div class="text-sm font-mono" flex-1>
<h2 class="flex items-center text-lg font-semibold" gap-4>
<div flex-1>
Time Simulation
</div>
<div class="text-sm font-mono">
{{ currentSimulatedTime }}
</div>
<div class="flex flex-wrap items-center gap-4">
<button
:class="{ 'bg-red-100 dark:bg-red-900': isTimeAccelerated, 'bg-green-100 dark:bg-green-900': !isTimeAccelerated }"
class="rounded-lg px-4 py-2 font-medium transition-colors"
@click="toggleTimeAcceleration"
>
<div v-if="isTimeAccelerated" i-solar:pause-bold />
<div v-else i-solar:play-bold />
</button>
<button
:class="{ 'bg-red-100 dark:bg-red-900': isTimeAccelerated, 'bg-green-100 dark:bg-green-900': !isTimeAccelerated }"
class="rounded-lg px-4 py-2 font-medium transition-colors"
@click="toggleTimeAcceleration"
>
<div v-if="isTimeAccelerated" i-solar:pause-bold />
<div v-else i-solar:play-bold />
</button>
<button
class="rounded-lg bg-neutral-200 px-4 py-2 font-medium dark:bg-neutral-700"
@click="resetTime"
>
<div i-solar:restart-line-duotone />
</button>
</div>
<button
class="rounded-lg bg-neutral-200 px-4 py-2 font-medium dark:bg-neutral-700"
@click="resetTime"
>
<div i-solar:restart-line-duotone />
</button>
</div>
</h2>
<!-- Time jump shortcuts -->
<div class="mt-4 flex flex-wrap gap-2">
@@ -158,12 +158,14 @@ function jumpAhead(amount, unit) {
</button>
</div>
<h2 class="mt-4 text-lg font-semibold">
Speed
<h2 class="mt-4 flex items-center text-lg font-semibold">
<div flex-1>
Speed
</div>
<div class="text-sm font-mono">
{{ formattedTimeMultiplier }}
</div>
</h2>
<div class="mt-4 font-medium">
{{ formattedTimeMultiplier }}
</div>
<!-- Time multiplier presets -->
<div class="grid grid-cols-2 mt-4 gap-2 md:grid-cols-6 sm:grid-cols-3">
@@ -0,0 +1,607 @@
<script setup lang="ts">
import type { EmotionalMemoryItem } from '../../types/memory/emotional-memory'
import { useDark } from '@vueuse/core'
import * as d3 from 'd3'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
const props = defineProps<{
memoryData: EmotionalMemoryItem[]
selectedMemoryId: string
decayRate: number
maxDaysToProject: number
longTermThreshold: number
muscleMemoryThreshold: number
joyBoostFactor: number
joyDecaySteepness: number
aversionSpikeFactor: number
aversionStability: number
}>()
interface DataPoint {
x: number
y: number
label: string
type: string
}
const chartContainer = ref(null)
const tooltip = ref(null)
let resizeObserver = null
const isDark = useDark()
// Render chart when dependencies change
watchEffect(() => {
if (chartContainer.value && props.memoryData.length && props.selectedMemoryId) {
renderChart()
}
})
// Render D3 chart
function renderChart() {
// Clear existing chart
d3.select(chartContainer.value).selectAll('*').remove()
// Find the selected memory
const memory = props.memoryData.find(m => m.id === props.selectedMemoryId)
if (!memory)
return
// Get container dimensions
const containerRect = chartContainer.value.getBoundingClientRect()
const containerWidth = containerRect.width
const containerHeight = containerRect.height || 700
// Set chart margins
const margin = { top: 40, right: 30, bottom: 40, left: 60 }
const width = containerWidth - margin.left - margin.right
const height = containerHeight - margin.top - margin.bottom
// Prepare data for chart
const originalScore = Number.parseFloat(String(memory.score))
const joyScore = Number.parseFloat(String(memory.joy_score))
const aversionScore = Number.parseFloat(String(memory.aversion_score))
const retrievalCount = Number.parseInt(String(memory.retrieval_count))
const dr = props.decayRate
const maxDays = props.maxDaysToProject
const ageInDays = Number.parseFloat(String(memory.age_in_seconds)) / (24 * 60 * 60)
const timeSinceRetrievalDays = Number.parseFloat(String(memory.time_since_retrieval)) / (24 * 60 * 60)
// Calculate LTM factor
const ltmFactor = retrievalCount >= props.longTermThreshold
? 1.0 - (0.3 ** (retrievalCount / props.longTermThreshold))
: 0
// Calculate current score components
const currentJoyEffect = joyScore * props.joyBoostFactor
* Math.exp(-props.joyDecaySteepness * timeSinceRetrievalDays)
const currentAversionEffect = aversionScore * props.aversionSpikeFactor
* props.aversionStability ** Math.ceil(retrievalCount / 5)
// Generate baseline data (standard decay without emotional effect)
const baselineData = [{
x: 0,
y: originalScore,
label: 'Original',
type: 'baseline',
}]
// Generate data with emotional effects
const emotionalData = [{
x: 0,
y: originalScore,
label: 'Original',
type: 'emotional',
}]
// Generate joy component data
const joyData = [{
x: 0,
y: joyScore * props.joyBoostFactor * originalScore,
label: 'Original Joy',
type: 'joy',
}]
// Generate aversion component data
const aversionData = [{
x: 0,
y: aversionScore * props.aversionSpikeFactor * originalScore,
label: 'Original Aversion',
type: 'aversion',
}]
// Current point (today)
baselineData.push({
x: ageInDays,
y: originalScore * Math.exp(-dr * ageInDays * (1 - ltmFactor)),
label: 'Current',
type: 'baseline',
})
emotionalData.push({
x: ageInDays,
y: Number.parseFloat(String(memory.decayed_score)),
label: 'Current',
type: 'emotional',
})
joyData.push({
x: ageInDays,
y: originalScore * currentJoyEffect,
label: 'Current Joy',
type: 'joy',
})
aversionData.push({
x: ageInDays,
y: originalScore * currentAversionEffect,
label: 'Current Aversion',
type: 'aversion',
})
// Add future projection points
const dayStep = Math.ceil(maxDays / 20) // Adjust steps based on projection days
for (let day = dayStep; day <= maxDays; day += dayStep) {
const projectedDay = ageInDays + day
const projectedDaysSinceRetrieval = timeSinceRetrievalDays + day
const label = `+${day}d`
// Calculate LTM-adjusted decay
const baseDecay = Math.exp(-dr * projectedDay * (1 - ltmFactor))
// Joy decays more quickly over time
const joyEffect = joyScore * props.joyBoostFactor
* Math.exp(-props.joyDecaySteepness * projectedDaysSinceRetrieval)
// Aversion is more stable, especially with repeated retrievals
const aversionEffect = aversionScore * props.aversionSpikeFactor
* props.aversionStability ** Math.ceil(retrievalCount / 5)
// Combined emotional effect
const emotionalEffect = (1 + joyEffect) * (1 + aversionEffect)
// Add baseline point (standard decay)
baselineData.push({
x: projectedDay,
y: originalScore * baseDecay,
label,
type: 'baseline',
})
// Add emotional point (with joy and aversion effects)
emotionalData.push({
x: projectedDay,
y: originalScore * baseDecay * emotionalEffect,
label,
type: 'emotional',
})
// Add joy component (isolates joy effect)
joyData.push({
x: projectedDay,
y: originalScore * joyEffect,
label,
type: 'joy',
})
// Add aversion component (isolates aversion effect)
aversionData.push({
x: projectedDay,
y: originalScore * aversionEffect,
label,
type: 'aversion',
})
}
// Create tooltip if it doesn't exist
if (!tooltip.value) {
tooltip.value = d3.select(chartContainer.value)
.append('div')
.attr('class', 'absolute pointer-events-none transition-opacity duration-200 bg-white dark:bg-neutral-800 p-2 rounded shadow-md text-sm z-10')
.style('opacity', 0)
}
// Create SVG
const svg = d3.select(chartContainer.value)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${containerWidth} ${containerHeight}`)
.attr('preserveAspectRatio', 'xMidYMid meet')
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
// Set up scales
const xExtent = [0, maxDays + ageInDays]
const yExtent = [0, d3.max([
...emotionalData,
...baselineData,
...joyData,
...aversionData,
], d => d.y) * 1.1]
const xScale = d3.scaleLinear()
.domain(xExtent)
.range([0, width])
const yScale = d3.scaleLinear()
.domain(yExtent)
.range([height, 0])
// Add axes and gridlines
addAxesAndGrids(svg, xScale, yScale, width, height)
// Add data lines
addDataLines(svg, baselineData, emotionalData, joyData, aversionData, xScale, yScale)
// Add key threshold markers
if (retrievalCount > 0) {
const halfLife = Math.log(2) / dr
if (halfLife <= maxDays + ageInDays) {
addThresholdLine(svg, halfLife, xScale, height, 'Half-life', 'rgba(220, 38, 38, 0.6)')
}
}
// Add memory type marker
let memoryTypeLabel = 'Short-term Memory'
let memoryTypeColor = 'rgba(220, 38, 38, 0.8)'
if (retrievalCount >= props.muscleMemoryThreshold) {
memoryTypeLabel = 'Muscle Memory'
memoryTypeColor = 'rgba(139, 92, 246, 0.8)'
}
else if (retrievalCount >= props.longTermThreshold) {
memoryTypeLabel = 'Long-term Memory'
memoryTypeColor = 'rgba(59, 130, 246, 0.8)'
}
else if (retrievalCount > 0) {
memoryTypeLabel = 'Working Memory'
memoryTypeColor = 'rgba(14, 165, 233, 0.8)'
}
addMemoryTypeMarker(svg, memoryTypeLabel, memoryTypeColor, retrievalCount, width)
// Add chart title and legends
addTitleAndLegends(svg, memory.id, joyScore, aversionScore, retrievalCount, width)
// Add data points with interaction
addDataPoints(svg, emotionalData, baselineData, xScale, yScale)
}
function addAxesAndGrids(
svg: d3.Selection<SVGGElement, unknown, null, undefined>,
xScale: d3.ScaleLinear<number, number>,
yScale: d3.ScaleLinear<number, number>,
width: number,
height: number,
): void {
const xAxis = d3.axisBottom(xScale)
.ticks(5)
.tickFormat((d: d3.NumberValue) => `${Math.round(d.valueOf())} days`)
const yAxis = d3.axisLeft(yScale)
.ticks(5)
.tickFormat((d: d3.NumberValue) => `${Math.round(d.valueOf())}`)
// Add gridlines
svg.append('g')
.attr('class', 'grid')
.attr('transform', `translate(0,${height})`)
.call(
d3.axisBottom(xScale)
.tickSize(-height)
.tickFormat(() => ''),
)
.selectAll('line')
.attr('stroke', isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.7)')
.attr('stroke-width', 1)
svg.append('g')
.attr('class', 'grid')
.call(
d3.axisLeft(yScale)
.tickSize(-width)
.tickFormat(() => ''),
)
.selectAll('line')
.attr('stroke', isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.7)')
.attr('stroke-width', 1)
// Add axes
svg.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${height})`)
.call(xAxis)
.append('text')
.attr('class', 'axis-label')
.attr('x', width / 2)
.attr('y', 36)
.attr('text-anchor', 'middle')
.text('Time (days)')
.attr('fill', 'currentColor')
svg.append('g')
.attr('class', 'y-axis')
.call(yAxis)
.append('text')
.attr('class', 'axis-label')
.attr('transform', 'rotate(-90)')
.attr('y', -40)
.attr('x', -height / 2)
.attr('text-anchor', 'middle')
.text('Memory Strength')
.attr('fill', 'currentColor')
}
function addDataLines(
svg: d3.Selection<SVGGElement, unknown, null, undefined>,
baselineData: DataPoint[],
emotionalData: DataPoint[],
joyData: DataPoint[],
aversionData: DataPoint[],
xScale: d3.ScaleLinear<number, number>,
yScale: d3.ScaleLinear<number, number>,
): void {
// Create line generator
const line = d3.line<DataPoint>()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.curve(d3.curveMonotoneX)
// Add baseline decay line
svg.append('path')
.datum(baselineData)
.attr('class', 'line baseline')
.attr('d', line)
.attr('fill', 'none')
.attr('stroke', 'rgba(156, 163, 175, 0.8)')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '5,3')
// Add joy component line (transparent fill under curve)
svg.append('path')
.datum(joyData)
.attr('class', 'line joy')
.attr('d', line)
.attr('fill', 'none')
.attr('stroke', 'rgba(250, 204, 21, 0.8)')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '3,2')
// Add aversion component line (transparent fill under curve)
svg.append('path')
.datum(aversionData)
.attr('class', 'line aversion')
.attr('d', line)
.attr('fill', 'none')
.attr('stroke', 'rgba(220, 38, 38, 0.8)')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '3,2')
// Add emotional effect line (main result)
svg.append('path')
.datum(emotionalData)
.attr('class', 'line emotional')
.attr('d', line)
.attr('fill', 'none')
.attr('stroke', 'rgba(59, 130, 246, 1)')
.attr('stroke-width', 3)
}
function addThresholdLine(
svg: d3.Selection<SVGGElement, unknown, null, undefined>,
position: number,
xScale: d3.ScaleLinear<number, number>,
height: number,
label: string,
color: string,
): void {
svg.append('line')
.attr('class', 'threshold-line')
.attr('x1', xScale(position))
.attr('y1', 0)
.attr('x2', xScale(position))
.attr('y2', height)
.attr('stroke', color)
.attr('stroke-width', 1)
.attr('stroke-dasharray', '5,5')
svg.append('text')
.attr('class', 'threshold-label')
.attr('x', xScale(position))
.attr('y', -8)
.attr('text-anchor', 'middle')
.attr('font-size', '12px')
.attr('fill', color)
.text(`${label}: ${position.toFixed(1)} days`)
}
function addMemoryTypeMarker(
svg: d3.Selection<SVGGElement, unknown, null, undefined>,
typeLabel: string,
color: string,
retrievalCount: number,
width: number,
): void {
svg.append('text')
.attr('class', 'memory-type-label')
.attr('x', width / 2)
.attr('y', -20)
.attr('text-anchor', 'middle')
.attr('font-size', '14px')
.attr('fill', color)
.text(`${typeLabel} (${retrievalCount} retrievals)`)
}
function addDataPoints(
svg: d3.Selection<SVGGElement, unknown, null, undefined>,
emotionalData: DataPoint[],
baselineData: DataPoint[],
xScale: d3.ScaleLinear<number, number>,
yScale: d3.ScaleLinear<number, number>,
): void {
// Add key data points for emotional line
svg.selectAll('.point-emotional')
.data(emotionalData.filter((d, i) => i === 0 || i === 1 || i % 5 === 0))
.enter()
.append('circle')
.attr('class', 'point-emotional')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', (d, i) => i < 2 ? 6 : 4)
.attr('fill', 'rgba(59, 130, 246, 0.8)')
.attr('stroke', 'white')
.attr('stroke-width', 2)
.on('mouseover', function (event, d) {
d3.select(this)
.transition()
.duration(200)
.attr('r', 8)
const baselinePoint = baselineData.find(bd => bd.x === d.x)
const baselineValue = baselinePoint ? Math.round(baselinePoint.y) : 'N/A'
tooltip.value
.style('opacity', 1)
.html(`
<div class="font-semibold">${d.label}</div>
<div>Total: ${Math.round(d.y)}</div>
<div>Base: ${baselineValue}</div>
<div>Day: ${Math.round(d.x)}</div>
`)
.style('left', `${event.offsetX + 15}px`)
.style('top', `${event.offsetY - 28}px`)
})
.on('mouseout', function () {
d3.select(this)
.transition()
.duration(200)
.attr('r', (d, i) => i < 2 ? 6 : 4)
tooltip.value
.transition()
.duration(200)
.style('opacity', 0)
})
// Add labels to important emotional points
svg.selectAll('.emotional-point-label')
.data(emotionalData.filter((d, i) => i === 0 || i === 1 || i === emotionalData.length - 1))
.enter()
.append('text')
.attr('class', 'emotional-point-label')
.attr('x', d => xScale(d.x))
.attr('y', d => yScale(d.y) - 15)
.attr('text-anchor', 'middle')
.attr('font-size', '12px')
.attr('font-weight', 'bold')
.attr('fill', 'rgba(59, 130, 246, 1)')
.text(d => Math.round(d.y))
}
function addTitleAndLegends(
svg: d3.Selection<SVGGElement, unknown, null, undefined>,
memoryId: string,
joyScore: number,
aversionScore: number,
retrievalCount: number,
width: number,
): void {
// Add emotional scores label
const joyPercent = Math.round(joyScore * 100)
const aversionPercent = Math.round(aversionScore * 100)
// Add legend
const legendData = [
{ label: 'Emotional Memory', color: 'rgba(59, 130, 246, 0.8)' },
{ label: 'Base Decay', color: 'rgba(156, 163, 175, 0.8)' },
{ label: `Joy Component (${joyPercent}%)`, color: 'rgba(250, 204, 21, 0.8)' },
{ label: `Aversion Component (${aversionPercent}%)`, color: 'rgba(220, 38, 38, 0.8)' },
]
const legend = svg.append('g')
.attr('class', 'legend')
.attr('transform', `translate(${width - 220}, 10)`)
const legendItems = legend.selectAll('.legend-item')
.data(legendData)
.enter()
.append('g')
.attr('class', 'legend-item')
.attr('transform', (d, i) => `translate(10, ${i * 20})`)
legendItems.append('line')
.attr('x1', 0)
.attr('y1', 8)
.attr('x2', 20)
.attr('y2', 8)
.attr('stroke', d => d.color)
.attr('stroke-width', 2)
.attr('stroke-dasharray', (d, i) => i === 0 ? '0' : i === 1 ? '5,3' : '3,2')
legendItems.append('text')
.attr('x', 25)
.attr('y', 12)
.attr('font-size', '12px')
.attr('fill', 'currentColor')
.text(d => d.label)
}
onMounted(() => {
// Setup resize observer
resizeObserver = new ResizeObserver(() => {
if (props.memoryData.length && props.selectedMemoryId) {
renderChart()
}
})
if (chartContainer.value) {
resizeObserver.observe(chartContainer.value)
}
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
})
</script>
<template>
<div class="rounded-lg bg-white p-4 shadow dark:bg-neutral-800/50">
<h2 class="mb-4 text-lg font-semibold">
Emotional Memory Projection
</h2>
<!-- D3.js chart container -->
<div ref="chartContainer" class="relative h-[800px] w-full">
<!-- D3 will render the chart here -->
</div>
</div>
</template>
<style scoped>
/* D3 Chart Styles */
:deep(.axis-label) {
font-size: 12px;
}
:deep(.x-axis path),
:deep(.y-axis path),
:deep(.x-axis line),
:deep(.y-axis line),
:deep(.domain) {
stroke: #dce0e3;
}
.dark {
:deep(.x-axis path),
:deep(.y-axis path),
:deep(.x-axis line),
:deep(.y-axis line),
:deep(.domain) {
stroke: #374151;
}
}
</style>
@@ -0,0 +1,339 @@
<script setup lang="ts">
import type { EmotionalMemoryItem } from '../../types/memory/emotional-memory'
import { computed } from 'vue'
const props = defineProps<{
memory: EmotionalMemoryItem
longTermThreshold: number
muscleMemoryThreshold: number
}>()
const emit = defineEmits(['retrieve'])
// Calculate age in days
const ageInDays = computed(() => {
return Math.round(props.memory.age_in_seconds / (24 * 60 * 60))
})
// Calculate time since last retrieval
const daysSinceRetrieved = computed(() => {
return Math.round(props.memory.time_since_retrieval / (24 * 60 * 60))
})
// Calculate memory status
const memoryStatus = computed(() => {
if (props.memory.retrieval_count >= props.muscleMemoryThreshold) {
return {
type: 'muscle-memory',
label: 'Muscle Memory',
color: 'text-purple-600 dark:text-purple-400',
}
}
else if (props.memory.retrieval_count >= props.longTermThreshold) {
return {
type: 'long-term',
label: 'Long-term Memory',
color: 'text-indigo-600 dark:text-indigo-400',
}
}
else if (props.memory.retrieval_count > 0) {
return {
type: 'working',
label: 'Working Memory',
color: 'text-blue-600 dark:text-blue-400',
}
}
else {
return {
type: 'short-term',
label: 'Short-term Memory',
color: 'text-red-600 dark:text-red-400',
}
}
})
// Joy and aversion levels as percentage
const joyPercentage = computed(() => {
return Math.round(props.memory.joy_score * 100)
})
const aversionPercentage = computed(() => {
return Math.round(props.memory.aversion_score * 100)
})
// Progress percentage for memory strength
const strengthPercentage = computed(() => {
return Math.min(100, Math.round((props.memory.decayed_score / props.memory.score) * 100))
})
// Progress towards long-term memory
const ltmPercentage = computed(() => {
if (props.memory.retrieval_count >= props.longTermThreshold) {
return 100
}
return Math.round((props.memory.retrieval_count / props.longTermThreshold) * 100)
})
// Progress towards muscle memory
const musclePercentage = computed(() => {
if (props.memory.retrieval_count >= props.muscleMemoryThreshold) {
return 100
}
if (props.memory.retrieval_count < props.longTermThreshold) {
return 0
}
return Math.round(((props.memory.retrieval_count - props.longTermThreshold)
/ (props.muscleMemoryThreshold - props.longTermThreshold)) * 100)
})
// Emotional effect on memory score
const emotionalMultiplier = computed(() => {
// This is a simplified calculation - would match your SQL formula
const joyEffect = props.memory.joy_score > 0 ? props.memory.joy_score : 0
const aversionEffect = props.memory.aversion_score > 0 ? props.memory.aversion_score : 0
const combined = 1 + joyEffect + aversionEffect
return Math.round(combined * 100)
})
function simulateRetrieval(emotionalResponse = null) {
if (emotionalResponse === 'joy') {
emit('retrieve', props.memory.id, { joyModifier: 0.1, aversionModifier: -0.05 })
}
else if (emotionalResponse === 'aversion') {
emit('retrieve', props.memory.id, { joyModifier: -0.05, aversionModifier: 0.1 })
}
else if (emotionalResponse === 'neutral') {
emit('retrieve', props.memory.id, { joyModifier: 0, aversionModifier: 0 })
}
else if (emotionalResponse === 'strong-joy') {
emit('retrieve', props.memory.id, { joyModifier: 0.2, aversionModifier: -0.1 })
}
else if (emotionalResponse === 'strong-aversion') {
emit('retrieve', props.memory.id, { joyModifier: -0.1, aversionModifier: 0.2 })
}
}
</script>
<template>
<div class="flex flex-col justify-between rounded-lg bg-white p-4 shadow dark:bg-neutral-800/50">
<div class="mb-4 flex justify-between">
<h3 class="text-xl font-bold">
{{ memory.id }}
</h3>
<span
class="inline-flex items-center rounded-lg px-2 py-1 text-xs font-medium"
:class="{
'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300': memoryStatus.type === 'muscle-memory',
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300': memoryStatus.type === 'long-term',
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300': memoryStatus.type === 'working',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300': memoryStatus.type === 'short-term',
}"
>
{{ memoryStatus.label }}
</span>
</div>
<div class="grid grid-cols-2 mb-4 gap-3 text-sm font-mono">
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Base Score:
</div>
<div class="text-right font-medium">
{{ Math.round(memory.score) }}
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Current Score:
</div>
<div class="text-right font-medium">
{{ Math.round(memory.decayed_score) }}
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Emotional Effect:
</div>
<div class="text-right font-medium">
{{ emotionalMultiplier }}%
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Retrieval Count:
</div>
<div class="text-right font-medium">
{{ memory.retrieval_count }}
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Memory Age:
</div>
<div class="text-right font-medium">
{{ ageInDays }} days
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Last Retrieved:
</div>
<div class="text-right font-medium">
{{ daysSinceRetrieved > 0 ? `${daysSinceRetrieved} days ago` : 'Today' }}
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Joy Score:
</div>
<div
class="text-right font-medium"
:class="joyPercentage > 50 ? 'text-yellow-500 dark:text-yellow-400' : ''"
>
{{ joyPercentage }}%
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Aversion Score:
</div>
<div
class="text-right font-medium"
:class="aversionPercentage > 50 ? 'text-red-500 dark:text-red-400' : ''"
>
{{ aversionPercentage }}%
</div>
</div>
<!-- Emotional retrieval buttons -->
<div class="mb-6">
<h4 class="mb-2 text-sm text-neutral-500 font-medium dark:text-neutral-400">
Simulate Retrieval with Emotion:
</h4>
<div class="grid grid-cols-3 gap-2">
<button
class="rounded-lg bg-yellow-100 px-3 py-2 text-sm font-medium dark:bg-yellow-900/50 hover:bg-yellow-200 dark:hover:bg-yellow-900"
@click="simulateRetrieval('strong-joy')"
>
Very Joyful
</button>
<button
class="rounded-lg bg-yellow-50 px-3 py-2 text-sm font-medium dark:bg-yellow-900/20 hover:bg-yellow-100 dark:hover:bg-yellow-900/40"
@click="simulateRetrieval('joy')"
>
Mild Joy
</button>
<button
class="rounded-lg bg-neutral-100 px-3 py-2 text-sm font-medium dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700"
@click="simulateRetrieval('neutral')"
>
Neutral
</button>
<button
class="rounded-lg bg-red-50 px-3 py-2 text-sm font-medium dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40"
@click="simulateRetrieval('aversion')"
>
Mild Aversion
</button>
<button
class="rounded-lg bg-red-100 px-3 py-2 text-sm font-medium dark:bg-red-900/50 hover:bg-red-200 dark:hover:bg-red-900"
@click="simulateRetrieval('strong-aversion')"
>
Strong Aversion
</button>
</div>
</div>
<!-- Visual representation of memory state -->
<div>
<!-- Memory Strength Progress -->
<div class="mt-4 font-mono">
<div class="mb-1 text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Current Memory Strength:
</div>
<div class="h-7 overflow-hidden rounded-lg bg-neutral-200 dark:bg-neutral-600">
<div
class="h-full max-w-full transition-all duration-500"
:class="{
'bg-purple-400': memoryStatus.type === 'muscle-memory',
'bg-indigo-500': memoryStatus.type === 'long-term',
'bg-blue-500': memoryStatus.type === 'working',
'bg-red-500': memoryStatus.type === 'short-term',
}"
:style="`width: ${strengthPercentage}%`"
/>
</div>
<div class="mt-1 flex justify-between text-xs">
<span>0%</span>
<span>{{ Math.round(memory.score / 2) }}</span>
<span>{{ Math.round(memory.score) }}</span>
</div>
</div>
<!-- Memory Formation Progress -->
<div class="mt-4 font-mono">
<div class="mb-1 text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Long-term Memory Progress:
</div>
<div class="h-7 overflow-hidden rounded-lg bg-neutral-200 dark:bg-neutral-600">
<div
class="h-full max-w-full bg-indigo-400 transition-all duration-500"
:style="`width: ${ltmPercentage}%`"
/>
</div>
<div class="mt-1 flex items-center justify-between text-xs">
<span>Working</span>
<span v-if="memory.retrieval_count < longTermThreshold" class="text-sm font-medium">
{{ memory.retrieval_count }}/{{ longTermThreshold }} retrievals
</span>
<span v-else class="text-sm text-indigo-600 font-medium dark:text-indigo-400">
LTM Stabled
</span>
<span>Long-term</span>
</div>
</div>
<!-- Muscle Memory Progress -->
<div class="mt-4 font-mono">
<div class="mb-1 text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Muscle Memory Progress:
</div>
<div class="h-7 overflow-hidden rounded-lg bg-neutral-200 dark:bg-neutral-600">
<div
class="h-full max-w-full bg-purple-400 transition-all duration-500"
:style="`width: ${musclePercentage}%`"
/>
</div>
<div class="mt-1 flex items-center justify-between text-xs">
<span>Conscious</span>
<span v-if="memory.retrieval_count < muscleMemoryThreshold" class="text-sm font-medium">
{{ memory.retrieval_count }}/{{ muscleMemoryThreshold }} retrievals
</span>
<span v-else class="text-sm text-purple-600 font-medium dark:text-purple-400">
MM formed
</span>
<span>Automatic</span>
</div>
</div>
<!-- Emotional Components -->
<div class="grid grid-cols-2 mt-4 gap-4">
<div>
<div class="mb-1 text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Joy Factor:
</div>
<div class="h-7 overflow-hidden rounded-lg bg-neutral-200 dark:bg-neutral-600">
<div
class="h-full max-w-full bg-yellow-400 transition-all duration-500"
:style="`width: ${joyPercentage}%`"
/>
</div>
</div>
<div>
<div class="mb-1 text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Aversion Factor:
</div>
<div class="h-7 overflow-hidden rounded-lg bg-neutral-200 dark:bg-neutral-600">
<div
class="h-full max-w-full bg-red-400 transition-all duration-500"
:style="`width: ${aversionPercentage}%`"
/>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
memory: any
longTermMemoryThreshold: number
muscleMemoryThreshold: number
}>()
const emit = defineEmits(['retrieve'])
// Joy and aversion values
const joyLevel = computed(() => {
return props.memory.joy_score || 0
})
const aversionLevel = computed(() => {
return props.memory.aversion_score || 0
})
// Calculate age in days
const ageInDays = computed(() => {
return Math.round(props.memory.age_in_seconds / (24 * 60 * 60))
})
// Calculate memory status
const memoryStatus = computed(() => {
if (props.memory.retrieval_count >= props.muscleMemoryThreshold) {
return {
type: 'muscle-memory',
label: 'Muscle Memory',
color: 'text-purple-600 dark:text-purple-400',
}
}
else if (props.memory.retrieval_count >= props.longTermMemoryThreshold) {
return {
type: 'long-term',
label: 'Long-term',
color: 'text-indigo-600 dark:text-indigo-400',
}
}
else if (props.memory.retrieval_count > 0) {
return {
type: 'working',
label: 'Working',
color: 'text-blue-600 dark:text-blue-400',
}
}
else {
return {
type: 'short-term',
label: 'Short-term',
color: 'text-red-600 dark:text-red-400',
}
}
})
// Progress percentage for memory strength
const strengthPercentage = computed(() => {
return Math.round((props.memory.decayed_score / props.memory.score) * 100)
})
// Progress towards long-term memory
const ltmPercentage = computed(() => {
if (props.memory.retrieval_count >= props.longTermMemoryThreshold) {
return 100
}
return Math.round((props.memory.retrieval_count / props.longTermMemoryThreshold) * 100)
})
// Progress towards muscle memory
const musclePercentage = computed(() => {
if (props.memory.retrieval_count >= props.muscleMemoryThreshold) {
return 100
}
return Math.round((props.memory.retrieval_count / props.muscleMemoryThreshold) * 100)
})
function simulateRetrieval(emotionalResponse = null) {
if (emotionalResponse === 'joy') {
emit('retrieve', props.memory.id, { joyModifier: 0.1, aversionModifier: -0.05 })
}
else if (emotionalResponse === 'aversion') {
emit('retrieve', props.memory.id, { joyModifier: -0.05, aversionModifier: 0.1 })
}
else {
emit('retrieve', props.memory.id, { joyModifier: 0, aversionModifier: 0 })
}
}
</script>
<template>
<div class="flex flex-col justify-between rounded-lg bg-white p-4 shadow dark:bg-neutral-800/50">
<div class="mb-4 flex justify-between">
<h3 class="font-bold">
{{ memory.id }}
</h3>
<div class="flex gap-2">
<button
class="rounded-lg bg-blue-100 px-3 py-1 text-xs font-medium dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800" h-fit
@click="simulateRetrieval()"
>
Neutral Retrieval
</button>
</div>
</div>
<div class="grid grid-cols-2 mb-4 gap-3" text-sm font-mono>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Memory Phase:
</div>
<div class="font-medium" text-right text-nowrap :class="memoryStatus.color">
{{ memoryStatus.label }}
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Base Score:
</div>
<div class="font-medium" text-right>
{{ Math.round(memory.score) }}
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Current Score:
</div>
<div class="font-medium" text-right>
{{ Math.round(memory.decayed_score) }}
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Retrieval Count:
</div>
<div class="font-medium" text-right>
{{ memory.retrieval_count }}
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Age:
</div>
<div class="font-medium" text-right>
{{ ageInDays }} days
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Joy Score:
</div>
<div class="font-medium" text-right :class="joyLevel > 0.5 ? 'text-yellow-500' : ''">
{{ (joyLevel * 100).toFixed(0) }}%
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Aversion Score:
</div>
<div class="font-medium" text-right :class="aversionLevel > 0.5 ? 'text-red-500' : ''">
{{ (aversionLevel * 100).toFixed(0) }}%
</div>
</div>
<!-- Emotional response buttons -->
<div class="grid grid-cols-2 mb-4 gap-2">
<button
class="rounded-lg bg-yellow-100 px-3 py-2 text-sm font-medium dark:bg-yellow-900 hover:bg-yellow-200 dark:hover:bg-yellow-800"
@click="simulateRetrieval('joy')"
>
Joyful Retrieval (+0.1)
</button>
<button
class="rounded-lg bg-red-100 px-3 py-2 text-sm font-medium dark:bg-red-900 hover:bg-red-200 dark:hover:bg-red-800"
@click="simulateRetrieval('aversion')"
>
Aversive Retrieval (+0.1)
</button>
</div>
<!-- Visual representation of current state -->
<div>
<!-- Memory Strength Progress -->
<div class="mt-4" font-mono>
<div class="mb-1 text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Memory Strength:
</div>
<div class="h-7 overflow-hidden rounded-lg bg-neutral-200 dark:bg-neutral-600">
<div
class="h-full max-w-full transition-all duration-500"
:class="memoryStatus.type === 'muscle-memory' ? 'bg-purple-400' : memoryStatus.type === 'long-term' ? 'bg-indigo-500' : 'bg-blue-500'"
:style="`width: ${strengthPercentage}%`"
/>
</div>
<div class="mt-1 flex justify-between text-xs">
<span>0%</span>
<span>50%</span>
<span>100%</span>
</div>
</div>
<!-- Long-term Memory Progress -->
<div class="mt-4" font-mono>
<div class="mb-1 text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Long-term Memory Progress:
</div>
<div class="h-7 overflow-hidden rounded-lg bg-neutral-200 dark:bg-neutral-600">
<div
class="h-full max-w-full bg-indigo-400 transition-all duration-500"
:style="`width: ${ltmPercentage}%`"
/>
</div>
<div class="mt-1 flex items-center justify-between text-xs">
<span>Short-term</span>
<span v-if="memory.retrieval_count < longTermMemoryThreshold" class="text-sm font-medium">
{{ memory.retrieval_count }}/{{ longTermMemoryThreshold }} retrievals
</span>
<span v-else class="text-sm text-indigo-600 font-medium dark:text-indigo-400">
LTM formed
</span>
<span>Long-term</span>
</div>
</div>
<!-- Muscle Memory Progress -->
<div class="mt-4" font-mono>
<div class="mb-1 text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Muscle Memory Progress:
</div>
<div class="h-7 overflow-hidden rounded-lg bg-neutral-200 dark:bg-neutral-600">
<div
class="h-full max-w-full bg-purple-400 transition-all duration-500"
:style="`width: ${musclePercentage}%`"
/>
</div>
<div class="mt-1 flex items-center justify-between text-xs">
<span>Conscious</span>
<span v-if="memory.retrieval_count < muscleMemoryThreshold" class="text-sm font-medium">
{{ memory.retrieval_count }}/{{ muscleMemoryThreshold }} retrievals
</span>
<span v-else class="text-sm text-purple-600 font-medium dark:text-purple-400">
MM formed
</span>
<span>Automatic</span>
</div>
</div>
<!-- Emotional Components -->
<div class="grid grid-cols-2 mt-4 gap-4">
<div>
<div class="mb-1 text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Joy Factor:
</div>
<div class="h-7 overflow-hidden rounded-lg bg-neutral-200 dark:bg-neutral-600">
<div
class="h-full max-w-full bg-yellow-400 transition-all duration-500"
:style="`width: ${joyLevel * 100}%`"
/>
</div>
</div>
<div>
<div class="mb-1 text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Aversion Factor:
</div>
<div class="h-7 overflow-hidden rounded-lg bg-neutral-200 dark:bg-neutral-600">
<div
class="h-full max-w-full bg-red-400 transition-all duration-500"
:style="`width: ${aversionLevel * 100}%`"
/>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed } from 'vue'
import Range from '../Range.vue'
const joyBoostFactor = defineModel<number>('joyBoostFactor', { default: 1.5 })
const joyDecaySteepness = defineModel<number>('joyDecaySteepness', { default: 3.0 })
const aversionSpikeFactor = defineModel<number>('aversionSpikeFactor', { default: 2.0 })
const aversionStability = defineModel<number>('aversionStability', { default: 1.2 })
const randomRecallProbability = defineModel<number>('randomRecallProbability', { default: 0.05 })
const flashbackIntensity = defineModel<number>('flashbackIntensity', { default: 2.0 })
// Computed percent values for display
const randomRecallPercent = computed(() => (randomRecallProbability.value * 100).toFixed(0))
</script>
<template>
<div>
<div class="my-4 rounded-lg bg-neutral-100 dark:bg-neutral-800/50">
<h2 class="mb-2 text-lg font-semibold">
Emotional Memory Parameters
</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Joy/Euphoria Settings -->
<div class="flex flex-col gap-2">
<label class="font-medium">Joy Boost Factor</label>
<div class="flex items-center gap-2">
<Range
v-model="joyBoostFactor"
:min="0.1"
:max="3.0"
:step="0.1"
class="w-full"
/>
<span class="w-16 text-right font-mono">{{ joyBoostFactor.toFixed(1) }}x</span>
</div>
<div class="text-xs text-neutral-500">
How much joy increases memory strength
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium">Joy Decay Steepness</label>
<div class="flex items-center gap-2">
<Range
v-model="joyDecaySteepness"
:min="0.5"
:max="5.0"
:step="0.1"
class="w-full"
/>
<span class="w-16 text-right font-mono">{{ joyDecaySteepness.toFixed(1) }}</span>
</div>
<div class="text-xs text-neutral-500">
How quickly joy effect fades (higher = faster)
</div>
</div>
<!-- Aversion/Trauma Settings -->
<div class="flex flex-col gap-2">
<label class="font-medium">Aversion Spike Factor</label>
<div class="flex items-center gap-2">
<Range
v-model="aversionSpikeFactor"
:min="0.1"
:max="5.0"
:step="0.1"
class="w-full"
/>
<span class="w-16 text-right font-mono">{{ aversionSpikeFactor.toFixed(1) }}x</span>
</div>
<div class="text-xs text-neutral-500">
How strongly aversive memories spike in recall
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium">Aversion Stability</label>
<div class="flex items-center gap-2">
<Range
v-model="aversionStability"
:min="1.0"
:max="1.5"
:step="0.05"
class="w-full"
/>
<span class="w-16 text-right font-mono">{{ aversionStability.toFixed(2) }}</span>
</div>
<div class="text-xs text-neutral-500">
How persistent aversive memories become (PTSD-like)
</div>
</div>
<!-- Random Recall/Flashback -->
<div class="flex flex-col gap-2">
<label class="font-medium">Random Recall Probability</label>
<div class="flex items-center gap-2">
<Range
v-model="randomRecallProbability"
:min="0"
:max="0.3"
:step="0.01"
class="w-full"
/>
<span class="w-16 text-right font-mono">{{ randomRecallPercent }}%</span>
</div>
<div class="text-xs text-neutral-500">
Chance of random memory flashbacks
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium">Flashback Intensity</label>
<div class="flex items-center gap-2">
<Range
v-model="flashbackIntensity"
:min="1.0"
:max="5.0"
:step="0.1"
class="w-full"
/>
<span class="w-16 text-right font-mono">{{ flashbackIntensity.toFixed(1) }}x</span>
</div>
<div class="text-xs text-neutral-500">
How strong random memory flashbacks can be
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,487 @@
<script setup lang="ts">
import type { EmotionalMemoryItem } from '../../types/memory/emotional-memory'
import * as d3 from 'd3'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
const props = defineProps<{
memoryData: EmotionalMemoryItem[]
simulatedTimeOffset: number
timeRange: number // days to display in the heatmap
}>()
interface HeatmapCell {
id: string
day: number
value: number
isRetrieval: boolean
isRandom: boolean
joyScore: number
aversionScore: number
}
const heatmapContainer = ref<HTMLDivElement>()
const tooltip = ref<d3.Selection<HTMLDivElement, unknown, null, undefined>>()
let resizeObserver: ResizeObserver | undefined
// Add mode toggle
const viewMode = ref<'patterns' | 'counts'>('patterns')
watchEffect(() => {
if (heatmapContainer.value && props.memoryData.length > 0) {
viewMode.value === 'patterns' ? renderHeatmap() : renderCountHeatmap()
}
})
function renderHeatmap() {
// Clear existing chart
d3.select(heatmapContainer.value).selectAll('*').remove()
// Get container dimensions
const containerRect = heatmapContainer.value.getBoundingClientRect()
const containerWidth = containerRect.width
const containerHeight = 300 // Fixed height for the heatmap
// Set chart margins
const margin = { top: 30, right: 20, bottom: 60, left: 120 }
const width = containerWidth - margin.left - margin.right
const height = containerHeight - margin.top - margin.bottom
// Prepare data for heatmap
const heatmapData: HeatmapCell[] = []
// Generate heatmap data from memory items
for (const memory of props.memoryData) {
// const ageInDays = Math.floor(memory.age_in_seconds / (24 * 60 * 60))
const retrievalDaysAgo = Math.floor(memory.time_since_retrieval / (24 * 60 * 60))
// Only consider memories retrieved within the time range
if (retrievalDaysAgo <= props.timeRange) {
// For each memory, add a heatmap cell for the retrieval day
heatmapData.push({
id: memory.id,
day: props.timeRange - retrievalDaysAgo,
value: 1, // Strength of retrieval
isRetrieval: true,
isRandom: Math.random() < 0.2, // Simulate random recall events (20% chance)
joyScore: memory.joy_score,
aversionScore: memory.aversion_score,
})
// Add additional cells for fade trail of past retrievals
const fadeLength = 5 // Days of fade trail
for (let i = 1; i <= fadeLength; i++) {
if (props.timeRange - retrievalDaysAgo - i >= 0) {
heatmapData.push({
id: memory.id,
day: props.timeRange - retrievalDaysAgo - i,
value: 0.2 * (fadeLength - i + 1) / fadeLength, // Decreasing intensity
isRetrieval: false,
isRandom: false,
joyScore: memory.joy_score * 0.5, // Reduced emotional impact
aversionScore: memory.aversion_score * 0.5,
})
}
}
}
}
// Create tooltip if it doesn't exist
if (!tooltip.value) {
tooltip.value = d3.select(heatmapContainer.value)
.append('div')
.attr('class', 'absolute pointer-events-none transition-opacity duration-200 bg-white dark:bg-neutral-800 p-2 rounded shadow-md text-sm z-10')
.style('opacity', 0)
}
// Create SVG
const svg = d3.select(heatmapContainer.value)
.append('svg')
.attr('width', containerWidth)
.attr('height', containerHeight)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
// Extract unique memory IDs
const memoryIds = Array.from(new Set(props.memoryData.map(m => m.id)))
// Set up scales
const xScale = d3.scaleLinear()
.domain([0, props.timeRange])
.range([0, width])
const yScale = d3.scaleBand()
.domain(memoryIds)
.range([0, height])
.padding(0.1)
const colorScale = d3.scaleSequential()
.domain([0, 1])
.interpolator(d3.interpolateBlues)
// Create x-axis (time)
const xAxis = d3.axisBottom(xScale)
.ticks(Math.min(props.timeRange, 10))
svg.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${height})`)
.call(xAxis)
.selectAll('text')
.attr('transform', 'rotate(-45)')
.style('text-anchor', 'end')
// Create y-axis (memory IDs)
const yAxis = d3.axisLeft(yScale)
svg.append('g')
.attr('class', 'y-axis')
.call(yAxis)
// Create heatmap cells
svg.selectAll('.heatmap-cell')
.data(heatmapData)
.enter()
.append('rect')
.attr('class', 'heatmap-cell')
.attr('x', d => xScale(d.day))
.attr('y', d => yScale(d.id))
.attr('width', _d => Math.max(2, width / props.timeRange)) // Ensure cells are visible
.attr('height', yScale.bandwidth())
.attr('fill', (d) => {
if (d.isRetrieval) {
// Create color based on emotional components
if (d.joyScore > 0.5)
return d3.interpolateYlOrRd(d.joyScore)
if (d.aversionScore > 0.5)
return d3.interpolatePurples(d.aversionScore)
return colorScale(d.value)
}
else {
// Fade trails
return colorScale(d.value)
}
})
.attr('stroke', d => d.isRandom ? 'rgba(255, 0, 0, 0.5)' : 'none')
.attr('stroke-width', 1)
.on('mouseover', function (event, d) {
d3.select(this)
.attr('stroke', '#fff')
.attr('stroke-width', 2)
let tooltipContent = `
<div class="font-semibold">${d.id}</div>
<div>${d.isRetrieval ? 'Retrieval' : 'Echo'}</div>
<div>${props.timeRange - d.day} days ago</div>
`
if (d.isRetrieval) {
tooltipContent += `
<div>Joy: ${Math.round(d.joyScore * 100)}%</div>
<div>Aversion: ${Math.round(d.aversionScore * 100)}%</div>
${d.isRandom ? '<div class="text-red-500">Random Recall</div>' : ''}
`
}
tooltip.value
.style('opacity', 1)
.html(tooltipContent)
.style('left', `${event.offsetX + 10}px`)
.style('top', `${event.offsetY - 15}px`)
})
.on('mouseout', function () {
d3.select(this)
// @ts-expect-error - d is of type HeatmapCell
.attr('stroke', d => d.isRandom ? 'rgba(255, 0, 0, 0.5)' : 'none')
// @ts-expect-error - d is of type HeatmapCell
.attr('stroke-width', d => d.isRandom ? 1 : 0)
tooltip.value
.style('opacity', 0)
})
// Add legend
const legend = svg.append('g')
.attr('class', 'legend')
.attr('transform', `translate(${width - 120}, -30)`)
// Legend items
const legendItems = [
{ label: 'Retrieval', color: colorScale(1) },
{ label: 'Joy', color: d3.interpolateYlOrRd(0.8) },
{ label: 'Aversion', color: d3.interpolatePurples(0.8) },
{ label: 'Random', color: 'rgba(70, 130, 180, 0.8)', stroke: 'rgba(255, 0, 0, 0.5)' },
]
legendItems.forEach((item, i) => {
const g = legend.append('g')
.attr('transform', `translate(${i * 70}, 0)`)
g.append('rect')
.attr('width', 15)
.attr('height', 15)
.attr('fill', item.color)
.attr('stroke', item.stroke || 'none')
.attr('stroke-width', item.stroke ? 1 : 0)
g.append('text')
.attr('x', 20)
.attr('y', 12)
.attr('font-size', '10px')
.attr('fill', 'currentColor')
.text(item.label)
})
}
// New function to render the count heatmap
function renderCountHeatmap() {
// Clear existing chart
d3.select(heatmapContainer.value).selectAll('*').remove()
// Get container dimensions
const containerRect = heatmapContainer.value.getBoundingClientRect()
const containerWidth = containerRect.width
const containerHeight = 300 // Fixed height for the heatmap
// Set chart margins
const margin = { top: 30, right: 20, bottom: 60, left: 120 }
const width = containerWidth - margin.left - margin.right
const height = containerHeight - margin.top - margin.bottom
// Create tooltip if it doesn't exist
if (!tooltip.value) {
tooltip.value = d3.select(heatmapContainer.value)
.append('div')
.attr('class', 'absolute pointer-events-none transition-opacity duration-200 bg-white dark:bg-neutral-800 p-2 rounded shadow-md text-sm z-10')
.style('opacity', 0)
}
// Create SVG
const svg = d3.select(heatmapContainer.value)
.append('svg')
.attr('width', containerWidth)
.attr('height', containerHeight)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
// Extract unique memory IDs
const memoryIds = Array.from(new Set(props.memoryData.map(m => m.id)))
// Get retrieval count data
const countData = memoryIds.map((id) => {
const memory = props.memoryData.find(m => m.id === id)
return {
id,
count: memory ? memory.retrieval_count : 0,
joyScore: memory ? memory.joy_score : 0,
aversionScore: memory ? memory.aversion_score : 0,
}
}).sort((a, b) => b.count - a.count) // Sort by count (highest first)
// Get sorted IDs for the y-axis
const sortedIds = countData.map(d => d.id)
// Set up scales
const yScale = d3.scaleBand()
.domain(sortedIds)
.range([0, height])
.padding(0.1)
// Find max count for color scale
const maxCount = d3.max(countData, d => d.count) || 1
// Create color scale for counts
const colorScale = d3.scaleSequential()
.domain([0, maxCount])
.interpolator(d3.interpolateReds)
// Create bar width scale
const xScale = d3.scaleLinear()
.domain([0, maxCount])
.range([0, width - 50]) // Leave space for count text
// Create y-axis (memory IDs)
const yAxis = d3.axisLeft(yScale)
svg.append('g')
.attr('class', 'y-axis')
.call(yAxis)
// Create count bars
const bars = svg.selectAll('.count-bar')
.data(countData)
.enter()
.append('g')
.attr('class', 'count-bar')
.attr('transform', d => `translate(0, ${yScale(d.id)})`)
// Add bars
bars.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', d => xScale(d.count))
.attr('height', yScale.bandwidth())
.attr('fill', (d) => {
// Color based on emotional components and count
if (d.joyScore > 0.5 && d.count > 0)
return d3.interpolateYlOrRd(Math.min(1, d.joyScore * (d.count / maxCount)))
if (d.aversionScore > 0.5 && d.count > 0)
return d3.interpolatePurples(Math.min(1, d.aversionScore * (d.count / maxCount)))
return colorScale(d.count)
})
.attr('stroke', 'rgba(255, 255, 255, 0.3)')
.attr('stroke-width', 1)
.on('mouseover', function (event, d) {
d3.select(this)
.attr('stroke', '#fff')
.attr('stroke-width', 2)
const tooltipContent = `
<div class="font-semibold">${d.id}</div>
<div>Retrievals: ${d.count}</div>
<div>Joy: ${Math.round(d.joyScore * 100)}%</div>
<div>Aversion: ${Math.round(d.aversionScore * 100)}%</div>
`
tooltip.value
.style('opacity', 1)
.html(tooltipContent)
.style('left', `${event.offsetX + 10}px`)
.style('top', `${event.offsetY - 15}px`)
})
.on('mouseout', function () {
d3.select(this)
.attr('stroke', 'rgba(255, 255, 255, 0.3)')
.attr('stroke-width', 1)
tooltip.value
.style('opacity', 0)
})
// Add count labels
bars.append('text')
.attr('x', d => xScale(d.count) + 5)
.attr('y', yScale.bandwidth() / 2 + 5)
.attr('fill', 'currentColor')
.attr('font-size', '12px')
.attr('font-weight', 'bold')
.text(d => d.count)
// Add title
svg.append('text')
.attr('x', width / 2)
.attr('y', -10)
.attr('text-anchor', 'middle')
.attr('font-size', '14px')
.attr('font-weight', 'bold')
.attr('fill', 'currentColor')
.text('Memory Retrieval Counts')
// Add legend
const legend = svg.append('g')
.attr('class', 'legend')
.attr('transform', `translate(${width - 120}, -30)`)
// Legend items
const legendItems = [
{ label: 'Low', color: colorScale(maxCount * 0.2) },
{ label: 'Medium', color: colorScale(maxCount * 0.5) },
{ label: 'High', color: colorScale(maxCount * 0.8) },
{ label: 'Joy Impact', color: d3.interpolateYlOrRd(0.8) },
{ label: 'Aversion', color: d3.interpolatePurples(0.8) },
]
legendItems.forEach((item, i) => {
const g = legend.append('g')
.attr('transform', `translate(${(i % 3) * 70}, ${Math.floor(i / 3) * 20})`)
g.append('rect')
.attr('width', 15)
.attr('height', 15)
.attr('fill', item.color)
g.append('text')
.attr('x', 20)
.attr('y', 12)
.attr('font-size', '10px')
.attr('fill', 'currentColor')
.text(item.label)
})
}
onMounted(() => {
// Setup resize observer
resizeObserver = new ResizeObserver(() => {
if (props.memoryData.length > 0) {
viewMode.value === 'patterns' ? renderHeatmap() : renderCountHeatmap()
}
})
if (heatmapContainer.value) {
resizeObserver.observe(heatmapContainer.value)
}
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
})
</script>
<template>
<div class="rounded-lg bg-white p-4 shadow dark:bg-neutral-800/50">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">
Memory Retrieval Analysis
</h2>
<div class="flex rounded-lg bg-neutral-100 dark:bg-neutral-700">
<button
class="px-3 py-1 text-sm font-medium transition-colors"
:class="viewMode === 'patterns' ? 'bg-blue-500 text-white rounded-lg' : 'text-neutral-600 dark:text-neutral-300'"
@click="viewMode = 'patterns'"
>
Patterns
</button>
<button
class="px-3 py-1 text-sm font-medium transition-colors"
:class="viewMode === 'counts' ? 'bg-blue-500 text-white rounded-lg' : 'text-neutral-600 dark:text-neutral-300'"
@click="viewMode = 'counts'"
>
Counts
</button>
</div>
</div>
<div ref="heatmapContainer" class="relative h-[300px] w-full">
<!-- D3.js will render the heatmap here -->
</div>
<div v-if="viewMode === 'patterns'" class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
Heatmap shows when memories were retrieved, with color intensity showing emotional impact.
Red outlines indicate possible random "flashback" retrievals.
</div>
<div v-else class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
Bar chart shows total retrieval counts for each memory, sorted by frequency.
Color intensity and hue indicate emotional impact.
</div>
</div>
</template>
<style scoped>
:deep(.x-axis path),
:deep(.y-axis path),
:deep(.x-axis line),
:deep(.y-axis line),
:deep(.domain) {
stroke: #dce0e3;
}
.dark {
:deep(.x-axis path),
:deep(.y-axis path),
:deep(.x-axis line),
:deep(.y-axis line),
:deep(.domain) {
stroke: #374151;
}
}
</style>
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref } from 'vue'
const props = withDefaults(defineProps<{
modelValue: number
min?: number
max?: number
step?: number
@@ -20,20 +19,19 @@ const props = withDefaults(defineProps<{
trackValueColor: 'red',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()
const modelValue = defineModel<number>('modelValue', { required: true })
const scaledMin = computed(() => props.min * 10000)
const scaledMax = computed(() => props.max * 10000)
const scaledStep = computed(() => props.step * 10000)
const sliderRef = ref<HTMLInputElement>()
const sliderValue = ref(props.modelValue * 10000)
watch(sliderValue, (value) => {
emit('update:modelValue', value / 10000)
updateTrackColor()
const sliderValue = computed({
get: () => modelValue.value * 10000,
set: (value: number) => {
modelValue.value = value / 10000
updateTrackColor()
},
})
onMounted(() => {
@@ -113,3 +113,150 @@ export async function simulateRetrieval(db, storyId, simulatedTime) {
WHERE id = '${storyId}'
`)
}
export async function generateEmotionalDecayQuery(db, {
simulatedTimeOffset,
decayRate,
timeUnitInSeconds,
longTermMemoryEnabled,
longTermMemoryThreshold,
longTermMemoryStability,
retrievalBoost,
retrievalDecaySlowdown,
joyBoostFactor,
joyDecaySteepness,
aversionSpikeFactor,
aversionStability,
randomRecallProbability,
flashbackIntensity,
}) {
const simulatedTimestamp = `(CAST(now() AS TIMESTAMP) + INTERVAL '${simulatedTimeOffset} seconds')`
const ltmFactorClause = longTermMemoryEnabled
? `
CASE
WHEN retrieval_count >= ${longTermMemoryThreshold}
THEN 1.0 - ((${longTermMemoryStability}) ^ (retrieval_count / ${longTermMemoryThreshold}))
ELSE 0
END AS ltm_factor,
`
: ''
const ltmDecayModifier = longTermMemoryEnabled
? `* (1 - ltm_factor)`
: ''
// New emotional memory components
const emotionalComponents = `
-- Random recall probability (flashback effect)
(CASE WHEN RANDOM() < ${randomRecallProbability} THEN ${flashbackIntensity} ELSE 1 END) *
-- Joy/euphoria boost with steep decay
(1 + (joy_score * ${joyBoostFactor} * EXP(-${joyDecaySteepness} *
(EXTRACT(EPOCH FROM (${simulatedTimestamp} - last_retrieved_at)))/(${timeUnitInSeconds})))) *
-- Aversion spike for traumatic memories
(1 + (aversion_score * ${aversionSpikeFactor} *
POWER(${aversionStability}, CEIL((retrieval_count / 5)))))
`
const query = `
SELECT
id,
score,
updated_at,
last_retrieved_at,
retrieval_count,
joy_score,
aversion_score,
CAST(updated_at AS VARCHAR) as updated_at_str,
CAST(last_retrieved_at AS VARCHAR) as last_retrieved_str,
(EXTRACT(EPOCH FROM (${simulatedTimestamp} - updated_at))) as age_in_seconds,
(EXTRACT(EPOCH FROM (${simulatedTimestamp} - last_retrieved_at))) as time_since_retrieval,
${ltmFactorClause}
score *
exp(-${decayRate} * (EXTRACT(EPOCH FROM (${simulatedTimestamp} - updated_at)))/(${timeUnitInSeconds}) ${ltmDecayModifier}) *
(1 + (retrieval_count * ${retrievalBoost} *
exp(-${decayRate * retrievalDecaySlowdown} *
(EXTRACT(EPOCH FROM (${simulatedTimestamp} - last_retrieved_at)))/(${timeUnitInSeconds})))) *
${emotionalComponents} as decayed_score
FROM emotional_memories_test_table
ORDER BY decayed_score DESC
`
return query
}
export async function createEmotionalSchema(db) {
await db.execute(`
CREATE TABLE IF NOT EXISTS emotional_memories_test_table (
id VARCHAR,
score DOUBLE,
updated_at TIMESTAMP,
last_retrieved_at TIMESTAMP,
retrieval_count INTEGER DEFAULT 0,
joy_score DOUBLE DEFAULT 0,
aversion_score DOUBLE DEFAULT 0
)
`)
}
export async function loadEmotionalSampleData(db) {
const now = new Date()
const sampleData = []
for (let i = 1; i <= 20; i++) {
const daysAgo = Math.random() * 60
const lastUpdate = new Date(now.getTime() - (daysAgo * 24 * 60 * 60 * 1000))
const retrievalCount = Math.floor(Math.random() * 10)
let retrievalDaysAgo = retrievalCount > 0 ? Math.random() * daysAgo * (1 - retrievalCount / 15) : daysAgo
retrievalDaysAgo = Math.max(0, retrievalDaysAgo)
const lastRetrievedAt = new Date(now.getTime() - (retrievalDaysAgo * 24 * 60 * 60 * 1000))
const score = Math.floor(Math.random() * 900) + 100
// Generate emotional scores
const joyScore = Math.random() * (i % 3 === 0 ? 0.8 : 0.3)
const aversionScore = Math.random() * (i % 4 === 0 ? 0.7 : 0.2)
sampleData.push({
id: `memory-${i}`,
score,
updated_at: lastUpdate.toISOString(),
last_retrieved_at: lastRetrievedAt.toISOString(),
retrieval_count: retrievalCount,
joy_score: joyScore.toFixed(2),
aversion_score: aversionScore.toFixed(2),
})
}
for (const item of sampleData) {
await db.execute(`
INSERT INTO emotional_memories_test_table
(id, score, updated_at, last_retrieved_at, retrieval_count, joy_score, aversion_score)
VALUES
('${item.id}', ${item.score}, '${item.updated_at}', '${item.last_retrieved_at}',
${item.retrieval_count}, ${item.joy_score}, ${item.aversion_score})
`)
}
}
export async function simulateEmotionalRetrieval(db, memoryId, simulatedTime, { joyModifier = 0, aversionModifier = 0 }) {
const simulatedTimestamp = simulatedTime.toISOString()
await db.execute(`
UPDATE emotional_memories_test_table
SET last_retrieved_at = '${simulatedTimestamp}',
retrieval_count = retrieval_count + 1,
joy_score = CASE
WHEN joy_score + ${joyModifier} < 0 THEN 0
WHEN joy_score + ${joyModifier} > 1 THEN 1
ELSE joy_score + ${joyModifier}
END,
aversion_score = CASE
WHEN aversion_score + ${aversionModifier} < 0 THEN 0
WHEN aversion_score + ${aversionModifier} > 1 THEN 1
ELSE aversion_score + ${aversionModifier}
END
WHERE id = '${memoryId}'
`)
}
@@ -273,6 +273,15 @@ onUnmounted(() => {
</p>
</div>
<!-- Time Controls -->
<TimeControls
v-model:simulated-time-offset="simulatedTimeOffset"
v-model:is-time-accelerated="isTimeAccelerated"
v-model:time-multiplier="timeMultiplier"
class="mb-4"
@time-jump="handleTimeJump"
/>
<!-- Interactive Chart Section -->
<div class="grid grid-cols-1 mb-4 gap-4 lg:grid-cols-3">
<!-- Chart -->
@@ -299,15 +308,6 @@ onUnmounted(() => {
/>
</div>
<!-- Time Controls -->
<TimeControls
v-model:simulated-time-offset="simulatedTimeOffset"
v-model:is-time-accelerated="isTimeAccelerated"
v-model:time-multiplier="timeMultiplier"
class="mb-4"
@time-jump="handleTimeJump"
/>
<!-- Memory Model Settings -->
<MemoryModelSettings
v-model:long-term-memory-enabled="longTermMemoryEnabled"
@@ -1,9 +1,614 @@
<script setup lang="ts">
import type { EmotionalMemoryItem } from '../types/memory/emotional-memory'
import { useDebounceFn } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
// Import the components
import EmotionalMemoryChart from '../components/Memory/EmotionalMemoryChart.vue'
import EmotionalMemoryDetail from '../components/Memory/EmotionalMemoryDetail.vue'
import EmotionalSettings from '../components/Memory/EmotionalSettings.vue'
import MemoryRetrievalHeatmap from '../components/Memory/MemoryRetrievalHeatmap.vue'
import Range from '../components/Range.vue'
import {
connectToDatabase,
createEmotionalSchema,
generateEmotionalDecayQuery as generateEmotionalQuery,
loadEmotionalSampleData,
simulateEmotionalRetrieval,
} from '../composables/memory/memory-decay-db'
// Database references
const db = ref(null)
const isMigrated = ref(false)
// Data and query results
const processedResults = ref<EmotionalMemoryItem[]>([])
const selectedMemoryId = ref(null)
// Memory model parameters
const decayRate = ref(0.0990)
const timeUnit = ref('days')
const maxDaysToProject = ref(2000)
// Emotional memory parameters
const joyBoostFactor = ref(1.5)
const joyDecaySteepness = ref(3.0)
const aversionSpikeFactor = ref(2.0)
const aversionStability = ref(1.2)
const randomRecallProbability = ref(0.05)
const flashbackIntensity = ref(2.0)
// Time simulation parameters
const timeMultiplier = ref(1) // Default: 1 day/second
const isTimeAccelerated = ref(false)
const simulatedTimeOffset = ref(0)
const lastTickTime = ref(Date.now())
// Memory access thresholds
const longTermThreshold = ref(5)
const muscleMemoryThreshold = ref(40)
// UI controls
const showAdvancedSettings = ref(false)
// Selected memory
const selectedMemory = computed(() => {
if (!selectedMemoryId.value || !processedResults.value.length)
return null
return processedResults.value.find(m => m.id === selectedMemoryId.value)
})
// Time unit in seconds
const timeUnitInSeconds = computed(() => {
switch (timeUnit.value) {
case 'hours': return 60 * 60
case 'days': return 24 * 60 * 60
case 'weeks': return 7 * 24 * 60 * 60
case 'months': return 30 * 24 * 60 * 60
default: return 24 * 60 * 60
}
})
// Current simulated time
const currentSimulatedTime = computed(() => {
const now = new Date()
now.setSeconds(now.getSeconds() + simulatedTimeOffset.value)
return now
})
// Add a new ref for heatmap configuration
const heatmapTimeRange = ref(30) // Show last 30 days by default
// Add this after the other refs
const memoryHistory = ref(new Map())
// Maximum number of history points to track per memory
const historyLength = 20
// Initialize database and load data
async function initialize() {
db.value = await connectToDatabase()
await createEmotionalSchema(db.value)
await loadEmotionalSampleData(db.value)
isMigrated.value = true
await runDecayQuery()
}
// Emotional decay query
const emotionalDecayQuery = ref('')
// Watch for parameter changes
watch([
simulatedTimeOffset,
decayRate,
timeUnit,
longTermThreshold,
muscleMemoryThreshold,
joyBoostFactor,
joyDecaySteepness,
aversionSpikeFactor,
aversionStability,
randomRecallProbability,
flashbackIntensity,
], async () => {
const query = await generateEmotionalQuery(db.value, {
simulatedTimeOffset: simulatedTimeOffset.value,
decayRate: decayRate.value,
timeUnitInSeconds: timeUnitInSeconds.value,
longTermMemoryEnabled: true,
longTermMemoryThreshold: longTermThreshold.value,
longTermMemoryStability: 0.3,
retrievalBoost: 0.25,
retrievalDecaySlowdown: 0.8,
joyBoostFactor: joyBoostFactor.value,
joyDecaySteepness: joyDecaySteepness.value,
aversionSpikeFactor: aversionSpikeFactor.value,
aversionStability: aversionStability.value,
randomRecallProbability: randomRecallProbability.value,
flashbackIntensity: flashbackIntensity.value,
})
emotionalDecayQuery.value = query
})
// Run the decay query
async function runDecayQuery() {
if (!emotionalDecayQuery.value)
return
const queryResults = await db.value?.execute(emotionalDecayQuery.value) || []
// Update memory history with new data points
for (const memory of queryResults) {
// Initialize history array if doesn't exist
if (!memoryHistory.value.has(memory.id)) {
memoryHistory.value.set(memory.id, [])
}
const history = memoryHistory.value.get(memory.id)
// Add current state to history with timestamp
history.push({
timestamp: Date.now(),
simulatedTime: simulatedTimeOffset.value,
score: memory.decayed_score,
joy: memory.joy_score,
aversion: memory.aversion_score,
retrievalCount: memory.retrieval_count,
})
// Trim history to maintain fixed length
if (history.length > historyLength) {
history.shift()
}
}
processedResults.value = queryResults
// Initialize selectedMemoryId if not set or update if selection changed
if (!selectedMemoryId.value && processedResults.value.length) {
selectedMemoryId.value = processedResults.value[0].id
}
generateChartData()
}
// Generate chart data
function generateChartData() {
// Add calculated properties to the memory items for easier access
processedResults.value = processedResults.value.map((item) => {
const ageInDays = Math.round(item.age_in_seconds / (24 * 60 * 60))
return {
...item,
age_in_days: ageInDays,
joyComponent: item.joy_score * joyBoostFactor.value,
aversionComponent: item.aversion_score * aversionSpikeFactor.value,
effective_score: item.decayed_score,
}
})
}
// Handle simulated retrieval with emotional response
async function handleRetrieval(memoryId, { joyModifier = 0, aversionModifier = 0 } = {}) {
await simulateEmotionalRetrieval(db.value, memoryId, new Date(Date.now() + simulatedTimeOffset.value * 1000), {
joyModifier,
aversionModifier,
})
await runDecayQuery()
}
// New function to process random retrievals
async function processRandomRetrievals() {
// Only run if database is initialized
if (!db.value || !processedResults.value?.length)
return
// For each memory, check if it should be randomly retrieved based on randomRecallProbability
for (const memory of processedResults.value) {
// Apply the same random check that's in the SQL
if (Math.random() < randomRecallProbability.value) {
// Determine if this should be a joy or aversion-based recall
// Use the existing emotional components to guide this
let joyModifier = 0
let aversionModifier = 0
if (memory.joy_score > memory.aversion_score && memory.joy_score > 0.3) {
joyModifier = 0.05
}
else if (memory.aversion_score > 0.3) {
aversionModifier = 0.05
}
// Call the regular retrieval function
await simulateEmotionalRetrieval(
db.value,
memory.id,
new Date(Date.now() + simulatedTimeOffset.value * 1000),
{ joyModifier, aversionModifier },
)
}
}
// Update the UI
await runDecayQuery()
}
// Handle memory projection reset
async function resetProjection() {
await loadEmotionalSampleData(db.value) // true flag to reset data
simulatedTimeOffset.value = 0
await runDecayQuery()
}
// Handle time jump
async function handleTimeJump({ amount, unit }) {
let secondsToAdd = 0
switch (unit) {
case 'hour':
secondsToAdd = 60 * 60
break
case 'day':
secondsToAdd = 24 * 60 * 60
break
case 'week':
secondsToAdd = 7 * 24 * 60 * 60
break
case 'month':
secondsToAdd = 30 * 24 * 60 * 60
break
case 'year':
secondsToAdd = 365 * 24 * 60 * 60
break
}
simulatedTimeOffset.value += amount * secondsToAdd
await runDecayQuery()
}
// Time acceleration handling
function baseTickTime() {
if (!isTimeAccelerated.value)
return
const now = Date.now()
const elapsedMs = now - lastTickTime.value
simulatedTimeOffset.value += (elapsedMs / 1000) * timeMultiplier.value
lastTickTime.value = now
runDecayQuery()
// Process random retrievals based on elapsed time
// We'll check for random retrievals every few seconds of simulated time
if (Math.random() < (elapsedMs / 5000) * timeMultiplier.value) {
processRandomRetrievals()
}
}
const debouncedTickTime = useDebounceFn(() => {
baseTickTime()
requestAnimationFrame(debouncedTickTime)
}, 1000)
// Add a button to manually trigger random retrievals for testing
async function triggerRandomRetrievals() {
await processRandomRetrievals()
}
// Watch time acceleration state
watch(isTimeAccelerated, (newValue) => {
if (newValue) {
lastTickTime.value = Date.now()
debouncedTickTime()
}
})
// Watch time multiplier changes
watch(timeMultiplier, () => {
if (isTimeAccelerated.value) {
lastTickTime.value = Date.now()
}
})
// Lifecycle hooks
onMounted(async () => {
await initialize()
isTimeAccelerated.value = true
})
onUnmounted(() => {
isTimeAccelerated.value = false
db.value?.$client.then(client => client.close())
})
</script>
<template>
<div>
<h1>Memory Simulator</h1>
<template v-if="!isMigrated">
<!-- Loading state -->
<div class="py-8 text-center">
<div class="mx-auto h-8 w-8 animate-spin border-4 border-blue-500 border-t-transparent rounded-full" />
<p class="mt-4">
Initializing database and sample data...
</p>
</div>
</template>
<template v-else>
<header class="mb-4">
<h1 class="mb-2 text-2xl font-bold">
Memory Flashback & Retrieval Simulator
</h1>
<div class="flex items-center justify-between">
<p class="max-w-2xl text-neutral-600 dark:text-neutral-300">
Visualize how memories could be retrieved and impacted by emotional state
</p>
</div>
</header>
<!-- Time Controls -->
<div class="my-4 rounded-lg bg-neutral-100 p-4 dark:bg-neutral-800/50">
<h2 class="mb-2 flex items-center text-lg font-semibold" gap-4>
<div flex-1>
Time Simulation
</div>
<div class="text-sm font-mono">
{{ currentSimulatedTime.toLocaleString() }}
</div>
<button
:class="{ 'bg-red-100 dark:bg-red-900': isTimeAccelerated, 'bg-green-100 dark:bg-green-900': !isTimeAccelerated }"
class="rounded-lg px-4 py-2 font-medium transition-colors"
@click="isTimeAccelerated = !isTimeAccelerated"
>
<div v-if="isTimeAccelerated" i-solar:pause-bold />
<div v-else i-solar:play-bold />
</button>
<button
class="rounded-lg bg-neutral-200 px-4 py-2 font-medium dark:bg-neutral-700"
@click="resetProjection"
>
<div i-solar:restart-line-duotone />
</button>
</h2>
<!-- Time jump shortcuts -->
<div class="mt-4 flex flex-wrap gap-2">
<button
class="rounded-lg bg-blue-100 px-3 py-1 text-sm dark:bg-blue-900"
@click="handleTimeJump({ amount: 1, unit: 'day' })"
>
+1 Day
</button>
<button
class="rounded-lg bg-blue-100 px-3 py-1 text-sm dark:bg-blue-900"
@click="handleTimeJump({ amount: 1, unit: 'week' })"
>
+1 Week
</button>
<button
class="rounded-lg bg-blue-100 px-3 py-1 text-sm dark:bg-blue-900"
@click="handleTimeJump({ amount: 1, unit: 'month' })"
>
+1 Month
</button>
<button
class="rounded-lg bg-blue-100 px-3 py-1 text-sm dark:bg-blue-900"
@click="handleTimeJump({ amount: 1, unit: 'year' })"
>
+1 Year
</button>
<button
class="rounded-lg bg-blue-100 px-3 py-1 text-sm dark:bg-blue-900"
@click="handleTimeJump({ amount: 5, unit: 'year' })"
>
+5 Years
</button>
<!-- New button to trigger random retrievals -->
<button
class="rounded-lg bg-purple-100 px-3 py-1 text-sm dark:bg-purple-900"
title="Trigger random memory recalls based on probability settings"
@click="triggerRandomRetrievals"
>
Random Recalls
</button>
</div>
<!-- Speed controls -->
<div class="mt-4">
<h3 class="font-medium">
Speed
</h3>
<div class="grid grid-cols-2 mt-2 gap-2 md:grid-cols-6 sm:grid-cols-3">
<button
v-for="(speed, index) in [
{ label: '1 second/s', value: 1 },
{ label: '1 minute/s', value: 60 },
{ label: '1 hour/s', value: 60 * 60 },
{ label: '1 day/s', value: 60 * 60 * 24 },
{ label: '1 week/s', value: 60 * 60 * 24 * 7 },
{ label: '1 month/s', value: 60 * 60 * 24 * 30 },
]"
:key="index"
class="rounded-lg px-3 py-2 text-sm font-medium transition-colors"
:class="timeMultiplier === speed.value ? 'bg-blue-200 dark:bg-blue-800' : 'bg-blue-100 dark:bg-blue-900'"
@click="timeMultiplier = speed.value"
>
{{ speed.label }}
</button>
</div>
</div>
</div>
<div>
<MemoryRetrievalHeatmap
:memory-data="processedResults"
:simulated-time-offset="simulatedTimeOffset"
:time-range="heatmapTimeRange"
class="mb-4"
/>
</div>
<div>
<!-- Chart and Detail View -->
<div class="grid grid-cols-1 mb-4 gap-4 lg:grid-cols-3">
<!-- Chart -->
<EmotionalMemoryChart
v-if="selectedMemory"
:memory-data="processedResults"
:selected-memory-id="selectedMemoryId"
:decay-rate="decayRate"
:max-days-to-project="maxDaysToProject"
:long-term-threshold="longTermThreshold"
:muscle-memory-threshold="muscleMemoryThreshold"
:joy-boost-factor="joyBoostFactor"
:joy-decay-steepness="joyDecaySteepness"
:aversion-spike-factor="aversionSpikeFactor"
:aversion-stability="aversionStability"
class="lg:col-span-2"
/>
<!-- Memory Details -->
<EmotionalMemoryDetail
v-if="selectedMemory"
:memory="selectedMemory"
:long-term-threshold="longTermThreshold"
:muscle-memory-threshold="muscleMemoryThreshold"
@retrieve="handleRetrieval"
/>
</div>
<div class="my-4">
<label class="mb-1 block text-sm font-medium">Heatmap Time Range (days)</label>
<div class="flex items-center gap-2">
<Range
v-model="heatmapTimeRange"
:min="7"
:max="120"
:step="1"
class="w-full"
/>
<span class="w-12 text-right font-mono">{{ heatmapTimeRange }}</span>
</div>
</div>
<!-- Time Controls -->
<div class="mb-4 rounded-lg bg-neutral-100 p-4 dark:bg-neutral-800/50">
<EmotionalSettings
v-model:joy-boost-factor="joyBoostFactor"
v-model:joy-decay-steepness="joyDecaySteepness"
v-model:aversion-spike-factor="aversionSpikeFactor"
v-model:aversion-stability="aversionStability"
v-model:random-recall-probability="randomRecallProbability"
v-model:flashback-intensity="flashbackIntensity"
/>
</div>
<!-- Only show table if viewMode is table or combined -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
<thead class="bg-neutral-100 dark:bg-neutral-800">
<tr>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
Rank
</th>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
ID
</th>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
Base Score
</th>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
Joy Score
</th>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
Aversion Score
</th>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
Retrievals
</th>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
Age (days)
</th>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
Effective Score
</th>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
Actions
</th>
</tr>
</thead>
<tbody v-if="processedResults.length" class="bg-white divide-y divide-neutral-200 dark:bg-neutral-900 dark:divide-neutral-800">
<tr
v-for="(memory, idx) in processedResults"
:key="memory.id"
class="cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800"
:class="{ 'bg-blue-50 dark:bg-blue-900/20': memory.id === selectedMemoryId }"
@click="selectedMemoryId = memory.id"
>
<td class="whitespace-nowrap px-4 py-2 text-sm font-medium">
{{ idx + 1 }}
</td>
<td class="whitespace-nowrap px-4 py-2 text-sm">
{{ memory.id }}
</td>
<td class="whitespace-nowrap px-4 py-2 text-sm">
{{ Math.round(memory.score) }}
</td>
<td class="whitespace-nowrap px-4 py-2 text-sm">
<span :class="memory.joy_score > 0.5 ? 'text-yellow-600 dark:text-yellow-400' : ''">
{{ Math.round(memory.joy_score * 100) }}%
</span>
</td>
<td class="whitespace-nowrap px-4 py-2 text-sm">
<span :class="memory.aversion_score > 0.5 ? 'text-red-600 dark:text-red-400' : ''">
{{ Math.round(memory.aversion_score * 100) }}%
</span>
</td>
<td class="whitespace-nowrap px-4 py-2 text-sm">
{{ memory.retrieval_count }}
</td>
<td class="whitespace-nowrap px-4 py-2 text-sm">
{{ Math.round(memory.age_in_seconds / (24 * 60 * 60)) }}
</td>
<td class="whitespace-nowrap px-4 py-2 text-sm font-medium">
{{ Math.round(memory.decayed_score) }}
</td>
<td class="whitespace-nowrap px-4 py-2">
<button
class="rounded-lg bg-blue-100 px-3 py-1 text-xs font-medium dark:bg-blue-900"
@click.stop="handleRetrieval(memory.id)"
>
Retrieve
</button>
</td>
</tr>
</tbody>
<tbody v-else class="bg-white dark:bg-neutral-900">
<tr>
<td colspan="9" class="px-4 py-8 text-center text-neutral-500">
No memory data available
</td>
</tr>
</tbody>
</table>
</div>
<!-- SQL Query Preview (Optional for debugging) -->
<div v-if="showAdvancedSettings" class="mt-6 max-w-full rounded-xl bg-neutral-50 dark:bg-neutral-800">
<div class="overflow-x-scroll rounded bg-neutral-800 p-4 text-sm text-neutral-200">
<pre class="whitespace-pre-wrap">
<code>{{ emotionalDecayQuery }}</code>
</pre>
</div>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,31 @@
export interface EmotionalMemoryItem {
// Basic memory properties
id: string
score: number
decayed_score: number
updated_at: string
last_retrieved_at: string
retrieval_count: number
// Emotional components
joy_score: number
aversion_score: number
// Timing information
updated_at_str?: string
last_retrieved_str?: string
age_in_seconds: number
time_since_retrieval: number
// Memory status factors
ltm_factor?: number // Long-term memory factor (0-1)
// Optional calculated components for display
age_in_days?: number
effective_score?: number
}
export interface EmotionalRetrievalModifiers {
joyModifier: number
aversionModifier: number
}
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref } from 'vue'
const props = withDefaults(defineProps<{
modelValue: number
min?: number
max?: number
step?: number
@@ -20,20 +19,19 @@ const props = withDefaults(defineProps<{
trackValueColor: 'red',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()
const modelValue = defineModel<number>('modelValue', { required: true })
const scaledMin = computed(() => props.min * 10000)
const scaledMax = computed(() => props.max * 10000)
const scaledStep = computed(() => props.step * 10000)
const sliderRef = ref<HTMLInputElement>()
const sliderValue = ref(props.modelValue * 10000)
watch(sliderValue, (value) => {
emit('update:modelValue', value / 10000)
updateTrackColor()
const sliderValue = computed({
get: () => modelValue.value * 10000,
set: (value: number) => {
modelValue.value = value / 10000
updateTrackColor()
},
})
onMounted(() => {
@@ -58,7 +56,7 @@ function updateTrackColor() {
:min="scaledMin"
:max="scaledMax"
:step="scaledStep"
class="form_input-range slider-progress"
class="slider-progress form_input-range"
@input="(e) => {
(e.target as HTMLInputElement).style.setProperty('--value', (e.target as HTMLInputElement).value)
}"