fullstackgtm 0.20.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/market.d.ts +14 -0
- package/dist/marketReport.js +183 -89
- package/dist/marketScale.d.ts +53 -24
- package/dist/marketScale.js +144 -44
- package/package.json +1 -1
- package/src/index.ts +9 -1
- package/src/market.ts +14 -0
- package/src/marketReport.ts +189 -89
- package/src/marketScale.ts +204 -64
package/src/marketScale.ts
CHANGED
|
@@ -1,111 +1,251 @@
|
|
|
1
1
|
import type { MarketConfig, ScaleSignal } from "./market.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Relative scale
|
|
5
|
-
* "bubble size = market share". True segment market share is unknowable from
|
|
6
|
-
* public data for mostly-private vendor sets, so this computes a composite
|
|
7
|
-
* index from whatever citable signals exist per vendor (review counts,
|
|
8
|
-
* headcount, disclosed revenue, self-reported customers), each of which is
|
|
9
|
-
* biased in a different direction; the composite triangulates.
|
|
4
|
+
* Relative scale estimation over the mapped vendor set — v2, dimensional.
|
|
10
5
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* it cannot rank anyone).
|
|
16
|
-
* 3. A vendor's index = arithmetic mean of its normalized metric scores
|
|
17
|
-
* (mean-of-normalized rather than geometric-of-raw so missing signals
|
|
18
|
-
* neither punish nor reward), reported with coverage (which metrics).
|
|
6
|
+
* v1 normalized every signal onto [0,1] and averaged, which quietly mixed
|
|
7
|
+
* dimensions: review/customer counts proxy CUSTOMER COUNT (N), while
|
|
8
|
+
* employees and revenue proxy REVENUE (N × ACV). Averaging the two inflates
|
|
9
|
+
* many-small-customer vendors against few-big-customer ones — the SMB bias.
|
|
19
10
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
11
|
+
* v2 converts every signal into REVENUE SPACE before combining:
|
|
12
|
+
*
|
|
13
|
+
* 1. Signals are classed by dimension: revenue (used directly),
|
|
14
|
+
* headcount (× revenue-per-employee), customers (× revenue-per-customer).
|
|
15
|
+
* 2. Conversion ratios are CALIBRATED within the set, per metric, as the
|
|
16
|
+
* median ratio over vendors that have both the metric and a revenue
|
|
17
|
+
* signal — and customer-dimension ratios are stratified by each
|
|
18
|
+
* vendor's `acvBand` (smb / mid / enterprise), because revenue-per-
|
|
19
|
+
* review spans ~75× between SMB tools and enterprise suites. A band
|
|
20
|
+
* without calibration pairs falls back to the global median; a metric
|
|
21
|
+
* with no pairs anywhere is unusable and reported as skipped.
|
|
22
|
+
* 3. A vendor's estimated revenue is the weighted geometric mean of its
|
|
23
|
+
* per-signal estimates (revenue weight 3, headcount 2, customers 1 —
|
|
24
|
+
* reliability order), with an uncertainty band = max/min estimate
|
|
25
|
+
* ratio, reported, never hidden.
|
|
26
|
+
* 4. index = share of the set's summed estimated revenue; bubbles render
|
|
27
|
+
* area-proportional to it. Labeled "estimated revenue share" with the
|
|
28
|
+
* calibration disclosed — still never "market share" unqualified:
|
|
29
|
+
* it is revenue share OF THE MAPPED SET, from citable-but-unaudited
|
|
30
|
+
* signals.
|
|
31
|
+
*
|
|
32
|
+
* Deterministic and auditable end to end: same config, same estimates.
|
|
22
33
|
*/
|
|
23
34
|
|
|
35
|
+
export type ScaleDimension = "revenue" | "headcount" | "customers";
|
|
36
|
+
|
|
37
|
+
const DIMENSION_WEIGHT: Record<ScaleDimension, number> = { revenue: 3, headcount: 2, customers: 1 };
|
|
38
|
+
|
|
39
|
+
/** Used only when headcount has zero calibration pairs in the whole set. */
|
|
40
|
+
const FALLBACK_REVENUE_PER_EMPLOYEE = 200_000;
|
|
41
|
+
|
|
42
|
+
export function dimensionForMetric(metric: string): ScaleDimension {
|
|
43
|
+
const name = metric.toLowerCase();
|
|
44
|
+
if (name.includes("revenue") || name.includes("arr")) return "revenue";
|
|
45
|
+
if (name.includes("employee") || name.includes("headcount")) return "headcount";
|
|
46
|
+
return "customers"; // reviews, customers, installs — count-of-customers proxies
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SignalEstimate = {
|
|
50
|
+
metric: string;
|
|
51
|
+
dimension: ScaleDimension;
|
|
52
|
+
rawValue: number;
|
|
53
|
+
/** Revenue-per-unit ratio applied (1 for revenue signals); null = unusable. */
|
|
54
|
+
ratio: number | null;
|
|
55
|
+
estimatedRevenue: number | null;
|
|
56
|
+
/** What calibrated the ratio: "direct", "band:<name>", "global", "fallback". */
|
|
57
|
+
calibration: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
24
60
|
export type VendorScale = {
|
|
25
61
|
vendorId: string;
|
|
26
|
-
|
|
62
|
+
acvBand?: string;
|
|
63
|
+
estimates: SignalEstimate[];
|
|
64
|
+
/** Weighted geometric mean of usable estimates; null with no usable signals. */
|
|
65
|
+
estimatedRevenue: number | null;
|
|
66
|
+
/** max/min across usable estimates — 1 = perfect agreement among signals. */
|
|
67
|
+
uncertainty: number | null;
|
|
68
|
+
/** Share of the set's summed estimated revenue; drives bubble area. */
|
|
27
69
|
index: number | null;
|
|
28
|
-
/** Metrics that contributed, with their normalized scores. */
|
|
29
|
-
coverage: Array<{ metric: string; value: number; normalized: number }>;
|
|
30
70
|
signals: ScaleSignal[];
|
|
31
71
|
};
|
|
32
72
|
|
|
33
73
|
export type ScaleReport = {
|
|
34
74
|
vendors: VendorScale[];
|
|
35
|
-
/** Metrics used (present for ≥2 vendors) and metrics skipped (singletons). */
|
|
36
75
|
metricsUsed: string[];
|
|
37
76
|
metricsSkipped: string[];
|
|
77
|
+
/** Calibrated ratios for the appendix: metric × stratum → revenue-per-unit. */
|
|
78
|
+
calibrations: Array<{ metric: string; stratum: string; revenuePerUnit: number; pairs: number }>;
|
|
38
79
|
complete: boolean;
|
|
39
80
|
};
|
|
40
81
|
|
|
82
|
+
function median(values: number[]): number {
|
|
83
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
84
|
+
const mid = Math.floor(sorted.length / 2);
|
|
85
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
86
|
+
}
|
|
87
|
+
|
|
41
88
|
export function computeScaleIndex(config: MarketConfig): ScaleReport {
|
|
42
|
-
|
|
89
|
+
type Row = { vendorId: string; band: string; metric: string; dimension: ScaleDimension; value: number };
|
|
90
|
+
const rows: Row[] = [];
|
|
43
91
|
for (const vendor of config.vendors) {
|
|
44
92
|
for (const signal of vendor.scaleSignals ?? []) {
|
|
45
|
-
if (!Number.isFinite(signal.value) || signal.value
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
93
|
+
if (!Number.isFinite(signal.value) || signal.value <= 0) continue;
|
|
94
|
+
rows.push({
|
|
95
|
+
vendorId: vendor.id,
|
|
96
|
+
band: vendor.acvBand ?? "unknown",
|
|
97
|
+
metric: signal.metric,
|
|
98
|
+
dimension: signal.dimension ?? dimensionForMetric(signal.metric),
|
|
99
|
+
value: signal.value,
|
|
100
|
+
});
|
|
49
101
|
}
|
|
50
102
|
}
|
|
51
103
|
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
104
|
+
// A vendor's reference revenue for calibration: median of its revenue signals.
|
|
105
|
+
const revenueByVendor = new Map<string, number>();
|
|
106
|
+
for (const vendor of config.vendors) {
|
|
107
|
+
const revenues = rows
|
|
108
|
+
.filter((row) => row.vendorId === vendor.id && row.dimension === "revenue")
|
|
109
|
+
.map((row) => row.value);
|
|
110
|
+
if (revenues.length > 0) revenueByVendor.set(vendor.id, median(revenues));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Per-metric calibration. Customer-dimension metrics stratify by acvBand;
|
|
114
|
+
// headcount calibrates globally (revenue-per-employee is the most stable
|
|
115
|
+
// ratio in B2B software, which is also why headcount outweighs customers).
|
|
116
|
+
const calibrations: ScaleReport["calibrations"] = [];
|
|
117
|
+
const ratioFor = new Map<string, { global: number | null; byBand: Map<string, number> }>();
|
|
118
|
+
const nonRevenueMetrics = [...new Set(rows.filter((row) => row.dimension !== "revenue").map((row) => row.metric))].sort();
|
|
119
|
+
for (const metric of nonRevenueMetrics) {
|
|
120
|
+
const pairs = rows
|
|
121
|
+
.filter((row) => row.metric === metric && revenueByVendor.has(row.vendorId))
|
|
122
|
+
.map((row) => ({ band: row.band, ratio: (revenueByVendor.get(row.vendorId) as number) / row.value }));
|
|
123
|
+
const byBand = new Map<string, number>();
|
|
124
|
+
if (dimensionForMetric(metric) === "customers") {
|
|
125
|
+
for (const band of [...new Set(pairs.map((pair) => pair.band))]) {
|
|
126
|
+
const bandRatios = pairs.filter((pair) => pair.band === band).map((pair) => pair.ratio);
|
|
127
|
+
if (bandRatios.length >= 1) {
|
|
128
|
+
byBand.set(band, median(bandRatios));
|
|
129
|
+
calibrations.push({ metric, stratum: `band:${band}`, revenuePerUnit: Math.round(median(bandRatios)), pairs: bandRatios.length });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
71
132
|
}
|
|
72
|
-
|
|
133
|
+
const global = pairs.length > 0 ? median(pairs.map((pair) => pair.ratio)) : null;
|
|
134
|
+
if (global !== null) calibrations.push({ metric, stratum: "global", revenuePerUnit: Math.round(global), pairs: pairs.length });
|
|
135
|
+
ratioFor.set(metric, { global, byBand });
|
|
73
136
|
}
|
|
74
|
-
|
|
75
|
-
|
|
137
|
+
|
|
138
|
+
const metricsUsed = new Set<string>();
|
|
139
|
+
const metricsSkipped = new Set<string>();
|
|
76
140
|
|
|
77
141
|
const vendors: VendorScale[] = config.vendors.map((vendor) => {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
142
|
+
const vendorRows = rows.filter((row) => row.vendorId === vendor.id);
|
|
143
|
+
const estimates: SignalEstimate[] = vendorRows.map((row) => {
|
|
144
|
+
if (row.dimension === "revenue") {
|
|
145
|
+
metricsUsed.add(row.metric);
|
|
146
|
+
return { metric: row.metric, dimension: row.dimension, rawValue: row.value, ratio: 1, estimatedRevenue: row.value, calibration: "direct" };
|
|
147
|
+
}
|
|
148
|
+
const calibration = ratioFor.get(row.metric);
|
|
149
|
+
let ratio: number | null = null;
|
|
150
|
+
let stratum = "fallback";
|
|
151
|
+
if (calibration) {
|
|
152
|
+
if (row.dimension === "customers" && calibration.byBand.has(row.band)) {
|
|
153
|
+
ratio = calibration.byBand.get(row.band) as number;
|
|
154
|
+
stratum = `band:${row.band}`;
|
|
155
|
+
} else if (calibration.global !== null) {
|
|
156
|
+
ratio = calibration.global;
|
|
157
|
+
stratum = "global";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (ratio === null && row.dimension === "headcount") {
|
|
161
|
+
ratio = FALLBACK_REVENUE_PER_EMPLOYEE;
|
|
162
|
+
stratum = "fallback";
|
|
163
|
+
}
|
|
164
|
+
if (ratio === null) {
|
|
165
|
+
metricsSkipped.add(row.metric);
|
|
166
|
+
return { metric: row.metric, dimension: row.dimension, rawValue: row.value, ratio: null, estimatedRevenue: null, calibration: "uncalibratable" };
|
|
167
|
+
}
|
|
168
|
+
metricsUsed.add(row.metric);
|
|
169
|
+
return { metric: row.metric, dimension: row.dimension, rawValue: row.value, ratio, estimatedRevenue: row.value * ratio, calibration: stratum };
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const usable = estimates.filter(
|
|
173
|
+
(estimate): estimate is SignalEstimate & { estimatedRevenue: number } => estimate.estimatedRevenue !== null,
|
|
174
|
+
);
|
|
175
|
+
let estimatedRevenue: number | null = null;
|
|
176
|
+
let uncertainty: number | null = null;
|
|
177
|
+
if (usable.length > 0) {
|
|
178
|
+
let weightSum = 0;
|
|
179
|
+
let logSum = 0;
|
|
180
|
+
for (const estimate of usable) {
|
|
181
|
+
const weight = DIMENSION_WEIGHT[estimate.dimension];
|
|
182
|
+
weightSum += weight;
|
|
183
|
+
logSum += weight * Math.log(estimate.estimatedRevenue);
|
|
184
|
+
}
|
|
185
|
+
estimatedRevenue = Math.exp(logSum / weightSum);
|
|
186
|
+
const values = usable.map((estimate) => estimate.estimatedRevenue);
|
|
187
|
+
uncertainty = Number((Math.max(...values) / Math.min(...values)).toFixed(2));
|
|
82
188
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
189
|
+
return {
|
|
190
|
+
vendorId: vendor.id,
|
|
191
|
+
acvBand: vendor.acvBand,
|
|
192
|
+
estimates,
|
|
193
|
+
estimatedRevenue,
|
|
194
|
+
uncertainty,
|
|
195
|
+
index: null,
|
|
196
|
+
signals: vendor.scaleSignals ?? [],
|
|
197
|
+
};
|
|
88
198
|
});
|
|
89
199
|
|
|
200
|
+
const total = vendors.reduce((sum, vendor) => sum + (vendor.estimatedRevenue ?? 0), 0);
|
|
201
|
+
for (const vendor of vendors) {
|
|
202
|
+
vendor.index = vendor.estimatedRevenue !== null && total > 0 ? Number((vendor.estimatedRevenue / total).toFixed(4)) : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
90
205
|
return {
|
|
91
206
|
vendors,
|
|
92
|
-
metricsUsed,
|
|
93
|
-
metricsSkipped,
|
|
207
|
+
metricsUsed: [...metricsUsed].sort(),
|
|
208
|
+
metricsSkipped: [...metricsSkipped].filter((metric) => !metricsUsed.has(metric)).sort(),
|
|
209
|
+
calibrations,
|
|
94
210
|
complete: vendors.every((vendor) => vendor.index !== null),
|
|
95
211
|
};
|
|
96
212
|
}
|
|
97
213
|
|
|
214
|
+
function money(value: number): string {
|
|
215
|
+
if (value >= 1e9) return `$${(value / 1e9).toFixed(1)}B`;
|
|
216
|
+
if (value >= 1e6) return `$${(value / 1e6).toFixed(1)}M`;
|
|
217
|
+
return `$${Math.round(value / 1e3)}K`;
|
|
218
|
+
}
|
|
219
|
+
|
|
98
220
|
export function scaleReportToText(config: MarketConfig, report: ScaleReport): string {
|
|
99
221
|
const names = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
|
|
100
222
|
const lines: string[] = [];
|
|
101
|
-
lines.push(`
|
|
102
|
-
lines.push(
|
|
223
|
+
lines.push(`Estimated revenue share (of this ${config.vendors.length}-vendor set; calibrated from citable signals, NOT audited):`);
|
|
224
|
+
lines.push(
|
|
225
|
+
`metrics: ${report.metricsUsed.join(", ") || "none"}${report.metricsSkipped.length ? ` · uncalibratable: ${report.metricsSkipped.join(", ")}` : ""}`,
|
|
226
|
+
);
|
|
103
227
|
lines.push("");
|
|
104
|
-
const ranked = [...report.vendors].sort((a, b) => (b.
|
|
228
|
+
const ranked = [...report.vendors].sort((a, b) => (b.estimatedRevenue ?? -1) - (a.estimatedRevenue ?? -1));
|
|
105
229
|
for (const vendor of ranked) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
230
|
+
if (vendor.estimatedRevenue === null) {
|
|
231
|
+
lines.push(` n/a ${(names.get(vendor.vendorId) ?? vendor.vendorId).padEnd(22)} no usable signals`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const share = `${((vendor.index ?? 0) * 100).toFixed(1)}%`.padStart(6);
|
|
235
|
+
const spread = vendor.uncertainty !== null && vendor.uncertainty > 1 ? ` (×${vendor.uncertainty.toFixed(1)} signal spread)` : "";
|
|
236
|
+
lines.push(
|
|
237
|
+
`${share} ${(names.get(vendor.vendorId) ?? vendor.vendorId).padEnd(22)} ~${money(vendor.estimatedRevenue)}${spread} [${vendor.estimates
|
|
238
|
+
.filter((estimate) => estimate.estimatedRevenue !== null)
|
|
239
|
+
.map((estimate) => `${estimate.metric}→${money(estimate.estimatedRevenue as number)}`)
|
|
240
|
+
.join(", ")}]`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
lines.push("");
|
|
244
|
+
lines.push("calibrations (median revenue-per-unit):");
|
|
245
|
+
for (const calibration of report.calibrations) {
|
|
246
|
+
lines.push(
|
|
247
|
+
` ${calibration.metric.padEnd(26)} ${calibration.stratum.padEnd(16)} ${money(calibration.revenuePerUnit)}/unit (${calibration.pairs} pair${calibration.pairs === 1 ? "" : "s"})`,
|
|
248
|
+
);
|
|
109
249
|
}
|
|
110
250
|
return `${lines.join("\n")}\n`;
|
|
111
251
|
}
|