From 28d26b3c87f47f66c220cf26606e7e2c9e0a5d30 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sat, 28 Feb 2026 12:02:57 +0000 Subject: [PATCH] Replace FeedItem.priority with signals (#39) * 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 * fix: use TimeRelevance enum in all tests Co-authored-by: Ona --------- Co-authored-by: Ona --- packages/aris-core/src/data-source.ts | 2 +- packages/aris-core/src/feed-engine.test.ts | 16 +++-- packages/aris-core/src/feed-engine.ts | 5 -- packages/aris-core/src/feed-source.test.ts | 17 ++--- packages/aris-core/src/feed.ts | 31 ++++++++- packages/aris-core/src/index.ts | 3 +- packages/aris-core/src/reconciler.ts | 2 - .../src/data-source.test.ts | 11 +-- .../src/data-source.ts | 68 ++++++++++++++----- .../src/calendar-source.test.ts | 62 ++++++++++------- .../src/calendar-source.ts | 25 +++---- .../src/google-calendar-source.test.ts | 22 +++--- .../src/google-calendar-source.ts | 37 ++++++---- .../aris-source-tfl/src/tfl-source.test.ts | 17 +++-- packages/aris-source-tfl/src/tfl-source.ts | 27 ++++++-- .../src/weather-source.test.ts | 10 +-- .../src/weather-source.ts | 68 ++++++++++++++----- 17 files changed, 278 insertions(+), 145 deletions(-) diff --git a/packages/aris-core/src/data-source.ts b/packages/aris-core/src/data-source.ts index 8b8f940..71ee4d1 100644 --- a/packages/aris-core/src/data-source.ts +++ b/packages/aris-core/src/data-source.ts @@ -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" }, * }] * } * } diff --git a/packages/aris-core/src/feed-engine.test.ts b/packages/aris-core/src/feed-engine.test.ts index 1c4c867..e506f5b 100644 --- a/packages/aris-core/src/feed-engine.test.ts +++ b/packages/aris-core/src/feed-engine.test.ts @@ -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 { { 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 () => { diff --git a/packages/aris-core/src/feed-engine.ts b/packages/aris-core/src/feed-engine.ts index c2ab866..64c4972 100644 --- a/packages/aris-core/src/feed-engine.ts +++ b/packages/aris-core/src/feed-engine.ts @@ -150,9 +150,6 @@ export class FeedEngine { } } - // Sort by priority descending - items.sort((a, b) => b.priority - a.priority) - this.context = context const result: FeedResult = { context, items: items as TItems[], errors } @@ -314,8 +311,6 @@ export class FeedEngine { } } - items.sort((a, b) => b.priority - a.priority) - const result: FeedResult = { context: this.context, items: items as TItems[], diff --git a/packages/aris-core/src/feed-source.test.ts b/packages/aris-core/src/feed-source.test.ts index 4804aab..0b4785a 100644 --- a/packages/aris-core/src/feed-source.test.ts +++ b/packages/aris-core/src/feed-source.test.ts @@ -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 { { 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 () => { diff --git a/packages/aris-core/src/feed.ts b/packages/aris-core/src/feed.ts index e9fb822..9291a9e 100644 --- a/packages/aris-core/src/feed.ts +++ b/packages/aris-core/src/feed.ts @@ -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 } diff --git a/packages/aris-core/src/index.ts b/packages/aris-core/src/index.ts index 349c51f..7b38c9e 100644 --- a/packages/aris-core/src/index.ts +++ b/packages/aris-core/src/index.ts @@ -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" diff --git a/packages/aris-core/src/reconciler.ts b/packages/aris-core/src/reconciler.ts index 35451ad..537e306 100644 --- a/packages/aris-core/src/reconciler.ts +++ b/packages/aris-core/src/reconciler.ts @@ -72,8 +72,6 @@ export class Reconciler { } }) - items.sort((a, b) => b.priority - a.priority) - return { items, errors } as ReconcileResult } } diff --git a/packages/aris-data-source-weatherkit/src/data-source.test.ts b/packages/aris-data-source-weatherkit/src/data-source.test.ts index 4be9dba..fb452cb 100644 --- a/packages/aris-data-source-weatherkit/src/data-source.test.ts +++ b/packages/aris-data-source-weatherkit/src/data-source.test.ts @@ -190,21 +190,22 @@ describe("query() with mocked client", () => { expect(imperialTemp).toBeCloseTo(expectedImperial, 2) }) - test("assigns priority based on weather conditions", async () => { + test("assigns signals based on weather conditions", async () => { const dataSource = new WeatherKitDataSource({ client: mockClient }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const items = await dataSource.query(context) for (const item of items) { - expect(item.priority).toBeGreaterThanOrEqual(0) - expect(item.priority).toBeLessThanOrEqual(1) + expect(item.signals).toBeDefined() + expect(item.signals!.urgency).toBeGreaterThanOrEqual(0) + expect(item.signals!.urgency).toBeLessThanOrEqual(1) + expect(item.signals!.timeRelevance).toBeDefined() } const currentItem = items.find((i) => i.type === WeatherFeedItemType.current) expect(currentItem).toBeDefined() - // Base priority for current is 0.5, may be adjusted for conditions - expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5) + expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5) }) test("generates unique IDs for each item", async () => { diff --git a/packages/aris-data-source-weatherkit/src/data-source.ts b/packages/aris-data-source-weatherkit/src/data-source.ts index 634bbed..a6a16b4 100644 --- a/packages/aris-data-source-weatherkit/src/data-source.ts +++ b/packages/aris-data-source-weatherkit/src/data-source.ts @@ -1,4 +1,6 @@ -import type { Context, DataSource } from "@aris/core" +import type { Context, DataSource, FeedItemSignals } from "@aris/core" + +import { TimeRelevance } from "@aris/core" import { WeatherFeedItemType, @@ -105,7 +107,7 @@ export class WeatherKitDataSource implements DataSource([ ConditionCode.BlowingSnow, ]) -function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number { +function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number { if (SEVERE_CONDITIONS.has(conditionCode)) { - return Math.min(1, basePriority + 0.3) + return Math.min(1, baseUrgency + 0.3) } if (MODERATE_CONDITIONS.has(conditionCode)) { - return Math.min(1, basePriority + 0.15) + return Math.min(1, baseUrgency + 0.15) } - return basePriority + return baseUrgency } -function adjustPriorityForAlertSeverity(severity: Severity): number { +function adjustUrgencyForAlertSeverity(severity: Severity): number { switch (severity) { case Severity.Extreme: return 1 @@ -153,7 +155,29 @@ function adjustPriorityForAlertSeverity(severity: Severity): number { case Severity.Moderate: return 0.75 case Severity.Minor: - return BASE_PRIORITY.alert + return BASE_URGENCY.alert + } +} + +function timeRelevanceForCondition(conditionCode: ConditionCode): TimeRelevance { + if (SEVERE_CONDITIONS.has(conditionCode)) { + return TimeRelevance.Imminent + } + if (MODERATE_CONDITIONS.has(conditionCode)) { + return TimeRelevance.Upcoming + } + return TimeRelevance.Ambient +} + +function timeRelevanceForAlertSeverity(severity: Severity): TimeRelevance { + switch (severity) { + case Severity.Extreme: + case Severity.Severe: + return TimeRelevance.Imminent + case Severity.Moderate: + return TimeRelevance.Upcoming + case Severity.Minor: + return TimeRelevance.Ambient } } @@ -197,12 +221,14 @@ function createCurrentWeatherFeedItem( timestamp: Date, units: Units, ): CurrentWeatherFeedItem { - const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode) + const signals: FeedItemSignals = { + urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode), + timeRelevance: timeRelevanceForCondition(current.conditionCode), + } return { id: `weather-current-${timestamp.getTime()}`, type: WeatherFeedItemType.current, - priority, timestamp, data: { conditionCode: current.conditionCode, @@ -219,6 +245,7 @@ function createCurrentWeatherFeedItem( windGust: convertSpeed(current.windGust, units), windSpeed: convertSpeed(current.windSpeed, units), }, + signals, } } @@ -228,12 +255,14 @@ function createHourlyWeatherFeedItem( timestamp: Date, units: Units, ): HourlyWeatherFeedItem { - const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode) + const signals: FeedItemSignals = { + urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode), + timeRelevance: timeRelevanceForCondition(hourly.conditionCode), + } return { id: `weather-hourly-${timestamp.getTime()}-${index}`, type: WeatherFeedItemType.hourly, - priority, timestamp, data: { forecastTime: new Date(hourly.forecastStart), @@ -250,6 +279,7 @@ function createHourlyWeatherFeedItem( windGust: convertSpeed(hourly.windGust, units), windSpeed: convertSpeed(hourly.windSpeed, units), }, + signals, } } @@ -259,12 +289,14 @@ function createDailyWeatherFeedItem( timestamp: Date, units: Units, ): DailyWeatherFeedItem { - const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode) + const signals: FeedItemSignals = { + urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode), + timeRelevance: timeRelevanceForCondition(daily.conditionCode), + } return { id: `weather-daily-${timestamp.getTime()}-${index}`, type: WeatherFeedItemType.daily, - priority, timestamp, data: { forecastDate: new Date(daily.forecastStart), @@ -279,16 +311,19 @@ function createDailyWeatherFeedItem( temperatureMax: convertTemperature(daily.temperatureMax, units), temperatureMin: convertTemperature(daily.temperatureMin, units), }, + signals, } } function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem { - const priority = adjustPriorityForAlertSeverity(alert.severity) + const signals: FeedItemSignals = { + urgency: adjustUrgencyForAlertSeverity(alert.severity), + timeRelevance: timeRelevanceForAlertSeverity(alert.severity), + } return { id: `weather-alert-${alert.id}`, type: WeatherFeedItemType.alert, - priority, timestamp, data: { alertId: alert.id, @@ -302,5 +337,6 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath source: alert.source, urgency: alert.urgency, }, + signals, } } diff --git a/packages/aris-source-apple-calendar/src/calendar-source.test.ts b/packages/aris-source-apple-calendar/src/calendar-source.test.ts index 5523a69..f55c2da 100644 --- a/packages/aris-source-apple-calendar/src/calendar-source.test.ts +++ b/packages/aris-source-apple-calendar/src/calendar-source.test.ts @@ -1,6 +1,6 @@ import type { Context } from "@aris/core" -import { contextValue } from "@aris/core" +import { TimeRelevance, contextValue } from "@aris/core" import { describe, expect, test } from "bun:test" import { readFileSync } from "node:fs" import { join } from "node:path" @@ -15,7 +15,7 @@ import type { } from "./types.ts" import { CalendarKey } from "./calendar-context.ts" -import { CalendarSource, computePriority } from "./calendar-source.ts" +import { CalendarSource, computeSignals } from "./calendar-source.ts" function loadFixture(name: string): string { return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8") @@ -185,7 +185,7 @@ describe("CalendarSource", () => { expect(items[0]!.timestamp).toEqual(now) }) - test("assigns priority based on event proximity", async () => { + test("assigns signals based on event proximity", async () => { const objects: Record = { "/cal/work": [ { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }, @@ -203,8 +203,10 @@ describe("CalendarSource", () => { const standup = items.find((i) => i.data.title === "Team Standup") const holiday = items.find((i) => i.data.title === "Company Holiday") - expect(standup!.priority).toBe(0.7) // within 2 hours - expect(holiday!.priority).toBe(0.3) // all-day + expect(standup!.signals!.urgency).toBe(0.7) // within 2 hours + expect(standup!.signals!.timeRelevance).toBe(TimeRelevance.Upcoming) + expect(holiday!.signals!.urgency).toBe(0.3) // all-day + expect(holiday!.signals!.timeRelevance).toBe(TimeRelevance.Ambient) }) test("handles calendar with non-string displayName", async () => { @@ -378,7 +380,7 @@ describe("CalendarSource.fetchContext", () => { }) }) -describe("computePriority", () => { +describe("computeSignals", () => { const now = new Date("2026-01-15T12:00:00Z") function makeEvent(overrides: Partial): CalendarEventData { @@ -401,73 +403,83 @@ describe("computePriority", () => { } } - test("all-day events get priority 0.3", () => { + test("all-day events get urgency 0.3 and ambient relevance", () => { const event = makeEvent({ isAllDay: true }) - expect(computePriority(event, now)).toBe(0.3) + const signals = computeSignals(event, now) + expect(signals.urgency).toBe(0.3) + expect(signals.timeRelevance).toBe(TimeRelevance.Ambient) }) - test("events starting within 30 minutes get priority 0.9", () => { + test("events starting within 30 minutes get urgency 0.9 and imminent relevance", () => { const event = makeEvent({ startDate: new Date("2026-01-15T12:20:00Z"), }) - expect(computePriority(event, now)).toBe(0.9) + const signals = computeSignals(event, now) + expect(signals.urgency).toBe(0.9) + expect(signals.timeRelevance).toBe(TimeRelevance.Imminent) }) - test("events starting exactly at 30 minutes get priority 0.9", () => { + test("events starting exactly at 30 minutes get urgency 0.9", () => { const event = makeEvent({ startDate: new Date("2026-01-15T12:30:00Z"), }) - expect(computePriority(event, now)).toBe(0.9) + expect(computeSignals(event, now).urgency).toBe(0.9) }) - test("events starting within 2 hours get priority 0.7", () => { + test("events starting within 2 hours get urgency 0.7 and upcoming relevance", () => { const event = makeEvent({ startDate: new Date("2026-01-15T13:00:00Z"), }) - expect(computePriority(event, now)).toBe(0.7) + const signals = computeSignals(event, now) + expect(signals.urgency).toBe(0.7) + expect(signals.timeRelevance).toBe(TimeRelevance.Upcoming) }) - test("events later today get priority 0.5", () => { + test("events later today get urgency 0.5", () => { const event = makeEvent({ startDate: new Date("2026-01-15T20:00:00Z"), }) - expect(computePriority(event, now)).toBe(0.5) + expect(computeSignals(event, now).urgency).toBe(0.5) }) - test("in-progress events get priority 0.8", () => { + test("in-progress events get urgency 0.8 and imminent relevance", () => { const event = makeEvent({ startDate: new Date("2026-01-15T11:00:00Z"), endDate: new Date("2026-01-15T13:00:00Z"), }) - expect(computePriority(event, now)).toBe(0.8) + const signals = computeSignals(event, now) + expect(signals.urgency).toBe(0.8) + expect(signals.timeRelevance).toBe(TimeRelevance.Imminent) }) - test("fully past events get priority 0.2", () => { + test("fully past events get urgency 0.2 and ambient relevance", () => { const event = makeEvent({ startDate: new Date("2026-01-15T09:00:00Z"), endDate: new Date("2026-01-15T10:00:00Z"), }) - expect(computePriority(event, now)).toBe(0.2) + const signals = computeSignals(event, now) + expect(signals.urgency).toBe(0.2) + expect(signals.timeRelevance).toBe(TimeRelevance.Ambient) }) - test("events on future days get priority 0.2", () => { + test("events on future days get urgency 0.2", () => { const event = makeEvent({ startDate: new Date("2026-01-16T10:00:00Z"), }) - expect(computePriority(event, now)).toBe(0.2) + expect(computeSignals(event, now).urgency).toBe(0.2) }) - test("priority boundaries are correct", () => { + test("urgency boundaries are correct", () => { // 31 minutes from now should be 0.7 (within 2 hours, not within 30 min) const event31min = makeEvent({ startDate: new Date("2026-01-15T12:31:00Z"), }) - expect(computePriority(event31min, now)).toBe(0.7) + expect(computeSignals(event31min, now).urgency).toBe(0.7) // 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours) const event2h1m = makeEvent({ startDate: new Date("2026-01-15T14:01:00Z"), }) - expect(computePriority(event2h1m, now)).toBe(0.5) + expect(computeSignals(event2h1m, now).urgency).toBe(0.5) }) }) diff --git a/packages/aris-source-apple-calendar/src/calendar-source.ts b/packages/aris-source-apple-calendar/src/calendar-source.ts index 309e63d..14275d8 100644 --- a/packages/aris-source-apple-calendar/src/calendar-source.ts +++ b/packages/aris-source-apple-calendar/src/calendar-source.ts @@ -1,6 +1,6 @@ -import type { ActionDefinition, Context, FeedSource } from "@aris/core" -import { UnknownActionError } from "@aris/core" +import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core" +import { TimeRelevance, UnknownActionError } from "@aris/core" import { DAVClient } from "tsdav" import type { @@ -202,9 +202,9 @@ function computeTimeRange(now: Date, lookAheadDays: number): { start: Date; end: return { start, end } } -export function computePriority(event: CalendarEventData, now: Date): number { +export function computeSignals(event: CalendarEventData, now: Date): FeedItemSignals { if (event.isAllDay) { - return 0.3 + return { urgency: 0.3, timeRelevance: TimeRelevance.Ambient } } const msUntilStart = event.startDate.getTime() - now.getTime() @@ -212,40 +212,41 @@ export function computePriority(event: CalendarEventData, now: Date): number { // Event already started if (msUntilStart < 0) { const isInProgress = now.getTime() < event.endDate.getTime() - // Currently happening events are high priority; fully past events are low - return isInProgress ? 0.8 : 0.2 + return isInProgress + ? { urgency: 0.8, timeRelevance: TimeRelevance.Imminent } + : { urgency: 0.2, timeRelevance: TimeRelevance.Ambient } } // Starting within 30 minutes if (msUntilStart <= 30 * 60 * 1000) { - return 0.9 + return { urgency: 0.9, timeRelevance: TimeRelevance.Imminent } } // Starting within 2 hours if (msUntilStart <= 2 * 60 * 60 * 1000) { - return 0.7 + return { urgency: 0.7, timeRelevance: TimeRelevance.Upcoming } } - // Later today (within 24 hours from start of day) + // Later today const startOfDay = new Date(now) startOfDay.setUTCHours(0, 0, 0, 0) const endOfDay = new Date(startOfDay) endOfDay.setUTCDate(endOfDay.getUTCDate() + 1) if (event.startDate.getTime() < endOfDay.getTime()) { - return 0.5 + return { urgency: 0.5, timeRelevance: TimeRelevance.Upcoming } } // Future days - return 0.2 + return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient } } function createFeedItem(event: CalendarEventData, now: Date): CalendarFeedItem { return { id: `calendar-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`, type: "calendar-event", - priority: computePriority(event, now), timestamp: now, data: event, + signals: computeSignals(event, now), } } diff --git a/packages/aris-source-google-calendar/src/google-calendar-source.test.ts b/packages/aris-source-google-calendar/src/google-calendar-source.test.ts index 588a192..9aa6652 100644 --- a/packages/aris-source-google-calendar/src/google-calendar-source.test.ts +++ b/packages/aris-source-google-calendar/src/google-calendar-source.test.ts @@ -1,4 +1,4 @@ -import { contextValue, type Context } from "@aris/core" +import { TimeRelevance, contextValue, type Context } from "@aris/core" import { describe, expect, test } from "bun:test" import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types" @@ -81,16 +81,17 @@ describe("GoogleCalendarSource", () => { expect(allDayItems.length).toBe(1) }) - test("ongoing events get highest priority (1.0)", async () => { + test("ongoing events get highest urgency (1.0)", async () => { const source = new GoogleCalendarSource({ client: defaultMockClient() }) const items = await source.fetchItems(createContext()) const ongoing = items.find((i) => i.data.eventId === "evt-ongoing") expect(ongoing).toBeDefined() - expect(ongoing!.priority).toBe(1.0) + expect(ongoing!.signals!.urgency).toBe(1.0) + expect(ongoing!.signals!.timeRelevance).toBe(TimeRelevance.Imminent) }) - test("upcoming events get higher priority when sooner", async () => { + test("upcoming events get higher urgency when sooner", async () => { const source = new GoogleCalendarSource({ client: defaultMockClient() }) const items = await source.fetchItems(createContext()) @@ -99,16 +100,17 @@ describe("GoogleCalendarSource", () => { expect(soon).toBeDefined() expect(later).toBeDefined() - expect(soon!.priority).toBeGreaterThan(later!.priority) + expect(soon!.signals!.urgency).toBeGreaterThan(later!.signals!.urgency!) }) - test("all-day events get flat priority (0.4)", async () => { + test("all-day events get flat urgency (0.4)", async () => { const source = new GoogleCalendarSource({ client: defaultMockClient() }) const items = await source.fetchItems(createContext()) const allDay = items.find((i) => i.data.eventId === "evt-allday") expect(allDay).toBeDefined() - expect(allDay!.priority).toBe(0.4) + expect(allDay!.signals!.urgency).toBe(0.4) + expect(allDay!.signals!.timeRelevance).toBe(TimeRelevance.Ambient) }) test("generates unique IDs for each item", async () => { @@ -280,7 +282,7 @@ describe("GoogleCalendarSource", () => { }) }) - describe("priority ordering", () => { + describe("urgency ordering", () => { test("ongoing > upcoming > all-day", async () => { const source = new GoogleCalendarSource({ client: defaultMockClient() }) const items = await source.fetchItems(createContext()) @@ -289,8 +291,8 @@ describe("GoogleCalendarSource", () => { const upcoming = items.find((i) => i.data.eventId === "evt-soon")! const allDay = items.find((i) => i.data.eventId === "evt-allday")! - expect(ongoing.priority).toBeGreaterThan(upcoming.priority) - expect(upcoming.priority).toBeGreaterThan(allDay.priority) + expect(ongoing.signals!.urgency).toBeGreaterThan(upcoming.signals!.urgency!) + expect(upcoming.signals!.urgency).toBeGreaterThan(allDay.signals!.urgency!) }) }) }) diff --git a/packages/aris-source-google-calendar/src/google-calendar-source.ts b/packages/aris-source-google-calendar/src/google-calendar-source.ts index 7aba03a..d77fe29 100644 --- a/packages/aris-source-google-calendar/src/google-calendar-source.ts +++ b/packages/aris-source-google-calendar/src/google-calendar-source.ts @@ -1,6 +1,6 @@ -import type { ActionDefinition, Context, FeedSource } from "@aris/core" +import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core" -import { UnknownActionError } from "@aris/core" +import { TimeRelevance, UnknownActionError } from "@aris/core" import type { ApiCalendarEvent, @@ -35,10 +35,10 @@ import { DefaultGoogleCalendarClient } from "./google-calendar-api" const DEFAULT_LOOKAHEAD_HOURS = 24 -const PRIORITY_ONGOING = 1.0 -const PRIORITY_UPCOMING_MAX = 0.9 -const PRIORITY_UPCOMING_MIN = 0.3 -const PRIORITY_ALL_DAY = 0.4 +const URGENCY_ONGOING = 1.0 +const URGENCY_UPCOMING_MAX = 0.9 +const URGENCY_UPCOMING_MIN = 0.3 +const URGENCY_ALL_DAY = 0.4 /** * A FeedSource that provides Google Calendar events and next-event context. @@ -171,9 +171,13 @@ function parseEvent(event: ApiCalendarEvent, calendarId: string): CalendarEventD } } -function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: number): number { +function computeSignals( + event: CalendarEventData, + nowMs: number, + lookaheadMs: number, +): FeedItemSignals { if (event.isAllDay) { - return PRIORITY_ALL_DAY + return { urgency: URGENCY_ALL_DAY, timeRelevance: TimeRelevance.Ambient } } const startMs = event.startTime.getTime() @@ -181,17 +185,23 @@ function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: n // Ongoing: start <= now < end if (startMs <= nowMs && nowMs < endMs) { - return PRIORITY_ONGOING + return { urgency: URGENCY_ONGOING, timeRelevance: TimeRelevance.Imminent } } - // Upcoming: linear decay from PRIORITY_UPCOMING_MAX to PRIORITY_UPCOMING_MIN + // Upcoming: linear decay from URGENCY_UPCOMING_MAX to URGENCY_UPCOMING_MIN const msUntilStart = startMs - nowMs if (msUntilStart <= 0) { - return PRIORITY_UPCOMING_MIN + return { urgency: URGENCY_UPCOMING_MIN, timeRelevance: TimeRelevance.Ambient } } const ratio = Math.min(msUntilStart / lookaheadMs, 1) - return PRIORITY_UPCOMING_MAX - ratio * (PRIORITY_UPCOMING_MAX - PRIORITY_UPCOMING_MIN) + const urgency = URGENCY_UPCOMING_MAX - ratio * (URGENCY_UPCOMING_MAX - URGENCY_UPCOMING_MIN) + + // Within 30 minutes = imminent, otherwise upcoming + const timeRelevance = + msUntilStart <= 30 * 60 * 1000 ? TimeRelevance.Imminent : TimeRelevance.Upcoming + + return { urgency, timeRelevance } } function createFeedItem( @@ -199,14 +209,13 @@ function createFeedItem( nowMs: number, lookaheadMs: number, ): CalendarFeedItem { - const priority = computePriority(event, nowMs, lookaheadMs) const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event return { id: `calendar-${event.calendarId}-${event.eventId}`, type: itemType, - priority, timestamp: new Date(nowMs), data: event, + signals: computeSignals(event, nowMs, lookaheadMs), } } diff --git a/packages/aris-source-tfl/src/tfl-source.test.ts b/packages/aris-source-tfl/src/tfl-source.test.ts index 7a7c667..9653f85 100644 --- a/packages/aris-source-tfl/src/tfl-source.test.ts +++ b/packages/aris-source-tfl/src/tfl-source.test.ts @@ -184,7 +184,8 @@ describe("TflSource", () => { expect(typeof item.id).toBe("string") expect(item.id).toMatch(/^tfl-alert-/) expect(item.type).toBe("tfl-alert") - expect(typeof item.priority).toBe("number") + expect(item.signals).toBeDefined() + expect(typeof item.signals!.urgency).toBe("number") expect(item.timestamp).toBeInstanceOf(Date) } }) @@ -220,29 +221,29 @@ describe("TflSource", () => { expect(uniqueIds.size).toBe(ids.length) }) - test("feed items are sorted by priority descending", async () => { + test("feed items are sorted by urgency descending", async () => { const source = new TflSource({ client: api }) const items = await source.fetchItems(createContext()) for (let i = 1; i < items.length; i++) { const prev = items[i - 1]! const curr = items[i]! - expect(prev.priority).toBeGreaterThanOrEqual(curr.priority) + expect(prev.signals!.urgency).toBeGreaterThanOrEqual(curr.signals!.urgency!) } }) - test("priority values match severity levels", async () => { + test("urgency values match severity levels", async () => { const source = new TflSource({ client: api }) const items = await source.fetchItems(createContext()) - const severityPriority: Record = { + const severityUrgency: Record = { closure: 1.0, "major-delays": 0.8, "minor-delays": 0.6, } for (const item of items) { - expect(item.priority).toBe(severityPriority[item.data.severity]!) + expect(item.signals!.urgency).toBe(severityUrgency[item.data.severity]!) } }) @@ -316,9 +317,7 @@ describe("TflSource", () => { test("executeAction throws on invalid input", async () => { const source = new TflSource({ client: api }) - await expect( - source.executeAction("set-lines-of-interest", "not-an-array"), - ).rejects.toThrow() + await expect(source.executeAction("set-lines-of-interest", "not-an-array")).rejects.toThrow() }) test("executeAction throws for unknown action", async () => { diff --git a/packages/aris-source-tfl/src/tfl-source.ts b/packages/aris-source-tfl/src/tfl-source.ts index d9f40bf..b5546a3 100644 --- a/packages/aris-source-tfl/src/tfl-source.ts +++ b/packages/aris-source-tfl/src/tfl-source.ts @@ -1,6 +1,6 @@ -import type { ActionDefinition, Context, FeedSource } from "@aris/core" +import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core" -import { UnknownActionError, contextValue } from "@aris/core" +import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core" import { LocationKey } from "@aris/source-location" import { type } from "arktype" @@ -18,12 +18,18 @@ import { TflApi, lineId } from "./tfl-api.ts" const setLinesInput = lineId.array() -const SEVERITY_PRIORITY: Record = { +const SEVERITY_URGENCY: Record = { closure: 1.0, "major-delays": 0.8, "minor-delays": 0.6, } +const SEVERITY_TIME_RELEVANCE: Record = { + closure: TimeRelevance.Imminent, + "major-delays": TimeRelevance.Imminent, + "minor-delays": TimeRelevance.Upcoming, +} + /** * A FeedSource that provides TfL (Transport for London) service alerts. * @@ -137,19 +143,26 @@ export class TflSource implements FeedSource { closestStationDistance, } + const signals: FeedItemSignals = { + urgency: SEVERITY_URGENCY[status.severity], + timeRelevance: SEVERITY_TIME_RELEVANCE[status.severity], + } + return { id: `tfl-alert-${status.lineId}-${status.severity}`, type: "tfl-alert", - priority: SEVERITY_PRIORITY[status.severity], timestamp: context.time, data, + signals, } }) - // Sort by severity (desc), then by proximity (asc) if location available + // Sort by urgency (desc), then by proximity (asc) if location available items.sort((a, b) => { - if (b.priority !== a.priority) { - return b.priority - a.priority + const aUrgency = a.signals?.urgency ?? 0 + const bUrgency = b.signals?.urgency ?? 0 + if (bUrgency !== aUrgency) { + return bUrgency - aUrgency } if (a.data.closestStationDistance !== null && b.data.closestStationDistance !== null) { return a.data.closestStationDistance - b.data.closestStationDistance diff --git a/packages/aris-source-weatherkit/src/weather-source.test.ts b/packages/aris-source-weatherkit/src/weather-source.test.ts index c649f3f..f7f51f1 100644 --- a/packages/aris-source-weatherkit/src/weather-source.test.ts +++ b/packages/aris-source-weatherkit/src/weather-source.test.ts @@ -145,20 +145,22 @@ describe("WeatherSource", () => { } }) - test("assigns priority based on weather conditions", async () => { + test("assigns signals based on weather conditions", async () => { const source = new WeatherSource({ client: mockClient }) const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) const items = await source.fetchItems(context) for (const item of items) { - expect(item.priority).toBeGreaterThanOrEqual(0) - expect(item.priority).toBeLessThanOrEqual(1) + expect(item.signals).toBeDefined() + expect(item.signals!.urgency).toBeGreaterThanOrEqual(0) + expect(item.signals!.urgency).toBeLessThanOrEqual(1) + expect(item.signals!.timeRelevance).toBeDefined() } const currentItem = items.find((i) => i.type === WeatherFeedItemType.current) expect(currentItem).toBeDefined() - expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5) + expect(currentItem!.signals!.urgency).toBeGreaterThanOrEqual(0.5) }) test("generates unique IDs for each item", async () => { diff --git a/packages/aris-source-weatherkit/src/weather-source.ts b/packages/aris-source-weatherkit/src/weather-source.ts index a1390c2..a8862db 100644 --- a/packages/aris-source-weatherkit/src/weather-source.ts +++ b/packages/aris-source-weatherkit/src/weather-source.ts @@ -1,6 +1,6 @@ -import type { ActionDefinition, Context, FeedSource } from "@aris/core" +import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core" -import { UnknownActionError, contextValue } from "@aris/core" +import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core" import { LocationKey } from "@aris/source-location" import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items" @@ -38,7 +38,7 @@ export interface WeatherSourceOptions { const DEFAULT_HOURLY_LIMIT = 12 const DEFAULT_DAILY_LIMIT = 7 -const BASE_PRIORITY = { +const BASE_URGENCY = { current: 0.5, hourly: 0.3, daily: 0.2, @@ -199,17 +199,17 @@ export class WeatherSource implements FeedSource { } } -function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number { +function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number { if (SEVERE_CONDITIONS.has(conditionCode)) { - return Math.min(1, basePriority + 0.3) + return Math.min(1, baseUrgency + 0.3) } if (MODERATE_CONDITIONS.has(conditionCode)) { - return Math.min(1, basePriority + 0.15) + return Math.min(1, baseUrgency + 0.15) } - return basePriority + return baseUrgency } -function adjustPriorityForAlertSeverity(severity: Severity): number { +function adjustUrgencyForAlertSeverity(severity: Severity): number { switch (severity) { case "extreme": return 1 @@ -218,7 +218,29 @@ function adjustPriorityForAlertSeverity(severity: Severity): number { case "moderate": return 0.75 case "minor": - return BASE_PRIORITY.alert + return BASE_URGENCY.alert + } +} + +function timeRelevanceForCondition(conditionCode: ConditionCode): TimeRelevance { + if (SEVERE_CONDITIONS.has(conditionCode)) { + return TimeRelevance.Imminent + } + if (MODERATE_CONDITIONS.has(conditionCode)) { + return TimeRelevance.Upcoming + } + return TimeRelevance.Ambient +} + +function timeRelevanceForAlertSeverity(severity: Severity): TimeRelevance { + switch (severity) { + case "extreme": + case "severe": + return TimeRelevance.Imminent + case "moderate": + return TimeRelevance.Upcoming + case "minor": + return TimeRelevance.Ambient } } @@ -262,12 +284,14 @@ function createCurrentWeatherFeedItem( timestamp: Date, units: Units, ): WeatherFeedItem { - const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode) + const signals: FeedItemSignals = { + urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode), + timeRelevance: timeRelevanceForCondition(current.conditionCode), + } return { id: `weather-current-${timestamp.getTime()}`, type: WeatherFeedItemType.current, - priority, timestamp, data: { conditionCode: current.conditionCode, @@ -284,6 +308,7 @@ function createCurrentWeatherFeedItem( windGust: convertSpeed(current.windGust, units), windSpeed: convertSpeed(current.windSpeed, units), }, + signals, } } @@ -293,12 +318,14 @@ function createHourlyWeatherFeedItem( timestamp: Date, units: Units, ): WeatherFeedItem { - const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode) + const signals: FeedItemSignals = { + urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode), + timeRelevance: timeRelevanceForCondition(hourly.conditionCode), + } return { id: `weather-hourly-${timestamp.getTime()}-${index}`, type: WeatherFeedItemType.hourly, - priority, timestamp, data: { forecastTime: new Date(hourly.forecastStart), @@ -315,6 +342,7 @@ function createHourlyWeatherFeedItem( windGust: convertSpeed(hourly.windGust, units), windSpeed: convertSpeed(hourly.windSpeed, units), }, + signals, } } @@ -324,12 +352,14 @@ function createDailyWeatherFeedItem( timestamp: Date, units: Units, ): WeatherFeedItem { - const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode) + const signals: FeedItemSignals = { + urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode), + timeRelevance: timeRelevanceForCondition(daily.conditionCode), + } return { id: `weather-daily-${timestamp.getTime()}-${index}`, type: WeatherFeedItemType.daily, - priority, timestamp, data: { forecastDate: new Date(daily.forecastStart), @@ -344,16 +374,19 @@ function createDailyWeatherFeedItem( temperatureMax: convertTemperature(daily.temperatureMax, units), temperatureMin: convertTemperature(daily.temperatureMin, units), }, + signals, } } function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem { - const priority = adjustPriorityForAlertSeverity(alert.severity) + const signals: FeedItemSignals = { + urgency: adjustUrgencyForAlertSeverity(alert.severity), + timeRelevance: timeRelevanceForAlertSeverity(alert.severity), + } return { id: `weather-alert-${alert.id}`, type: WeatherFeedItemType.alert, - priority, timestamp, data: { alertId: alert.id, @@ -367,5 +400,6 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath source: alert.source, urgency: alert.urgency, }, + signals, } }