diff --git a/src/core/trajectory.ts b/src/core/trajectory.ts index 7e2af0fb0..2c454792e 100644 --- a/src/core/trajectory.ts +++ b/src/core/trajectory.ts @@ -34,7 +34,7 @@ export interface TrajectoryRegression { from_date: string; // YYYY-MM-DD to_value: number; to_date: string; - delta_pct: number; // negative for a numeric drop; may be < -1 across zero + delta_pct: number; // negative for a drop; range typically [-1, 0) } export interface TrajectoryStats { @@ -82,10 +82,8 @@ function cosineSim(a: Float32Array, b: Float32Array): number { * * Iterates per-metric (so trajectories that interleave mrr + arr + team_size * don't trip false regressions across metric boundaries). Within each metric, - * walks consecutive value pairs; a pair fires when the newer value is lower - * than the older value by at least the threshold. The relative delta uses - * `abs(older)` as the denominator so negative-valued metrics (net income, - * cash flow, etc.) do not invert improvement and regression. + * walks consecutive value pairs; a pair fires when + * `(newer - older) / older <= -threshold`. * * Pre-condition: caller passed points sorted by (valid_from ASC, fact_id ASC). * The engine's `findTrajectory` enforces this. No re-sort here. @@ -113,7 +111,7 @@ export function detectRegressions( // Guard against division-by-zero: a metric starting at exactly 0 // can't compute a relative delta. Skip. if (oldVal === 0) continue; - const delta = (newVal - oldVal) / Math.abs(oldVal); + const delta = (newVal - oldVal) / oldVal; if (delta <= -threshold) { out.push({ metric, diff --git a/test/trajectory.test.ts b/test/trajectory.test.ts deleted file mode 100644 index 879e3b8bd..000000000 --- a/test/trajectory.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import type { TrajectoryPoint } from '../src/core/engine.ts'; -import { - DEFAULT_REGRESSION_THRESHOLD, - detectRegressions, -} from '../src/core/trajectory.ts'; - -function point(args: { - id: number; - metric?: string; - value: number; - date: string; -}): TrajectoryPoint { - return { - fact_id: args.id, - valid_from: new Date(args.date), - metric: args.metric ?? 'net_income', - value: args.value, - unit: 'USD', - period: 'monthly', - event_type: null, - text: `${args.metric ?? 'net_income'} = ${args.value}`, - source_session: null, - source_markdown_slug: null, - embedding: null, - }; -} - -describe('detectRegressions', () => { - test('keeps existing positive-valued drop behavior', () => { - const regs = detectRegressions([ - point({ id: 1, metric: 'mrr', value: 200000, date: '2026-01-01' }), - point({ id: 2, metric: 'mrr', value: 150000, date: '2026-02-01' }), - ], DEFAULT_REGRESSION_THRESHOLD); - - expect(regs).toHaveLength(1); - expect(regs[0]).toMatchObject({ - metric: 'mrr', - from_value: 200000, - to_value: 150000, - }); - expect(regs[0].delta_pct).toBeCloseTo(-0.25, 4); - }); - - test('does not flag a negative-valued metric improving toward zero', () => { - const regs = detectRegressions([ - point({ id: 1, value: -1000, date: '2026-01-01' }), - point({ id: 2, value: -500, date: '2026-02-01' }), - ], DEFAULT_REGRESSION_THRESHOLD); - - expect(regs).toEqual([]); - }); - - test('flags a negative-valued metric worsening away from zero', () => { - const regs = detectRegressions([ - point({ id: 1, value: -500, date: '2026-01-01' }), - point({ id: 2, value: -1000, date: '2026-02-01' }), - ], DEFAULT_REGRESSION_THRESHOLD); - - expect(regs).toHaveLength(1); - expect(regs[0]).toMatchObject({ - metric: 'net_income', - from_value: -500, - to_value: -1000, - from_date: '2026-01-01', - to_date: '2026-02-01', - }); - expect(regs[0].delta_pct).toBeCloseTo(-1.0, 4); - }); -});