diff --git a/.gitignore b/.gitignore index e6d5efa..80a81ba 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ coverage/ *.log +package-lock.json diff --git a/core/HotpathPolicy.ts b/core/HotpathPolicy.ts index f8e5bf0..6362755 100644 --- a/core/HotpathPolicy.ts +++ b/core/HotpathPolicy.ts @@ -1,141 +1,137 @@ -import type { TierQuotas } from "./types"; +// --------------------------------------------------------------------------- +// HotpathPolicy — Williams Bound policy foundation +// --------------------------------------------------------------------------- +// +// Central source of truth for the Williams Bound architecture. +// All hotpath constants live here as a frozen default policy object. +// Policy-derived != model-derived — kept strictly separate from ModelDefaults. +// --------------------------------------------------------------------------- + +import type { SalienceWeights, TierQuotaRatios, TierQuotas } from "./types"; // --------------------------------------------------------------------------- -// Weight / ratio parameter types +// HotpathPolicy interface // --------------------------------------------------------------------------- -export interface SalienceWeights { - alpha: number; // Hebbian in-degree weight - beta: number; // recency weight - gamma: number; // query-hit weight -} +export interface HotpathPolicy { + /** Scaling factor in H(t) = ceil(c * sqrt(t * log2(1+t))) */ + readonly c: number; + + /** Salience weights: sigma = alpha*H_in + beta*R + gamma*Q */ + readonly salienceWeights: SalienceWeights; -export interface TierQuotaRatios { - shelf: number; - volume: number; - book: number; - page: number; + /** Fractional tier quota ratios (must sum to 1.0) */ + readonly tierQuotaRatios: TierQuotaRatios; } // --------------------------------------------------------------------------- -// Frozen default policy constants +// Frozen default policy object // --------------------------------------------------------------------------- -export const DEFAULT_HOTPATH_POLICY = Object.freeze({ - /** Capacity scaling constant. */ +export const DEFAULT_HOTPATH_POLICY: HotpathPolicy = Object.freeze({ c: 0.5, - /** Hebbian in-degree weight (α). */ - alpha: 0.5, - /** Recency weight (β). */ - beta: 0.3, - /** Query-hit weight (γ). */ - gamma: 0.2, - /** Tier quota ratios. */ - q_s: 0.10, - q_v: 0.20, - q_b: 0.20, - q_p: 0.50, + salienceWeights: Object.freeze({ + alpha: 0.5, // Hebbian connectivity + beta: 0.3, // recency + gamma: 0.2, // query-hit frequency + }), + tierQuotaRatios: Object.freeze({ + shelf: 0.10, + volume: 0.20, + book: 0.20, + page: 0.50, + }), }); // --------------------------------------------------------------------------- -// computeCapacity — H(t) = ⌈c · √(t · log₂(1+t))⌉ +// H(t) — Resident hotpath capacity // --------------------------------------------------------------------------- /** - * Williams Bound capacity function. + * Compute the resident hotpath capacity H(t) = ceil(c * sqrt(t * log2(1+t))). * - * Returns an integer ≥ 1 for any non-negative finite `graphMass`. - * For `graphMass === 0` the inner product is 0, so ⌈0⌉ = 0, but we clamp to 1 - * to guarantee at least one hotpath slot is always available. + * Properties guaranteed by tests: + * - Monotonically non-decreasing + * - Sublinear growth (H(t)/t shrinks as t grows) + * - Returns a finite integer >= 1 for any non-negative finite t */ -export function computeCapacity(graphMass: number): number { - const c = DEFAULT_HOTPATH_POLICY.c; - const t = Math.max(0, graphMass); - - if (!Number.isFinite(t)) { - // Handle Infinity / NaN — return a safe large integer - return Number.MAX_SAFE_INTEGER; +export function computeCapacity( + graphMass: number, + c: number = DEFAULT_HOTPATH_POLICY.c, +): number { + if (!Number.isFinite(graphMass) || graphMass < 0) { + return 1; } + if (graphMass === 0) return 1; - const log2 = Math.log2(1 + t); - const inner = t * log2; - const raw = c * Math.sqrt(inner); - - if (!Number.isFinite(raw)) { - return Number.MAX_SAFE_INTEGER; - } + const log2 = Math.log2(1 + graphMass); + const raw = c * Math.sqrt(graphMass * log2); - return Math.max(1, Math.ceil(raw)); + if (!Number.isFinite(raw) || raw < 1) return 1; + return Math.ceil(raw); } // --------------------------------------------------------------------------- -// computeSalience — σ = α·H_in + β·R + γ·Q +// Node salience — sigma = alpha*H_in + beta*R + gamma*Q // --------------------------------------------------------------------------- /** - * Computes salience score for a hotpath candidate. + * Compute node salience: sigma = alpha*H_in + beta*R + gamma*Q. * - * Always returns a finite number. Inputs that produce `NaN` or `Infinity` are - * clamped to `0`. + * @param hebbianIn Sum of incident Hebbian edge weights + * @param recency Recency score (0-1, exponential decay) + * @param queryHits Query-hit count for the node + * @param weights Tunable weights (default from policy) */ export function computeSalience( hebbianIn: number, recency: number, queryHits: number, - weights?: SalienceWeights, + weights: SalienceWeights = DEFAULT_HOTPATH_POLICY.salienceWeights, ): number { - const α = weights?.alpha ?? DEFAULT_HOTPATH_POLICY.alpha; - const β = weights?.beta ?? DEFAULT_HOTPATH_POLICY.beta; - const γ = weights?.gamma ?? DEFAULT_HOTPATH_POLICY.gamma; - - const raw = α * hebbianIn + β * recency + γ * queryHits; + const raw = weights.alpha * hebbianIn + + weights.beta * recency + + weights.gamma * queryHits; if (!Number.isFinite(raw)) return 0; return raw; } // --------------------------------------------------------------------------- -// deriveTierQuotas — allocate H(t) across tiers +// Tier quota derivation // --------------------------------------------------------------------------- /** - * Distributes `capacity` slots across four tiers according to `quotaRatios`. + * Allocate H(t) across shelf/volume/book/page tiers. * - * The distribution uses a largest-remainder method so the integer counts - * always sum **exactly** to `capacity`. + * Uses largest-remainder method so quotas sum exactly to `capacity`. */ export function deriveTierQuotas( capacity: number, - quotaRatios?: TierQuotaRatios, + ratios: TierQuotaRatios = DEFAULT_HOTPATH_POLICY.tierQuotaRatios, ): TierQuotas { - const ratios = quotaRatios ?? { - shelf: DEFAULT_HOTPATH_POLICY.q_s, - volume: DEFAULT_HOTPATH_POLICY.q_v, - book: DEFAULT_HOTPATH_POLICY.q_b, - page: DEFAULT_HOTPATH_POLICY.q_p, - }; + const tiers: (keyof TierQuotas)[] = ["shelf", "volume", "book", "page"]; - const cap = Math.max(0, Math.floor(capacity)); - const keys: (keyof TierQuotas)[] = ["shelf", "volume", "book", "page"]; - - // Normalise ratios so they sum to 1 - const rawTotal = keys.reduce((sum, k) => sum + ratios[k], 0); - const normalised = keys.map((k) => (rawTotal > 0 ? ratios[k] / rawTotal : 0.25)); - - // Compute proportional (floating) values, then floor - const proportional = normalised.map((r) => r * cap); - const floors = proportional.map(Math.floor); - let floorSum = floors.reduce((a, b) => a + b, 0); - - // Distribute remainders via largest-remainder method - const remainders = proportional.map((p, i) => ({ idx: i, rem: p - floors[i] })); - remainders.sort((a, b) => b.rem - a.rem); + // Normalize ratios so they sum to 1 + const rawTotal = tiers.reduce((sum, t) => sum + ratios[t], 0); + const normalized = tiers.map((t) => (rawTotal > 0 ? ratios[t] / rawTotal : 0.25)); - let i = 0; - while (floorSum < cap) { - floors[remainders[i].idx] += 1; - floorSum += 1; - i += 1; + const cap = Math.max(0, Math.floor(capacity)); + const idealShares = normalized.map((r) => r * cap); + const floors = idealShares.map((s) => Math.floor(s)); + let remaining = cap - floors.reduce((a, b) => a + b, 0); + + // Distribute remainders by largest fractional part + const remainders = idealShares.map((s, i) => ({ + index: i, + remainder: s - floors[i], + })); + remainders.sort((a, b) => b.remainder - a.remainder); + + for (const r of remainders) { + if (remaining <= 0) break; + floors[r.index]++; + remaining--; } return { @@ -147,15 +143,16 @@ export function deriveTierQuotas( } // --------------------------------------------------------------------------- -// deriveCommunityQuotas — proportional with min(1) guarantee +// Community quota derivation // --------------------------------------------------------------------------- /** - * Distributes `tierBudget` slots proportionally among communities given their - * sizes, with a minimum of 1 slot per community (when budget allows). + * Distribute a tier budget proportionally across communities. * - * Returns an empty array when `communitySizes` is empty. + * Uses largest-remainder method so quotas sum exactly to `tierBudget`. + * Each community receives a minimum of 1 slot when budget allows. * + * Returns an empty array when `communitySizes` is empty. * If `tierBudget` is 0, every community receives 0. */ export function deriveCommunityQuotas( @@ -173,13 +170,13 @@ export function deriveCommunityQuotas( // Phase 1: assign minimum 1 to each community if budget allows const minPerCommunity = budget >= n ? 1 : 0; const quotas = new Array(n).fill(minPerCommunity); - const remaining = budget - minPerCommunity * n; + const remainingBudget = budget - minPerCommunity * n; - if (remaining === 0 || totalSize === 0) return quotas; + if (remainingBudget === 0 || totalSize === 0) return quotas; // Phase 2: distribute remaining proportionally (largest-remainder) const proportional = communitySizes.map( - (s) => (Math.max(0, s) / totalSize) * remaining, + (s) => (Math.max(0, s) / totalSize) * remainingBudget, ); const floors = proportional.map(Math.floor); let floorSum = floors.reduce((a, b) => a + b, 0); @@ -188,7 +185,7 @@ export function deriveCommunityQuotas( remainders.sort((a, b) => b.rem - a.rem); let j = 0; - while (floorSum < remaining) { + while (floorSum < remainingBudget) { floors[remainders[j].idx] += 1; floorSum += 1; j += 1; diff --git a/core/SalienceEngine.ts b/core/SalienceEngine.ts new file mode 100644 index 0000000..65af71a --- /dev/null +++ b/core/SalienceEngine.ts @@ -0,0 +1,420 @@ +// --------------------------------------------------------------------------- +// SalienceEngine — Decision-making layer for hotpath admission +// --------------------------------------------------------------------------- +// +// Provides per-node salience computation, promotion/eviction lifecycle +// helpers, and community-aware admission logic. +// --------------------------------------------------------------------------- + +import type { Hash, HotpathEntry, MetadataStore } from "./types"; +import { + computeCapacity, + computeSalience, + DEFAULT_HOTPATH_POLICY, + deriveCommunityQuotas, + deriveTierQuotas, + type HotpathPolicy, +} from "./HotpathPolicy"; + +// --------------------------------------------------------------------------- +// Recency helper +// --------------------------------------------------------------------------- + +/** + * Compute recency score R(v) as exponential decay from the most recent + * activity timestamp. Returns a value in [0, 1]. + * + * Uses a half-life of 7 days — after 7 days of inactivity the recency + * score drops to ~0.5; after 30 days it drops to ~0.05. + */ +function recencyScore(isoTimestamp: string | undefined, now: number): number { + if (!isoTimestamp) return 0; + const ts = Date.parse(isoTimestamp); + if (!Number.isFinite(ts)) return 0; + const ageMs = Math.max(0, now - ts); + const HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + return Math.exp((-Math.LN2 * ageMs) / HALF_LIFE_MS); +} + +// --------------------------------------------------------------------------- +// P0-G1: Core salience computation +// --------------------------------------------------------------------------- + +/** + * Fetch PageActivity and incident Hebbian edges for a single page, + * then compute salience via HotpathPolicy. + */ +export async function computeNodeSalience( + pageId: Hash, + metadataStore: MetadataStore, + policy: HotpathPolicy = DEFAULT_HOTPATH_POLICY, + now: number = Date.now(), +): Promise { + const [activity, neighbors] = await Promise.all([ + metadataStore.getPageActivity(pageId), + metadataStore.getNeighbors(pageId), + ]); + + const hebbianIn = neighbors.reduce((sum, e) => sum + e.weight, 0); + + const recency = recencyScore( + activity?.lastQueryAt, + now, + ); + + const queryHits = activity?.queryHitCount ?? 0; + + return computeSalience(hebbianIn, recency, queryHits, policy.salienceWeights); +} + +/** + * Efficient batch version of `computeNodeSalience`. + */ +export async function batchComputeSalience( + pageIds: Hash[], + metadataStore: MetadataStore, + policy: HotpathPolicy = DEFAULT_HOTPATH_POLICY, + now: number = Date.now(), +): Promise> { + const results = new Map(); + + // Parallelize I/O across all pages + const entries = await Promise.all( + pageIds.map(async (id) => { + const salience = await computeNodeSalience(id, metadataStore, policy, now); + return [id, salience] as const; + }), + ); + + for (const [id, salience] of entries) { + results.set(id, salience); + } + + return results; +} + +/** + * Admission gating: should a candidate be promoted into the hotpath? + * + * - During bootstrap (capacity remaining > 0): always admit. + * - During steady-state: admit only if candidate salience exceeds + * the weakest resident salience. + */ +export function shouldPromote( + candidateSalience: number, + weakestResidentSalience: number, + capacityRemaining: number, +): boolean { + if (capacityRemaining > 0) return true; + return candidateSalience > weakestResidentSalience; +} + +/** + * Find the weakest resident in a given tier/community bucket. + * + * Returns the entityId of the weakest entry, or undefined if the + * tier/community bucket is empty. + */ +export async function selectEvictionTarget( + tier: HotpathEntry["tier"], + communityId: string | undefined, + metadataStore: MetadataStore, +): Promise { + const entries = await metadataStore.getHotpathEntries(tier); + + const filtered = communityId !== undefined + ? entries.filter((e) => e.communityId === communityId) + : entries; + + if (filtered.length === 0) return undefined; + + // Find the entry with the lowest salience (deterministic: stable sort by entityId on tie) + let weakest = filtered[0]; + for (let i = 1; i < filtered.length; i++) { + const e = filtered[i]; + if ( + e.salience < weakest.salience || + (e.salience === weakest.salience && e.entityId < weakest.entityId) + ) { + weakest = e; + } + } + + return weakest.entityId; +} + +// --------------------------------------------------------------------------- +// P0-G2: Promotion / eviction lifecycle helpers +// --------------------------------------------------------------------------- + +/** + * Bootstrap phase: fill hotpath greedily by salience while + * resident count < H(t). + * + * Computes salience for all candidate pages, then admits in + * descending salience order until the capacity is reached, + * respecting tier quotas. + */ +export async function bootstrapHotpath( + metadataStore: MetadataStore, + policy: HotpathPolicy = DEFAULT_HOTPATH_POLICY, + candidatePageIds: Hash[] = [], + now: number = Date.now(), +): Promise { + if (candidatePageIds.length === 0) return; + + // Compute salience for all candidates + const salienceMap = await batchComputeSalience( + candidatePageIds, + metadataStore, + policy, + now, + ); + + // Fetch page activities for community info + const activities = await Promise.all( + candidatePageIds.map((id) => metadataStore.getPageActivity(id)), + ); + const communityMap = new Map(); + for (let i = 0; i < candidatePageIds.length; i++) { + communityMap.set(candidatePageIds[i], activities[i]?.communityId); + } + + // Sort candidates by salience descending; break ties by entityId for determinism + const sorted = [...candidatePageIds].sort((a, b) => { + const diff = (salienceMap.get(b) ?? 0) - (salienceMap.get(a) ?? 0); + return diff !== 0 ? diff : a.localeCompare(b); + }); + + // Determine current graph mass for capacity calculation + const currentEntries = await metadataStore.getHotpathEntries(); + const currentCount = currentEntries.length; + + // Estimate graph mass: existing residents + candidates gives a lower bound + // For bootstrap, use total candidate count as graph mass estimate + const graphMass = currentCount + candidatePageIds.length; + const capacity = computeCapacity(graphMass, policy.c); + const tierQuotas = deriveTierQuotas(capacity, policy.tierQuotaRatios); + + // Track how many are already in each tier + const tierCounts: Record = { shelf: 0, volume: 0, book: 0, page: 0 }; + for (const entry of currentEntries) { + tierCounts[entry.tier] = (tierCounts[entry.tier] ?? 0) + 1; + } + + let totalResident = currentCount; + + for (const candidateId of sorted) { + if (totalResident >= capacity) break; + + const tier: HotpathEntry["tier"] = "page"; // bootstrap admits at page tier + if (tierCounts[tier] >= tierQuotas[tier]) continue; + + const salience = salienceMap.get(candidateId) ?? 0; + const entry: HotpathEntry = { + entityId: candidateId, + tier, + salience, + communityId: communityMap.get(candidateId), + }; + + await metadataStore.putHotpathEntry(entry); + tierCounts[tier]++; + totalResident++; + } +} + +/** + * Steady-state promotion sweep: for each candidate, promote if its + * salience exceeds the weakest resident in the same tier/community + * bucket. On promotion, evict the weakest. + * + * Tier quotas and community quotas are enforced regardless of whether + * overall capacity is full, preventing any single tier or community + * from monopolizing the hotpath during ramp-up. + */ +export async function runPromotionSweep( + candidateIds: Hash[], + metadataStore: MetadataStore, + policy: HotpathPolicy = DEFAULT_HOTPATH_POLICY, + now: number = Date.now(), +): Promise { + if (candidateIds.length === 0) return; + + // Compute salience for all candidates + const salienceMap = await batchComputeSalience( + candidateIds, + metadataStore, + policy, + now, + ); + + // Fetch page activities for community info + const activities = await Promise.all( + candidateIds.map((id) => metadataStore.getPageActivity(id)), + ); + const communityMap = new Map(); + for (let i = 0; i < candidateIds.length; i++) { + communityMap.set(candidateIds[i], activities[i]?.communityId); + } + + // Sort candidates by salience descending for deterministic processing + const sorted = [...candidateIds].sort((a, b) => { + const diff = (salienceMap.get(b) ?? 0) - (salienceMap.get(a) ?? 0); + return diff !== 0 ? diff : a.localeCompare(b); + }); + + // Load initial state into an in-memory cache to avoid repeated store reads + let cachedEntries = await metadataStore.getHotpathEntries(); + + for (const candidateId of sorted) { + const candidateSalience = salienceMap.get(candidateId) ?? 0; + const communityId = communityMap.get(candidateId); + const tier: HotpathEntry["tier"] = "page"; + + // Derive capacity and quotas from current state + const currentCount = cachedEntries.length; + const graphMass = currentCount + candidateIds.length; + const capacity = computeCapacity(graphMass, policy.c); + const capacityRemaining = capacity - currentCount; + const tierQuotas = deriveTierQuotas(capacity, policy.tierQuotaRatios); + const tierEntries = cachedEntries.filter((e) => e.tier === tier); + const tierFull = tierEntries.length >= tierQuotas[tier]; + + // --- Community quota check (enforced regardless of capacityRemaining) --- + if (communityId !== undefined && tierFull) { + const communitySizes = getCommunityDistribution(tierEntries, communityId); + const communityQuotas = deriveCommunityQuotas( + tierQuotas[tier], + communitySizes.sizes, + ); + const communityIdx = communitySizes.communityIndex; + const communityBudget = communityIdx < communityQuotas.length + ? communityQuotas[communityIdx] + : 0; + const communityCount = communitySizes.candidateCommunityCount; + + if (communityCount >= communityBudget && communityCount > 0) { + // Community is at quota — only promote if candidate beats weakest in community + const weakestId = findWeakestIn(tierEntries, communityId); + if (weakestId === undefined) continue; + + const weakestSalience = tierEntries.find((e) => e.entityId === weakestId)?.salience ?? 0; + + if (candidateSalience > weakestSalience) { + await metadataStore.removeHotpathEntry(weakestId); + const newEntry: HotpathEntry = { + entityId: candidateId, + tier, + salience: candidateSalience, + communityId, + }; + await metadataStore.putHotpathEntry(newEntry); + cachedEntries = cachedEntries.filter((e) => e.entityId !== weakestId); + cachedEntries.push(newEntry); + } + continue; + } + } + + // --- Tier quota check (enforced regardless of capacityRemaining) --- + if (tierFull) { + // Tier is at quota — evict the weakest in the entire tier (not scoped + // to candidate's community, so new communities can displace weak entries) + const weakestId = findWeakestIn(tierEntries, undefined); + if (weakestId === undefined) continue; + + const weakestSalience = tierEntries.find((e) => e.entityId === weakestId)?.salience ?? 0; + + if (candidateSalience <= weakestSalience) continue; + + await metadataStore.removeHotpathEntry(weakestId); + const newEntry: HotpathEntry = { + entityId: candidateId, + tier, + salience: candidateSalience, + communityId, + }; + await metadataStore.putHotpathEntry(newEntry); + cachedEntries = cachedEntries.filter((e) => e.entityId !== weakestId); + cachedEntries.push(newEntry); + } else if (capacityRemaining > 0) { + // Both tier and overall capacity available — admit directly + const newEntry: HotpathEntry = { + entityId: candidateId, + tier, + salience: candidateSalience, + communityId, + }; + await metadataStore.putHotpathEntry(newEntry); + cachedEntries.push(newEntry); + } + // else: tier has room but overall capacity is full — skip + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Compute community distribution within a set of tier entries, + * including the candidate's community. + * + * The candidate's community is counted as having at least size 1 for + * quota derivation, so that a brand-new community can receive its + * first slot via the largest-remainder method. + */ +function getCommunityDistribution( + tierEntries: HotpathEntry[], + candidateCommunityId: string, +): { + sizes: number[]; + communityIndex: number; + candidateCommunityCount: number; +} { + const communityCountMap = new Map(); + + for (const e of tierEntries) { + const cid = e.communityId ?? "__none__"; + communityCountMap.set(cid, (communityCountMap.get(cid) ?? 0) + 1); + } + + // Ensure candidate's community is represented with at least size 1 + // so that deriveCommunityQuotas can allocate it a slot + const actualCount = communityCountMap.get(candidateCommunityId) ?? 0; + communityCountMap.set(candidateCommunityId, Math.max(1, actualCount)); + + const communities = [...communityCountMap.keys()].sort(); + const sizes = communities.map((c) => communityCountMap.get(c) ?? 0); + const communityIndex = communities.indexOf(candidateCommunityId); + + return { sizes, communityIndex, candidateCommunityCount: actualCount }; +} + +/** + * Find the weakest entry in a list, optionally filtered by communityId. + * Deterministic: breaks ties by entityId (smallest wins). + */ +function findWeakestIn( + entries: HotpathEntry[], + communityId: string | undefined, +): Hash | undefined { + const filtered = communityId !== undefined + ? entries.filter((e) => e.communityId === communityId) + : entries; + + if (filtered.length === 0) return undefined; + + let weakest = filtered[0]; + for (let i = 1; i < filtered.length; i++) { + const e = filtered[i]; + if ( + e.salience < weakest.salience || + (e.salience === weakest.salience && e.entityId < weakest.entityId) + ) { + weakest = e; + } + } + return weakest.entityId; +} diff --git a/core/types.ts b/core/types.ts index 58b1135..12e5989 100644 --- a/core/types.ts +++ b/core/types.ts @@ -70,7 +70,7 @@ export interface Edge { export interface MetroidNeighbor { neighborPageId: Hash; cosineSimilarity: number; // threshold is defined by runtime policy - distance: number; // 1 – cosineSimilarity (ready for TSP) + distance: number; // 1 - cosineSimilarity (ready for TSP) } export interface MetroidSubgraph { @@ -82,20 +82,23 @@ export interface MetroidSubgraph { // Hotpath / Williams Bound types // --------------------------------------------------------------------------- +/** Lightweight per-page activity metadata for salience computation. */ export interface PageActivity { pageId: Hash; - queryHitCount: number; - lastQueryAt: string; - communityId?: string; + queryHitCount: number; // incremented on each query hit + lastQueryAt: string; // ISO timestamp of most recent query hit + communityId?: string; // set by Daydreamer label propagation } +/** Record for HOT membership — used in both RAM index and IndexedDB snapshot. */ export interface HotpathEntry { - entityId: Hash; + entityId: Hash; // pageId, bookId, volumeId, or shelfId tier: "shelf" | "volume" | "book" | "page"; - salience: number; - communityId?: string; + salience: number; // salience value at last computation + communityId?: string; // community this entry counts against } +/** Per-tier slot budgets derived from H(t). */ export interface TierQuotas { shelf: number; volume: number; @@ -103,6 +106,21 @@ export interface TierQuotas { page: number; } +/** Fractional quota ratios for each tier (must sum to 1.0). */ +export interface TierQuotaRatios { + shelf: number; + volume: number; + book: number; + page: number; +} + +/** Tunable weights for the salience formula: sigma = alpha*H_in + beta*R + gamma*Q. */ +export interface SalienceWeights { + alpha: number; // weight for Hebbian connectivity + beta: number; // weight for recency + gamma: number; // weight for query-hit frequency +} + // --------------------------------------------------------------------------- // Storage abstractions // --------------------------------------------------------------------------- @@ -173,8 +191,11 @@ export interface MetadataStore { // --- Hotpath index --- putHotpathEntry(entry: HotpathEntry): Promise; getHotpathEntries(tier?: HotpathEntry["tier"]): Promise; + removeHotpathEntry(entityId: Hash): Promise; evictWeakest(tier: HotpathEntry["tier"], communityId?: string): Promise; getResidentCount(): Promise; + + // --- Page activity --- putPageActivity(activity: PageActivity): Promise; getPageActivity(pageId: Hash): Promise; } diff --git a/storage/IndexedDbMetadataStore.ts b/storage/IndexedDbMetadataStore.ts index bbee51e..5eafd95 100644 --- a/storage/IndexedDbMetadataStore.ts +++ b/storage/IndexedDbMetadataStore.ts @@ -159,7 +159,7 @@ export class IndexedDbMetadataStore implements MetadataStore { // Store the book itself tx.objectStore(STORE.books).put(book); - // Update page→book reverse index for every page in this book + // Update page->book reverse index for every page in this book const idxStore = tx.objectStore(STORE.pageToBook); for (const pageId of book.pageIds) { const getReq = idxStore.get(pageId); @@ -403,55 +403,59 @@ export class IndexedDbMetadataStore implements MetadataStore { return this._put(STORE.hotpathIndex, entry); } - getHotpathEntries(tier?: HotpathEntry["tier"]): Promise { - return new Promise((resolve, reject) => { - const tx = this.db.transaction(STORE.hotpathIndex, "readonly"); - const store = tx.objectStore(STORE.hotpathIndex); - - if (tier !== undefined) { - const idx = store.index("by-tier"); + async getHotpathEntries(tier?: HotpathEntry["tier"]): Promise { + if (tier !== undefined) { + return new Promise((resolve, reject) => { + const tx = this.db.transaction(STORE.hotpathIndex, "readonly"); + const idx = tx.objectStore(STORE.hotpathIndex).index("by-tier"); const req = idx.getAll(IDBKeyRange.only(tier)); req.onsuccess = () => resolve(req.result as HotpathEntry[]); req.onerror = () => reject(req.error); - } else { - const req = store.getAll(); - req.onsuccess = () => resolve(req.result as HotpathEntry[]); - req.onerror = () => reject(req.error); - } + }); + } + return new Promise((resolve, reject) => { + const tx = this.db.transaction(STORE.hotpathIndex, "readonly"); + const req = tx.objectStore(STORE.hotpathIndex).getAll(); + req.onsuccess = () => resolve(req.result as HotpathEntry[]); + req.onerror = () => reject(req.error); }); } - evictWeakest(tier: HotpathEntry["tier"], communityId?: string): Promise { + removeHotpathEntry(entityId: Hash): Promise { return new Promise((resolve, reject) => { const tx = this.db.transaction(STORE.hotpathIndex, "readwrite"); - const store = tx.objectStore(STORE.hotpathIndex); - const idx = store.index("by-tier"); - const req = idx.getAll(IDBKeyRange.only(tier)); - - req.onsuccess = () => { - let entries = req.result as HotpathEntry[]; - if (communityId !== undefined) { - entries = entries.filter((e) => e.communityId === communityId); - } - if (entries.length === 0) { - resolve(); - return; - } - // Find the entry with the lowest salience - let weakest = entries[0]; - for (let i = 1; i < entries.length; i++) { - if (entries[i].salience < weakest.salience) { - weakest = entries[i]; - } - } - store.delete(weakest.entityId); - promisifyTransaction(tx).then(resolve).catch(reject); - }; - req.onerror = () => reject(req.error); + tx.objectStore(STORE.hotpathIndex).delete(entityId); + promisifyTransaction(tx).then(resolve).catch(reject); }); } - getResidentCount(): Promise { + async evictWeakest( + tier: HotpathEntry["tier"], + communityId?: string, + ): Promise { + const entries = await this.getHotpathEntries(tier); + const filtered = communityId !== undefined + ? entries.filter((e) => e.communityId === communityId) + : entries; + + if (filtered.length === 0) return; + + // Deterministic: break ties by entityId (smallest wins) + let weakest = filtered[0]; + for (let i = 1; i < filtered.length; i++) { + const e = filtered[i]; + if ( + e.salience < weakest.salience || + (e.salience === weakest.salience && e.entityId < weakest.entityId) + ) { + weakest = e; + } + } + + await this.removeHotpathEntry(weakest.entityId); + } + + async getResidentCount(): Promise { return new Promise((resolve, reject) => { const tx = this.db.transaction(STORE.hotpathIndex, "readonly"); const req = tx.objectStore(STORE.hotpathIndex).count(); diff --git a/tests/HotpathPolicy.test.ts b/tests/HotpathPolicy.test.ts index aa37ba2..07af156 100644 --- a/tests/HotpathPolicy.test.ts +++ b/tests/HotpathPolicy.test.ts @@ -9,7 +9,7 @@ import { } from "../core/HotpathPolicy"; // --------------------------------------------------------------------------- -// computeCapacity — H(t) = ⌈c · √(t · log₂(1+t))⌉ +// computeCapacity — H(t) = ceil(c * sqrt(t * log2(1+t))) // --------------------------------------------------------------------------- describe("computeCapacity", () => { @@ -62,7 +62,7 @@ describe("computeCapacity", () => { }); // --------------------------------------------------------------------------- -// computeSalience — σ = α·H_in + β·R + γ·Q +// computeSalience — sigma = alpha*H_in + beta*R + gamma*Q // --------------------------------------------------------------------------- describe("computeSalience", () => { @@ -86,7 +86,7 @@ describe("computeSalience", () => { }); it("uses default weights when none provided", () => { - const { alpha, beta, gamma } = DEFAULT_HOTPATH_POLICY; + const { alpha, beta, gamma } = DEFAULT_HOTPATH_POLICY.salienceWeights; const expected = alpha * 3 + beta * 0.5 + gamma * 7; expect(computeSalience(3, 0.5, 7)).toBe(expected); }); @@ -205,12 +205,12 @@ describe("DEFAULT_HOTPATH_POLICY", () => { it("contains expected constant values", () => { expect(DEFAULT_HOTPATH_POLICY.c).toBe(0.5); - expect(DEFAULT_HOTPATH_POLICY.alpha).toBe(0.5); - expect(DEFAULT_HOTPATH_POLICY.beta).toBe(0.3); - expect(DEFAULT_HOTPATH_POLICY.gamma).toBe(0.2); - expect(DEFAULT_HOTPATH_POLICY.q_s).toBe(0.10); - expect(DEFAULT_HOTPATH_POLICY.q_v).toBe(0.20); - expect(DEFAULT_HOTPATH_POLICY.q_b).toBe(0.20); - expect(DEFAULT_HOTPATH_POLICY.q_p).toBe(0.50); + expect(DEFAULT_HOTPATH_POLICY.salienceWeights.alpha).toBe(0.5); + expect(DEFAULT_HOTPATH_POLICY.salienceWeights.beta).toBe(0.3); + expect(DEFAULT_HOTPATH_POLICY.salienceWeights.gamma).toBe(0.2); + expect(DEFAULT_HOTPATH_POLICY.tierQuotaRatios.shelf).toBe(0.10); + expect(DEFAULT_HOTPATH_POLICY.tierQuotaRatios.volume).toBe(0.20); + expect(DEFAULT_HOTPATH_POLICY.tierQuotaRatios.book).toBe(0.20); + expect(DEFAULT_HOTPATH_POLICY.tierQuotaRatios.page).toBe(0.50); }); }); diff --git a/tests/SalienceEngine.test.ts b/tests/SalienceEngine.test.ts new file mode 100644 index 0000000..54ebcaf --- /dev/null +++ b/tests/SalienceEngine.test.ts @@ -0,0 +1,651 @@ +/** + * SalienceEngine test coverage (P0-G3). + * + * Uses an in-memory mock of MetadataStore (with hotpath/activity support) + * to validate salience computation, promotion/eviction lifecycle, + * community quotas, and deterministic eviction. + */ + +import { beforeEach, describe, expect, it } from "vitest"; + +import type { + Edge, + Hash, + HotpathEntry, + MetadataStore, + PageActivity, +} from "../core/types"; +import { + computeCapacity, + computeSalience, + DEFAULT_HOTPATH_POLICY, + deriveCommunityQuotas, + deriveTierQuotas, + type HotpathPolicy, +} from "../core/HotpathPolicy"; +import { + batchComputeSalience, + bootstrapHotpath, + computeNodeSalience, + runPromotionSweep, + selectEvictionTarget, + shouldPromote, +} from "../core/SalienceEngine"; + +// --------------------------------------------------------------------------- +// In-memory MetadataStore mock (minimal, only hotpath-relevant methods) +// --------------------------------------------------------------------------- + +class MockMetadataStore implements MetadataStore { + private edges: Edge[] = []; + private activities = new Map(); + private hotpath = new Map(); + + // --- Hebbian edges --- + async putEdges(edges: Edge[]): Promise { + for (const e of edges) this.edges.push(e); + } + + async getNeighbors(pageId: Hash): Promise { + return this.edges + .filter((e) => e.fromPageId === pageId) + .sort((a, b) => b.weight - a.weight); + } + + // --- Hotpath index --- + async putHotpathEntry(entry: HotpathEntry): Promise { + this.hotpath.set(entry.entityId, { ...entry }); + } + + async getHotpathEntries(tier?: HotpathEntry["tier"]): Promise { + const all = [...this.hotpath.values()]; + return tier !== undefined ? all.filter((e) => e.tier === tier) : all; + } + + async removeHotpathEntry(entityId: Hash): Promise { + this.hotpath.delete(entityId); + } + + async evictWeakest( + tier: HotpathEntry["tier"], + communityId?: string, + ): Promise { + const entries = await this.getHotpathEntries(tier); + const filtered = communityId !== undefined + ? entries.filter((e) => e.communityId === communityId) + : entries; + if (filtered.length === 0) return; + + let weakest = filtered[0]; + for (let i = 1; i < filtered.length; i++) { + if ( + filtered[i].salience < weakest.salience || + (filtered[i].salience === weakest.salience && + filtered[i].entityId < weakest.entityId) + ) { + weakest = filtered[i]; + } + } + this.hotpath.delete(weakest.entityId); + } + + async getResidentCount(): Promise { + return this.hotpath.size; + } + + // --- Page activity --- + async putPageActivity(activity: PageActivity): Promise { + this.activities.set(activity.pageId, { ...activity }); + } + + async getPageActivity(pageId: Hash): Promise { + return this.activities.get(pageId); + } + + // --- Stubs for unused MetadataStore methods --- + async putPage(): Promise { /* stub */ } + async getPage(): Promise { return undefined; } + async putBook(): Promise { /* stub */ } + async getBook(): Promise { return undefined; } + async putVolume(): Promise { /* stub */ } + async getVolume(): Promise { return undefined; } + async putShelf(): Promise { /* stub */ } + async getShelf(): Promise { return undefined; } + async getBooksByPage(): Promise { return []; } + async getVolumesByBook(): Promise { return []; } + async getShelvesByVolume(): Promise { return []; } + async putMetroidNeighbors(): Promise { /* stub */ } + async getMetroidNeighbors(): Promise { return []; } + async getInducedMetroidSubgraph() { return { nodes: [], edges: [] }; } + async needsMetroidRecalc(): Promise { return false; } + async flagVolumeForMetroidRecalc(): Promise { /* stub */ } + async clearMetroidRecalcFlag(): Promise { /* stub */ } +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/** Fixed timestamp for deterministic tests. */ +const NOW = Date.parse("2026-03-13T00:00:00.000Z"); + +function makeEdges(fromId: Hash, targets: { toId: Hash; weight: number }[]): Edge[] { + return targets.map(({ toId, weight }) => ({ + fromPageId: fromId, + toPageId: toId, + weight, + lastUpdatedAt: new Date(NOW).toISOString(), + })); +} + +function makeActivity( + pageId: Hash, + queryHitCount: number, + lastQueryAt: string, + communityId?: string, +): PageActivity { + return { pageId, queryHitCount, lastQueryAt, communityId }; +} + +// --------------------------------------------------------------------------- +// HotpathPolicy unit tests +// --------------------------------------------------------------------------- + +describe("HotpathPolicy", () => { + describe("computeCapacity (H(t))", () => { + it("returns 1 for t=0", () => { + expect(computeCapacity(0)).toBe(1); + }); + + it("returns 1 for t=1", () => { + expect(computeCapacity(1)).toBe(1); + }); + + it("H(t) grows sublinearly: H(10000)/10000 < H(1000)/1000", () => { + const ratio1000 = computeCapacity(1000) / 1000; + const ratio10000 = computeCapacity(10000) / 10000; + expect(ratio10000).toBeLessThan(ratio1000); + }); + + it("H(t) is monotonically non-decreasing", () => { + const points = [0, 1, 2, 10, 100, 1_000, 10_000, 100_000]; + for (let i = 1; i < points.length; i++) { + expect(computeCapacity(points[i])).toBeGreaterThanOrEqual( + computeCapacity(points[i - 1]), + ); + } + }); + + it("H(t) is a finite integer >= 1 for edge inputs", () => { + for (const t of [0, 1, Number.MAX_SAFE_INTEGER]) { + const h = computeCapacity(t); + expect(Number.isFinite(h)).toBe(true); + expect(Number.isInteger(h)).toBe(true); + expect(h).toBeGreaterThanOrEqual(1); + } + }); + }); + + describe("computeSalience", () => { + it("returns weighted sum with default weights", () => { + const sigma = computeSalience(2.0, 0.5, 3); + // 0.5*2.0 + 0.3*0.5 + 0.2*3 = 1.0 + 0.15 + 0.6 = 1.75 + expect(sigma).toBeCloseTo(1.75, 6); + }); + + it("returns 0 when all inputs are 0", () => { + expect(computeSalience(0, 0, 0)).toBe(0); + }); + + it("uses custom weights when provided", () => { + const sigma = computeSalience(1, 1, 1, { alpha: 0.3, beta: 0.3, gamma: 0.4 }); + expect(sigma).toBeCloseTo(1.0, 6); + }); + }); + + describe("deriveTierQuotas", () => { + it("quotas sum to capacity", () => { + const cap = 100; + const q = deriveTierQuotas(cap); + expect(q.shelf + q.volume + q.book + q.page).toBe(cap); + }); + + it("distributes 10 correctly", () => { + const q = deriveTierQuotas(10); + expect(q.shelf).toBe(1); + expect(q.volume).toBe(2); + expect(q.book).toBe(2); + expect(q.page).toBe(5); + }); + + it("handles capacity of 1", () => { + const q = deriveTierQuotas(1); + expect(q.shelf + q.volume + q.book + q.page).toBe(1); + }); + }); + + describe("deriveCommunityQuotas", () => { + it("quotas sum to tier budget", () => { + const quotas = deriveCommunityQuotas(10, [50, 30, 20]); + expect(quotas.reduce((a, b) => a + b, 0)).toBe(10); + }); + + it("returns zero array for empty sizes", () => { + expect(deriveCommunityQuotas(10, [])).toEqual([]); + }); + + it("proportional to community sizes", () => { + const quotas = deriveCommunityQuotas(100, [70, 30]); + expect(quotas[0]).toBe(70); + expect(quotas[1]).toBe(30); + }); + }); +}); + +// --------------------------------------------------------------------------- +// SalienceEngine unit tests (P0-G1) +// --------------------------------------------------------------------------- + +describe("SalienceEngine", () => { + let store: MockMetadataStore; + + beforeEach(() => { + store = new MockMetadataStore(); + }); + + describe("computeNodeSalience", () => { + it("returns 0 for a page with no edges and no activity", async () => { + const salience = await computeNodeSalience("page-1", store, DEFAULT_HOTPATH_POLICY, NOW); + expect(salience).toBe(0); + }); + + it("incorporates Hebbian edge weights", async () => { + await store.putEdges(makeEdges("page-1", [ + { toId: "page-2", weight: 0.8 }, + { toId: "page-3", weight: 0.6 }, + ])); + + const salience = await computeNodeSalience("page-1", store, DEFAULT_HOTPATH_POLICY, NOW); + // hebbianIn = 0.8 + 0.6 = 1.4; alpha=0.5 -> contribution = 0.7 + // No activity -> recency=0, queryHits=0 + expect(salience).toBeCloseTo(0.7, 6); + }); + + it("incorporates query hit count", async () => { + await store.putPageActivity(makeActivity("page-1", 5, new Date(NOW).toISOString())); + + const salience = await computeNodeSalience("page-1", store, DEFAULT_HOTPATH_POLICY, NOW); + // hebbianIn=0, recency~=1 (just now), queryHits=5 + // 0 + 0.3*1 + 0.2*5 = 0.3 + 1.0 = 1.3 + expect(salience).toBeCloseTo(1.3, 1); + }); + + it("recency decays over time", async () => { + const oneWeekAgo = new Date(NOW - 7 * 24 * 60 * 60 * 1000).toISOString(); + await store.putPageActivity(makeActivity("page-1", 0, oneWeekAgo)); + + const salience = await computeNodeSalience("page-1", store, DEFAULT_HOTPATH_POLICY, NOW); + // After 7 days (half-life), recency ~= 0.5; beta=0.3 -> contribution ~= 0.15 + expect(salience).toBeCloseTo(0.15, 1); + }); + }); + + describe("batchComputeSalience", () => { + it("returns a map with salience for all given pages", async () => { + await store.putEdges(makeEdges("page-1", [{ toId: "page-2", weight: 1.0 }])); + await store.putPageActivity(makeActivity("page-2", 3, new Date(NOW).toISOString())); + + const map = await batchComputeSalience( + ["page-1", "page-2"], + store, + DEFAULT_HOTPATH_POLICY, + NOW, + ); + + expect(map.size).toBe(2); + expect(map.get("page-1")).toBeGreaterThan(0); + expect(map.get("page-2")).toBeGreaterThan(0); + }); + + it("returns empty map for empty input", async () => { + const map = await batchComputeSalience([], store); + expect(map.size).toBe(0); + }); + }); + + describe("shouldPromote", () => { + it("returns true during bootstrap (capacity remaining > 0)", () => { + expect(shouldPromote(0.1, 0.5, 10)).toBe(true); + }); + + it("returns true when candidate beats weakest at steady state", () => { + expect(shouldPromote(0.8, 0.3, 0)).toBe(true); + }); + + it("returns false when candidate does not beat weakest", () => { + expect(shouldPromote(0.3, 0.8, 0)).toBe(false); + }); + + it("returns false when candidate equals weakest (strict >)", () => { + expect(shouldPromote(0.5, 0.5, 0)).toBe(false); + }); + }); + + describe("selectEvictionTarget", () => { + it("returns the weakest resident in a tier", async () => { + await store.putHotpathEntry({ entityId: "p1", tier: "page", salience: 0.9 }); + await store.putHotpathEntry({ entityId: "p2", tier: "page", salience: 0.3 }); + await store.putHotpathEntry({ entityId: "p3", tier: "page", salience: 0.6 }); + + const target = await selectEvictionTarget("page", undefined, store); + expect(target).toBe("p2"); + }); + + it("returns undefined for empty tier", async () => { + const target = await selectEvictionTarget("shelf", undefined, store); + expect(target).toBeUndefined(); + }); + + it("filters by communityId when provided", async () => { + await store.putHotpathEntry({ entityId: "p1", tier: "page", salience: 0.9, communityId: "c1" }); + await store.putHotpathEntry({ entityId: "p2", tier: "page", salience: 0.1, communityId: "c2" }); + await store.putHotpathEntry({ entityId: "p3", tier: "page", salience: 0.5, communityId: "c1" }); + + const target = await selectEvictionTarget("page", "c1", store); + expect(target).toBe("p3"); // weakest in c1 + }); + + it("breaks ties deterministically by entityId", async () => { + await store.putHotpathEntry({ entityId: "z-page", tier: "page", salience: 0.5 }); + await store.putHotpathEntry({ entityId: "a-page", tier: "page", salience: 0.5 }); + await store.putHotpathEntry({ entityId: "m-page", tier: "page", salience: 0.5 }); + + const target = await selectEvictionTarget("page", undefined, store); + // Smallest entityId wins when salience is tied + expect(target).toBe("a-page"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// P0-G2: Promotion / eviction lifecycle +// --------------------------------------------------------------------------- + +describe("SalienceEngine lifecycle", () => { + let store: MockMetadataStore; + + beforeEach(() => { + store = new MockMetadataStore(); + }); + + describe("bootstrapHotpath", () => { + it("fills hotpath to exactly H(t) given enough candidates", async () => { + // Create many candidates with varying salience + const candidateIds: Hash[] = []; + for (let i = 0; i < 50; i++) { + const id = `page-${String(i).padStart(3, "0")}`; + candidateIds.push(id); + await store.putEdges(makeEdges(id, [ + { toId: `neighbor-${i}`, weight: (50 - i) * 0.1 }, + ])); + await store.putPageActivity( + makeActivity(id, i, new Date(NOW).toISOString()), + ); + } + + await bootstrapHotpath(store, DEFAULT_HOTPATH_POLICY, candidateIds, NOW); + + const count = await store.getResidentCount(); + const graphMass = 50; // candidate count + const expectedCapacity = computeCapacity(graphMass, DEFAULT_HOTPATH_POLICY.c); + const tierQuotas = deriveTierQuotas(expectedCapacity, DEFAULT_HOTPATH_POLICY.tierQuotaRatios); + + // Bootstrap admits at page tier; should fill to min(page quota, total capacity) + expect(count).toBeLessThanOrEqual(expectedCapacity); + expect(count).toBeLessThanOrEqual(tierQuotas.page); + // And we should have filled as much as possible + expect(count).toBe(Math.min(tierQuotas.page, expectedCapacity)); + }); + + it("does nothing with empty candidate list", async () => { + await bootstrapHotpath(store, DEFAULT_HOTPATH_POLICY, [], NOW); + expect(await store.getResidentCount()).toBe(0); + }); + + it("admits highest-salience candidates first", async () => { + // 3 candidates, but capacity for only some + const ids = ["page-lo", "page-hi", "page-mid"]; + await store.putEdges(makeEdges("page-lo", [{ toId: "x", weight: 0.1 }])); + await store.putEdges(makeEdges("page-hi", [{ toId: "x", weight: 5.0 }])); + await store.putEdges(makeEdges("page-mid", [{ toId: "x", weight: 1.0 }])); + + await bootstrapHotpath(store, DEFAULT_HOTPATH_POLICY, ids, NOW); + + const entries = await store.getHotpathEntries("page"); + // page-hi should have highest salience and be admitted + const admittedIds = entries.map((e) => e.entityId); + expect(admittedIds).toContain("page-hi"); + + // If any was excluded, it should be the lowest + if (admittedIds.length < 3) { + expect(admittedIds).not.toContain("page-lo"); + } + }); + }); + + describe("runPromotionSweep", () => { + it("promotes candidate when it beats weakest resident", async () => { + // Pre-fill hotpath with a weak resident + await store.putHotpathEntry({ + entityId: "weak-resident", + tier: "page", + salience: 0.01, + }); + + // Strong candidate + await store.putEdges(makeEdges("strong-candidate", [ + { toId: "x", weight: 10.0 }, + ])); + await store.putPageActivity( + makeActivity("strong-candidate", 100, new Date(NOW).toISOString()), + ); + + // Use a policy with c that gives capacity exactly matching resident count + // to test steady-state behavior + const policy: HotpathPolicy = { + ...DEFAULT_HOTPATH_POLICY, + c: 0.01, // very small -> capacity will be small + }; + + // With c=0.01 and graphMass=2, capacity will likely be 1 + // So the sweep should evict weak-resident and promote strong-candidate + await runPromotionSweep(["strong-candidate"], store, policy, NOW); + + const entries = await store.getHotpathEntries(); + const ids = entries.map((e) => e.entityId); + + // Strong candidate should be in + expect(ids).toContain("strong-candidate"); + }); + + it("does not promote when candidate is weaker than weakest", async () => { + // Pre-fill with a strong resident + await store.putHotpathEntry({ + entityId: "strong-resident", + tier: "page", + salience: 100.0, + }); + + // Weak candidate (no edges, no activity -> salience = 0) + const policy: HotpathPolicy = { + ...DEFAULT_HOTPATH_POLICY, + c: 0.01, + }; + + await runPromotionSweep(["weak-candidate"], store, policy, NOW); + + const entries = await store.getHotpathEntries(); + const ids = entries.map((e) => e.entityId); + + expect(ids).toContain("strong-resident"); + expect(ids).not.toContain("weak-candidate"); + }); + + it("evicts exactly the weakest resident, not a random entry", async () => { + // Pre-fill with multiple residents + await store.putHotpathEntry({ entityId: "r-strong", tier: "page", salience: 10.0 }); + await store.putHotpathEntry({ entityId: "r-medium", tier: "page", salience: 5.0 }); + await store.putHotpathEntry({ entityId: "r-weak", tier: "page", salience: 0.1 }); + + // Strong candidate + await store.putEdges(makeEdges("candidate", [{ toId: "x", weight: 20.0 }])); + await store.putPageActivity( + makeActivity("candidate", 50, new Date(NOW).toISOString()), + ); + + // Use c that gives capacity <= 3 so tier is full + const policy: HotpathPolicy = { + ...DEFAULT_HOTPATH_POLICY, + c: 0.01, + }; + + await runPromotionSweep(["candidate"], store, policy, NOW); + + const entries = await store.getHotpathEntries(); + const ids = entries.map((e) => e.entityId); + + // r-weak should be evicted (weakest), not r-strong or r-medium + expect(ids).not.toContain("r-weak"); + expect(ids).toContain("r-strong"); + expect(ids).toContain("r-medium"); + expect(ids).toContain("candidate"); + }); + + it("does nothing with empty candidate list", async () => { + await store.putHotpathEntry({ entityId: "r1", tier: "page", salience: 1.0 }); + await runPromotionSweep([], store, DEFAULT_HOTPATH_POLICY, NOW); + expect(await store.getResidentCount()).toBe(1); + }); + }); + + describe("community quotas", () => { + it("prevent a single community from consuming all page-tier slots", async () => { + // Pre-fill 3 page entries from community "big" with low salience + await store.putHotpathEntry({ entityId: "big-0", tier: "page", salience: 0.1, communityId: "big" }); + await store.putHotpathEntry({ entityId: "big-1", tier: "page", salience: 0.2, communityId: "big" }); + await store.putHotpathEntry({ entityId: "big-2", tier: "page", salience: 0.3, communityId: "big" }); + + // A strong candidate from a brand-new community "small" + await store.putEdges(makeEdges("small-candidate", [{ toId: "x", weight: 50.0 }])); + await store.putPageActivity( + makeActivity("small-candidate", 100, new Date(NOW).toISOString(), "small"), + ); + + // c=0.01 -> very small capacity, page tier is full + const policy: HotpathPolicy = { ...DEFAULT_HOTPATH_POLICY, c: 0.01 }; + + await runPromotionSweep(["small-candidate"], store, policy, NOW); + + const entries = await store.getHotpathEntries("page"); + const bigCount = entries.filter((e) => e.communityId === "big").length; + const smallCount = entries.filter((e) => e.communityId === "small").length; + + // The new community was admitted by displacing the weakest "big" entry + expect(smallCount).toBeGreaterThanOrEqual(1); + // "big" lost one slot + expect(bigCount).toBeLessThan(3); + }); + + it("enforces tier quotas even when overall capacity is not full", async () => { + // With c=2.0 and 4 page entries + 1 candidate: + // graphMass = 5, capacity = 8, pageBudget = 4 + // tierFull = true (4 >= 4), capacityRemaining = 4 > 0 + // A weak candidate should NOT be admitted despite overall capacity. + const policy: HotpathPolicy = { ...DEFAULT_HOTPATH_POLICY, c: 2.0 }; + + for (let i = 0; i < 4; i++) { + await store.putHotpathEntry({ + entityId: `p-${i}`, + tier: "page", + salience: 0.5 + i * 0.1, + }); + } + + // Weak candidate (no edges, no activity -> salience = 0) + await runPromotionSweep(["weak-candidate"], store, policy, NOW); + + const entries = await store.getHotpathEntries("page"); + const sweepGraphMass = 4 + 1; + const sweepCapacity = computeCapacity(sweepGraphMass, policy.c); + const sweepPageQuota = deriveTierQuotas(sweepCapacity, policy.tierQuotaRatios).page; + + // Page tier must not exceed its quota + expect(entries.length).toBeLessThanOrEqual(sweepPageQuota); + // Weak candidate was not admitted + expect(entries.map((e) => e.entityId)).not.toContain("weak-candidate"); + // Verify our capacity assumptions + expect(sweepCapacity).toBeGreaterThan(4); // overall capacity has room + expect(sweepPageQuota).toBe(4); // but page tier is exactly full + }); + }); + + describe("determinism", () => { + it("eviction is deterministic under the same state", async () => { + // Run the same scenario twice and verify same result + async function runScenario(): Promise { + const s = new MockMetadataStore(); + await s.putHotpathEntry({ entityId: "p1", tier: "page", salience: 0.5 }); + await s.putHotpathEntry({ entityId: "p2", tier: "page", salience: 0.3 }); + await s.putHotpathEntry({ entityId: "p3", tier: "page", salience: 0.8 }); + + await s.putEdges(makeEdges("candidate", [{ toId: "x", weight: 5.0 }])); + await s.putPageActivity( + makeActivity("candidate", 10, new Date(NOW).toISOString()), + ); + + const policy: HotpathPolicy = { + ...DEFAULT_HOTPATH_POLICY, + c: 0.01, + }; + + await runPromotionSweep(["candidate"], s, policy, NOW); + + const entries = await s.getHotpathEntries(); + return entries.map((e) => e.entityId).sort(); + } + + const run1 = await runScenario(); + const run2 = await runScenario(); + + expect(run1).toEqual(run2); + }); + + it("selectEvictionTarget returns same result for same state", async () => { + await store.putHotpathEntry({ entityId: "a", tier: "page", salience: 0.5 }); + await store.putHotpathEntry({ entityId: "b", tier: "page", salience: 0.3 }); + await store.putHotpathEntry({ entityId: "c", tier: "page", salience: 0.7 }); + + const t1 = await selectEvictionTarget("page", undefined, store); + const t2 = await selectEvictionTarget("page", undefined, store); + expect(t1).toBe(t2); + expect(t1).toBe("b"); // weakest by salience + }); + }); + + describe("tier quotas", () => { + it("prevent one hierarchy level from dominating", () => { + const capacity = 100; + const quotas = deriveTierQuotas(capacity); + + // Page tier gets 50%, shelf gets only 10% + expect(quotas.page).toBeGreaterThan(quotas.shelf); + expect(quotas.shelf + quotas.volume + quotas.book + quotas.page).toBe(capacity); + + // No single tier gets more than 50% + expect(quotas.shelf).toBeLessThanOrEqual(capacity * 0.5); + expect(quotas.volume).toBeLessThanOrEqual(capacity * 0.5); + expect(quotas.book).toBeLessThanOrEqual(capacity * 0.5); + expect(quotas.page).toBeLessThanOrEqual(capacity * 0.5); + }); + }); +});