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/dist/marketScale.js
CHANGED
|
@@ -1,68 +1,168 @@
|
|
|
1
|
+
const DIMENSION_WEIGHT = { revenue: 3, headcount: 2, customers: 1 };
|
|
2
|
+
/** Used only when headcount has zero calibration pairs in the whole set. */
|
|
3
|
+
const FALLBACK_REVENUE_PER_EMPLOYEE = 200_000;
|
|
4
|
+
export function dimensionForMetric(metric) {
|
|
5
|
+
const name = metric.toLowerCase();
|
|
6
|
+
if (name.includes("revenue") || name.includes("arr"))
|
|
7
|
+
return "revenue";
|
|
8
|
+
if (name.includes("employee") || name.includes("headcount"))
|
|
9
|
+
return "headcount";
|
|
10
|
+
return "customers"; // reviews, customers, installs — count-of-customers proxies
|
|
11
|
+
}
|
|
12
|
+
function median(values) {
|
|
13
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
14
|
+
const mid = Math.floor(sorted.length / 2);
|
|
15
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
16
|
+
}
|
|
1
17
|
export function computeScaleIndex(config) {
|
|
2
|
-
const
|
|
18
|
+
const rows = [];
|
|
3
19
|
for (const vendor of config.vendors) {
|
|
4
20
|
for (const signal of vendor.scaleSignals ?? []) {
|
|
5
|
-
if (!Number.isFinite(signal.value) || signal.value
|
|
21
|
+
if (!Number.isFinite(signal.value) || signal.value <= 0)
|
|
6
22
|
continue;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
23
|
+
rows.push({
|
|
24
|
+
vendorId: vendor.id,
|
|
25
|
+
band: vendor.acvBand ?? "unknown",
|
|
26
|
+
metric: signal.metric,
|
|
27
|
+
dimension: signal.dimension ?? dimensionForMetric(signal.metric),
|
|
28
|
+
value: signal.value,
|
|
29
|
+
});
|
|
10
30
|
}
|
|
11
31
|
}
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
// A vendor's reference revenue for calibration: median of its revenue signals.
|
|
33
|
+
const revenueByVendor = new Map();
|
|
34
|
+
for (const vendor of config.vendors) {
|
|
35
|
+
const revenues = rows
|
|
36
|
+
.filter((row) => row.vendorId === vendor.id && row.dimension === "revenue")
|
|
37
|
+
.map((row) => row.value);
|
|
38
|
+
if (revenues.length > 0)
|
|
39
|
+
revenueByVendor.set(vendor.id, median(revenues));
|
|
40
|
+
}
|
|
41
|
+
// Per-metric calibration. Customer-dimension metrics stratify by acvBand;
|
|
42
|
+
// headcount calibrates globally (revenue-per-employee is the most stable
|
|
43
|
+
// ratio in B2B software, which is also why headcount outweighs customers).
|
|
44
|
+
const calibrations = [];
|
|
45
|
+
const ratioFor = new Map();
|
|
46
|
+
const nonRevenueMetrics = [...new Set(rows.filter((row) => row.dimension !== "revenue").map((row) => row.metric))].sort();
|
|
47
|
+
for (const metric of nonRevenueMetrics) {
|
|
48
|
+
const pairs = rows
|
|
49
|
+
.filter((row) => row.metric === metric && revenueByVendor.has(row.vendorId))
|
|
50
|
+
.map((row) => ({ band: row.band, ratio: revenueByVendor.get(row.vendorId) / row.value }));
|
|
51
|
+
const byBand = new Map();
|
|
52
|
+
if (dimensionForMetric(metric) === "customers") {
|
|
53
|
+
for (const band of [...new Set(pairs.map((pair) => pair.band))]) {
|
|
54
|
+
const bandRatios = pairs.filter((pair) => pair.band === band).map((pair) => pair.ratio);
|
|
55
|
+
if (bandRatios.length >= 1) {
|
|
56
|
+
byBand.set(band, median(bandRatios));
|
|
57
|
+
calibrations.push({ metric, stratum: `band:${band}`, revenuePerUnit: Math.round(median(bandRatios)), pairs: bandRatios.length });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
31
60
|
}
|
|
32
|
-
|
|
61
|
+
const global = pairs.length > 0 ? median(pairs.map((pair) => pair.ratio)) : null;
|
|
62
|
+
if (global !== null)
|
|
63
|
+
calibrations.push({ metric, stratum: "global", revenuePerUnit: Math.round(global), pairs: pairs.length });
|
|
64
|
+
ratioFor.set(metric, { global, byBand });
|
|
33
65
|
}
|
|
34
|
-
metricsUsed
|
|
35
|
-
metricsSkipped
|
|
66
|
+
const metricsUsed = new Set();
|
|
67
|
+
const metricsSkipped = new Set();
|
|
36
68
|
const vendors = config.vendors.map((vendor) => {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
69
|
+
const vendorRows = rows.filter((row) => row.vendorId === vendor.id);
|
|
70
|
+
const estimates = vendorRows.map((row) => {
|
|
71
|
+
if (row.dimension === "revenue") {
|
|
72
|
+
metricsUsed.add(row.metric);
|
|
73
|
+
return { metric: row.metric, dimension: row.dimension, rawValue: row.value, ratio: 1, estimatedRevenue: row.value, calibration: "direct" };
|
|
74
|
+
}
|
|
75
|
+
const calibration = ratioFor.get(row.metric);
|
|
76
|
+
let ratio = null;
|
|
77
|
+
let stratum = "fallback";
|
|
78
|
+
if (calibration) {
|
|
79
|
+
if (row.dimension === "customers" && calibration.byBand.has(row.band)) {
|
|
80
|
+
ratio = calibration.byBand.get(row.band);
|
|
81
|
+
stratum = `band:${row.band}`;
|
|
82
|
+
}
|
|
83
|
+
else if (calibration.global !== null) {
|
|
84
|
+
ratio = calibration.global;
|
|
85
|
+
stratum = "global";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (ratio === null && row.dimension === "headcount") {
|
|
89
|
+
ratio = FALLBACK_REVENUE_PER_EMPLOYEE;
|
|
90
|
+
stratum = "fallback";
|
|
91
|
+
}
|
|
92
|
+
if (ratio === null) {
|
|
93
|
+
metricsSkipped.add(row.metric);
|
|
94
|
+
return { metric: row.metric, dimension: row.dimension, rawValue: row.value, ratio: null, estimatedRevenue: null, calibration: "uncalibratable" };
|
|
95
|
+
}
|
|
96
|
+
metricsUsed.add(row.metric);
|
|
97
|
+
return { metric: row.metric, dimension: row.dimension, rawValue: row.value, ratio, estimatedRevenue: row.value * ratio, calibration: stratum };
|
|
98
|
+
});
|
|
99
|
+
const usable = estimates.filter((estimate) => estimate.estimatedRevenue !== null);
|
|
100
|
+
let estimatedRevenue = null;
|
|
101
|
+
let uncertainty = null;
|
|
102
|
+
if (usable.length > 0) {
|
|
103
|
+
let weightSum = 0;
|
|
104
|
+
let logSum = 0;
|
|
105
|
+
for (const estimate of usable) {
|
|
106
|
+
const weight = DIMENSION_WEIGHT[estimate.dimension];
|
|
107
|
+
weightSum += weight;
|
|
108
|
+
logSum += weight * Math.log(estimate.estimatedRevenue);
|
|
109
|
+
}
|
|
110
|
+
estimatedRevenue = Math.exp(logSum / weightSum);
|
|
111
|
+
const values = usable.map((estimate) => estimate.estimatedRevenue);
|
|
112
|
+
uncertainty = Number((Math.max(...values) / Math.min(...values)).toFixed(2));
|
|
42
113
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
:
|
|
46
|
-
|
|
114
|
+
return {
|
|
115
|
+
vendorId: vendor.id,
|
|
116
|
+
acvBand: vendor.acvBand,
|
|
117
|
+
estimates,
|
|
118
|
+
estimatedRevenue,
|
|
119
|
+
uncertainty,
|
|
120
|
+
index: null,
|
|
121
|
+
signals: vendor.scaleSignals ?? [],
|
|
122
|
+
};
|
|
47
123
|
});
|
|
124
|
+
const total = vendors.reduce((sum, vendor) => sum + (vendor.estimatedRevenue ?? 0), 0);
|
|
125
|
+
for (const vendor of vendors) {
|
|
126
|
+
vendor.index = vendor.estimatedRevenue !== null && total > 0 ? Number((vendor.estimatedRevenue / total).toFixed(4)) : null;
|
|
127
|
+
}
|
|
48
128
|
return {
|
|
49
129
|
vendors,
|
|
50
|
-
metricsUsed,
|
|
51
|
-
metricsSkipped,
|
|
130
|
+
metricsUsed: [...metricsUsed].sort(),
|
|
131
|
+
metricsSkipped: [...metricsSkipped].filter((metric) => !metricsUsed.has(metric)).sort(),
|
|
132
|
+
calibrations,
|
|
52
133
|
complete: vendors.every((vendor) => vendor.index !== null),
|
|
53
134
|
};
|
|
54
135
|
}
|
|
136
|
+
function money(value) {
|
|
137
|
+
if (value >= 1e9)
|
|
138
|
+
return `$${(value / 1e9).toFixed(1)}B`;
|
|
139
|
+
if (value >= 1e6)
|
|
140
|
+
return `$${(value / 1e6).toFixed(1)}M`;
|
|
141
|
+
return `$${Math.round(value / 1e3)}K`;
|
|
142
|
+
}
|
|
55
143
|
export function scaleReportToText(config, report) {
|
|
56
144
|
const names = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
|
|
57
145
|
const lines = [];
|
|
58
|
-
lines.push(`
|
|
59
|
-
lines.push(`metrics
|
|
146
|
+
lines.push(`Estimated revenue share (of this ${config.vendors.length}-vendor set; calibrated from citable signals, NOT audited):`);
|
|
147
|
+
lines.push(`metrics: ${report.metricsUsed.join(", ") || "none"}${report.metricsSkipped.length ? ` · uncalibratable: ${report.metricsSkipped.join(", ")}` : ""}`);
|
|
60
148
|
lines.push("");
|
|
61
|
-
const ranked = [...report.vendors].sort((a, b) => (b.
|
|
149
|
+
const ranked = [...report.vendors].sort((a, b) => (b.estimatedRevenue ?? -1) - (a.estimatedRevenue ?? -1));
|
|
62
150
|
for (const vendor of ranked) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
151
|
+
if (vendor.estimatedRevenue === null) {
|
|
152
|
+
lines.push(` n/a ${(names.get(vendor.vendorId) ?? vendor.vendorId).padEnd(22)} no usable signals`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const share = `${((vendor.index ?? 0) * 100).toFixed(1)}%`.padStart(6);
|
|
156
|
+
const spread = vendor.uncertainty !== null && vendor.uncertainty > 1 ? ` (×${vendor.uncertainty.toFixed(1)} signal spread)` : "";
|
|
157
|
+
lines.push(`${share} ${(names.get(vendor.vendorId) ?? vendor.vendorId).padEnd(22)} ~${money(vendor.estimatedRevenue)}${spread} [${vendor.estimates
|
|
158
|
+
.filter((estimate) => estimate.estimatedRevenue !== null)
|
|
159
|
+
.map((estimate) => `${estimate.metric}→${money(estimate.estimatedRevenue)}`)
|
|
160
|
+
.join(", ")}]`);
|
|
161
|
+
}
|
|
162
|
+
lines.push("");
|
|
163
|
+
lines.push("calibrations (median revenue-per-unit):");
|
|
164
|
+
for (const calibration of report.calibrations) {
|
|
165
|
+
lines.push(` ${calibration.metric.padEnd(26)} ${calibration.stratum.padEnd(16)} ${money(calibration.revenuePerUnit)}/unit (${calibration.pairs} pair${calibration.pairs === 1 ? "" : "s"})`);
|
|
66
166
|
}
|
|
67
167
|
return `${lines.join("\n")}\n`;
|
|
68
168
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
package/src/index.ts
CHANGED
|
@@ -197,7 +197,15 @@ export {
|
|
|
197
197
|
type OverlayStats,
|
|
198
198
|
type VendorMentionStats,
|
|
199
199
|
} from "./marketOverlay.ts";
|
|
200
|
-
export {
|
|
200
|
+
export {
|
|
201
|
+
computeScaleIndex,
|
|
202
|
+
dimensionForMetric,
|
|
203
|
+
scaleReportToText,
|
|
204
|
+
type ScaleDimension,
|
|
205
|
+
type ScaleReport,
|
|
206
|
+
type SignalEstimate,
|
|
207
|
+
type VendorScale,
|
|
208
|
+
} from "./marketScale.ts";
|
|
201
209
|
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
202
210
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
203
211
|
export type {
|
package/src/market.ts
CHANGED
|
@@ -63,6 +63,12 @@ export type ScaleSignal = {
|
|
|
63
63
|
quote: string;
|
|
64
64
|
asOf: string;
|
|
65
65
|
caveat?: string;
|
|
66
|
+
/**
|
|
67
|
+
* What the signal proxies: revenue (used directly), headcount, or
|
|
68
|
+
* customers (count-of-customers proxies like reviews). Inferred from the
|
|
69
|
+
* metric name when omitted; set explicitly for unusual metrics.
|
|
70
|
+
*/
|
|
71
|
+
dimension?: "revenue" | "headcount" | "customers";
|
|
66
72
|
};
|
|
67
73
|
|
|
68
74
|
export type MarketVendor = {
|
|
@@ -78,6 +84,14 @@ export type MarketVendor = {
|
|
|
78
84
|
aliases?: string[];
|
|
79
85
|
/** Public scale signals; see ScaleSignal. */
|
|
80
86
|
scaleSignals?: ScaleSignal[];
|
|
87
|
+
/**
|
|
88
|
+
* ACV stratum ("smb" | "mid" | "enterprise" by convention) used to
|
|
89
|
+
* calibrate customer-count → revenue conversion in the scale index.
|
|
90
|
+
* Revenue-per-customer differs ~75× between SMB tools and enterprise
|
|
91
|
+
* suites; stratifying kills the many-small-customers bias. Usually
|
|
92
|
+
* obvious from the vendor's own pricing page (which the map captures).
|
|
93
|
+
*/
|
|
94
|
+
acvBand?: string;
|
|
81
95
|
notes?: string;
|
|
82
96
|
};
|
|
83
97
|
|