Skip to content

Commit 51d67e8

Browse files
fix: NPV bug
1 parent 27d5aab commit 51d67e8

6 files changed

Lines changed: 152 additions & 16 deletions

File tree

mcp-server/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"manifest_version": "0.2",
33
"name": "sherpa",
44
"display_name": "Sherpa — AI Feature ROI Calculator",
5-
"version": "1.0.38",
5+
"version": "1.0.39",
66
"description": "CFO-grade ROI for AI features in SaaS: cohort revenue model + LLM token costs → NPV, IRR, payback, TCO. 100% local.",
77
"long_description": "Sherpa lets you model the business case for AI features conversationally. Define AI services with their token usage, group them into packs and pricing plans, model your customer base as cohorts, and ask Claude to build and compare ROI scenarios: net present value, internal rate of return, payback period, total cost of ownership and sensitivity (tornado) analysis. All data stays in a local SQLite database — nothing is sent to the cloud. On first run the extension creates a demo workspace so you can start asking questions immediately.",
88
"author": {

mcp-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sherpa-mcp-server",
3-
"version": "1.0.38",
3+
"version": "1.0.39",
44
"type": "module",
55
"scripts": {
66
"prebuild": "node ../scripts/gen-guidance.mjs",

mcp-server/src/index.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -365,15 +365,17 @@ function getFullScenario(scenarioId: string): Scenario | null {
365365
SELECT id, name, vertical_id, current_users, monthly_acquisition,
366366
acquisition_growth_rate, monthly_churn_rate, retention_floor,
367367
monthly_expansion_rate, ai_adoption_rate, base_arpu,
368-
arpu_uplift, arpu_uplift_percent, churn_reduction, acquisition_uplift
368+
arpu_uplift, arpu_uplift_percent, churn_reduction, acquisition_uplift,
369+
gross_margin, adoption_ramp_months
369370
FROM cohort_configs
370371
`).all() as any[];
371372
} else if (s.scope_type === 'verticals') {
372373
baseCohorts = db.prepare(`
373374
SELECT c.id, c.name, c.vertical_id, c.current_users, c.monthly_acquisition,
374375
c.acquisition_growth_rate, c.monthly_churn_rate, c.retention_floor,
375376
c.monthly_expansion_rate, c.ai_adoption_rate, c.base_arpu,
376-
c.arpu_uplift, c.arpu_uplift_percent, c.churn_reduction, c.acquisition_uplift
377+
c.arpu_uplift, c.arpu_uplift_percent, c.churn_reduction, c.acquisition_uplift,
378+
c.gross_margin, c.adoption_ramp_months
377379
FROM cohort_configs c
378380
JOIN verticals v ON c.vertical_id = v.id
379381
JOIN scenario_verticals sv ON sv.vertical_id = v.id
@@ -385,7 +387,8 @@ function getFullScenario(scenarioId: string): Scenario | null {
385387
SELECT c.id, c.name, c.vertical_id, c.current_users, c.monthly_acquisition,
386388
c.acquisition_growth_rate, c.monthly_churn_rate, c.retention_floor,
387389
c.monthly_expansion_rate, c.ai_adoption_rate, c.base_arpu,
388-
c.arpu_uplift, c.arpu_uplift_percent, c.churn_reduction, c.acquisition_uplift
390+
c.arpu_uplift, c.arpu_uplift_percent, c.churn_reduction, c.acquisition_uplift,
391+
c.gross_margin, c.adoption_ramp_months
389392
FROM cohort_configs c
390393
JOIN scenario_cohorts sc ON sc.cohort_config_id = c.id
391394
WHERE sc.scenario_id = ?
@@ -397,6 +400,7 @@ function getFullScenario(scenarioId: string): Scenario | null {
397400
SELECT id, scenario_id, target_type, target_id, monthly_churn_rate, monthly_acquisition,
398401
acquisition_growth_rate, ai_adoption_rate, retention_floor, expansion_rate, arpu_override,
399402
arpu_uplift, arpu_uplift_percent, churn_reduction, acquisition_uplift,
403+
gross_margin, adoption_ramp_months,
400404
evc_extra_value_multiplier, evc_negative_value_multiplier, evc_nba_multiplier
401405
FROM scenario_scope_overrides
402406
WHERE scenario_id = ?
@@ -3270,9 +3274,11 @@ server.tool(
32703274
if (scenario.modeling_type === 'incremental' || carrier === 'cohort') {
32713275
throw new Error(`Monetization overrides are disabled for incremental or cohort-carrier scenarios.`);
32723276
}
3273-
const expectedEntityType = carrier === 'feature' ? 'service' : carrier;
3274-
if (entityType !== expectedEntityType) {
3275-
throw new Error(`Monetization overrides for this scenario can only be set on '${expectedEntityType}' entities (since the resolved carrier is '${carrier}').`);
3277+
if (carrier !== 'composite') {
3278+
const expectedEntityType = carrier === 'feature' ? 'service' : carrier;
3279+
if (entityType !== expectedEntityType) {
3280+
throw new Error(`Monetization overrides for this scenario can only be set on '${expectedEntityType}' entities (since the resolved carrier is '${carrier}').`);
3281+
}
32763282
}
32773283
}
32783284
if (monType === 'none') {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "sherpa",
33
"private": true,
4-
"version": "1.0.38",
4+
"version": "1.0.39",
55
"type": "module",
66
"scripts": {
77
"dev": "vite dev",

src/lib/components/wizard/ScenarioWizard.svelte

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@
133133
arpu_uplift_percent: number | null;
134134
churn_reduction: number | null;
135135
acquisition_uplift: number | null;
136+
gross_margin: number | null;
137+
adoption_ramp_months: number | null;
136138
};
137139
let overrides = $state<OverrideRow[]>([]);
138140
let openOverrides = $state<Record<string, boolean>>({});
@@ -147,7 +149,9 @@
147149
arpu_uplift: o.arpu_uplift !== null ? parseFloat(o.arpu_uplift as any) : null,
148150
arpu_uplift_percent: o.arpu_uplift_percent !== null ? o.arpu_uplift_percent / 100 : null,
149151
churn_reduction: o.churn_reduction !== null ? o.churn_reduction / 100 : null,
150-
acquisition_uplift: o.acquisition_uplift !== null ? o.acquisition_uplift / 100 : null
152+
acquisition_uplift: o.acquisition_uplift !== null ? o.acquisition_uplift / 100 : null,
153+
gross_margin: o.gross_margin !== null ? o.gross_margin / 100 : null,
154+
adoption_ramp_months: o.adoption_ramp_months !== null ? parseInt(o.adoption_ramp_months as any) : null
151155
}))));
152156
153157
// Sync overrides structure to selection
@@ -164,7 +168,8 @@
164168
target_type: 'all_clients', target_id: 'all', name: 'Global Client Base',
165169
monthly_churn_rate: null, monthly_acquisition: null, acquisition_growth_rate: null,
166170
ai_adoption_rate: null, retention_floor: null, expansion_rate: null, arpu_override: null,
167-
arpu_uplift: null, arpu_uplift_percent: null, churn_reduction: null, acquisition_uplift: null
171+
arpu_uplift: null, arpu_uplift_percent: null, churn_reduction: null, acquisition_uplift: null,
172+
gross_margin: null, adoption_ramp_months: null
168173
});
169174
} else if (_scopeType === 'cohorts') {
170175
for (const c of data.cohorts) {
@@ -174,7 +179,8 @@
174179
target_type: 'cohort', target_id: c.id, name: c.name,
175180
monthly_churn_rate: null, monthly_acquisition: null, acquisition_growth_rate: null,
176181
ai_adoption_rate: null, retention_floor: null, expansion_rate: null, arpu_override: null,
177-
arpu_uplift: null, arpu_uplift_percent: null, churn_reduction: null, acquisition_uplift: null
182+
arpu_uplift: null, arpu_uplift_percent: null, churn_reduction: null, acquisition_uplift: null,
183+
gross_margin: null, adoption_ramp_months: null
178184
});
179185
}
180186
}
@@ -311,7 +317,9 @@
311317
arpu_uplift: ov.arpu_uplift,
312318
arpu_uplift_percent: ov.arpu_uplift_percent !== null ? Math.round(ov.arpu_uplift_percent * 1000) / 10 : null,
313319
churn_reduction: ov.churn_reduction !== null ? Math.round(ov.churn_reduction * 1000) / 10 : null,
314-
acquisition_uplift: ov.acquisition_uplift !== null ? Math.round(ov.acquisition_uplift * 1000) / 10 : null
320+
acquisition_uplift: ov.acquisition_uplift !== null ? Math.round(ov.acquisition_uplift * 1000) / 10 : null,
321+
gross_margin: ov.gross_margin !== null && ov.gross_margin !== undefined ? ov.gross_margin * 100 : null,
322+
adoption_ramp_months: ov.adoption_ramp_months ?? null
315323
};
316324
});
317325
} else {
@@ -369,7 +377,9 @@
369377
arpu_uplift: o.arpu_uplift !== null ? parseFloat(o.arpu_uplift as any) : null,
370378
arpu_uplift_percent: o.arpu_uplift_percent !== null ? o.arpu_uplift_percent / 100 : null,
371379
churn_reduction: o.churn_reduction !== null ? o.churn_reduction / 100 : null,
372-
acquisition_uplift: o.acquisition_uplift !== null ? o.acquisition_uplift / 100 : null
380+
acquisition_uplift: o.acquisition_uplift !== null ? o.acquisition_uplift / 100 : null,
381+
gross_margin: o.gross_margin !== null ? o.gross_margin / 100 : null,
382+
adoption_ramp_months: o.adoption_ramp_months !== null ? parseInt(o.adoption_ramp_months as any) : null
373383
}));
374384
return resolveScenarioCohortsClient(scopeType, data.cohorts, selectedCohorts, formattedOverrides as any[]);
375385
});
@@ -385,7 +395,9 @@
385395
arpu_uplift: o.arpu_uplift !== null ? parseFloat(o.arpu_uplift as any) : null,
386396
arpu_uplift_percent: o.arpu_uplift_percent !== null ? o.arpu_uplift_percent / 100 : null,
387397
churn_reduction: o.churn_reduction !== null ? o.churn_reduction / 100 : null,
388-
acquisition_uplift: o.acquisition_uplift !== null ? o.acquisition_uplift / 100 : null
398+
acquisition_uplift: o.acquisition_uplift !== null ? o.acquisition_uplift / 100 : null,
399+
gross_margin: o.gross_margin !== null ? o.gross_margin / 100 : null,
400+
adoption_ramp_months: o.adoption_ramp_months !== null ? parseInt(o.adoption_ramp_months as any) : null
389401
}));
390402
391403
return buildDraftScenario(
@@ -846,7 +858,9 @@
846858
arpu_uplift: { label: 'AI ARPU Uplift ($ Flat)', chip: 'ARPU Uplift $', isPercent: false, symbol: '$' },
847859
arpu_uplift_percent: { label: 'AI ARPU Uplift (%)', chip: 'ARPU Uplift %', isPercent: true, symbol: '%' },
848860
churn_reduction: { label: 'AI Churn Reduction', chip: 'Churn Reduction', isPercent: true, symbol: '%' },
849-
acquisition_uplift: { label: 'AI Acquisition Uplift', chip: 'Acquisition Uplift', isPercent: true, symbol: '%' }
861+
acquisition_uplift: { label: 'AI Acquisition Uplift', chip: 'Acquisition Uplift', isPercent: true, symbol: '%' },
862+
gross_margin: { label: 'Gross Margin (%)', chip: 'Gross Margin', isPercent: true, symbol: '%' },
863+
adoption_ramp_months: { label: 'Adoption Ramp (months)', chip: 'Ramp Months', isPercent: false, symbol: 'mo' }
850864
};
851865
852866
function getCohortListForTarget(ov: OverrideRow) {
@@ -1373,6 +1387,14 @@
13731387
<Label class="text-xs font-semibold">AI Adoption Rate (%)</Label>
13741388
<Input type="number" step="0.1" min="0" max="100" placeholder="Inherit" bind:value={ov.ai_adoption_rate} class="bg-background text-xs font-mono" />
13751389
</div>
1390+
<div class="space-y-1">
1391+
<Label class="text-xs font-semibold">Gross Margin (%)</Label>
1392+
<Input type="number" step="0.1" min="0" max="100" placeholder="Inherit" bind:value={ov.gross_margin} class="bg-background text-xs font-mono" />
1393+
</div>
1394+
<div class="space-y-1">
1395+
<Label class="text-xs font-semibold">Adoption Ramp (months)</Label>
1396+
<Input type="number" step="1" min="0" max="120" placeholder="Inherit" bind:value={ov.adoption_ramp_months} class="bg-background text-xs font-mono" />
1397+
</div>
13761398
</div>
13771399
</div>
13781400

src/lib/shared/financial-math.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3592,6 +3592,114 @@ describe('ADR 0012 amendment — two knobs', () => {
35923592
expect(result.compositeBreakdown?.agentMonetization.revenuePv).toBeCloseTo(10000, 0);
35933593
});
35943594
});
3595+
3596+
describe('adoption_ramp_months regression — ramp duration MUST affect NPV', () => {
3597+
// Minimal fixtures for ramp regression testing
3598+
const rampProvider: Provider = {
3599+
id: 'rp1', name: 'RampProvider', model_name: 'gpt-4', input_price: 5, output_price: 15,
3600+
is_predefined: true, currency: 'USD',
3601+
input_tokens_per_credit: 1000000, output_tokens_per_credit: 333333, updated_at: ''
3602+
};
3603+
3604+
const rampCohort: CohortConfig = {
3605+
id: 'rc1', name: 'Ramp Cohort',
3606+
current_users: 5250,
3607+
monthly_acquisition: 0,
3608+
acquisition_growth_rate: 0,
3609+
monthly_churn_rate: 0.02,
3610+
retention_floor: 0.50,
3611+
monthly_expansion_rate: 0,
3612+
ai_adoption_rate: 0.08,
3613+
base_arpu: 100,
3614+
arpu_uplift: 349,
3615+
gross_margin: 0.90,
3616+
adoption_ramp_months: 3
3617+
};
3618+
3619+
it('cohort carrier: ramp 3 vs ramp 11 produces different NPVs', () => {
3620+
const baseScenario: Scenario = {
3621+
id: 'ramp-cohort-sc', name: 'Ramp Cohort Test',
3622+
projection_months: 12,
3623+
discount_rate: 0.10,
3624+
scope_type: 'cohorts',
3625+
scope_cohorts: [{ ...rampCohort, adoption_ramp_months: 3 }],
3626+
services: [],
3627+
costs: []
3628+
};
3629+
const resultsRamp3 = calculateScenario(baseScenario, [rampProvider]);
3630+
3631+
const scenarioRamp11 = {
3632+
...baseScenario,
3633+
scope_cohorts: [{ ...rampCohort, adoption_ramp_months: 11 }]
3634+
};
3635+
const resultsRamp11 = calculateScenario(scenarioRamp11, [rampProvider]);
3636+
3637+
// Longer ramp → delayed revenue → lower NPV
3638+
expect(resultsRamp3.npvUpper).toBeGreaterThan(resultsRamp11.npvUpper);
3639+
// The difference should be non-trivial (at least 1%)
3640+
const diffPct = (resultsRamp3.npvUpper - resultsRamp11.npvUpper) / resultsRamp3.npvUpper;
3641+
expect(diffPct).toBeGreaterThan(0.01);
3642+
});
3643+
3644+
it('composite carrier + addon service: ramp 3 vs ramp 11 produces different NPVs', () => {
3645+
const addonService: Service & { rollout_month: number; monetization?: MonetizationConfig } = {
3646+
id: 'ramp-svc1', name: 'AI Insights Addon', status: 'planned',
3647+
provider_id: 'rp1',
3648+
avg_input_tokens: 1000, avg_output_tokens: 500,
3649+
avg_requests_per_user_month: 10,
3650+
fixed_cost_per_month: 0,
3651+
service_type: 'agent',
3652+
rollout_month: 0,
3653+
monetization: { monetization_type: 'addon', addon_monthly_fee: 50 }
3654+
};
3655+
3656+
const compositeBase: Scenario = {
3657+
id: 'ramp-comp-sc', name: 'Ramp Composite Test',
3658+
projection_months: 12,
3659+
discount_rate: 0.10,
3660+
scope_type: 'cohorts',
3661+
modeling_type: 'composite',
3662+
revenue_carrier: 'composite',
3663+
arpu_uplift_includes_monetization: false,
3664+
scope_cohorts: [{ ...rampCohort, arpu_uplift: 0, adoption_ramp_months: 3 }],
3665+
services: [addonService],
3666+
costs: []
3667+
};
3668+
const resultsRamp3 = calculateScenario(compositeBase, [rampProvider]);
3669+
3670+
const compositeRamp11 = {
3671+
...compositeBase,
3672+
scope_cohorts: [{ ...rampCohort, arpu_uplift: 0, adoption_ramp_months: 11 }]
3673+
};
3674+
const resultsRamp11 = calculateScenario(compositeRamp11, [rampProvider]);
3675+
3676+
// Longer ramp → fewer AI users in early months → lower addon revenue → lower NPV
3677+
expect(resultsRamp3.npvUpper).toBeGreaterThan(resultsRamp11.npvUpper);
3678+
const diffPct = (resultsRamp3.npvUpper - resultsRamp11.npvUpper) / resultsRamp3.npvUpper;
3679+
expect(diffPct).toBeGreaterThan(0.01);
3680+
});
3681+
3682+
it('ramp=0 (instant adoption) should produce the highest NPV', () => {
3683+
const baseScenario: Scenario = {
3684+
id: 'ramp-zero-sc', name: 'Ramp Zero Test',
3685+
projection_months: 12,
3686+
discount_rate: 0.10,
3687+
scope_type: 'cohorts',
3688+
scope_cohorts: [{ ...rampCohort, adoption_ramp_months: 0 }],
3689+
services: [],
3690+
costs: []
3691+
};
3692+
const resultsNoRamp = calculateScenario(baseScenario, [rampProvider]);
3693+
3694+
const scenarioRamp6 = {
3695+
...baseScenario,
3696+
scope_cohorts: [{ ...rampCohort, adoption_ramp_months: 6 }]
3697+
};
3698+
const resultsRamp6 = calculateScenario(scenarioRamp6, [rampProvider]);
3699+
3700+
expect(resultsNoRamp.npvUpper).toBeGreaterThan(resultsRamp6.npvUpper);
3701+
});
3702+
});
35953703
});
35963704

35973705

0 commit comments

Comments
 (0)