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,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 byMetric = new Map();
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 < 0)
21
+ if (!Number.isFinite(signal.value) || signal.value <= 0)
6
22
  continue;
7
- const rows = byMetric.get(signal.metric) ?? [];
8
- rows.push({ vendorId: vendor.id, value: signal.value });
9
- byMetric.set(signal.metric, rows);
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
- const metricsUsed = [];
13
- const metricsSkipped = [];
14
- const normalized = new Map();
15
- for (const [metric, rows] of byMetric) {
16
- // Last write wins if a vendor lists the same metric twice.
17
- const perVendor = new Map(rows.map((row) => [row.vendorId, row.value]));
18
- if (perVendor.size < 2) {
19
- metricsSkipped.push(metric);
20
- continue;
21
- }
22
- metricsUsed.push(metric);
23
- const logs = new Map([...perVendor].map(([vendorId, value]) => [vendorId, Math.log10(value + 1)]));
24
- const values = [...logs.values()];
25
- const lo = Math.min(...values);
26
- const hi = Math.max(...values);
27
- const span = hi - lo || 1;
28
- const scores = new Map();
29
- for (const [vendorId, log] of logs) {
30
- scores.set(vendorId, { value: perVendor.get(vendorId), normalized: (log - lo) / span });
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
- normalized.set(metric, scores);
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.sort();
35
- metricsSkipped.sort();
66
+ const metricsUsed = new Set();
67
+ const metricsSkipped = new Set();
36
68
  const vendors = config.vendors.map((vendor) => {
37
- const coverage = [];
38
- for (const metric of metricsUsed) {
39
- const score = normalized.get(metric)?.get(vendor.id);
40
- if (score)
41
- coverage.push({ metric, value: score.value, normalized: Number(score.normalized.toFixed(4)) });
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
- const index = coverage.length > 0
44
- ? Number((coverage.reduce((sum, entry) => sum + entry.normalized, 0) / coverage.length).toFixed(4))
45
- : null;
46
- return { vendorId: vendor.id, index, coverage, signals: vendor.scaleSignals ?? [] };
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(`Scale index (relative, within this ${config.vendors.length}-vendor set not market share):`);
59
- lines.push(`metrics used: ${report.metricsUsed.join(", ") || "none"}${report.metricsSkipped.length ? ` · skipped (single-vendor): ${report.metricsSkipped.join(", ")}` : ""}`);
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.index ?? -1) - (a.index ?? -1));
149
+ const ranked = [...report.vendors].sort((a, b) => (b.estimatedRevenue ?? -1) - (a.estimatedRevenue ?? -1));
62
150
  for (const vendor of ranked) {
63
- const idx = vendor.index === null ? " n/a" : vendor.index.toFixed(2);
64
- const cov = vendor.coverage.map((entry) => `${entry.metric}=${entry.value}`).join(", ") || "no signals";
65
- lines.push(` ${idx} ${(names.get(vendor.vendorId) ?? vendor.vendorId).padEnd(22)} ${cov}`);
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.20.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 { computeScaleIndex, scaleReportToText, type ScaleReport, type VendorScale } from "./marketScale.ts";
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