diff --git a/packages/aris-core/src/feed-engine.ts b/packages/aris-core/src/feed-engine.ts index 1797365..3a4b33a 100644 --- a/packages/aris-core/src/feed-engine.ts +++ b/packages/aris-core/src/feed-engine.ts @@ -299,6 +299,7 @@ export class FeedEngine { let currentItems = items const allGroupedItems: ItemGroup[] = [] const allErrors = [...errors] + const boostScores = new Map() for (const processor of this.postProcessors) { const snapshot = currentItems @@ -321,6 +322,12 @@ export class FeedEngine { if (enhancement.groupedItems?.length) { allGroupedItems.push(...enhancement.groupedItems) } + + if (enhancement.boost) { + for (const [id, score] of Object.entries(enhancement.boost)) { + boostScores.set(id, (boostScores.get(id) ?? 0) + score) + } + } } catch (err) { const sourceId = processor.name || "anonymous" allErrors.push({ @@ -331,6 +338,12 @@ export class FeedEngine { } } + // Apply boost reordering: positive-boost first (desc), then zero, then negative (asc). + // Stable sort within each tier preserves original relative order. + if (boostScores.size > 0) { + currentItems = applyBoostOrder(currentItems, boostScores) + } + // Remove stale item IDs from groups and drop empty groups const itemIds = new Set(currentItems.map((item) => item.id)) const validGroups = allGroupedItems.reduce((acc, group) => { @@ -486,6 +499,47 @@ export class FeedEngine { } } +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +function applyBoostOrder(items: T[], boostScores: Map): T[] { + const positive: T[] = [] + const neutral: T[] = [] + const negative: T[] = [] + + for (const item of items) { + const raw = boostScores.get(item.id) + if (raw === undefined || raw === 0) { + neutral.push(item) + } else { + const clamped = clamp(raw, -1, 1) + if (clamped > 0) { + positive.push(item) + } else if (clamped < 0) { + negative.push(item) + } else { + neutral.push(item) + } + } + } + + // Sort positive descending by boost, negative ascending (most negative last) + positive.sort((a, b) => { + const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1) + const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1) + return bScore - aScore + }) + + negative.sort((a, b) => { + const aScore = clamp(boostScores.get(a.id) ?? 0, -1, 1) + const bScore = clamp(boostScores.get(b.id) ?? 0, -1, 1) + return bScore - aScore + }) + + return [...positive, ...neutral, ...negative] +} + function buildGraph(sources: FeedSource[]): SourceGraph { const byId = new Map() for (const source of sources) { diff --git a/packages/aris-core/src/feed-post-processor.test.ts b/packages/aris-core/src/feed-post-processor.test.ts index 00ce945..5426bbf 100644 --- a/packages/aris-core/src/feed-post-processor.test.ts +++ b/packages/aris-core/src/feed-post-processor.test.ts @@ -209,6 +209,163 @@ describe("FeedPostProcessor", () => { }) }) + // ============================================================================= + // BOOST + // ============================================================================= + + describe("boost", () => { + test("positive boost moves item before non-boosted items", async () => { + const engine = new FeedEngine() + .register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)])) + .registerPostProcessor(async () => ({ boost: { w2: 0.8 } })) + + const result = await engine.refresh() + expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"]) + }) + + test("negative boost moves item after non-boosted items", async () => { + const engine = new FeedEngine() + .register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)])) + .registerPostProcessor(async () => ({ boost: { w1: -0.5 } })) + + const result = await engine.refresh() + expect(result.items.map((i) => i.id)).toEqual(["w2", "w1"]) + }) + + test("multiple boosted items are sorted by boost descending", async () => { + const engine = new FeedEngine() + .register( + createWeatherSource([ + weatherItem("w1", 20), + weatherItem("w2", 25), + weatherItem("w3", 30), + ]), + ) + .registerPostProcessor(async () => ({ + boost: { w3: 0.3, w1: 0.9 }, + })) + + const result = await engine.refresh() + // w1 (0.9) first, w3 (0.3) second, w2 (no boost) last + expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"]) + }) + + test("multiple processors accumulate boost scores", async () => { + const engine = new FeedEngine() + .register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)])) + .registerPostProcessor(async () => ({ boost: { w1: 0.3 } })) + .registerPostProcessor(async () => ({ boost: { w1: 0.4 } })) + + const result = await engine.refresh() + // w1 accumulated boost = 0.7, moves before w2 + expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"]) + }) + + test("accumulated boost is clamped to [-1, 1]", async () => { + const engine = new FeedEngine() + .register( + createWeatherSource([ + weatherItem("w1", 20), + weatherItem("w2", 25), + weatherItem("w3", 30), + ]), + ) + .registerPostProcessor(async () => ({ boost: { w1: 0.8, w2: 0.9 } })) + .registerPostProcessor(async () => ({ boost: { w1: 0.8 } })) + + const result = await engine.refresh() + // w1 accumulated = 1.6 clamped to 1, w2 = 0.9 — w1 still first + expect(result.items.map((i) => i.id)).toEqual(["w1", "w2", "w3"]) + }) + + test("out-of-range boost values are clamped", async () => { + const engine = new FeedEngine() + .register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)])) + .registerPostProcessor(async () => ({ boost: { w1: 5.0 } })) + + const result = await engine.refresh() + // Clamped to 1, still boosted to front + expect(result.items.map((i) => i.id)).toEqual(["w1", "w2"]) + }) + + test("boosting a suppressed item is a no-op", async () => { + const engine = new FeedEngine() + .register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)])) + .registerPostProcessor(async () => ({ + suppress: ["w1"], + boost: { w1: 1.0 }, + })) + + const result = await engine.refresh() + expect(result.items).toHaveLength(1) + expect(result.items[0].id).toBe("w2") + }) + + test("boosting a nonexistent item ID is a no-op", async () => { + const engine = new FeedEngine() + .register(createWeatherSource([weatherItem("w1", 20)])) + .registerPostProcessor(async () => ({ boost: { nonexistent: 1.0 } })) + + const result = await engine.refresh() + expect(result.items).toHaveLength(1) + expect(result.items[0].id).toBe("w1") + }) + + test("items with equal boost retain original relative order", async () => { + const engine = new FeedEngine() + .register( + createWeatherSource([ + weatherItem("w1", 20), + weatherItem("w2", 25), + weatherItem("w3", 30), + ]), + ) + .registerPostProcessor(async () => ({ + boost: { w1: 0.5, w3: 0.5 }, + })) + + const result = await engine.refresh() + // w1 and w3 have equal boost — original order preserved: w1 before w3 + expect(result.items.map((i) => i.id)).toEqual(["w1", "w3", "w2"]) + }) + + test("negative boosts preserve relative order among demoted items", async () => { + const engine = new FeedEngine() + .register( + createWeatherSource([ + weatherItem("w1", 20), + weatherItem("w2", 25), + weatherItem("w3", 30), + ]), + ) + .registerPostProcessor(async () => ({ + boost: { w1: -0.3, w2: -0.3 }, + })) + + const result = await engine.refresh() + // w3 (neutral) first, then w1 and w2 (equal negative) in original order + expect(result.items.map((i) => i.id)).toEqual(["w3", "w1", "w2"]) + }) + + test("boost works alongside additionalItems and groupedItems", async () => { + const extra = calendarItem("c1", "Meeting") + + const engine = new FeedEngine() + .register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)])) + .registerPostProcessor(async () => ({ + additionalItems: [extra], + boost: { c1: 1.0 }, + groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }], + })) + + const result = await engine.refresh() + // c1 boosted to front + expect(result.items[0].id).toBe("c1") + expect(result.items).toHaveLength(3) + expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }]) + }) + }) + // ============================================================================= // PIPELINE ORDERING // ============================================================================= diff --git a/packages/aris-core/src/feed-post-processor.ts b/packages/aris-core/src/feed-post-processor.ts index 0b211e3..87f5381 100644 --- a/packages/aris-core/src/feed-post-processor.ts +++ b/packages/aris-core/src/feed-post-processor.ts @@ -14,6 +14,8 @@ export interface FeedEnhancement { groupedItems?: ItemGroup[] /** Item IDs to remove from the feed */ suppress?: string[] + /** Map of item ID to boost score (-1 to 1). Positive promotes, negative demotes. */ + boost?: Record } /**