fullstackgtm 0.19.0 → 0.20.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 +38 -0
- package/dist/cli.js +89 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/market.d.ts +28 -0
- package/dist/marketOverlay.d.ts +116 -0
- package/dist/marketOverlay.js +258 -0
- package/dist/marketReport.js +16 -3
- package/dist/marketScale.d.ts +42 -0
- package/dist/marketScale.js +68 -0
- package/package.json +1 -1
- package/src/cli.ts +108 -1
- package/src/index.ts +16 -0
- package/src/market.ts +29 -0
- package/src/marketOverlay.ts +410 -0
- package/src/marketReport.ts +20 -4
- package/src/marketScale.ts +111 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import type { AuditFinding, CanonicalGtmSnapshot, GtmEvidence, PatchOperation, PatchPlan } from "./types.ts";
|
|
2
|
+
import type { ClaimFront, MarketConfig, ObservationSet } from "./market.ts";
|
|
3
|
+
import { computeFrontStates, diffFrontStates } from "./market.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The directive layer: the market map joined to the company's own ground
|
|
7
|
+
* truth. The map alone says what the category looks like; the overlay says
|
|
8
|
+
* what THIS company should do about it — and two companies running the same
|
|
9
|
+
* map see different directives, because the overlay is their own conversion
|
|
10
|
+
* fingerprint.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is deterministic. Inputs are the observation store (front
|
|
13
|
+
* states), a CRM snapshot (won/lost deals), and call documents (transcript
|
|
14
|
+
* text, optionally linked to deals). Claim mentions are found by exact
|
|
15
|
+
* word-boundary term matching against each claim's configured `terms` —
|
|
16
|
+
* the same posture as the rest of the map: no model in the loop, every
|
|
17
|
+
* directive carries at least one observation and at least one CRM statistic
|
|
18
|
+
* with its sample size, and small samples refuse to become claims (the
|
|
19
|
+
* minimum-evidence thresholds are explicit options, not vibes).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type CallDocument = {
|
|
23
|
+
id: string;
|
|
24
|
+
text: string;
|
|
25
|
+
/** Links the document to a deal for win/loss statistics; optional. */
|
|
26
|
+
dealId?: string;
|
|
27
|
+
occurredAt?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ClaimMentionStats = {
|
|
31
|
+
claimId: string;
|
|
32
|
+
/** Documents whose text matches any of the claim's terms. */
|
|
33
|
+
mentionDocIds: string[];
|
|
34
|
+
/** Distinct deals among those documents (only docs with dealId count). */
|
|
35
|
+
mentionDealIds: string[];
|
|
36
|
+
wonDeals: number;
|
|
37
|
+
lostDeals: number;
|
|
38
|
+
/** won / (won + lost) among closed mentioned deals; null below any closure. */
|
|
39
|
+
winRateWhenMentioned: number | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type VendorMentionStats = {
|
|
43
|
+
vendorId: string;
|
|
44
|
+
mentionDocIds: string[];
|
|
45
|
+
mentionDealIds: string[];
|
|
46
|
+
wonWhenMentioned: number;
|
|
47
|
+
lostWhenMentioned: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type OverlayStats = {
|
|
51
|
+
documents: number;
|
|
52
|
+
documentsWithDeal: number;
|
|
53
|
+
deals: { total: number; closed: number; won: number; baselineWinRate: number | null };
|
|
54
|
+
claims: ClaimMentionStats[];
|
|
55
|
+
vendors: VendorMentionStats[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type DirectiveType = "occupy" | "promote" | "urgent" | "retreat";
|
|
59
|
+
|
|
60
|
+
export type DirectiveStat = { name: string; value: number | string; n: number };
|
|
61
|
+
|
|
62
|
+
export type MarketDirective = {
|
|
63
|
+
id: string;
|
|
64
|
+
type: DirectiveType;
|
|
65
|
+
claimId: string;
|
|
66
|
+
title: string;
|
|
67
|
+
summary: string;
|
|
68
|
+
recommendation: string;
|
|
69
|
+
/** ≥1 observation id and ≥1 CRM stat — the spec's evidence-chain rule. */
|
|
70
|
+
observationIds: string[];
|
|
71
|
+
stats: DirectiveStat[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type OverlayOptions = {
|
|
75
|
+
/** Minimum mention documents before OCCUPY/PROMOTE may fire (default 3). */
|
|
76
|
+
minMentions?: number;
|
|
77
|
+
/** Minimum win-rate lift over baseline for PROMOTE (default 0.10). */
|
|
78
|
+
promoteLift?: number;
|
|
79
|
+
/** Minimum won deals in the corpus before RETREAT may fire (default 3). */
|
|
80
|
+
minWonDealsForRetreat?: number;
|
|
81
|
+
/** Prior run's observations: enables URGENT (front drift) directives. */
|
|
82
|
+
priorSet?: ObservationSet;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function fnv1a(value: string): string {
|
|
86
|
+
let hash = 0x811c9dc5;
|
|
87
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
88
|
+
hash ^= value.charCodeAt(i);
|
|
89
|
+
hash = Math.imul(hash, 0x01000193);
|
|
90
|
+
}
|
|
91
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function termPattern(terms: string[]): RegExp | null {
|
|
95
|
+
const cleaned = terms.map((term) => term.trim()).filter(Boolean);
|
|
96
|
+
if (cleaned.length === 0) return null;
|
|
97
|
+
const escaped = cleaned.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+"));
|
|
98
|
+
return new RegExp(`\\b(?:${escaped.join("|")})\\b`, "i");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Deterministic claim/vendor mention statistics over a call corpus.
|
|
103
|
+
* Claims match on their configured `terms` (claims without terms simply have
|
|
104
|
+
* no mention stats — the directives that need mentions will not fire for
|
|
105
|
+
* them); vendors match on name + configured `aliases`.
|
|
106
|
+
*/
|
|
107
|
+
export function computeOverlayStats(
|
|
108
|
+
config: MarketConfig,
|
|
109
|
+
snapshot: CanonicalGtmSnapshot,
|
|
110
|
+
documents: CallDocument[],
|
|
111
|
+
): OverlayStats {
|
|
112
|
+
const dealById = new Map(snapshot.deals.map((deal) => [deal.id, deal]));
|
|
113
|
+
const closed = snapshot.deals.filter((deal) => deal.isClosed === true);
|
|
114
|
+
const won = closed.filter((deal) => deal.isWon === true);
|
|
115
|
+
const baselineWinRate = closed.length > 0 ? won.length / closed.length : null;
|
|
116
|
+
|
|
117
|
+
const dealOutcome = (dealIds: string[]) => {
|
|
118
|
+
let wonCount = 0;
|
|
119
|
+
let lostCount = 0;
|
|
120
|
+
for (const dealId of dealIds) {
|
|
121
|
+
const deal = dealById.get(dealId);
|
|
122
|
+
if (!deal || deal.isClosed !== true) continue;
|
|
123
|
+
if (deal.isWon === true) wonCount += 1;
|
|
124
|
+
else lostCount += 1;
|
|
125
|
+
}
|
|
126
|
+
return { wonCount, lostCount };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const claims: ClaimMentionStats[] = config.claims.map((claim) => {
|
|
130
|
+
const pattern = termPattern(claim.terms ?? []);
|
|
131
|
+
const mentionDocs = pattern ? documents.filter((doc) => pattern.test(doc.text)) : [];
|
|
132
|
+
const mentionDealIds = [...new Set(mentionDocs.map((doc) => doc.dealId).filter((id): id is string => !!id))];
|
|
133
|
+
const { wonCount, lostCount } = dealOutcome(mentionDealIds);
|
|
134
|
+
return {
|
|
135
|
+
claimId: claim.id,
|
|
136
|
+
mentionDocIds: mentionDocs.map((doc) => doc.id),
|
|
137
|
+
mentionDealIds,
|
|
138
|
+
wonDeals: wonCount,
|
|
139
|
+
lostDeals: lostCount,
|
|
140
|
+
winRateWhenMentioned: wonCount + lostCount > 0 ? wonCount / (wonCount + lostCount) : null,
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const vendors: VendorMentionStats[] = config.vendors.map((vendor) => {
|
|
145
|
+
const pattern = termPattern([vendor.name, ...(vendor.aliases ?? [])]);
|
|
146
|
+
const mentionDocs = pattern ? documents.filter((doc) => pattern.test(doc.text)) : [];
|
|
147
|
+
const mentionDealIds = [...new Set(mentionDocs.map((doc) => doc.dealId).filter((id): id is string => !!id))];
|
|
148
|
+
const { wonCount, lostCount } = dealOutcome(mentionDealIds);
|
|
149
|
+
return {
|
|
150
|
+
vendorId: vendor.id,
|
|
151
|
+
mentionDocIds: mentionDocs.map((doc) => doc.id),
|
|
152
|
+
mentionDealIds,
|
|
153
|
+
wonWhenMentioned: wonCount,
|
|
154
|
+
lostWhenMentioned: lostCount,
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
documents: documents.length,
|
|
160
|
+
documentsWithDeal: documents.filter((doc) => doc.dealId).length,
|
|
161
|
+
deals: { total: snapshot.deals.length, closed: closed.length, won: won.length, baselineWinRate },
|
|
162
|
+
claims,
|
|
163
|
+
vendors,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Directive rules v1 — deterministic over (front states × overlay stats),
|
|
169
|
+
* with explicit minimum-evidence thresholds so small samples cannot mint
|
|
170
|
+
* strategy. Requires config.anchorVendor: directives are advice to someone.
|
|
171
|
+
*
|
|
172
|
+
* OCCUPY — open/vacant front the anchor doesn't own loudly, and buyers
|
|
173
|
+
* demonstrably talk about it (≥ minMentions documents).
|
|
174
|
+
* PROMOTE — anchor is quiet on a claim whose mentioned-deal win rate beats
|
|
175
|
+
* baseline by ≥ promoteLift (with ≥ minMentions mentioned deals).
|
|
176
|
+
* URGENT — a front the anchor is loud on drifted toward saturation since
|
|
177
|
+
* the prior run (requires priorSet).
|
|
178
|
+
* RETREAT — saturated front the anchor is loud on, with zero presence in
|
|
179
|
+
* won-deal conversations despite a corpus that contains wins.
|
|
180
|
+
*/
|
|
181
|
+
export function computeDirectives(
|
|
182
|
+
config: MarketConfig,
|
|
183
|
+
set: ObservationSet,
|
|
184
|
+
stats: OverlayStats,
|
|
185
|
+
options: OverlayOptions = {},
|
|
186
|
+
): MarketDirective[] {
|
|
187
|
+
const anchor = config.anchorVendor;
|
|
188
|
+
if (!anchor) throw new Error("market overlay requires anchorVendor in the config — directives are advice to someone");
|
|
189
|
+
const minMentions = options.minMentions ?? 3;
|
|
190
|
+
const promoteLift = options.promoteLift ?? 0.1;
|
|
191
|
+
const minWonDealsForRetreat = options.minWonDealsForRetreat ?? 3;
|
|
192
|
+
|
|
193
|
+
const fronts = computeFrontStates(config, set);
|
|
194
|
+
const frontByClaim = new Map<string, ClaimFront>(fronts.map((front) => [front.claimId, front]));
|
|
195
|
+
const statsByClaim = new Map(stats.claims.map((claim) => [claim.claimId, claim]));
|
|
196
|
+
const anchorIntensity = new Map<string, { intensity: string; observationId: string }>();
|
|
197
|
+
const loudObservationIds = new Map<string, string[]>();
|
|
198
|
+
for (const obs of set.observations) {
|
|
199
|
+
if (obs.vendorId === anchor) anchorIntensity.set(obs.claimId, { intensity: obs.intensity, observationId: obs.id });
|
|
200
|
+
if (obs.intensity === "loud") {
|
|
201
|
+
const ids = loudObservationIds.get(obs.claimId) ?? [];
|
|
202
|
+
ids.push(obs.id);
|
|
203
|
+
loudObservationIds.set(obs.claimId, ids);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const directives: MarketDirective[] = [];
|
|
208
|
+
const claimName = new Map(config.claims.map((claim) => [claim.id, claim.capability.split(":")[0]]));
|
|
209
|
+
const push = (
|
|
210
|
+
type: DirectiveType,
|
|
211
|
+
claimId: string,
|
|
212
|
+
summary: string,
|
|
213
|
+
recommendation: string,
|
|
214
|
+
observationIds: string[],
|
|
215
|
+
statsList: DirectiveStat[],
|
|
216
|
+
) => {
|
|
217
|
+
directives.push({
|
|
218
|
+
id: `dir_${fnv1a(`${config.category}|${set.runLabel}|${type}|${claimId}`)}`,
|
|
219
|
+
type,
|
|
220
|
+
claimId,
|
|
221
|
+
title: `${type.toUpperCase()}: ${claimName.get(claimId) ?? claimId}`,
|
|
222
|
+
summary,
|
|
223
|
+
recommendation,
|
|
224
|
+
observationIds: [...new Set(observationIds)].filter(Boolean),
|
|
225
|
+
stats: statsList,
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
for (const claim of config.claims) {
|
|
230
|
+
const front = frontByClaim.get(claim.id);
|
|
231
|
+
const mention = statsByClaim.get(claim.id);
|
|
232
|
+
const anchorObs = anchorIntensity.get(claim.id);
|
|
233
|
+
if (!front || !anchorObs) continue;
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
(front.state === "open" || front.state === "vacant") &&
|
|
237
|
+
(anchorObs.intensity === "absent" || anchorObs.intensity === "quiet") &&
|
|
238
|
+
mention &&
|
|
239
|
+
mention.mentionDocIds.length >= minMentions
|
|
240
|
+
) {
|
|
241
|
+
push(
|
|
242
|
+
"occupy",
|
|
243
|
+
claim.id,
|
|
244
|
+
`No vendor is loud on this claim, and it came up in ${mention.mentionDocIds.length} of ${stats.documents} call documents.`,
|
|
245
|
+
`Claim it: buyers already talk about this and nobody owns the message. Anchor is currently ${anchorObs.intensity}.`,
|
|
246
|
+
[anchorObs.observationId],
|
|
247
|
+
[
|
|
248
|
+
{ name: "mention_documents", value: mention.mentionDocIds.length, n: stats.documents },
|
|
249
|
+
{ name: "front_state", value: front.state, n: config.vendors.length },
|
|
250
|
+
],
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (
|
|
255
|
+
anchorObs.intensity === "quiet" &&
|
|
256
|
+
mention &&
|
|
257
|
+
mention.winRateWhenMentioned !== null &&
|
|
258
|
+
stats.deals.baselineWinRate !== null &&
|
|
259
|
+
mention.wonDeals + mention.lostDeals >= minMentions &&
|
|
260
|
+
mention.winRateWhenMentioned >= stats.deals.baselineWinRate + promoteLift
|
|
261
|
+
) {
|
|
262
|
+
push(
|
|
263
|
+
"promote",
|
|
264
|
+
claim.id,
|
|
265
|
+
`Anchor ships this quietly; deals where it comes up close at ${(mention.winRateWhenMentioned * 100).toFixed(0)}% vs ${(stats.deals.baselineWinRate * 100).toFixed(0)}% baseline.`,
|
|
266
|
+
"Turn it loud: the conversion fingerprint says this claim wins deals when discussed.",
|
|
267
|
+
[anchorObs.observationId],
|
|
268
|
+
[
|
|
269
|
+
{ name: "win_rate_when_mentioned", value: Number(mention.winRateWhenMentioned.toFixed(3)), n: mention.wonDeals + mention.lostDeals },
|
|
270
|
+
{ name: "baseline_win_rate", value: Number(stats.deals.baselineWinRate.toFixed(3)), n: stats.deals.closed },
|
|
271
|
+
],
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (
|
|
276
|
+
front.state === "saturated" &&
|
|
277
|
+
anchorObs.intensity === "loud" &&
|
|
278
|
+
mention &&
|
|
279
|
+
stats.deals.won >= minWonDealsForRetreat &&
|
|
280
|
+
stats.documentsWithDeal > 0 &&
|
|
281
|
+
mention.wonDeals === 0
|
|
282
|
+
) {
|
|
283
|
+
push(
|
|
284
|
+
"retreat",
|
|
285
|
+
claim.id,
|
|
286
|
+
`${front.loudVendorIds.length} vendors shout this claim; it appears in zero of the anchor's won-deal conversations (${stats.deals.won} won deals in corpus).`,
|
|
287
|
+
"Stop spending message budget on a saturated front that never shows up in wins.",
|
|
288
|
+
[anchorObs.observationId, ...(loudObservationIds.get(claim.id) ?? [])],
|
|
289
|
+
[
|
|
290
|
+
{ name: "won_deals_mentioning", value: 0, n: stats.deals.won },
|
|
291
|
+
{ name: "loud_vendors", value: front.loudVendorIds.length, n: config.vendors.length },
|
|
292
|
+
],
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (options.priorSet) {
|
|
298
|
+
const drift = diffFrontStates(computeFrontStates(config, options.priorSet), fronts);
|
|
299
|
+
const toward = (before: string, after: string) => {
|
|
300
|
+
const order = ["vacant", "open", "owned", "contested", "saturated"];
|
|
301
|
+
return order.indexOf(after) > order.indexOf(before);
|
|
302
|
+
};
|
|
303
|
+
for (const change of drift) {
|
|
304
|
+
const anchorObs = anchorIntensity.get(change.claimId);
|
|
305
|
+
if (!anchorObs || anchorObs.intensity !== "loud" || !toward(change.before, change.after)) continue;
|
|
306
|
+
const mention = statsByClaim.get(change.claimId);
|
|
307
|
+
push(
|
|
308
|
+
"urgent",
|
|
309
|
+
change.claimId,
|
|
310
|
+
`Front moved ${change.before} → ${change.after} since ${options.priorSet.runLabel} on a claim the anchor is loud on.`,
|
|
311
|
+
"The window is closing: decide whether to defend (differentiate the claim) or bank the position before it saturates.",
|
|
312
|
+
[anchorObs.observationId],
|
|
313
|
+
[
|
|
314
|
+
{ name: "front_drift", value: `${change.before}→${change.after}`, n: config.vendors.length },
|
|
315
|
+
{ name: "mention_documents", value: mention?.mentionDocIds.length ?? 0, n: stats.documents },
|
|
316
|
+
],
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return directives.sort((a, b) => a.type.localeCompare(b.type) || a.claimId.localeCompare(b.claimId));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Emit directives as a standard dry-run patch plan: one approval-gated
|
|
326
|
+
* create_task per directive against a designated CRM record (the company's
|
|
327
|
+
* own account/deal record — directives are strategy tasks, and the CRM
|
|
328
|
+
* needs somewhere to hang them). Approving and applying goes through the
|
|
329
|
+
* normal plans → approve → apply gate; nothing here writes.
|
|
330
|
+
*/
|
|
331
|
+
export function directivesToPlan(
|
|
332
|
+
config: MarketConfig,
|
|
333
|
+
set: ObservationSet,
|
|
334
|
+
directives: MarketDirective[],
|
|
335
|
+
target: { objectType: "account" | "deal"; objectId: string },
|
|
336
|
+
now: () => Date = () => new Date(),
|
|
337
|
+
): PatchPlan {
|
|
338
|
+
const createdAt = now().toISOString();
|
|
339
|
+
const findings: AuditFinding[] = directives.map((directive) => ({
|
|
340
|
+
id: `find_${fnv1a(directive.id)}`,
|
|
341
|
+
objectType: target.objectType,
|
|
342
|
+
objectId: target.objectId,
|
|
343
|
+
ruleId: `market_directive_${directive.type}`,
|
|
344
|
+
title: directive.title,
|
|
345
|
+
severity: directive.type === "urgent" ? "critical" : "warning",
|
|
346
|
+
summary: directive.summary,
|
|
347
|
+
recommendation: directive.recommendation,
|
|
348
|
+
evidenceIds: directive.observationIds,
|
|
349
|
+
}));
|
|
350
|
+
const evidence: GtmEvidence[] = directives.map((directive) => ({
|
|
351
|
+
id: `ev_${fnv1a(directive.id)}`,
|
|
352
|
+
sourceSystem: "web",
|
|
353
|
+
sourceObjectType: "market_map",
|
|
354
|
+
sourceObjectId: `${config.category}/${set.runLabel}`,
|
|
355
|
+
title: directive.title,
|
|
356
|
+
text: `${directive.summary} Stats: ${directive.stats.map((stat) => `${stat.name}=${stat.value} (n=${stat.n})`).join("; ")}`,
|
|
357
|
+
observedAt: set.runAt,
|
|
358
|
+
metadata: { observationIds: directive.observationIds, stats: directive.stats },
|
|
359
|
+
}));
|
|
360
|
+
const operations: PatchOperation[] = directives.map((directive) => ({
|
|
361
|
+
id: `op_${fnv1a(directive.id)}`,
|
|
362
|
+
objectType: target.objectType,
|
|
363
|
+
objectId: target.objectId,
|
|
364
|
+
operation: "create_task",
|
|
365
|
+
field: "task",
|
|
366
|
+
afterValue: `${directive.title} — ${directive.recommendation} (${directive.stats.map((stat) => `${stat.name}=${stat.value}, n=${stat.n}`).join("; ")})`,
|
|
367
|
+
reason: directive.summary,
|
|
368
|
+
riskLevel: "low",
|
|
369
|
+
approvalRequired: true,
|
|
370
|
+
sourceRuleOrPolicy: `market_directive_${directive.type}`,
|
|
371
|
+
evidenceIds: [`ev_${fnv1a(directive.id)}`],
|
|
372
|
+
}));
|
|
373
|
+
return {
|
|
374
|
+
id: `patch_plan_market_${fnv1a(`${config.category}|${set.runLabel}|${createdAt}`)}`,
|
|
375
|
+
title: `Market directives — ${config.category} (${set.runLabel})`,
|
|
376
|
+
createdAt,
|
|
377
|
+
status: "needs_approval",
|
|
378
|
+
dryRun: true,
|
|
379
|
+
summary: `${directives.length} directive(s) from the ${config.category} market map joined to CRM ground truth.`,
|
|
380
|
+
findings,
|
|
381
|
+
evidence,
|
|
382
|
+
operations,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function overlayToMarkdown(stats: OverlayStats, directives: MarketDirective[]): string {
|
|
387
|
+
const lines: string[] = [];
|
|
388
|
+
lines.push(
|
|
389
|
+
`Corpus: ${stats.documents} documents (${stats.documentsWithDeal} deal-linked) · ` +
|
|
390
|
+
`${stats.deals.closed} closed deals, ${stats.deals.won} won` +
|
|
391
|
+
(stats.deals.baselineWinRate !== null ? ` (baseline ${(stats.deals.baselineWinRate * 100).toFixed(0)}%)` : ""),
|
|
392
|
+
);
|
|
393
|
+
lines.push("");
|
|
394
|
+
if (directives.length === 0) {
|
|
395
|
+
lines.push("No directives met the evidence thresholds. That is an answer, not a failure:");
|
|
396
|
+
lines.push("either the corpus is too thin (add calls), or the current position needs no move.");
|
|
397
|
+
}
|
|
398
|
+
for (const directive of directives) {
|
|
399
|
+
lines.push(`## ${directive.title}`);
|
|
400
|
+
lines.push(directive.summary);
|
|
401
|
+
lines.push(`→ ${directive.recommendation}`);
|
|
402
|
+
lines.push(
|
|
403
|
+
`evidence: ${directive.observationIds.length} observation(s) · ${directive.stats
|
|
404
|
+
.map((stat) => `${stat.name}=${stat.value} (n=${stat.n})`)
|
|
405
|
+
.join(" · ")}`,
|
|
406
|
+
);
|
|
407
|
+
lines.push("");
|
|
408
|
+
}
|
|
409
|
+
return `${lines.join("\n")}\n`;
|
|
410
|
+
}
|
package/src/marketReport.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from "./market.ts";
|
|
8
8
|
import { computeFrontStates } from "./market.ts";
|
|
9
9
|
import { assessAxes, messageBreadth, type AxesReport } from "./marketAxes.ts";
|
|
10
|
+
import { computeScaleIndex } from "./marketScale.ts";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Render a market map as a client-ready deliverable: markdown for terminals
|
|
@@ -105,7 +106,8 @@ export function marketMapToMarkdown(config: MarketConfig, set: ObservationSet):
|
|
|
105
106
|
return `${lines.join("\n")}\n`;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
/** size is normalized [0,1]; rendered area-proportionally (radius ∝ √size). */
|
|
110
|
+
type ScatterPoint = { vendorId: string; name: string; x: number; y: number; size: number };
|
|
109
111
|
type ScatterAxis = { label: string; negativePole: string; positivePole: string; signed: boolean };
|
|
110
112
|
|
|
111
113
|
function svgScatter(
|
|
@@ -132,7 +134,8 @@ function svgScatter(
|
|
|
132
134
|
const e = escapeHtml;
|
|
133
135
|
const dots = points
|
|
134
136
|
.map((p) => {
|
|
135
|
-
|
|
137
|
+
// Area-proportional: perceived bubble area tracks the size metric.
|
|
138
|
+
const r = (mini ? 4 + 14 * Math.sqrt(p.size) : 8 + 26 * Math.sqrt(p.size));
|
|
136
139
|
const cls = p.vendorId === anchor ? "dot-anchor" : "dot";
|
|
137
140
|
return (
|
|
138
141
|
`<circle class="${cls}" cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}"/>` +
|
|
@@ -160,7 +163,20 @@ function axisSectionsHtml(
|
|
|
160
163
|
const e = escapeHtml;
|
|
161
164
|
const report = assessAxes(config, set);
|
|
162
165
|
const vendorNames = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
|
|
166
|
+
|
|
167
|
+
// Bubble size: scale index (relative market scale from citable signals)
|
|
168
|
+
// when every placeable vendor has one; LOUD count otherwise — never mix
|
|
169
|
+
// the two semantics on one chart.
|
|
170
|
+
const scale = computeScaleIndex(config);
|
|
171
|
+
const scaleIndex = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor.index]));
|
|
172
|
+
const useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
|
|
163
173
|
const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
|
|
174
|
+
const maxLoud = Math.max(1, ...loudCounts.values());
|
|
175
|
+
const sizeOf = (vendorId: string): number =>
|
|
176
|
+
useScale ? (scaleIndex.get(vendorId) as number) : (loudCounts.get(vendorId) ?? 0) / maxLoud;
|
|
177
|
+
const sizeCaption = useScale
|
|
178
|
+
? `Dot area ∝ relative scale index (within this vendor set, from: ${e(scale.metricsUsed.join(", "))} — citable signals, not true market share)`
|
|
179
|
+
: "Dot area ∝ LOUD count";
|
|
164
180
|
|
|
165
181
|
const breadthAxis: ScatterAxis & { id: string } = {
|
|
166
182
|
id: "breadth",
|
|
@@ -202,7 +218,7 @@ function axisSectionsHtml(
|
|
|
202
218
|
name: vendorNames.get(vendorId) ?? vendorId,
|
|
203
219
|
x: xs.get(vendorId) as number,
|
|
204
220
|
y: ys.get(vendorId) as number,
|
|
205
|
-
|
|
221
|
+
size: sizeOf(vendorId),
|
|
206
222
|
}));
|
|
207
223
|
};
|
|
208
224
|
|
|
@@ -215,7 +231,7 @@ function axisSectionsHtml(
|
|
|
215
231
|
<figure>${svgScatter(pointsFor(px, py), axInfo, ayInfo, config.anchorVendor, false)}
|
|
216
232
|
<figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
|
|
217
233
|
in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=½) of the claims it
|
|
218
|
-
voices.
|
|
234
|
+
voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
|
|
219
235
|
</figure>
|
|
220
236
|
</section>`;
|
|
221
237
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { MarketConfig, ScaleSignal } from "./market.ts";
|
|
2
|
+
|
|
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.
|
|
10
|
+
*
|
|
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).
|
|
19
|
+
*
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type VendorScale = {
|
|
25
|
+
vendorId: string;
|
|
26
|
+
/** [0, 1] within the mapped set; null when the vendor has no usable signals. */
|
|
27
|
+
index: number | null;
|
|
28
|
+
/** Metrics that contributed, with their normalized scores. */
|
|
29
|
+
coverage: Array<{ metric: string; value: number; normalized: number }>;
|
|
30
|
+
signals: ScaleSignal[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ScaleReport = {
|
|
34
|
+
vendors: VendorScale[];
|
|
35
|
+
/** Metrics used (present for ≥2 vendors) and metrics skipped (singletons). */
|
|
36
|
+
metricsUsed: string[];
|
|
37
|
+
metricsSkipped: string[];
|
|
38
|
+
complete: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function computeScaleIndex(config: MarketConfig): ScaleReport {
|
|
42
|
+
const byMetric = new Map<string, Array<{ vendorId: string; value: number }>>();
|
|
43
|
+
for (const vendor of config.vendors) {
|
|
44
|
+
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);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
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 });
|
|
71
|
+
}
|
|
72
|
+
normalized.set(metric, scores);
|
|
73
|
+
}
|
|
74
|
+
metricsUsed.sort();
|
|
75
|
+
metricsSkipped.sort();
|
|
76
|
+
|
|
77
|
+
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)) });
|
|
82
|
+
}
|
|
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 ?? [] };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
vendors,
|
|
92
|
+
metricsUsed,
|
|
93
|
+
metricsSkipped,
|
|
94
|
+
complete: vendors.every((vendor) => vendor.index !== null),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function scaleReportToText(config: MarketConfig, report: ScaleReport): string {
|
|
99
|
+
const names = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
|
|
100
|
+
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(", ")}` : ""}`);
|
|
103
|
+
lines.push("");
|
|
104
|
+
const ranked = [...report.vendors].sort((a, b) => (b.index ?? -1) - (a.index ?? -1));
|
|
105
|
+
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}`);
|
|
109
|
+
}
|
|
110
|
+
return `${lines.join("\n")}\n`;
|
|
111
|
+
}
|