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.
@@ -1,111 +1,251 @@
1
1
  import type { MarketConfig, ScaleSignal } from "./market.ts";
2
2
 
3
3
  /**
4
- * Relative scale index over the mapped vendor set — the honest version of
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
- * Method, deterministic and auditable:
12
- * 1. Per metric, log10(value + 1) these signals span orders of magnitude.
13
- * 2. Normalize each metric to [0, 1] across the vendors that HAVE it
14
- * (min–max within the set; a metric only one vendor has is skipped —
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
- * Vendors with zero signals get index null the report falls back to its
21
- * LOUD-count sizing for the whole map rather than mixing semantics.
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
- /** [0, 1] within the mapped set; null when the vendor has no usable signals. */
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
- const byMetric = new Map<string, Array<{ vendorId: string; value: number }>>();
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 < 0) continue;
46
- const rows = byMetric.get(signal.metric) ?? [];
47
- rows.push({ vendorId: vendor.id, value: signal.value });
48
- byMetric.set(signal.metric, rows);
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
- const metricsUsed: string[] = [];
53
- const metricsSkipped: string[] = [];
54
- const normalized = new Map<string, Map<string, { value: number; normalized: number }>>();
55
- for (const [metric, rows] of byMetric) {
56
- // Last write wins if a vendor lists the same metric twice.
57
- const perVendor = new Map(rows.map((row) => [row.vendorId, row.value]));
58
- if (perVendor.size < 2) {
59
- metricsSkipped.push(metric);
60
- continue;
61
- }
62
- metricsUsed.push(metric);
63
- const logs = new Map([...perVendor].map(([vendorId, value]) => [vendorId, Math.log10(value + 1)]));
64
- const values = [...logs.values()];
65
- const lo = Math.min(...values);
66
- const hi = Math.max(...values);
67
- const span = hi - lo || 1;
68
- const scores = new Map<string, { value: number; normalized: number }>();
69
- for (const [vendorId, log] of logs) {
70
- scores.set(vendorId, { value: perVendor.get(vendorId) as number, normalized: (log - lo) / span });
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
- normalized.set(metric, scores);
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
- metricsUsed.sort();
75
- metricsSkipped.sort();
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 coverage: VendorScale["coverage"] = [];
79
- for (const metric of metricsUsed) {
80
- const score = normalized.get(metric)?.get(vendor.id);
81
- if (score) coverage.push({ metric, value: score.value, normalized: Number(score.normalized.toFixed(4)) });
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
- const index =
84
- coverage.length > 0
85
- ? Number((coverage.reduce((sum, entry) => sum + entry.normalized, 0) / coverage.length).toFixed(4))
86
- : null;
87
- return { vendorId: vendor.id, index, coverage, signals: vendor.scaleSignals ?? [] };
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(`Scale index (relative, within this ${config.vendors.length}-vendor set not market share):`);
102
- lines.push(`metrics used: ${report.metricsUsed.join(", ") || "none"}${report.metricsSkipped.length ? ` · skipped (single-vendor): ${report.metricsSkipped.join(", ")}` : ""}`);
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.index ?? -1) - (a.index ?? -1));
228
+ const ranked = [...report.vendors].sort((a, b) => (b.estimatedRevenue ?? -1) - (a.estimatedRevenue ?? -1));
105
229
  for (const vendor of ranked) {
106
- const idx = vendor.index === null ? " n/a" : vendor.index.toFixed(2);
107
- const cov = vendor.coverage.map((entry) => `${entry.metric}=${entry.value}`).join(", ") || "no signals";
108
- lines.push(` ${idx} ${(names.get(vendor.vendorId) ?? vendor.vendorId).padEnd(22)} ${cov}`);
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
  }