Skip to content

Commit 07b700b

Browse files
authored
feat: apply variation to opportunity preview estimation (#3420)
1 parent f183655 commit 07b700b

4 files changed

Lines changed: 185 additions & 9 deletions

File tree

__tests__/common/number.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { applyDeterministicVariation } from '../../src/common/number';
2+
3+
describe('applyDeterministicVariation', () => {
4+
const baseValue = 1000;
5+
const maxVariationPercent = 7.6;
6+
7+
it('should return deterministic results for the same seed', () => {
8+
const seed = 'test-opportunity-id-123';
9+
10+
const result1 = applyDeterministicVariation({
11+
value: baseValue,
12+
seed,
13+
maxVariationPercent,
14+
});
15+
const result2 = applyDeterministicVariation({
16+
value: baseValue,
17+
seed,
18+
maxVariationPercent,
19+
});
20+
21+
expect(result1).toBe(result2);
22+
});
23+
24+
it('should return different results for different seeds', () => {
25+
const result1 = applyDeterministicVariation({
26+
value: baseValue,
27+
seed: 'completely-different-seed-alpha',
28+
maxVariationPercent,
29+
});
30+
const result2 = applyDeterministicVariation({
31+
value: baseValue,
32+
seed: 'another-unique-identifier-beta',
33+
maxVariationPercent,
34+
});
35+
36+
expect(result1).not.toBe(result2);
37+
});
38+
39+
it('should return results within the expected range', () => {
40+
const seeds = [
41+
'abc',
42+
'xyz',
43+
'123',
44+
'test',
45+
'opportunity-1',
46+
'opportunity-2',
47+
'some-uuid-here',
48+
'another-id',
49+
'short',
50+
'a-very-long-seed-string-to-test-with',
51+
];
52+
53+
const minExpected = Math.round(baseValue * (1 - maxVariationPercent / 100));
54+
const maxExpected = Math.round(baseValue * (1 + maxVariationPercent / 100));
55+
56+
seeds.forEach((seed) => {
57+
const result = applyDeterministicVariation({
58+
value: baseValue,
59+
seed,
60+
maxVariationPercent,
61+
});
62+
63+
expect(result).toBeGreaterThanOrEqual(minExpected);
64+
expect(result).toBeLessThanOrEqual(maxExpected);
65+
});
66+
});
67+
68+
it('should produce both positive and negative variations across different seeds', () => {
69+
// Use a large set of seeds to statistically ensure we get both positive and negative variations
70+
const seeds = Array.from({ length: 100 }, (_, i) => `seed-${i}`);
71+
72+
const results = seeds.map((seed) =>
73+
applyDeterministicVariation({
74+
value: baseValue,
75+
seed,
76+
maxVariationPercent,
77+
}),
78+
);
79+
80+
const hasPositiveVariation = results.some((r) => r > baseValue);
81+
const hasNegativeVariation = results.some((r) => r < baseValue);
82+
83+
expect(hasPositiveVariation).toBe(true);
84+
expect(hasNegativeVariation).toBe(true);
85+
});
86+
87+
it('should return an integer (rounded result)', () => {
88+
const result = applyDeterministicVariation({
89+
value: 1000,
90+
seed: 'test-seed',
91+
maxVariationPercent: 7.6,
92+
});
93+
94+
expect(Number.isInteger(result)).toBe(true);
95+
});
96+
97+
it('should scale variation based on maxVariationPercent', () => {
98+
const seed = 'consistent-seed';
99+
100+
const result = applyDeterministicVariation({
101+
value: baseValue,
102+
seed,
103+
maxVariationPercent: 15,
104+
});
105+
106+
// The deviation from base should be proportionally larger with higher maxVariationPercent
107+
const deviation = Math.abs(result - baseValue);
108+
109+
expect(deviation).toBe(34); // Expected deviation for 15% max variation
110+
});
111+
112+
it('should handle zero value', () => {
113+
const result = applyDeterministicVariation({
114+
value: 0,
115+
seed: 'any-seed',
116+
maxVariationPercent: 7.6,
117+
});
118+
119+
expect(result).toBe(0);
120+
});
121+
122+
it('should return original value when seed is empty', () => {
123+
const result = applyDeterministicVariation({
124+
value: baseValue,
125+
seed: '',
126+
maxVariationPercent: 7.6,
127+
});
128+
129+
// Empty seed should return original value without variation
130+
expect(result).toBe(baseValue);
131+
});
132+
});

__tests__/schema/opportunity.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6037,7 +6037,7 @@ describe('query opportunityPreview', () => {
60376037
anonUserId: 'test-anon-user-123',
60386038
preview: {
60396039
userIds: ['1', '2'],
6040-
totalCount: 2,
6040+
totalCount: 2000, // mocked total count
60416041
},
60426042
},
60436043
},
@@ -6053,7 +6053,7 @@ describe('query opportunityPreview', () => {
60536053
expect(res.data.opportunityPreview.result.opportunityId).toBe(
60546054
opportunitiesFixture[0].id,
60556055
);
6056-
expect(res.data.opportunityPreview.result.totalCount).toBe(2);
6056+
expect(res.data.opportunityPreview.result.totalCount).toBe(2122);
60576057
});
60586058

60596059
it('should return opportunity preview result structure', async () => {
@@ -6064,7 +6064,7 @@ describe('query opportunityPreview', () => {
60646064
anonUserId: 'test-anon-user-123',
60656065
preview: {
60666066
userIds: ['1'],
6067-
totalCount: 1,
6067+
totalCount: 1000, // mocked total count
60686068
},
60696069
},
60706070
},
@@ -6078,7 +6078,7 @@ describe('query opportunityPreview', () => {
60786078
const result = res.data.opportunityPreview.result;
60796079
expect(result).toMatchObject({
60806080
opportunityId: opportunitiesFixture[0].id,
6081-
totalCount: 1,
6081+
totalCount: 1061,
60826082
tags: expect.any(Array),
60836083
companies: expect.any(Array),
60846084
squads: expect.any(Array),
@@ -6095,7 +6095,7 @@ describe('query opportunityPreview', () => {
60956095
flags: {
60966096
preview: {
60976097
userIds: ['1', '2'],
6098-
totalCount: 2,
6098+
totalCount: 2000, // mocked total count
60996099
status: OpportunityPreviewStatus.READY,
61006100
},
61016101
},
@@ -6117,7 +6117,7 @@ describe('query opportunityPreview', () => {
61176117
expect(res.data.opportunityPreview.result.opportunityId).toBe(
61186118
opportunitiesFixture[0].id,
61196119
);
6120-
expect(res.data.opportunityPreview.result.totalCount).toBe(2);
6120+
expect(res.data.opportunityPreview.result.totalCount).toBe(2122);
61216121
expect(res.data.opportunityPreview.result.status).toBe(
61226122
OpportunityPreviewStatus.READY,
61236123
);
@@ -6189,7 +6189,7 @@ describe('query opportunityPreview', () => {
61896189
anonUserId: 'test-anon-user-123',
61906190
preview: {
61916191
userIds: ['1'],
6192-
totalCount: 1,
6192+
totalCount: 1000, // mocked total count
61936193
},
61946194
},
61956195
},
@@ -6203,7 +6203,7 @@ describe('query opportunityPreview', () => {
62036203
const result = res.data.opportunityPreview.result;
62046204
expect(result).toMatchObject({
62056205
opportunityId: opportunitiesFixture[0].id,
6206-
totalCount: 1,
6206+
totalCount: 1061,
62076207
tags: expect.any(Array),
62086208
companies: expect.any(Array),
62096209
squads: expect.any(Array),

src/common/number.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,42 @@ export const formatMetricValue = (value: number): string | null => {
6161
minimumFractionDigits: 0,
6262
});
6363
};
64+
65+
/**
66+
* Applies a deterministic percentage variation to a value based on a seed string.
67+
* The same seed will always produce the same variation.
68+
* Uses djb2 hash algorithm for consistent hashing.
69+
*
70+
* @param value - The original value
71+
* @param seed - A string to seed the variation (e.g., opportunity ID)
72+
* @param maxVariationPercent - Maximum variation percentage (e.g., 7.6 for ±7.6%)
73+
* @returns The value with deterministic variation applied, rounded to integer
74+
*/
75+
export const applyDeterministicVariation = ({
76+
value,
77+
seed,
78+
maxVariationPercent,
79+
}: {
80+
value: number;
81+
seed: string;
82+
maxVariationPercent: number;
83+
}): number => {
84+
if (!seed) {
85+
return value;
86+
}
87+
88+
// djb2 hash algorithm (produces 32-bit signed integers)
89+
let hash = 0;
90+
for (let i = 0; i < seed.length; i++) {
91+
hash = (hash << 5) - hash + seed.charCodeAt(i);
92+
hash = hash & hash;
93+
}
94+
95+
// Normalize hash to range [-1, 1] using the full 32-bit signed integer range
96+
// 2^31 = 2147483648, so dividing gives us approximately [-1, 1]
97+
const normalizedHash = hash / 2147483648;
98+
99+
const variationFactor = 1 + normalizedHash * (maxVariationPercent / 100);
100+
101+
return Math.round(value * variationFactor);
102+
};

src/schema/opportunity.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import {
8585
import { createOpportunityPrompt } from '../common/opportunity/prompt';
8686
import { queryPaginatedByDate } from '../common/datePageGenerator';
8787
import { queryReadReplica } from '../common/queryReadReplica';
88+
import { applyDeterministicVariation } from '../common/number';
8889
import { ConnectionArguments } from 'graphql-relay';
8990
import { ProfileResponse, snotraClient } from '../integrations/snotra';
9091
import { slackClient } from '../common/slack';
@@ -1681,7 +1682,11 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
16811682
tags,
16821683
companies,
16831684
squads,
1684-
totalCount: opportunityPreview.totalCount,
1685+
totalCount: applyDeterministicVariation({
1686+
value: opportunityPreview.totalCount,
1687+
seed: opportunity.id,
1688+
maxVariationPercent: 7.6,
1689+
}),
16851690
status: opportunityPreview.status,
16861691
opportunityId: opportunity.id,
16871692
},

0 commit comments

Comments
 (0)