Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/core/activation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import Database from "better-sqlite3";
import { applyActivationBoosts, loadBoosts } from "./activation.js";

function createDb(): InstanceType<typeof Database> {
const db = new Database(":memory:");
db.exec(`
CREATE TABLE IF NOT EXISTS boosts (
title TEXT PRIMARY KEY,
boost REAL NOT NULL,
updated TEXT NOT NULL
)
`);
return db;
}

describe("applyActivationBoosts", () => {
let db: InstanceType<typeof Database>;

beforeEach(() => {
db = createDb();
});

afterEach(() => {
db.close();
});

it("caps per-query contribution", () => {
// A single query producing a large boost should be capped
const boosts = new Map([["note-a", 0.20]]);
applyActivationBoosts(db, boosts);

const stored = loadBoosts(db);
expect(stored.get("note-a")).toBeCloseTo(0.05, 5);
});

it("accumulates with diminishing returns (log-scale)", () => {
// Apply the same boost 20 times rapidly (same timestamp effectively)
for (let i = 0; i < 20; i++) {
applyActivationBoosts(db, new Map([["note-a", 0.05]]));
}

const stored = loadBoosts(db);
const value = stored.get("note-a")!;

// With log-scale, 20 applications of 0.05 should NOT reach 1.0
// Linear: 20 * 0.05 = 1.0 (saturated)
// Log-scale: 1 - (1-0.05)^20 ≈ 0.64
expect(value).toBeLessThan(0.80);
expect(value).toBeGreaterThan(0.30);
});

it("does not saturate under bulk ingestion", () => {
// Simulate 100 queries hitting the same neighbor
for (let i = 0; i < 100; i++) {
applyActivationBoosts(db, new Map([["note-a", 0.10]]));
}

const stored = loadBoosts(db);
const value = stored.get("note-a")!;

// Should be high but not at ceiling
expect(value).toBeLessThan(0.995);
});

it("preserves human-pace accumulation", () => {
// A few queries should still produce meaningful boost
applyActivationBoosts(db, new Map([["note-a", 0.05]]));
const after1 = loadBoosts(db).get("note-a")!;

applyActivationBoosts(db, new Map([["note-a", 0.05]]));
const after2 = loadBoosts(db).get("note-a")!;

// Second application should add something meaningful
expect(after2).toBeGreaterThan(after1);
expect(after2 - after1).toBeGreaterThan(0.01);
});
});
23 changes: 12 additions & 11 deletions src/core/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ export function computeActivationSpread(
/** Decay constant: half-life ~7 days (exp(-0.1 * 7) ≈ 0.497) */
const DECAY_RATE = 0.1;

/** Maximum boost a single query can contribute to one note */
const PER_QUERY_CAP = 0.05;

/**
* Load all boosts from DB. Apply time-based decay at read time.
* Returns decayed current effective boosts.
Expand Down Expand Up @@ -127,7 +130,7 @@ export function loadBoosts(db: InstanceType<typeof Database>): Map<string, numbe

/**
* Write boosts to DB in one transaction.
* DECAY-BEFORE-ACCUMULATE: read existing, decay to now, add new, clamp to 1.0, store.
* DECAY-BEFORE-ACCUMULATE: read existing, decay to now, accumulate via log-scale, store.
*/
export function applyActivationBoosts(
db: InstanceType<typeof Database>,
Expand All @@ -145,19 +148,17 @@ export function applyActivationBoosts(

const transaction = db.transaction(() => {
for (const [title, newBoost] of boosts) {
let finalBoost = newBoost;
const cappedBoost = Math.min(newBoost, PER_QUERY_CAP);

// Decay existing stored value to now before adding
// Decay existing stored value to now before accumulating
const existing = selectStmt.get(title) as { boost: number; updated: string } | undefined;
if (existing) {
const updatedDate = new Date(existing.updated);
const daysSinceUpdate = Math.max(0, (now.getTime() - updatedDate.getTime()) / (1000 * 60 * 60 * 24));
const decayedExisting = existing.boost * Math.exp(-DECAY_RATE * daysSinceUpdate);
finalBoost = decayedExisting + newBoost;
}
const decayedExisting = existing
? existing.boost * Math.exp(-DECAY_RATE * Math.max(0,
(now.getTime() - new Date(existing.updated).getTime()) / (1000 * 60 * 60 * 24)))
: 0;

// Clamp to 1.0
finalBoost = Math.min(finalBoost, 1.0);
// Log-scale accumulation: asymptotic approach to 1.0
const finalBoost = 1 - (1 - decayedExisting) * (1 - cappedBoost);

upsertStmt.run(title, finalBoost, nowISO);
}
Expand Down
Loading