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 +31 -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 +5 -2
- 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 +5 -2
- package/src/marketScale.ts +204 -64
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 = {
|
package/dist/marketReport.js
CHANGED
|
@@ -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
|
-
|
|
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 ∝
|
|
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",
|
package/dist/marketScale.d.ts
CHANGED
|
@@ -1,41 +1,70 @@
|
|
|
1
1
|
import type { MarketConfig, ScaleSignal } from "./market.ts";
|
|
2
2
|
/**
|
|
3
|
-
* Relative scale
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
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;
|
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.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 {
|
|
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
|
|
package/src/marketReport.ts
CHANGED
|
@@ -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 ∝
|
|
181
|
+
? `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)`
|
|
179
182
|
: "Dot area ∝ LOUD count";
|
|
180
183
|
|
|
181
184
|
const breadthAxis: ScatterAxis & { id: string } = {
|
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
|
}
|