diff --git a/src/core/trajectory.ts b/src/core/trajectory.ts index 2c454792e..7e2af0fb0 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 drop; range typically [-1, 0) + delta_pct: number; // negative for a numeric drop; may be < -1 across zero } export interface TrajectoryStats { @@ -82,8 +82,10 @@ 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 - * `(newer - older) / older <= -threshold`. + * 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. * * Pre-condition: caller passed points sorted by (valid_from ASC, fact_id ASC). * The engine's `findTrajectory` enforces this. No re-sort here. @@ -111,7 +113,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) / oldVal; + const delta = (newVal - oldVal) / Math.abs(oldVal); if (delta <= -threshold) { out.push({ metric, diff --git a/test/trajectory.test.ts b/test/trajectory.test.ts new file mode 100644 index 000000000..879e3b8bd --- /dev/null +++ b/test/trajectory.test.ts @@ -0,0 +1,70 @@ +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); + }); +});