fullstackgtm 0.20.0 → 0.21.0

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 CHANGED
@@ -5,6 +5,37 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.21.0] — 2026-06-12
9
+
10
+ Scale estimation v2 — dimensional, calibrated, SMB-bias-robust.
11
+
12
+ ### Changed
13
+
14
+ - **`market scale` estimates revenue, not a normalized blend.** v1 averaged
15
+ [0,1]-normalized signals, which quietly mixed dimensions: review/customer
16
+ counts proxy CUSTOMER COUNT while employees/revenue proxy REVENUE, so
17
+ many-small-customer vendors outranked fewer-bigger-customer ones (observed
18
+ live: a ~$20M SMB dialer outranked a ~$33M mid-market platform). v2
19
+ converts every signal into revenue space first: revenue signals are used
20
+ directly; headcount × a revenue-per-employee ratio; customer counts × a
21
+ revenue-per-customer ratio **calibrated within the set (median over
22
+ vendors that have both) and stratified by the vendor's new `acvBand`** —
23
+ revenue-per-review spans ~75× between SMB tools and enterprise suites,
24
+ which is the bias, killed at the source. Per-vendor output is an
25
+ estimated revenue (weighted geometric mean: revenue 3 / headcount 2 /
26
+ customers 1) with a disclosed max/min **uncertainty spread**, an index =
27
+ share of the set's summed estimates, and the full calibration table.
28
+ Uncalibratable metrics (no revenue pair anywhere) are skipped and named.
29
+ - Report bubbles: dot area ∝ estimated revenue share (normalized to the
30
+ set's max for visual range — ratios preserved); caption now says
31
+ "estimated revenue share … citable but NOT audited" and points at
32
+ `market scale` for the per-vendor estimates and spreads.
33
+ - New config fields: `MarketVendor.acvBand` ("smb" | "mid" | "enterprise"
34
+ by convention — usually obvious from the pricing page the map already
35
+ captures) and `ScaleSignal.dimension` override.
36
+ - SMB-bias regression test: many cheap-product reviews must not outrank
37
+ fewer expensive-product reviews when band calibration says otherwise.
38
+
8
39
  ## [0.20.0] — 2026-06-12
9
40
 
10
41
  The directive layer: the market map joined to your own CRM ground truth.
package/dist/index.d.ts CHANGED
@@ -24,7 +24,7 @@ export { captureMarket, computeFrontStates, createFileObservationStore, diffFron
24
24
  export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, type AxesReport, type AxisAssessment, type AxisPairing, type PrincipalComponent, } from "./marketAxes.ts";
25
25
  export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type ClassifyMarketResult, type MarketWorksheet, } from "./marketClassify.ts";
26
26
  export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, type CallDocument, type ClaimMentionStats, type DirectiveStat, type DirectiveType, type MarketDirective, type OverlayOptions, type OverlayStats, type VendorMentionStats, } from "./marketOverlay.ts";
27
- export { computeScaleIndex, scaleReportToText, type ScaleReport, type VendorScale } from "./marketScale.ts";
27
+ export { computeScaleIndex, dimensionForMetric, scaleReportToText, type ScaleDimension, type ScaleReport, type SignalEstimate, type VendorScale, } from "./marketScale.ts";
28
28
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
29
29
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
30
30
  export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
package/dist/index.js CHANGED
@@ -24,6 +24,6 @@ export { captureMarket, computeFrontStates, createFileObservationStore, diffFron
24
24
  export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, } from "./marketAxes.js";
25
25
  export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
26
26
  export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
27
- export { computeScaleIndex, scaleReportToText } from "./marketScale.js";
27
+ export { computeScaleIndex, dimensionForMetric, scaleReportToText, } from "./marketScale.js";
28
28
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
29
29
  export { suggestValues } from "./suggest.js";
package/dist/market.d.ts CHANGED
@@ -53,6 +53,12 @@ export type ScaleSignal = {
53
53
  quote: string;
54
54
  asOf: string;
55
55
  caveat?: string;
56
+ /**
57
+ * What the signal proxies: revenue (used directly), headcount, or
58
+ * customers (count-of-customers proxies like reviews). Inferred from the
59
+ * metric name when omitted; set explicitly for unusual metrics.
60
+ */
61
+ dimension?: "revenue" | "headcount" | "customers";
56
62
  };
