feat(drizzle-duckdb-wasm): memory decay demo

This commit is contained in:
Neko Ayaka
2025-03-28 15:59:21 +08:00
parent 3b6f8d8183
commit 935fea0bc1
14 changed files with 2270 additions and 37 deletions
@@ -39,6 +39,13 @@ const toggleDark = useToggle(isDark)
>
<h1>Graph</h1>
</RouterLink>
<div bg="neutral-200 dark:neutral-600" h="1lh" w="0.5" />
<RouterLink
to="/memory-decay" px-3 py-2 bg="hover:neutral-200 dark:hover:neutral-700"
transition="all duration-250 ease-in-out"
>
<h1>Memory Decay</h1>
</RouterLink>
</nav>
<RouterView />
</div>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { SwitchRoot, SwitchThumb } from 'reka-ui'
const modelValue = defineModel<boolean>({ required: true })
</script>
<template>
<SwitchRoot
v-model="modelValue"
transition="background duration-250 ease-in-out"
outline="focus-within:none"
flex="~"
border="neutral-300 dark:neutral-700 data-[state=checked]:primary-200 data-[state=unchecked]:neutral-300 focus-within:neutral-800"
bg="data-[state=checked]:primary-400 data-[state=unchecked]:neutral-300 data-[state=checked]:dark:primary-400/80 dark:data-[state=unchecked]:neutral-800"
relative h-7 w="12.5" rounded-full
shadow="sm focus-within:shadow-neutral-800 focus-within:[0_0_0_1px] "
>
<SwitchThumb
my-auto size-6
flex items-center justify-center
translate-x="0.5 data-[state=checked]:full"
rounded-full bg-white text-xs shadow-xl
transition="transform duration-250 ease-in-out"
will-change-transform
/>
</SwitchRoot>
</template>
<style scoped>
.dark [border~='dark\:neutral-700'] {
--un-border-opacity: 1;
border-color: rgb(64 64 64 / var(--un-border-opacity));
}
[border~='data-\[state\=checked\]\:primary-200'][data-state='checked'] {
--un-border-opacity: 1;
border-color: oklch(90% var(--theme-colors-chroma-200) calc(var(--theme-colors-hue) + 0) / var(--un-border-opacity));
}
[bg~='data-\[state\=checked\]\:primary-400'][data-state='checked'] {
--un-bg-opacity: 1;
background-color: oklch(74% var(--theme-colors-chroma-400) calc(var(--theme-colors-hue) + 0) / var(--un-bg-opacity));
}
.dark [bg~='data-\[state\=checked\]\:dark\:primary-400\/80'][data-state='checked'] {
background-color: oklch(74% var(--theme-colors-chroma-400) calc(var(--theme-colors-hue) + 0) / 0.8);
}
</style>
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { computed } from 'vue'
import Checkbox from '../Checkbox.vue'
import Range from '../Range.vue'
const longTermMemoryEnabled = defineModel<boolean>('longTermMemoryEnabled', { default: false })
const longTermMemoryThreshold = defineModel<number>('longTermMemoryThreshold', { default: 1 })
const longTermMemoryStability = defineModel<number>('longTermMemoryStability', { default: 0.5 })
const longTermMemoryVisualize = defineModel<boolean>('longTermMemoryVisualize', { default: false })
const retrievalBoost = defineModel<number>('retrievalBoost', { default: 0.5 })
const retrievalDecaySlowdown = defineModel<number>('retrievalDecaySlowdown', { default: 0.5 })
const decayRate = defineModel<number>('decayRate', { default: 0.01 })
const timeUnit = defineModel<string>('timeUnit', { default: 'days' })
const maxDaysToShow = defineModel<number>('maxDaysToShow', { default: 30 })
// Computed percent values for display
const retrievalBoostPercent = computed(() => (retrievalBoost.value * 100).toFixed(0))
const decaySlowdownPercent = computed(() => (retrievalDecaySlowdown.value * 100).toFixed(0))
</script>
<template>
<div>
<!-- Long-term Memory Model -->
<div class="mb-4 rounded-lg bg-neutral-100 p-4 dark:bg-neutral-800/50">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">
Long-Term Memory Model
</h2>
<Checkbox v-model="longTermMemoryEnabled" />
</div>
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
Configure how memories transition from short-term to permanent long-term memory with repeated retrievals
</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="flex flex-col gap-2">
<label class="font-medium">Retrieval Threshold</label>
<div class="flex items-center gap-2">
<Range
v-model="longTermMemoryThreshold"
:min="1"
:max="20"
:step="1"
class="w-full"
:disabled="!longTermMemoryEnabled"
/>
<span class="w-10 text-right font-mono">{{ longTermMemoryEnabled }}</span>
</div>
<div class="text-xs text-neutral-500">
Retrievals needed to form stable long-term memory
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium">Stability Factor</label>
<div class="flex items-center gap-2">
<Range
v-model="longTermMemoryStability"
:min="0.01"
:max="0.9"
:step="0.01"
class="w-full"
:disabled="!longTermMemoryEnabled"
/>
<span class="w-16 text-right font-mono">{{ longTermMemoryStability.toFixed(2) }}</span>
</div>
<div class="text-xs text-neutral-500">
How quickly memories stabilize (lower = faster stabilization)
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium">Memory Model Settings</label>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2">
<Checkbox v-model="longTermMemoryVisualize" />
<label for="ltmVisualize" class="text-sm">Show LTM projection</label>
</div>
</div>
<div class="text-xs text-neutral-500">
Options for memory visualization
</div>
</div>
</div>
</div>
<!-- Retrieval Effect Settings -->
<div class="mb-4 rounded-lg bg-neutral-100 p-4 dark:bg-neutral-800/50">
<h2 class="mb-2 text-lg font-semibold">
Retrieval Effect Settings
</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="flex flex-col gap-2">
<label class="font-medium">Retrieval Boost Factor</label>
<div class="flex items-center gap-2">
<Range
v-model="retrievalBoost"
:min="0"
:max="1"
:step="0.05"
class="w-full"
/>
<span class="w-16 text-right font-mono">{{ retrievalBoostPercent }}%</span>
</div>
<div class="text-xs text-neutral-500">
How much each retrieval strengthens memory
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium">Decay Slowdown Factor</label>
<div class="flex items-center gap-2">
<Range
v-model="retrievalDecaySlowdown"
:min="0.1"
:max="1"
:step="0.05"
class="w-full"
/>
<span class="w-16 text-right font-mono">{{ decaySlowdownPercent }}%</span>
</div>
<div class="text-xs text-neutral-500">
How much retrievals slow future decay (lower = more slowdown)
</div>
</div>
</div>
</div>
<!-- General Decay Controls -->
<div class="grid grid-cols-1 mb-4 gap-4 rounded-lg bg-neutral-100 p-4 md:grid-cols-3 dark:bg-neutral-800/50">
<div class="flex flex-col gap-2">
<label class="font-medium">Base Decay Rate</label>
<div class="flex items-center gap-2">
<Range
v-model="decayRate"
:min="0.01"
:max="0.5"
:step="0.01"
class="w-full"
/>
<span class="w-16 text-right font-mono">{{ decayRate.toFixed(4) }}</span>
</div>
<div class="text-xs text-neutral-500">
Controls decay speed (0.0990 = ~10% per day)
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium">Time Unit</label>
<select
v-model="timeUnit"
class="w-full rounded-lg border-none bg-neutral-100 p-2 outline-none dark:bg-neutral-700/30"
>
<option value="hours">
Hours
</option>
<option value="days">
Days
</option>
<option value="weeks">
Weeks
</option>
<option value="months">
Months
</option>
</select>
<div class="text-xs text-neutral-500">
Unit used in calculations (affects decay rate)
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium">Future Projection (Days)</label>
<div class="flex items-center gap-2">
<Range
v-model="maxDaysToShow"
:min="7"
:max="90"
:step="1"
class="w-full"
/>
<span class="w-16 text-right font-mono">{{ maxDaysToShow }}</span>
</div>
<div class="text-xs text-neutral-500">
How many days to project into the future
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,224 @@
<!-- src/components/TimeControls.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
const emit = defineEmits([
'timeJump',
])
const simulatedTimeOffset = defineModel<number>('simulatedTimeOffset', { default: 0 })
const isTimeAccelerated = defineModel<boolean>('isTimeAccelerated', { default: false })
const timeMultiplier = defineModel<number>('timeMultiplier', { default: 60 * 60 * 24 })
const customTimeValue = ref(1)
const customTimeUnit = ref('days')
const timeMultiplierPresets = [
{ label: '1 hour/s', value: 60 * 60 },
{ label: '6 hours/s', value: 60 * 60 * 6 },
{ label: '1 day/s', value: 60 * 60 * 24 },
{ label: '3 days/s', value: 60 * 60 * 24 * 3 },
{ label: '1 week/s', value: 60 * 60 * 24 * 7 },
{ label: '1 month/s', value: 60 * 60 * 24 * 30 },
]
// Format the current time with offset
const currentSimulatedTime = computed(() => {
const now = new Date()
now.setSeconds(now.getSeconds() + simulatedTimeOffset.value)
return now.toLocaleString()
})
// Format time multiplier for display
const formattedTimeMultiplier = computed(() => {
const multiplier = timeMultiplier.value
if (multiplier === 60 * 60)
return '1 hour/s'
if (multiplier === 60 * 60 * 6)
return '6 hours/s'
if (multiplier === 60 * 60 * 24)
return '1 day/s'
if (multiplier === 60 * 60 * 24 * 3)
return '3 days/s'
if (multiplier === 60 * 60 * 24 * 7)
return '1 week/s'
if (multiplier === 60 * 60 * 24 * 30)
return '1 month/s'
if (multiplier < 60)
return `${multiplier} seconds/s`
if (multiplier < 60 * 60)
return `${Math.round(multiplier / 60)} minutes/s`
if (multiplier < 60 * 60 * 24)
return `${Math.round(multiplier / (60 * 60))} hours/s`
if (multiplier < 60 * 60 * 24 * 7)
return `${Math.round(multiplier / (60 * 60 * 24))} days/s`
if (multiplier < 60 * 60 * 24 * 30)
return `${Math.round(multiplier / (60 * 60 * 24 * 7))} weeks/s`
return `${Math.round(multiplier / (60 * 60 * 24 * 30))} months/s`
})
function toggleTimeAcceleration() {
isTimeAccelerated.value = !isTimeAccelerated.value
}
function resetTime() {
simulatedTimeOffset.value = 0
isTimeAccelerated.value = false
}
function setCustomTimeMultiplier() {
let multiplier = customTimeValue.value
switch (customTimeUnit.value) {
case 'seconds':
break
case 'minutes':
multiplier *= 60
break
case 'hours':
multiplier *= 60 * 60
break
case 'days':
multiplier *= 60 * 60 * 24
break
case 'weeks':
multiplier *= 60 * 60 * 24 * 7
break
case 'months':
multiplier *= 60 * 60 * 24 * 30
break
}
timeMultiplier.value = multiplier
}
function jumpAhead(amount, unit) {
emit('timeJump', { amount, unit })
}
</script>
<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>
{{ currentSimulatedTime }}
</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="toggleTimeAcceleration"
>
<div v-if="isTimeAccelerated" i-solar:play-bold />
<div v-else i-solar:pause-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>
<!-- 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="jumpAhead(1, 'hour')"
>
+1 Hour
</button>
<button
class="rounded-lg bg-blue-100 px-3 py-1 text-sm dark:bg-blue-900"
@click="jumpAhead(1, 'day')"
>
+1 Day
</button>
<button
class="rounded-lg bg-blue-100 px-3 py-1 text-sm dark:bg-blue-900"
@click="jumpAhead(1, 'week')"
>
+1 Week
</button>
<button
class="rounded-lg bg-blue-100 px-3 py-1 text-sm dark:bg-blue-900"
@click="jumpAhead(1, 'month')"
>
+1 Month
</button>
</div>
<h2 class="mt-4 text-lg font-semibold">
Speed
</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">
<button
v-for="preset in timeMultiplierPresets"
:key="preset.value"
class="rounded-lg px-3 py-2 text-sm font-medium transition-colors"
:class="timeMultiplier === preset.value ? 'bg-blue-200 dark:bg-blue-800' : 'bg-blue-100 dark:bg-blue-900'"
@click="timeMultiplier = preset.value"
>
{{ preset.label }}
</button>
</div>
<!-- Custom time multiplier -->
<div class="mt-4 flex flex-wrap items-center gap-2">
<div class="text-sm font-medium">
Custom:
</div>
<input
v-model.number="customTimeValue"
type="number"
min="1"
class="w-20 rounded-lg border-none bg-white p-2 dark:bg-neutral-700"
>
<select
v-model="customTimeUnit"
class="rounded-lg border-none bg-white p-2 dark:bg-neutral-700"
>
<option value="seconds">
seconds/s
</option>
<option value="minutes">
minutes/s
</option>
<option value="hours">
hours/s
</option>
<option value="days">
days/s
</option>
<option value="weeks">
weeks/s
</option>
<option value="months">
months/s
</option>
</select>
<button
class="rounded-lg bg-blue-100 px-3 py-2 text-sm font-medium dark:bg-blue-900"
@click="setCustomTimeMultiplier"
>
Apply
</button>
</div>
</div>
</template>
@@ -0,0 +1,192 @@
<!-- src/components/MemoryDetails.vue -->
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps({
memory: {
type: Object,
required: true,
},
longTermMemoryEnabled: {
type: Boolean,
required: true,
},
longTermMemoryThreshold: {
type: Number,
required: true,
},
})
const emit = defineEmits(['retrieve'])
// 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.longTermMemoryEnabled && props.memory.retrieval_count >= props.longTermMemoryThreshold) {
return {
type: 'long-term',
label: `Long-term`,
color: 'text-purple-600 dark:text-purple-400',
}
}
else if (props.memory.retrieval_count > 0) {
return {
type: 'working',
label: props.longTermMemoryEnabled
? `Working`
: 'Working memory',
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 percentage for LTM
const ltmPercentage = computed(() => {
if (props.memory.retrieval_count >= props.longTermMemoryThreshold) {
return Math.round(Number.parseFloat(props.memory.ltm_factor || 0) * 100)
}
else {
return Math.round((props.memory.retrieval_count / props.longTermMemoryThreshold) * 100)
}
})
function simulateRetrieval() {
emit('retrieve', props.memory.storyid)
}
</script>
<template>
<div class="flex flex-col justify-between rounded-lg bg-white p-4 shadow dark:bg-neutral-800/50">
<div class="flex justify-between">
<h3 class="font-bold">
{{ memory.storyid }}
</h3>
<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"
>
Simulate Retrieval
</button>
</div>
<div>
<div class="grid grid-cols-2 mb-6 mt-2 gap-3" text-sm font-mono>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
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>
Original 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>
% Remaining:
</div>
<div class="font-medium" text-right>
{{ strengthPercentage }}%
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Creation Date:
</div>
<div class="font-medium" text-right>
{{ new Date(memory.lastupdate).toLocaleDateString() }}
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400" tracking-tight>
Last Retrieved:
</div>
<div class="font-medium" text-right>
{{ new Date(memory.last_retrieved_at).toLocaleDateString() }}
</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>
</div>
<div>
<!-- Visual representation of decay -->
<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="longTermMemoryEnabled && memory.retrieval_count >= longTermMemoryThreshold ? 'bg-purple-400' : '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>
<!-- LTM Progress -->
<div v-if="longTermMemoryEnabled && memory.retrieval_count > 0" 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-purple-400 transition-all duration-500"
:style="`width: ${ltmPercentage}%`"
/>
</div>
<div class="mt-1 flex items-center justify-between">
<span class="text-xs">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-purple-600 font-medium dark:text-purple-400">
{{ Math.round(Number.parseFloat(memory.ltm_factor || 0) * 100) }}% stable
</span>
<span class="text-xs">Permanent</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,581 @@
<script setup lang="ts">
import { useDark } from '@vueuse/core'
import * as d3 from 'd3'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
const props = defineProps<{
memoryData: MemoryDataItem[]
selectedStoryId: string
decayRate: number
maxDaysToShow: number
longTermMemoryEnabled: boolean
longTermMemoryThreshold: number
longTermMemoryVisualize: boolean
retrievalBoost: number
retrievalDecaySlowdown: number
}>()
interface MemoryDataItem {
storyid: string
score: number
lastupdate: string // ISO timestamp
last_retrieved_at: string // ISO timestamp
retrieval_count: number
lastupdate_str: string
last_retrieved_str: string
age_in_seconds: number
time_since_retrieval: number
ltm_factor?: number // Optional since it's only present when longTermMemoryEnabled is true
decayed_score: number
}
interface DataPoint {
x: number
y: number
label: string
}
interface ChartData {
labels: string[]
dataPoints: {
withRetrievals: DataPoint[]
withoutRetrievals: DataPoint[]
ltmProjection: DataPoint[]
}
}
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.selectedStoryId) {
renderChart()
}
})
// Render D3 chart
function renderChart() {
// Clear existing chart
d3.select(chartContainer.value).selectAll('*').remove()
// Find the selected story
const story = props.memoryData.find(s => s.storyid === props.selectedStoryId)
if (!story)
return
// Get container dimensions
const containerRect = chartContainer.value.getBoundingClientRect()
const containerWidth = containerRect.width
const containerHeight = containerRect.height || 320
// Set chart margins
const margin = { top: 20, right: 0, bottom: 20, left: 30 }
const width = containerWidth - margin.left - margin.right
const height = containerHeight - margin.top - margin.bottom
// Prepare data for chart
const originalScore = Number.parseFloat(String(story.score))
const dr = props.decayRate
const maxDays = props.maxDaysToShow
const ageInDays = Number.parseFloat(String(story.age_in_seconds)) / (24 * 60 * 60)
const retrievals = Number.parseInt(String(story.retrieval_count))
const daysSinceRetrieval = Number.parseFloat(String(story.time_since_retrieval)) / (24 * 60 * 60)
const ltmFactor = props.longTermMemoryEnabled ? Number.parseFloat(String(story.ltm_factor || '0')) : 0
// Generate data points
const chartData = {
labels: ['Original', 'Current'],
dataPoints: {
withRetrievals: [
{ x: 0, y: originalScore, label: 'Original' },
{ x: ageInDays, y: Number.parseFloat(String(story.decayed_score)), label: 'Current' },
],
withoutRetrievals: [
{ x: 0, y: originalScore, label: 'Original' },
{ x: ageInDays, y: originalScore * Math.exp(-dr * ageInDays * (1 - ltmFactor)), label: 'Current' },
],
ltmProjection: props.longTermMemoryVisualize
? [
{ x: 0, y: originalScore, label: 'Original' },
{
x: ageInDays,
y: retrievals >= props.longTermMemoryThreshold
? originalScore * (1 - Math.max(0, 0.1 - 0.1 * retrievals))
: Number.parseFloat(String(story.decayed_score)),
label: 'Current',
},
]
: [],
},
}
// Add future projection points
const dayStep = Math.ceil(maxDays / 10)
for (let day = dayStep; day <= maxDays; day += dayStep) {
const futureDay = ageInDays + day
const futureDaysSinceRetrieval = daysSinceRetrieval + day
const label = `+${day}d`
// Point with retrieval effects
const scoreWithRetrievals = originalScore
* Math.exp(-dr * futureDay * (1 - ltmFactor))
* (1 + (retrievals * props.retrievalBoost
* Math.exp(-dr * props.retrievalDecaySlowdown * futureDaysSinceRetrieval)))
// Point without retrieval effects
const scoreWithoutRetrievals = originalScore * Math.exp(-dr * futureDay * (1 - ltmFactor))
// Add to data series
chartData.labels.push(label)
chartData.dataPoints.withRetrievals.push({ x: futureDay, y: scoreWithRetrievals, label })
chartData.dataPoints.withoutRetrievals.push({ x: futureDay, y: scoreWithoutRetrievals, label })
// Add LTM projection if enabled
if (props.longTermMemoryVisualize) {
const ltmProjectionValue = retrievals >= props.longTermMemoryThreshold
? originalScore * (1 - Math.max(0, 0.1 - 0.1 * retrievals)) * Math.exp(-0.01 * day)
: scoreWithRetrievals
chartData.dataPoints.ltmProjection.push({ x: futureDay, y: ltmProjectionValue, label })
}
}
// 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([
...chartData.dataPoints.withRetrievals,
...chartData.dataPoints.withoutRetrievals,
...chartData.dataPoints.ltmProjection,
], 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 and areas
addDataLines(svg, chartData, xScale, yScale, height)
// Add half-life indicator
const halfLife = Math.log(2) / dr
if (halfLife <= maxDays + ageInDays) {
addHalfLifeIndicator(svg, halfLife, xScale, height)
}
// Add data points with interaction
addDataPoints(svg, chartData.dataPoints.withRetrievals, xScale, yScale)
// Add chart title and legends
addTitleAndLegends(svg, story.storyid, retrievals, ltmFactor, width)
// Add LTM indicators
if (props.longTermMemoryEnabled) {
addLTMIndicators(svg, retrievals, props.longTermMemoryThreshold, ltmFactor, width)
}
}
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
if (!isDark.value) {
svg.append('g')
.attr('class', 'grid')
.attr('transform', `translate(0,${height})`)
.call(
d3.axisBottom(xScale)
.tickSize(-height)
.tickFormat(() => ''), // Use a function that returns empty string
)
.selectAll('line')
.attr('stroke', '#e5e7eb')
.attr('stroke-opacity', 0.5)
svg.append('g')
.attr('class', 'grid')
.call(
d3.axisLeft(yScale)
.tickSize(-width)
.tickFormat(() => ''), // Use a function that returns empty string
)
.selectAll('line')
.attr('stroke', '#e5e7eb')
.attr('stroke-opacity', 0.5)
}
else {
svg.append('g')
.attr('class', 'grid')
.attr('transform', `translate(0,${height})`)
.call(
d3.axisBottom(xScale)
.tickSize(-height)
.tickFormat(() => ''), // Use a function that returns empty string
)
.selectAll('line')
.attr('stroke', '#434345')
.attr('stroke-opacity', 0.5)
svg.append('g')
.attr('class', 'grid')
.call(
d3.axisLeft(yScale)
.tickSize(-width)
.tickFormat(() => ''), // Use a function that returns empty string
)
.selectAll('line')
.attr('stroke', '#434345')
.attr('stroke-opacity', 0.5)
}
// 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', 20)
.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', -10)
.attr('x', -height / 2)
.attr('text-anchor', 'middle')
.text('Memory Strength')
.attr('fill', 'currentColor')
}
function addDataLines(
svg: d3.Selection<SVGGElement, unknown, null, undefined>,
chartData: ChartData,
xScale: d3.ScaleLinear<number, number>,
yScale: d3.ScaleLinear<number, number>,
height: number,
): void {
// Create line generators with correct types
const line = d3.line<DataPoint>()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.curve(d3.curveMonotoneX)
// Create area generators with correct types
const area = d3.area<DataPoint>()
.x(d => xScale(d.x))
.y0(height)
.y1(d => yScale(d.y))
.curve(d3.curveMonotoneX)
// Add area fill for without retrievals
svg.append('path')
.datum(chartData.dataPoints.withoutRetrievals)
.attr('class', 'area without-retrievals')
.attr('d', area)
.attr('fill', 'rgba(156, 163, 175, 0.2)')
// Add line for without retrievals
svg.append('path')
.datum(chartData.dataPoints.withoutRetrievals)
.attr('class', 'line without-retrievals')
.attr('d', line)
.attr('fill', 'none')
.attr('stroke', 'rgba(156, 163, 175, 0.6)')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '5,3')
// Add area fill for with retrievals
svg.append('path')
.datum(chartData.dataPoints.withRetrievals)
.attr('class', 'area with-retrievals')
.attr('d', area)
.attr('fill', 'rgba(59, 130, 246, 0.2)')
// Add line for with retrievals
svg.append('path')
.datum(chartData.dataPoints.withRetrievals)
.attr('class', 'line with-retrievals')
.attr('d', line)
.attr('fill', 'none')
.attr('stroke', 'rgba(59, 130, 246, 1)')
.attr('stroke-width', 3)
// Add LTM projection line if enabled
if (props.longTermMemoryVisualize && chartData.dataPoints.ltmProjection.length > 0) {
svg.append('path')
.datum(chartData.dataPoints.ltmProjection)
.attr('class', 'line ltm-projection')
.attr('d', line)
.attr('fill', 'none')
.attr('stroke', 'rgba(139, 92, 246, 0.8)')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '3,2')
}
}
function addDataPoints(svg, dataPoints, xScale, yScale) {
svg.selectAll('.point-with-retrievals')
.data(dataPoints)
.enter()
.append('circle')
.attr('class', 'point-with-retrievals')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', (d, i) => i < 2 ? 6 : 5)
.attr('fill', (d, i) => i < 2 ? 'rgba(220, 38, 38, 0.8)' : '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)
tooltip.value
.style('opacity', 1)
.html(`
<div class="font-semibold">${d.label}</div>
<div>Score: ${Math.round(d.y)}</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 : 5)
tooltip.value
.transition()
.duration(200)
.style('opacity', 0)
})
// Add labels to important points
svg.selectAll('.point-label')
.data(dataPoints.filter((d, i) => i === 0 || i === 1 || i === dataPoints.length - 1 || i % 3 === 0))
.enter()
.append('text')
.attr('class', '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', 'currentColor')
.text(d => Math.round(d.y))
}
function addHalfLifeIndicator(svg, halfLife, xScale, height) {
svg.append('line')
.attr('class', 'half-life-line')
.attr('x1', xScale(halfLife))
.attr('y1', 0)
.attr('x2', xScale(halfLife))
.attr('y2', height)
.attr('stroke', 'rgba(220, 38, 38, 0.5)')
.attr('stroke-width', 1)
.attr('stroke-dasharray', '5,5')
svg.append('text')
.attr('class', 'half-life-label')
.attr('x', xScale(halfLife))
.attr('y', 0)
.attr('dy', -8)
.attr('text-anchor', 'middle')
.attr('font-size', '12px')
.attr('fill', 'rgba(220, 38, 38, 0.8)')
.text(`Half-life: ${halfLife.toFixed(1)} days`)
}
function addTitleAndLegends(svg, storyId, retrievals, ltmFactor, width) {
// Add chart title
let titleText = `Memory Decay for ${storyId} (${retrievals} retrievals)`
if (props.longTermMemoryEnabled) {
if (retrievals >= props.longTermMemoryThreshold) {
const ltmPercent = Math.round(ltmFactor * 100)
titleText += ` - LTM: ${ltmPercent}%`
}
else if (retrievals > 0) {
titleText += ` - LTM: ${retrievals}/${props.longTermMemoryThreshold} retrievals`
}
}
svg.append('text')
.attr('class', 'chart-title')
.attr('x', width / 2)
.attr('y', -30)
.attr('text-anchor', 'middle')
.attr('font-size', '16px')
.attr('font-weight', 'bold')
.attr('fill', 'currentColor')
.text(titleText)
// Add legend
const legendData = [
{ label: `Current (${retrievals} retrievals)`, color: 'rgba(59, 130, 246, 0.8)' },
{ label: 'Without retrievals', color: 'rgba(156, 163, 175, 0.8)' },
]
if (props.longTermMemoryVisualize) {
legendData.push({ label: 'Long-term memory', color: 'rgba(139, 92, 246, 0.8)' })
}
const legend = svg.append('g')
.attr('class', 'legend')
.attr('transform', `translate(${width - 180}, 10)`)
const legendItems = legend.selectAll('.legend-item')
.data(legendData)
.enter()
.append('g')
.attr('class', 'legend-item')
.attr('transform', (d, i) => `translate(10, ${i * 25 + 20})`)
legendItems.append('circle')
.attr('r', 6)
.attr('fill', d => d.color)
.attr('stroke', 'white')
.attr('stroke-width', 1)
legendItems.append('text')
.attr('x', 15)
.attr('y', 4)
.attr('font-size', '12px')
.attr('fill', 'currentColor')
.text(d => d.label)
}
function addLTMIndicators(svg, retrievals, threshold, ltmFactor, width) {
if (retrievals > 0 && retrievals < threshold) {
const progress = Math.round((retrievals / threshold) * 100)
svg.append('g')
.attr('class', 'progress-indicator')
.attr('transform', `translate(${width / 2}, ${-20})`)
.append('text')
.attr('text-anchor', 'middle')
.attr('font-size', '12px')
.attr('fill', 'rgba(139, 92, 246, 0.8)')
.text(`LTM Progress: ${progress}%`)
}
if (retrievals >= threshold) {
const stability = Math.round(ltmFactor * 100)
svg.append('text')
.attr('class', 'ltm-info')
.attr('x', width / 2)
.attr('y', -10)
.attr('text-anchor', 'middle')
.attr('font-size', '14px')
.attr('fill', 'rgba(139, 92, 246, 1)')
.text(`Long-term memory stability: ${stability}%`)
}
}
onMounted(() => {
// Setup resize observer
resizeObserver = new ResizeObserver(() => {
if (props.memoryData.length && props.selectedStoryId) {
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-xl font-semibold">
Memory Strength Projection
</h2>
<!-- D3.js chart container -->
<div ref="chartContainer" class="relative h-120 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) {
stroke: #dce0e3;
}
:deep(.grid line) {
stroke: #2c2d2e;
stroke-opacity: 0.5;
}
.dark {
:deep(.x-axis path),
:deep(.y-axis path),
:deep(.x-axis line),
:deep(.y-axis line) {
stroke: #1f2831;
}
}
</style>
@@ -0,0 +1,177 @@
<!-- src/components/MemoryTable.vue -->
<script setup lang="ts">
interface MemoryDataItem {
storyid: string
score: number
lastupdate: string // ISO timestamp
last_retrieved_at: string // ISO timestamp
retrieval_count: number
lastupdate_str: string
last_retrieved_str: string
age_in_seconds: number
time_since_retrieval: number
ltm_factor?: number // Optional since it's only present when longTermMemoryEnabled is true
decayed_score: number
}
const props = defineProps<{
memories: MemoryDataItem[]
selectedId: string | null
longTermMemoryEnabled: boolean
longTermMemoryThreshold: number
}>()
const emit = defineEmits(['select', 'retrieve'])
function selectMemory(id) {
emit('select', id)
}
function retrieveMemory(id, event) {
event.stopPropagation()
emit('retrieve', id)
}
function getMemoryStatus(memory) {
if (props.longTermMemoryEnabled && memory.retrieval_count >= props.longTermMemoryThreshold) {
return {
type: 'long-term',
label: `Long-term (${Math.round((memory.ltm_factor || 0) * 100)}%)`,
class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
}
}
else if (memory.retrieval_count > 0) {
return {
type: 'working',
label: props.longTermMemoryEnabled ? `Working (${memory.retrieval_count}/${props.longTermMemoryThreshold})` : 'Working',
class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
}
}
else {
return {
type: 'short-term',
label: 'Short-term',
class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
}
}
}
</script>
<template>
<div class="rounded-lg bg-neutral-50 shadow dark:bg-neutral-800/50">
<div class="border-b p-4 dark:border-neutral-700">
<h2 class="text-xl font-semibold">
All Memory Items
</h2>
<p class="mt-1 text-sm text-neutral-500">
Items are ranked by current memory strength (click to analyze or simulate retrieval)
</p>
</div>
<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">
Memory ID
</th>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
Original Score
</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">
Retrievals
</th>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
Last Retrieved
</th>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
Memory Status
</th>
<th class="px-4 py-3 text-left text-xs text-neutral-500 font-medium tracking-wider uppercase">
Current 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 class="bg-white divide-y divide-neutral-200 dark:bg-neutral-900 dark:divide-neutral-800">
<tr
v-for="(item, idx) in memories" :key="item.storyid"
:class="{ 'bg-blue-50 dark:bg-blue-900/20': item.storyid === selectedId }"
class="cursor-pointer transition duration-150 hover:bg-neutral-50 dark:hover:bg-neutral-800"
@click="selectMemory(item.storyid)"
>
<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">
{{ item.storyid }}
</td>
<td class="whitespace-nowrap px-4 py-2 text-sm">
{{ Math.round(item.score) }}
</td>
<td class="whitespace-nowrap px-4 py-2 text-sm">
{{ Math.round(item.age_in_seconds / (24 * 60 * 60)) }}
</td>
<td
class="whitespace-nowrap px-4 py-2 text-sm font-medium"
:class="item.retrieval_count > 0 ? 'text-green-600 dark:text-green-400' : ''"
>
{{ item.retrieval_count }}
</td>
<td class="whitespace-nowrap px-4 py-2 text-sm">
{{ item.retrieval_count > 0 ? new Date(item.last_retrieved_at).toLocaleDateString() : '-' }}
</td>
<td class="whitespace-nowrap px-4 py-2 text-sm">
<span
class="rounded px-2 py-1 text-xs font-medium"
:class="getMemoryStatus(item).class"
>
{{ getMemoryStatus(item).label }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-2">
<div class="flex items-center">
<div class="w-16 text-sm">
{{ Math.round(item.decayed_score) }}
</div>
<div class="ml-2 h-2 w-24 rounded-full bg-neutral-200 dark:bg-neutral-700">
<div
class="h-full max-w-full rounded-full"
:class="longTermMemoryEnabled && item.retrieval_count >= longTermMemoryThreshold ? 'bg-purple-500' : 'bg-blue-500'"
:style="`width: ${Math.round((item.decayed_score / item.score) * 100)}%`"
/>
</div>
</div>
</td>
<td class="whitespace-nowrap px-4 py-2 text-right">
<button
class="rounded-lg bg-green-100 px-3 py-1 text-xs font-medium dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-800"
@click="retrieveMemory(item.storyid, $event)"
>
Retrieve
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
@media (max-width: 768px) {
:deep(table) {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
</style>
@@ -0,0 +1,330 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
modelValue: number
min?: number
max?: number
step?: number
disabled?: boolean
thumbColor?: string
trackColor?: string
trackValueColor?: string
}>(), {
min: 0,
max: 100,
step: 1,
disabled: false,
thumbColor: '#9090906e',
trackColor: 'gray',
trackValueColor: 'red',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()
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()
})
onMounted(() => {
updateTrackColor()
})
function updateTrackColor() {
if (!sliderRef.value)
return
sliderRef.value.style.setProperty('--value', sliderRef.value.value)
sliderRef.value.style.setProperty('--min', !sliderRef.value.min ? props.min.toString() : sliderRef.value.min)
sliderRef.value.style.setProperty('--max', !sliderRef.value.max ? props.max.toString() : sliderRef.value.max)
}
</script>
<template>
<input
ref="sliderRef"
v-model.number="sliderValue"
type="range"
:min="scaledMin"
:max="scaledMax"
:step="scaledStep"
class="slider-progress form_input-range"
@input="(e) => {
(e.target as HTMLInputElement).style.setProperty('--value', (e.target as HTMLInputElement).value)
}"
>
</template>
<style scoped>
/*generated with Input range slider CSS style generator (version 20211225)
https://toughengineer.github.io/demo/slider-styler*/
.form_input-range {
--height: 2em;
min-height: var(--height);
appearance: none;
background: transparent;
border-radius: 4px;
transition: background-color 0.2s ease;
--thumb-width: 4px;
--thumb-height: var(--height);
--thumb-box-shadow: 0 0 0px #e6e6e6;
--thumb-border: none;
--thumb-border-radius: 999px;
--thumb-background: oklch(80% var(--theme-colors-chroma-200) calc(var(--theme-colors-hue) + 0));
--thumb-background-hover: oklch(90% var(--theme-colors-chroma-200) calc(var(--theme-colors-hue) + 0));
--thumb-background-active: oklch(70% var(--theme-colors-chroma-200) calc(var(--theme-colors-hue) + 0));
--track-height: calc(var(--height) - var(--track-value-padding) * 2);
--track-box-shadow: none;
--track-border: solid 2px rgb(238, 238, 238);
--track-border-radius: 6px;
--track-background: rgb(238, 238, 238);
--track-background-hover: rgb(238, 238, 238);
--track-background-active: rgb(238, 238, 238);
--track-value-background: rgb(255, 255, 255);
--track-value-background-hover: rgb(255, 255, 255);
--track-value-background-active: rgb(255, 255, 255);
--track-value-padding: 2px;
}
.dark .form_input-range {
--thumb-background: oklch(70% var(--theme-colors-chroma-200) calc(var(--theme-colors-hue) + 0));
--thumb-background-hover: oklch(90% var(--theme-colors-chroma-200) calc(var(--theme-colors-hue) + 0));
--thumb-background-active: oklch(80% var(--theme-colors-chroma-200) calc(var(--theme-colors-hue) + 0));
--track-border: solid 2px rgb(44, 44, 44);
--track-background: rgb(44, 44, 44);
--track-background-hover: rgb(44, 44, 44);
--track-background-active: rgb(44, 44, 44);
--track-value-background: rgb(164, 164, 164);
--track-value-background-hover: rgb(164, 164, 164);
--track-value-background-active: rgb(164, 164, 164);
}
/*progress support*/
.form_input-range.slider-progress {
--range: calc(var(--max) - var(--min));
--ratio: calc((var(--value) - var(--min)) / var(--range));
--sx: calc(0.5 * 0em + var(--ratio) * (100% - 0em));
}
.form_input-range:focus {
outline: none;
}
/*webkit*/
.form_input-range::-webkit-slider-thumb {
appearance: none;
width: var(--thumb-width);
height: var(--thumb-height);
border-radius: var(--thumb-border-radius);
background: var(--thumb-background);
border: var(--thumb-border);
box-shadow: var(--thumb-box-shadow);
margin-top: calc(var(--track-height) * 0.5 - var(--thumb-height) * 0.5 - 2px);
margin-left: calc(0 - var(--track-value-padding));
cursor: col-resize;
transition:
background 0.2s ease-in-out,
box-shadow 0.2s ease-in-out,
border-color 0.2s ease-in-out,
transform 0.2s ease-in-out;
}
.form_input-range::-webkit-slider-runnable-track {
height: var(--track-height);
border: var(--track-border);
border-radius: var(--track-border-radius);
background: var(--track-background);
box-shadow: var(--track-box-shadow);
position: relative;
cursor: col-resize;
transition:
box-shadow 0.2s ease-in-out,
border-color 0.2s ease-in-out;
}
.form_input-range::-webkit-slider-thumb:hover {
background: var(--thumb-background-hover);
}
.form_input-range:hover::-webkit-slider-runnable-track {
background: var(--track-background-hover);
}
.form_input-range::-webkit-slider-thumb:active {
background: var(--thumb-background-active);
}
.form_input-range:active::-webkit-slider-runnable-track {
background: var(--track-background-active);
}
.form_input-range.slider-progress::-webkit-slider-runnable-track {
/* margin-left: var(--track-value-padding); */
margin-right: calc(0 - var(--track-value-padding));
background:
linear-gradient(var(--track-value-background), var(--track-value-background)) 0 / var(--sx) 100% no-repeat,
var(--track-background);
}
.form_input-range.slider-progress:hover::-webkit-slider-runnable-track {
background:
linear-gradient(var(--track-value-background-hover), var(--track-value-background-hover)) 0 / var(--sx) 100%
no-repeat,
var(--track-background-hover);
}
.form_input-range.slider-progress:active::-webkit-slider-runnable-track {
background:
linear-gradient(var(--track-value-background-active), var(--track-value-background-active)) 0 / var(--sx) 100%
no-repeat,
var(--track-background-active);
}
/*mozilla*/
.form_input-range::-moz-range-thumb {
width: var(--thumb-width);
height: var(--thumb-height);
border-radius: var(--thumb-border-radius);
background: var(--thumb-background);
border: none;
box-shadow: var(--thumb-box-shadow);
cursor: col-resize;
margin-left: calc(0 - var(--track-value-padding));
}
.form_input-range::-moz-range-track {
height: var(--track-height);
border: var(--track-border);
border-radius: var(--track-border-radius);
background: var(--track-background);
box-shadow: var(--track-box-shadow);
cursor: col-resize;
/* Trim left and right paddings of track */
width: calc(100% - var(--track-value-padding) * 2);
}
.form_input-range::-moz-range-thumb:hover {
background: var(--thumb-background-hover);
}
.form_input-range:hover::-moz-range-track {
background: var(--track-background-hover);
}
.form_input-range::-moz-range-thumb:active {
background: var(--thumb-background-active);
}
.form_input-range:active::-moz-range-track {
background: var(--track-background-active);
}
.form_input-range.slider-progress::-moz-range-track {
background:
linear-gradient(var(--track-value-background), var(--track-value-background)) 0 / var(--sx) 100% no-repeat,
var(--track-background);
}
.form_input-range.slider-progress:hover::-moz-range-track {
background:
linear-gradient(var(--track-value-background-hover), var(--track-value-background-hover)) 0 / var(--sx) 100%
no-repeat,
var(--track-background-hover);
}
.form_input-range.slider-progress:active::-moz-range-track {
background:
linear-gradient(var(--track-value-background-active), var(--track-value-background-active)) 0 / var(--sx) 100%
no-repeat,
var(--track-background-active);
}
/*ms*/
.form_input-range::-ms-fill-upper {
background: transparent;
border-color: transparent;
}
.form_input-range::-ms-fill-lower {
background: transparent;
border-color: transparent;
}
.form_input-range::-ms-thumb {
width: var(--thumb-width);
height: var(--thumb-height);
border-radius: var(--thumb-border-radius);
background: var(--thumb-background);
border: var(--thumb-border);
box-shadow: var(--thumb-box-shadow);
/** Center thumb */
margin-top: 0;
/** Shift left thumb */
margin-left: calc(0 - var(--track-value-padding));
box-sizing: border-box;
cursor: col-resize;
}
.form_input-range::-ms-track {
height: var(--track-height);
border-radius: var(--track-border-radius);
background: var(--track-background);
border: var(--track-border);
box-shadow: var(--track-box-shadow);
box-sizing: border-box;
cursor: col-resize;
}
.form_input-range::-ms-thumb:hover {
background: var(--thumb-background-hover);
}
.form_input-range:hover::-ms-track {
background: var(--track-background-hover);
}
.form_input-range::-ms-thumb:active {
background: var(--thumb-background-active);
}
.form_input-range:active::-ms-track {
background: var(--track-background-active);
}
.form_input-range.slider-progress::-ms-fill-lower {
height: var(--track-height);
border-radius: var(--track-border-radius) 0 0 var(--track-border-radius);
margin: 0;
background: var(--track-value-background);
border: none;
border-right-width: 0;
/** Shift left thumb */
margin-left: calc(var(--track-value-padding));
/** Shift right thumb */
margin-right: calc(0 - var(--track-value-padding));
}
.form_input-range.slider-progress:hover::-ms-fill-lower {
background: var(--track-value-background-hover);
}
.form_input-range.slider-progress:active::-ms-fill-lower {
background: var(--track-value-background-active);
}
</style>
@@ -0,0 +1,115 @@
import { buildDSN } from '../../../../src/dsn'
import { drizzle } from '../../../../src/index'
import * as schema from '../../../db/schema'
export async function connectToDatabase() {
return drizzle(buildDSN({
scheme: 'duckdb-wasm:',
bundles: 'import-url',
logger: true,
}), { schema })
}
export async function createSchema(db) {
await db.execute(`
CREATE TABLE IF NOT EXISTS currentscores (
storyid VARCHAR,
score DOUBLE,
lastupdate TIMESTAMP,
last_retrieved_at TIMESTAMP,
retrieval_count INTEGER DEFAULT 0
)
`)
}
export async function loadSampleData(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
sampleData.push({
storyid: `story-${i}`,
score,
lastupdate: lastUpdate.toISOString(),
last_retrieved_at: lastRetrievedAt.toISOString(),
retrieval_count: retrievalCount,
})
}
for (const item of sampleData) {
await db.execute(`
INSERT INTO currentscores (storyid, score, lastupdate, last_retrieved_at, retrieval_count)
VALUES ('${item.storyid}', ${item.score}, '${item.lastupdate}', '${item.last_retrieved_at}', ${item.retrieval_count})
`)
}
}
export async function generateDecayQuery(db, {
simulatedTimeOffset,
decayRate,
timeUnitInSeconds,
longTermMemoryEnabled,
longTermMemoryThreshold,
longTermMemoryStability,
retrievalBoost,
retrievalDecaySlowdown,
}) {
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)`
: ''
const query = `
SELECT
storyid,
score,
lastupdate,
last_retrieved_at,
retrieval_count,
CAST(lastupdate AS VARCHAR) as lastupdate_str,
CAST(last_retrieved_at AS VARCHAR) as last_retrieved_str,
(EXTRACT(EPOCH FROM (${simulatedTimestamp} - lastupdate))) as age_in_seconds,
(EXTRACT(EPOCH FROM (${simulatedTimestamp} - last_retrieved_at))) as time_since_retrieval,
${ltmFactorClause}
score *
exp(-${decayRate} * (EXTRACT(EPOCH FROM (${simulatedTimestamp} - lastupdate)))/(${timeUnitInSeconds}) ${ltmDecayModifier}) *
(1 + (retrieval_count * ${retrievalBoost} *
exp(-${decayRate * retrievalDecaySlowdown} *
(EXTRACT(EPOCH FROM (${simulatedTimestamp} - last_retrieved_at)))/(${timeUnitInSeconds}))))
as decayed_score
FROM currentscores
ORDER BY decayed_score DESC
`
return query
}
export async function simulateRetrieval(db, storyId, simulatedTime) {
const simulatedTimestamp = simulatedTime.toISOString()
await db.execute(`
UPDATE currentscores
SET last_retrieved_at = '${simulatedTimestamp}',
retrieval_count = retrieval_count + 1
WHERE storyid = '${storyId}'
`)
}
@@ -5,6 +5,7 @@ import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import '@unocss/reset/tailwind.css'
import 'uno.css'
import './styles/themes.css'
const router = createRouter({ routes, history: createWebHashHistory() })
@@ -728,7 +728,7 @@ onUnmounted(() => {
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">Query Type</label>
<select v-model="queryType" class="w-full border rounded p-2">
<select v-model="queryType" class="w-full rounded border-none bg-neutral-100 p-2 outline-none dark:bg-neutral-800">
<option value="recursive">
Recursive (All Paths)
</option>
@@ -743,7 +743,7 @@ onUnmounted(() => {
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">Start Node</label>
<select v-model="selectedStartNode" class="w-full border rounded p-2">
<select v-model="selectedStartNode" class="w-full rounded border-none bg-neutral-100 p-2 outline-none dark:bg-neutral-800">
<option v-for="node in graphData.nodes" :key="`start-${node.id}`" :value="node.id">
{{ node.name }} ({{ node.type }})
</option>
@@ -752,7 +752,7 @@ onUnmounted(() => {
<div v-if="queryType === 'path'" class="mb-4">
<label class="mb-1 block text-sm font-medium">End Node</label>
<select v-model="selectedEndNode" class="w-full border rounded p-2">
<select v-model="selectedEndNode" class="w-full rounded border-none bg-neutral-100 p-2 outline-none dark:bg-neutral-800">
<option v-for="node in graphData.nodes" :key="`end-${node.id}`" :value="node.id">
{{ node.name }} ({{ node.type }})
</option>
@@ -0,0 +1,351 @@
<!-- src/MemoryDecaySimulator.vue -->
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import MemoryModelSettings from '../components/Memory/DecayModelSettings.vue'
import TimeControls from '../components/Memory/DecayTimeSettings.vue'
import MemoryDetails from '../components/Memory/RecordDetail.vue'
import MemoryChart from '../components/Memory/VisualizeChart.vue'
import MemoryTable from '../components/Memory/VisualizeTable.vue'
import { connectToDatabase, createSchema, generateDecayQuery, loadSampleData, simulateRetrieval } from '../composables/memory/db'
interface MemoryDataItem {
storyid: string
score: number
lastupdate: string // ISO timestamp
last_retrieved_at: string // ISO timestamp
retrieval_count: number
lastupdate_str: string
last_retrieved_str: string
age_in_seconds: number
time_since_retrieval: number
ltm_factor?: number // Optional since it's only present when longTermMemoryEnabled is true
decayed_score: number
}
// Database references
const db = ref(null)
const isMigrated = ref(false)
// Data and query results
const rawData = ref<MemoryDataItem[]>([])
const decayedResults = ref<MemoryDataItem[]>([])
// Parameters for decay function
const decayRate = ref(0.0990)
const timeUnit = ref('days')
const maxDaysToShow = ref(30)
const selectedStoryId = ref(null)
// Time acceleration parameters
const timeMultiplier = ref(60 * 60 * 24) // Default: 1 day/second
const isTimeAccelerated = ref(false)
const simulatedTimeOffset = ref(0)
const lastTickTime = ref(Date.now())
// Advanced memory model parameters
const retrievalBoost = ref(0.25)
const retrievalDecaySlowdown = ref(0.8)
const longTermMemoryEnabled = ref(true)
const longTermMemoryThreshold = ref(5)
const longTermMemoryStability = ref(0.3)
const longTermMemoryVisualize = ref(true)
// UI elements
const showInfoPanel = ref(false)
// Selected memory
const selectedMemory = computed(() => {
if (!selectedStoryId.value || !decayedResults.value.length)
return null
return decayedResults.value.find(s => s.storyid === selectedStoryId.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
})
// Initialize database and load data
async function initialize() {
db.value = await connectToDatabase()
await createSchema(db.value)
await loadSampleData(db.value)
isMigrated.value = true
await loadData()
}
// Load data from the database
async function loadData() {
rawData.value = await db.value?.execute(`
SELECT storyid, score, lastupdate, last_retrieved_at, retrieval_count,
CAST(lastupdate AS VARCHAR) as lastupdate_str,
CAST(last_retrieved_at AS VARCHAR) as last_retrieved_str
FROM currentscores
ORDER BY score DESC
`) || []
await runDecayQuery()
}
const decayQuery = ref('')
watch([simulatedTimeOffset, decayRate, timeUnit, longTermMemoryEnabled, longTermMemoryThreshold, longTermMemoryStability, retrievalBoost, retrievalDecaySlowdown], async () => {
const query = await generateDecayQuery(db.value, {
simulatedTimeOffset: simulatedTimeOffset.value,
decayRate: decayRate.value,
timeUnitInSeconds: timeUnitInSeconds.value,
longTermMemoryEnabled: longTermMemoryEnabled.value,
longTermMemoryThreshold: longTermMemoryThreshold.value,
longTermMemoryStability: longTermMemoryStability.value,
retrievalBoost: retrievalBoost.value,
retrievalDecaySlowdown: retrievalDecaySlowdown.value,
})
decayQuery.value = query
})
// Run the decay query
async function runDecayQuery() {
if (!decayQuery.value)
return
decayedResults.value = await db.value?.execute(decayQuery.value) || []
// Initialize selectedStoryId if not set or update if selection changed
if (!selectedStoryId.value && decayedResults.value.length) {
selectedStoryId.value = decayedResults.value[0].storyid
}
}
// Handle simulated retrieval
async function handleRetrieval(storyId) {
await simulateRetrieval(db.value, storyId, currentSimulatedTime.value)
await loadData()
}
// 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
}
simulatedTimeOffset.value += amount * secondsToAdd
await runDecayQuery()
}
// Toggle info panel
function toggleInfoPanel() {
showInfoPanel.value = !showInfoPanel.value
}
// Time acceleration handling
function tickTime() {
if (!isTimeAccelerated.value)
return
const now = Date.now()
const elapsedMs = now - lastTickTime.value
simulatedTimeOffset.value += (elapsedMs / 1000) * timeMultiplier.value
lastTickTime.value = now
runDecayQuery()
requestAnimationFrame(tickTime)
}
// Watch for parameter changes
watch([
decayRate,
timeUnit,
maxDaysToShow,
retrievalBoost,
retrievalDecaySlowdown,
longTermMemoryEnabled,
longTermMemoryThreshold,
longTermMemoryStability,
longTermMemoryVisualize,
], async () => {
if (isMigrated.value) {
await runDecayQuery()
}
})
// Watch time acceleration state
watch(isTimeAccelerated, (newValue) => {
if (newValue) {
lastTickTime.value = Date.now()
tickTime()
}
})
// 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 class="mx-auto max-w-7xl p-4">
<!-- Loading state -->
<div v-if="!isMigrated" 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>
<div v-else>
<header class="mb-6">
<h1 class="mb-2 text-2xl font-bold">
Memory Decay & Retention Simulator
</h1>
<div class="flex items-center justify-between">
<p class="max-w-2xl text-neutral-600 dark:text-neutral-300">
Visualize how memories decay over time and how repeated retrievals create long-term memory
<button class="ml-2 text-blue-500 hover:underline" @click="toggleInfoPanel">
{{ showInfoPanel ? 'Hide info' : 'Learn more' }}
</button>
</p>
</div>
</header>
<!-- Info Panel -->
<div v-if="showInfoPanel" class="mb-6 rounded-lg bg-neutral-100 p-4 dark:bg-neutral-700/20">
<h2 class="mb-2 text-lg font-semibold">
About Memory Decay and Long-Term Memory Formation
</h2>
<p class="mb-2">
This simulator models both the forgetting curve and how memories become more stable with repeated retrievals:
</p>
<div class="my-3 rounded bg-white p-3 text-center font-mono dark:bg-neutral-800">
score * exp(-decay_rate * time_elapsed / time_unit * (1 - ltm_factor)) * (1 + retrieval_boost)
</div>
<p class="font-medium">
The simulator models three key memory phenomena:
</p>
<ol class="mt-2 list-decimal pl-5 space-y-1">
<li>
<strong>Exponential decay</strong>: Memories naturally fade over time following Ebbinghaus' forgetting curve
</li>
<li>
<strong>Retrieval practice effect</strong>: Each retrieval boosts the memory strength temporarily
</li>
<li>
<strong>Long-term memory formation</strong>: After sufficient retrievals, memories become increasingly stable
and resistant to decay, eventually becoming "permanent"
</li>
</ol>
<p class="mt-3">
This reflects how the brain forms long-term memories through a process of neural consolidation,
where repeated activation of neural pathways leads to structural changes that stabilize memories.
The spaced repetition study method leverages this effect for efficient learning.
</p>
</div>
<!-- Interactive Chart Section -->
<div class="grid grid-cols-1 mb-4 gap-4 lg:grid-cols-3">
<!-- Chart -->
<MemoryChart
:memory-data="decayedResults"
:selected-story-id="selectedStoryId"
:decay-rate="decayRate"
:max-days-to-show="maxDaysToShow"
:long-term-memory-enabled="longTermMemoryEnabled"
:long-term-memory-threshold="longTermMemoryThreshold"
:long-term-memory-visualize="longTermMemoryVisualize"
:retrieval-boost="retrievalBoost"
:retrieval-decay-slowdown="retrievalDecaySlowdown"
class="lg:col-span-2"
/>
<!-- Selected Memory Details -->
<MemoryDetails
v-if="selectedMemory"
:memory="selectedMemory"
:long-term-memory-enabled="longTermMemoryEnabled"
:long-term-memory-threshold="longTermMemoryThreshold"
@retrieve="handleRetrieval"
/>
</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"
v-model:long-term-memory-threshold="longTermMemoryThreshold"
v-model:long-term-memory-stability="longTermMemoryStability"
v-model:long-term-memory-visualize="longTermMemoryVisualize"
v-model:retrieval-boost="retrievalBoost"
v-model:retrieval-decay-slowdown="retrievalDecaySlowdown"
v-model:decay-rate="decayRate"
v-model:time-unit="timeUnit"
v-model:max-days-to-show="maxDaysToShow"
/>
<!-- Memory Table -->
<MemoryTable
:memories="decayedResults"
:selected-id="selectedStoryId"
:long-term-memory-enabled="longTermMemoryEnabled"
:long-term-memory-threshold="longTermMemoryThreshold"
class="mb-4"
@select="selectedStoryId = $event"
@retrieve="handleRetrieval"
/>
<!-- SQL Query Preview -->
<div class="max-w-screen-lg overflow-x-scroll rounded-lg bg-neutral-50 p-4 dark:bg-neutral-800">
<h2 class="mb-2 text-xl font-semibold">
Current SQL Query
</h2>
<pre class="overflow-x-auto rounded bg-neutral-800 p-4 text-sm text-neutral-200">{{ decayQuery }}</pre>
</div>
</div>
</div>
</template>
@@ -0,0 +1,14 @@
:root {
--theme-colors-hue: 255;
--theme-colors-chroma: calc(0.18 + (cos(var(--theme-colors-hue) * 3.14159265 / 180) * 0.04));
--theme-colors-chroma-50: calc(var(--theme-colors-chroma) * 0.3);
--theme-colors-chroma-100: calc(var(--theme-colors-chroma) * 0.5);
--theme-colors-chroma-200: calc(var(--theme-colors-chroma) * 0.6);
--theme-colors-chroma-300: calc(var(--theme-colors-chroma) * 0.75);
--theme-colors-chroma-400: var(--theme-colors-chroma);
--theme-colors-chroma-600: calc(var(--theme-colors-chroma) * 1.15);
--theme-colors-chroma-700: calc(var(--theme-colors-chroma) * 1.1);
--theme-colors-chroma-800: calc(var(--theme-colors-chroma) * 0.85);
--theme-colors-chroma-900: calc(var(--theme-colors-chroma) * 0.7);
--theme-colors-chroma-950: calc(var(--theme-colors-chroma) * 0.5);
}