feat: replace FeedItem.priority with signals

Remove priority field from FeedItem and engine-level sorting.
Add FeedItemSignals with urgency and timeRelevance fields.
Update all source packages to emit signals instead of priority.

Ranking is now the post-processing layer's responsibility.
Urgency values are unchanged from the old priority values.

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-02-28 11:41:07 +00:00
parent 78b0ed94bd
commit bbefd01fe0
16 changed files with 272 additions and 140 deletions

View File

@@ -18,9 +18,9 @@ import type { FeedItem } from "./feed"
* return [{
* id: `weather-${Date.now()}`,
* type: this.type,
* priority: 0.5,
* timestamp: context.time,
* data: { temp: data.temperature },
* signals: { urgency: 0.5, timeRelevance: "ambient" },
* }]
* }
* }

View File

@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { FeedEngine } from "./feed-engine"
import { UnknownActionError, contextKey, contextValue } from "./index"
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources
const noActions = {
@@ -100,12 +100,12 @@ function createWeatherSource(
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: {
temperature: weather.temperature,
condition: weather.condition,
},
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
},
]
},
@@ -131,9 +131,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
{
id: "alert-storm",
type: "alert",
priority: 1.0,
timestamp: new Date(),
data: { message: "Storm warning!" },
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
},
]
}
@@ -322,7 +322,7 @@ describe("FeedEngine", () => {
expect(items[0]!.type).toBe("weather")
})
test("sorts items by priority descending", async () => {
test("returns items in source graph order (no engine-level sorting)", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
@@ -338,8 +338,12 @@ describe("FeedEngine", () => {
const { items } = await engine.refresh()
expect(items).toHaveLength(2)
expect(items[0]!.type).toBe("alert") // priority 1.0
expect(items[1]!.type).toBe("weather") // priority 0.5
// Items returned in topological order (weather before alert)
expect(items[0]!.type).toBe("weather")
expect(items[1]!.type).toBe("alert")
// Signals are preserved for post-processors to consume
expect(items[0]!.signals?.urgency).toBe(0.5)
expect(items[1]!.signals?.urgency).toBe(1.0)
})
test("handles missing upstream context gracefully", async () => {

View File

@@ -150,9 +150,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
}
}
// Sort by priority descending
items.sort((a, b) => b.priority - a.priority)
this.context = context
const result: FeedResult<TItems> = { context, items: items as TItems[], errors }
@@ -314,8 +311,6 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
}
}
items.sort((a, b) => b.priority - a.priority)
const result: FeedResult<TItems> = {
context: this.context,
items: items as TItems[],

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { UnknownActionError, contextKey, contextValue } from "./index"
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources
const noActions = {
@@ -99,12 +99,12 @@ function createWeatherSource(
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: {
temperature: weather.temperature,
condition: weather.condition,
},
signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
},
]
},
@@ -130,9 +130,9 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
{
id: "alert-storm",
type: "alert",
priority: 1.0,
timestamp: new Date(),
data: { message: "Storm warning!" },
signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
},
]
}
@@ -226,9 +226,6 @@ async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; ite
}
}
// Sort by priority descending
items.sort((a, b) => b.priority - a.priority)
return { context, items }
}
@@ -441,8 +438,12 @@ describe("FeedSource", () => {
const { items } = await refreshGraph(graph)
expect(items).toHaveLength(2)
expect(items[0]!.type).toBe("alert") // priority 1.0
expect(items[1]!.type).toBe("weather") // priority 0.5
// Items returned in topological order (weather before alert)
expect(items[0]!.type).toBe("weather")
expect(items[1]!.type).toBe("alert")
// Signals preserved for post-processors
expect(items[0]!.signals?.urgency).toBe(0.5)
expect(items[1]!.signals?.urgency).toBe(1.0)
})
test("source without location context returns empty items", async () => {

View File

@@ -1,3 +1,28 @@
/**
* Source-provided hints for post-processors.
*
* Sources express domain-specific relevance without determining final ranking.
* Post-processors consume these signals alongside other inputs (user affinity,
* time of day, interaction history) to produce the final feed order.
*/
export const TimeRelevance = {
/** Needs attention now (e.g., event starting in minutes, severe alert) */
Imminent: "imminent",
/** Relevant soon (e.g., event in the next hour, approaching deadline) */
Upcoming: "upcoming",
/** Background information (e.g., daily forecast, low-priority status) */
Ambient: "ambient",
} as const
export type TimeRelevance = (typeof TimeRelevance)[keyof typeof TimeRelevance]
export interface FeedItemSignals {
/** Source-assessed urgency (0-1). Post-processors use this as one ranking input. */
urgency?: number
/** How time-sensitive this item is relative to now. */
timeRelevance?: TimeRelevance
}
/**
* A single item in the feed.
*
@@ -8,9 +33,9 @@
* const item: WeatherItem = {
* id: "weather-123",
* type: "weather",
* priority: 0.5,
* timestamp: new Date(),
* data: { temp: 18, condition: "cloudy" },
* signals: { urgency: 0.5, timeRelevance: "ambient" },
* }
* ```
*/
@@ -22,10 +47,10 @@ export interface FeedItem<
id: string
/** Item type, matches the data source type */
type: TType
/** Sort priority (higher = more important, shown first) */
priority: number
/** When this item was generated */
timestamp: Date
/** Type-specific payload */
data: TData
/** Source-provided hints for post-processors. Optional — omit if no signals apply. */
signals?: FeedItemSignals
}

View File

@@ -7,7 +7,8 @@ export type { ActionDefinition } from "./action"
export { UnknownActionError } from "./action"
// Feed
export type { FeedItem } from "./feed"
export type { FeedItem, FeedItemSignals } from "./feed"
export { TimeRelevance } from "./feed"
// Feed Source
export type { FeedSource } from "./feed-source"

View File

@@ -72,8 +72,6 @@ export class Reconciler<TItems extends FeedItem = never> {
}
})
items.sort((a, b) => b.priority - a.priority)
return { items, errors } as ReconcileResult<TItems>
}
}