57
63
  export type MarketVendor = {
58
64
  id: string;
@@ -67,6 +73,14 @@ export type MarketVendor = {
67
73
  aliases?: string[];
68
74
  /** Public scale signals; see ScaleSignal. */
69
75
  scaleSignals?: ScaleSignal[];
76
+ /**
77
+ * ACV stratum ("smb" | "mid" | "enterprise" by convention) used to
78
+ * calibrate customer-count → revenue conversion in the scale index.
79
+ * Revenue-per-customer differs ~75× between SMB tools and enterprise
80
+ * suites; stratifying kills the many-small-customers bias. Usually
81
+ * obvious from the vendor's own pricing page (which the map captures).
82
+ */
83
+ acvBand?: string;
70
84
  notes?: string;
71
85
  };
72
86
  export type MarketAxis = {
@@ -131,9 +131,12 @@ function axisSectionsHtml(config, set) {
131
131
  const useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
132
132
  const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
133
133
  const maxLoud = Math.max(1, ...loudCounts.values());
134
- const sizeOf = (vendorId) => useScale ? scaleIndex.get(vendorId) : (loudCounts.get(vendorId) ?? 0) / maxLoud;
134
+ // Bubble areas stay proportional to the metric; dividing by the max just
135
+ // spends the full visual range without distorting any ratio.
136
+ const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
137
+ const sizeOf = (vendorId) => useScale ? scaleIndex.get(vendorId) / maxShare : (loudCounts.get(vendorId) ?? 0) / maxLoud;
135
138
  const sizeCaption = useScale
136
- ? `Dot area ∝ relative scale index (within this vendor set, from: ${e(scale.metricsUsed.join(", "))} citable signals, not true market share)`
139
+ ? `Dot area ∝ estimated revenue share of this vendor set (signals: ${e(scale.metricsUsed.join(", "))}; calibrated within-set, ACV-band stratified, citable but NOT audited — see \`market scale\` for estimates and spreads)`
137
140
  : "Dot area ∝ LOUD count";
138
141
  const breadthAxis = {
139
142
  id: "breadth",
@@ -1,41 +1,70 @@
1
1
  import type { MarketConfig, ScaleSignal } from "./market.ts";
2
2
  /**
3
- * Relative scale index over the mapped vendor set — the honest version of
4
- * "bubble size = market share". True segment market share is unknowable from
5
- * public data for mostly-private vendor sets, so this computes a composite
6
- * index from whatever citable signals exist per vendor (review counts,
7
- * headcount, disclosed revenue, self-reported customers), each of which is
8
- * biased in a different direction; the composite triangulates.
3
+ * Relative scale estimation over the mapped vendor set — v2, dimensional.
9
4
  *
10
- * Method, deterministic and auditable:
11
- * 1. Per metric, log10(value + 1) these signals span orders of magnitude.
12
- * 2. Normalize each metric to [0, 1] across the vendors that HAVE it
13
- * (min–max within the set; a metric only one vendor has is skipped —
14
- * it cannot rank anyone).
15
- * 3. A vendor's index = arithmetic mean of its normalized metric scores
16
- * (mean-of-normalized rather than geometric-of-raw so missing signals
17
- * neither punish nor reward), reported with coverage (which metrics).
5
+ * v1 normalized every signal onto [0,1] and averaged, which quietly mixed
6
+ * dimensions: review/customer counts proxy CUSTOMER COUNT (N), while
7
+ * employees and revenue proxy REVENUE (N × ACV). Averaging the two inflates
8
+ * many-small-customer vendors against few-big-customer ones the SMB bias.
18
9
  *
19
- * Vendors with zero signals get index null the report falls back to its
20
- * LOUD-count sizing for the whole map rather than mixing semantics.
10
+ * v2 converts every signal into REVENUE SPACE before combining:
11
+ *
12
+ * 1. Signals are classed by dimension: revenue (used directly),
13
+ * headcount (× revenue-per-employee), customers (× revenue-per-customer).
14
+ * 2. Conversion ratios are CALIBRATED within the set, per metric, as the
15
+ * median ratio over vendors that have both the metric and a revenue
16
+ * signal — and customer-dimension ratios are stratified by each
17
+ * vendor's `acvBand` (smb / mid / enterprise), because revenue-per-
18
+ * review spans ~75× between SMB tools and enterprise suites. A band
19
+ * without calibration pairs falls back to the global median; a metric
20
+ * with no pairs anywhere is unusable and reported as skipped.
21
+ * 3. A vendor's estimated revenue is the weighted geometric mean of its
22
+ * per-signal estimates (revenue weight 3, headcount 2, customers 1 —
23
+ * reliability order), with an uncertainty band = max/min estimate
24
+ * ratio, reported, never hidden.
25
+ * 4. index = share of the set's summed estimated revenue; bubbles render
26
+ * area-proportional to it. Labeled "estimated revenue share" with the
27
+ * calibration disclosed — still never "market share" unqualified:
28
+ * it is revenue share OF THE MAPPED SET, from citable-but-unaudited
29
+ * signals.
30
+ *
31
+ * Deterministic and auditable end to end: same config, same estimates.
21
32
  */
33
+ export type ScaleDimension = "revenue" | "headcount" | "customers";
34
+ export declare function dimensionForMetric(metric: string): ScaleDimension;
35
+ export type SignalEstimate = {
36
+ metric: string;
37
+ dimension: ScaleDimension;
38
+ rawValue: number;
39
+ /** Revenue-per-unit ratio applied (1 for revenue signals); null = unusable. */
40
+ ratio: number | null;
41
+ estimatedRevenue: number | null;
42
+ /** What calibrated the ratio: "direct", "band:<name>", "global", "fallback". */
43
+ calibration: string;
44
+ };
22
45
  export type VendorScale = {
23
46
  vendorId: string;
24
- /** [0, 1] within the mapped set; null when the vendor has no usable signals. */
47
+ acvBand?: string;
48
+ estimates: SignalEstimate[];
49
+ /** Weighted geometric mean of usable estimates; null with no usable signals. */
50
+ estimatedRevenue: number | null;
51
+ /** max/min across usable estimates — 1 = perfect agreement among signals. */
52
+ uncertainty: number | null;
53
+ /** Share of the set's summed estimated revenue; drives bubble area. */
25
54
  index: number | null;
26
- /** Metrics that contributed, with their normalized scores. */
27
- coverage: Array<{
28
- metric: string;
29
- value: number;
30
- normalized: number;
31
- }>;
32
55
  signals: ScaleSignal[];
33
56
  };
34
57
  export type ScaleReport = {
35
58
  vendors: VendorScale[];
36
- /** Metrics used (present for ≥2 vendors) and metrics skipped (singletons). */
37
59
  metricsUsed: string[];
38
60
  metricsSkipped: string[];
61
+ /** Calibrated ratios for the appendix: metric × stratum → revenue-per-unit. */
62
+ calibrations: Array<{
63
+ metric: string;
64
+ stratum: string;
65
+ revenuePerUnit: number;
66
+ pairs: number;
67
+ }>;
39
68
  complete: boolean;
40
69
  };
41
70
  export declare function computeScaleIndex(config: MarketConfig): ScaleReport;
@@ -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.0",
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
 
@@ -172,10 +172,13 @@ function axisSectionsHtml(
172
172
  const useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
173
173
  const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
174
174
  const maxLoud = Math.max(1, ...loudCounts.values());
175
+ // Bubble areas stay proportional to the metric; dividing by the max just
176
+ // spends the full visual range without distorting any ratio.
177
+ const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
175
178
  const sizeOf = (vendorId: string): number =>
176
- useScale ? (scaleIndex.get(vendorId) as number) : (loudCounts.get(vendorId) ?? 0) / maxLoud;
179
+ useScale ? (scaleIndex.get(vendorId) as number) / maxShare : (loudCounts.get(vendorId) ?? 0) / maxLoud;
177
180
  const sizeCaption = useScale
178
- ? `Dot area &#8733; relative scale index (within this vendor set, from: ${e(scale.metricsUsed.join(", "))} citable signals, not true market share)`
181
+ ? `Dot area &#8733; estimated revenue share of this vendor set (signals: ${e(scale.metricsUsed.join(", "))}; calibrated within-set, ACV-band stratified, citable but NOT audited — see \`market scale\` for estimates and spreads)`
179
182
  : "Dot area &#8733; LOUD count";
180
183
 
181
184
  const breadthAxis: ScatterAxis & { id: string } = {
@@ -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
  }