fullstackgtm 0.17.0 → 0.18.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 +33 -0
- package/INSTALL_FOR_AGENTS.md +10 -5
- package/README.md +17 -0
- package/dist/cli.js +21 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/market.d.ts +16 -0
- package/dist/market.js +24 -0
- package/dist/marketAxes.d.ts +77 -0
- package/dist/marketAxes.js +199 -0
- package/dist/marketReport.js +114 -1
- package/docs/api.md +29 -2
- package/llms.txt +16 -0
- package/package.json +1 -1
- package/src/cli.ts +22 -1
- package/src/index.ts +13 -0
- package/src/market.ts +38 -0
- package/src/marketAxes.ts +268 -0
- package/src/marketReport.ts +134 -1
package/docs/api.md
CHANGED
|
@@ -58,7 +58,9 @@ release.
|
|
|
58
58
|
## CLI
|
|
59
59
|
|
|
60
60
|
Commands: `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `plans`,
|
|
61
|
-
`apply`, `
|
|
61
|
+
`apply`, `suggest`, `call` (`parse` / `score` / `link` / `plan`), `resolve`,
|
|
62
|
+
`market` (`init` / `capture` / `classify` / `worksheet` / `observe` / `fronts` /
|
|
63
|
+
`axes` / `report` / `refresh`), `rules`, `profiles`, `doctor`.
|
|
62
64
|
Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
|
|
63
65
|
(`--fail-on`, `--fail-on-new-findings`). `--json` everywhere; JSON output shapes are stable.
|
|
64
66
|
|
|
@@ -78,7 +80,32 @@ deliverable in markdown or self-contained HTML: severity counts, prose summary,
|
|
|
78
80
|
per-rule detail with capped examples, and next steps. `auditReportToMarkdown` /
|
|
79
81
|
`auditReportToHtml` expose the same rendering programmatically.
|
|
80
82
|
|
|
83
|
+
## Market map
|
|
84
|
+
|
|
85
|
+
Newer surface (0.16–0.18); shapes are settling toward the 1.0 contract. A live
|
|
86
|
+
model of the competitive category: claim taxonomy + vendor registry as a
|
|
87
|
+
reviewable `market.config.json` (`MarketConfig`, `MarketClaim`, `MarketVendor`,
|
|
88
|
+
`MarketAxis`), content-addressed page captures (`captureMarket`,
|
|
89
|
+
`loadCaptureTexts`), append-only observations (`ObservationSet`,
|
|
90
|
+
`MarketObservation`, `ObservationStore` / `createFileObservationStore` —
|
|
91
|
+
profile-scoped under `<home>/market/<category>`), and deterministic
|
|
92
|
+
derivations: `computeFrontStates` / `diffFrontStates` (front rule v1),
|
|
93
|
+
`assessAxes` / `pcaTop2` / `axisPosition` (axis discovery), and
|
|
94
|
+
`marketMapToMarkdown` / `marketMapToHtml` (the field report; renders the
|
|
95
|
+
primary strategic 2×2 when `axes` / `primaryAxes` are configured).
|
|
96
|
+
|
|
97
|
+
Intensity readings are proposals: `classifyMarket` (LLM, bring-your-own-key,
|
|
98
|
+
provenance-marked) or `buildWorksheet` + `market observe` (agent/human). Every
|
|
99
|
+
quoted evidence span is mechanically verified verbatim
|
|
100
|
+
(`verifyEvidenceSpans`; whitespace and punctuation-spacing normalized) against
|
|
101
|
+
the stored capture it cites before a set is accepted; failed captures read as
|
|
102
|
+
`unobservable`, never `absent`.
|
|
103
|
+
|
|
81
104
|
## MCP
|
|
82
105
|
|
|
83
106
|
Tools: `fullstackgtm_audit`, `fullstackgtm_rules`, `fullstackgtm_apply`
|
|
84
|
-
(requires explicit `approvedOperationIds`)
|
|
107
|
+
(requires explicit `approvedOperationIds`), `fullstackgtm_suggest`,
|
|
108
|
+
`fullstackgtm_call_parse`, `fullstackgtm_resolve`,
|
|
109
|
+
`fullstackgtm_market_worksheet`, `fullstackgtm_market_observe` (validates,
|
|
110
|
+
verifies quoted spans against the stored captures, appends, returns front
|
|
111
|
+
states). Input schemas are stable.
|
package/llms.txt
CHANGED
|
@@ -31,6 +31,22 @@ coaching scorecards; `call link` suggests the deal with confidence + reason;
|
|
|
31
31
|
`call plan` proposes governed next-step writes through the standard
|
|
32
32
|
approve/apply lifecycle.
|
|
33
33
|
|
|
34
|
+
## Key invariants (market map)
|
|
35
|
+
|
|
36
|
+
`fullstackgtm market` models the competitive category: vendors + claim
|
|
37
|
+
taxonomy in `market.config.json`; `capture` stores vendor pages
|
|
38
|
+
content-addressed; `classify` (BYO key, same ladder as calls) or
|
|
39
|
+
`worksheet` + `observe` (agent/human channel) propose LOUD/QUIET/ABSENT
|
|
40
|
+
intensity readings per vendor × claim. Every quoted evidence span is
|
|
41
|
+
mechanically verified verbatim against the stored capture it cites;
|
|
42
|
+
unverifiable quotes are rejected (`--unverified` only when captures live
|
|
43
|
+
elsewhere). Failed captures read UNOBSERVABLE, never ABSENT. `fronts --diff`
|
|
44
|
+
= deterministic front states + drift between runs; `axes` = PCA axis
|
|
45
|
+
discovery + orthogonality screen; `report` = self-contained HTML field
|
|
46
|
+
report; `refresh` = capture → classify → drift → report in one command.
|
|
47
|
+
Storage is profile-scoped under `<home>/market/<category>`. MCP:
|
|
48
|
+
`fullstackgtm_market_worksheet`, `fullstackgtm_market_observe`.
|
|
49
|
+
|
|
34
50
|
## Key invariants
|
|
35
51
|
|
|
36
52
|
- Reads are safe by default; nothing is written without explicit `--approve`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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/cli.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
verifyEvidenceSpans,
|
|
52
52
|
type ObservationSet,
|
|
53
53
|
} from "./market.ts";
|
|
54
|
+
import { assessAxes, axesReportToText } from "./marketAxes.ts";
|
|
54
55
|
import { buildWorksheet, classifyMarket } from "./marketClassify.ts";
|
|
55
56
|
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
56
57
|
import {
|
|
@@ -114,6 +115,7 @@ Usage:
|
|
|
114
115
|
fullstackgtm market worksheet --vendor <id> [--out <path>]
|
|
115
116
|
fullstackgtm market observe --from <observations.json> [--unverified]
|
|
116
117
|
fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
|
|
118
|
+
fullstackgtm market axes [--run <label>] [--json]
|
|
117
119
|
fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
|
|
118
120
|
fullstackgtm market refresh [--run <label>] [--model m]
|
|
119
121
|
the live competitive map: capture vendor pages (content-addressed),
|
|
@@ -857,9 +859,17 @@ market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model
|
|
|
857
859
|
market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
|
|
858
860
|
market observe --from <observations.json> [--unverified]
|
|
859
861
|
market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
|
|
862
|
+
market axes [--config <path>] [--run <label>] [--json]
|
|
860
863
|
market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
|
|
861
864
|
market refresh [--run <label>] [--model m] capture → classify → fronts drift → HTML report
|
|
862
865
|
|
|
866
|
+
axes runs the axis-discovery math: PCA over the vendor × claim intensity
|
|
867
|
+
matrix (PC1 = the category's primary axis, PC2 = the max-differentiation
|
|
868
|
+
direction orthogonal to it), triangulation of configured axes against the
|
|
869
|
+
PCs, and an orthogonality screen (|r|>0.75 = one axis twice). Axes live in
|
|
870
|
+
the config as claim-scoring rubrics; the report's strategic map and axis
|
|
871
|
+
lab render from them.
|
|
872
|
+
|
|
863
873
|
classify uses your Anthropic/OpenAI key (like call parse) to read the stored
|
|
864
874
|
captures and propose intensity readings; worksheet is the no-key path (an
|
|
865
875
|
agent or human fills it, submits via observe). Either way, every quoted span
|
|
@@ -1053,8 +1063,19 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
1053
1063
|
return;
|
|
1054
1064
|
}
|
|
1055
1065
|
|
|
1066
|
+
if (subcommand === "axes") {
|
|
1067
|
+
const set = await loadSet();
|
|
1068
|
+
const report = assessAxes(config, set);
|
|
1069
|
+
if (rest.includes("--json")) {
|
|
1070
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
console.log(axesReportToText(report));
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1056
1077
|
throw new Error(
|
|
1057
|
-
`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, report, refresh)`,
|
|
1078
|
+
`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, report, refresh)`,
|
|
1058
1079
|
);
|
|
1059
1080
|
}
|
|
1060
1081
|
|
package/src/index.ts
CHANGED
|
@@ -151,6 +151,7 @@ export {
|
|
|
151
151
|
type ClaimIntensity,
|
|
152
152
|
type FrontDrift,
|
|
153
153
|
type FrontState,
|
|
154
|
+
type MarketAxis,
|
|
154
155
|
type MarketClaim,
|
|
155
156
|
type MarketConfig,
|
|
156
157
|
type MarketObservation,
|
|
@@ -160,6 +161,18 @@ export {
|
|
|
160
161
|
type ObservationStore,
|
|
161
162
|
type SpanVerificationFailure,
|
|
162
163
|
} from "./market.ts";
|
|
164
|
+
export {
|
|
165
|
+
assessAxes,
|
|
166
|
+
axesReportToText,
|
|
167
|
+
axisPosition,
|
|
168
|
+
messageBreadth,
|
|
169
|
+
pcaTop2,
|
|
170
|
+
pearson,
|
|
171
|
+
type AxesReport,
|
|
172
|
+
type AxisAssessment,
|
|
173
|
+
type AxisPairing,
|
|
174
|
+
type PrincipalComponent,
|
|
175
|
+
} from "./marketAxes.ts";
|
|
163
176
|
export {
|
|
164
177
|
buildWorksheet,
|
|
165
178
|
classifyMarket,
|
package/src/market.ts
CHANGED
|
@@ -52,6 +52,19 @@ export type MarketVendor = {
|
|
|
52
52
|
notes?: string;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
export type MarketAxis = {
|
|
56
|
+
id: string;
|
|
57
|
+
label: string;
|
|
58
|
+
negativePole: string;
|
|
59
|
+
positivePole: string;
|
|
60
|
+
/** How a human scores a claim on this axis — the axis IS this rubric. */
|
|
61
|
+
rubric: string;
|
|
62
|
+
/** e.g. "validated", "proposal", "proposal (PC2-validated)". Reviewer-facing. */
|
|
63
|
+
status?: string;
|
|
64
|
+
/** claimId → score in [-1, 1]; null = the axis does not apply to this claim. */
|
|
65
|
+
claimScores: Record<string, number | null>;
|
|
66
|
+
};
|
|
67
|
+
|
|
55
68
|
export type MarketConfig = {
|
|
56
69
|
category: string;
|
|
57
70
|
anchorVendor?: string;
|
|
@@ -59,6 +72,10 @@ export type MarketConfig = {
|
|
|
59
72
|
claims: MarketClaim[];
|
|
60
73
|
/** The LOUD/QUIET/ABSENT/UNOBSERVABLE judging rule, stated for reviewers. */
|
|
61
74
|
surfaceRule?: string;
|
|
75
|
+
/** Strategic axes as claim-scoring rubrics — config, not code. */
|
|
76
|
+
axes?: MarketAxis[];
|
|
77
|
+
/** [xAxisId, yAxisId] for the report's strategic map. */
|
|
78
|
+
primaryAxes?: [string, string];
|
|
62
79
|
};
|
|
63
80
|
|
|
64
81
|
export type MarketObservation = {
|
|
@@ -148,6 +165,27 @@ export function parseMarketConfig(raw: string): MarketConfig {
|
|
|
148
165
|
if (config.anchorVendor && !config.vendors.some((v) => v.id === config.anchorVendor)) {
|
|
149
166
|
throw new Error(`market config: anchorVendor "${config.anchorVendor}" is not in vendors`);
|
|
150
167
|
}
|
|
168
|
+
if (config.axes) {
|
|
169
|
+
const claimIds = new Set(config.claims.map((claim) => claim.id));
|
|
170
|
+
const axisIds = new Set<string>();
|
|
171
|
+
for (const axis of config.axes) {
|
|
172
|
+
if (!axis.id) throw new Error("market config: axis missing id");
|
|
173
|
+
if (axisIds.has(axis.id)) throw new Error(`market config: duplicate axis id "${axis.id}"`);
|
|
174
|
+
axisIds.add(axis.id);
|
|
175
|
+
for (const claimId of Object.keys(axis.claimScores ?? {})) {
|
|
176
|
+
if (!claimIds.has(claimId)) {
|
|
177
|
+
throw new Error(`market config: axis "${axis.id}" scores unknown claim "${claimId}"`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (config.primaryAxes) {
|
|
182
|
+
if (config.primaryAxes.length !== 2 || config.primaryAxes.some((id) => !axisIds.has(id))) {
|
|
183
|
+
throw new Error(`market config: primaryAxes must name two configured axes (got ${JSON.stringify(config.primaryAxes)})`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} else if (config.primaryAxes) {
|
|
187
|
+
throw new Error("market config: primaryAxes set but no axes configured");
|
|
188
|
+
}
|
|
151
189
|
return config;
|
|
152
190
|
}
|
|
153
191
|
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type { MarketAxis, MarketConfig, MarketObservation, ObservationSet } from "./market.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Axis discovery for a market map — the method that earns a strategic 2x2
|
|
5
|
+
* instead of asserting one. Axes are claim-scoring rubrics in the config
|
|
6
|
+
* (reviewable, versioned); a vendor's position on an axis is the
|
|
7
|
+
* intensity-weighted mean of the scores of claims it voices. Two checks keep
|
|
8
|
+
* axes honest, both computed deterministically from the stored observations:
|
|
9
|
+
*
|
|
10
|
+
* 1. Triangulation — PCA over the vendor × claim intensity matrix gives the
|
|
11
|
+
* category's own top variance directions; a real axis correlates with a
|
|
12
|
+
* principal component (it is derivable from the data, not just felt).
|
|
13
|
+
* 2. Orthogonality — two configured axes that correlate ≥ ~0.75 at the
|
|
14
|
+
* vendor level are one axis twice. Sometimes that redundancy is the
|
|
15
|
+
* finding: the category couples the two ideas, and the empty quadrant is
|
|
16
|
+
* the strategic white space.
|
|
17
|
+
*
|
|
18
|
+
* Everything here is pure math over the store: same observations, same map.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const VOICE_WEIGHT: Record<string, number> = { loud: 1.0, quiet: 0.5 };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Intensity-weighted mean of claim scores over claims the vendor voices.
|
|
25
|
+
* Claims scored null on the axis are excluded; returns null if the vendor
|
|
26
|
+
* voices nothing scoreable (e.g. fully unobservable).
|
|
27
|
+
*/
|
|
28
|
+
export function axisPosition(
|
|
29
|
+
vendorId: string,
|
|
30
|
+
claimScores: Record<string, number | null>,
|
|
31
|
+
observations: MarketObservation[],
|
|
32
|
+
): number | null {
|
|
33
|
+
let num = 0;
|
|
34
|
+
let den = 0;
|
|
35
|
+
for (const obs of observations) {
|
|
36
|
+
if (obs.vendorId !== vendorId) continue;
|
|
37
|
+
const score = claimScores[obs.claimId];
|
|
38
|
+
if (score === null || score === undefined) continue;
|
|
39
|
+
const weight = VOICE_WEIGHT[obs.intensity] ?? 0;
|
|
40
|
+
if (weight > 0) {
|
|
41
|
+
num += score * weight;
|
|
42
|
+
den += weight;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return den > 0 ? num / den : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Share of the claim space voiced (loud + half-weight quiet) over observable claims. */
|
|
49
|
+
export function messageBreadth(
|
|
50
|
+
vendorId: string,
|
|
51
|
+
observations: MarketObservation[],
|
|
52
|
+
): { breadth: number | null; loudCount: number } {
|
|
53
|
+
let voiced = 0;
|
|
54
|
+
let observable = 0;
|
|
55
|
+
let loudCount = 0;
|
|
56
|
+
for (const obs of observations) {
|
|
57
|
+
if (obs.vendorId !== vendorId) continue;
|
|
58
|
+
if (obs.intensity === "unobservable") continue;
|
|
59
|
+
observable += 1;
|
|
60
|
+
voiced += VOICE_WEIGHT[obs.intensity] ?? 0;
|
|
61
|
+
if (obs.intensity === "loud") loudCount += 1;
|
|
62
|
+
}
|
|
63
|
+
return { breadth: observable > 0 ? voiced / observable : null, loudCount };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function pearson(xs: number[], ys: number[]): number {
|
|
67
|
+
const n = xs.length;
|
|
68
|
+
if (n < 3) return 0;
|
|
69
|
+
const mx = xs.reduce((sum, x) => sum + x, 0) / n;
|
|
70
|
+
const my = ys.reduce((sum, y) => sum + y, 0) / n;
|
|
71
|
+
const sx = Math.sqrt(xs.reduce((sum, x) => sum + (x - mx) ** 2, 0));
|
|
72
|
+
const sy = Math.sqrt(ys.reduce((sum, y) => sum + (y - my) ** 2, 0));
|
|
73
|
+
if (!sx || !sy) return 0;
|
|
74
|
+
return xs.reduce((sum, x, i) => sum + (x - mx) * (ys[i] - my), 0) / (sx * sy);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// PCA — power iteration over the column-centered vendor × claim weight
|
|
79
|
+
// matrix. Pure and dependency-free; two components are all the canvas needs
|
|
80
|
+
// (PC1 should recover the category's primary axis; PC2 is the
|
|
81
|
+
// maximum-differentiation direction orthogonal to it).
|
|
82
|
+
|
|
83
|
+
export type PrincipalComponent = {
|
|
84
|
+
/** claimId → loading. Sign is arbitrary; read poles from the extremes. */
|
|
85
|
+
loadings: Array<{ claimId: string; loading: number }>;
|
|
86
|
+
/** vendorId → score on this component. */
|
|
87
|
+
scores: Array<{ vendorId: string; score: number }>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function pcaTop2(
|
|
91
|
+
config: MarketConfig,
|
|
92
|
+
set: ObservationSet,
|
|
93
|
+
): { vendors: string[]; pc1: PrincipalComponent; pc2: PrincipalComponent } {
|
|
94
|
+
const claimIds = config.claims.map((claim) => claim.id);
|
|
95
|
+
const byCell = new Map(set.observations.map((obs) => [`${obs.vendorId}|${obs.claimId}`, obs]));
|
|
96
|
+
// Exclude fully-unobservable vendors: they carry no information, only zeros.
|
|
97
|
+
const vendors = config.vendors
|
|
98
|
+
.map((vendor) => vendor.id)
|
|
99
|
+
.filter((vendorId) =>
|
|
100
|
+
claimIds.some((claimId) => {
|
|
101
|
+
const obs = byCell.get(`${vendorId}|${claimId}`);
|
|
102
|
+
return obs !== undefined && obs.intensity !== "unobservable";
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const matrix = vendors.map((vendorId) =>
|
|
107
|
+
claimIds.map((claimId) => VOICE_WEIGHT[byCell.get(`${vendorId}|${claimId}`)?.intensity ?? ""] ?? 0),
|
|
108
|
+
);
|
|
109
|
+
const means = claimIds.map((_, j) => matrix.reduce((sum, row) => sum + row[j], 0) / vendors.length);
|
|
110
|
+
const centered = matrix.map((row) => row.map((value, j) => value - means[j]));
|
|
111
|
+
|
|
112
|
+
const component = (deflate?: number[]): { loadings: number[]; scores: number[] } => {
|
|
113
|
+
let v = new Array<number>(claimIds.length).fill(1 / Math.sqrt(claimIds.length));
|
|
114
|
+
for (let iteration = 0; iteration < 300; iteration += 1) {
|
|
115
|
+
if (deflate) {
|
|
116
|
+
const dot = v.reduce((sum, x, k) => sum + x * deflate[k], 0);
|
|
117
|
+
v = v.map((x, k) => x - dot * deflate[k]);
|
|
118
|
+
}
|
|
119
|
+
const scores = centered.map((row) => row.reduce((sum, x, j) => sum + x * v[j], 0));
|
|
120
|
+
v = claimIds.map((_, j) => centered.reduce((sum, row, i) => sum + row[j] * scores[i], 0));
|
|
121
|
+
const norm = Math.sqrt(v.reduce((sum, x) => sum + x * x, 0)) || 1;
|
|
122
|
+
v = v.map((x) => x / norm);
|
|
123
|
+
}
|
|
124
|
+
return { loadings: v, scores: centered.map((row) => row.reduce((sum, x, j) => sum + x * v[j], 0)) };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const first = component();
|
|
128
|
+
const second = component(first.loadings);
|
|
129
|
+
const shape = (raw: { loadings: number[]; scores: number[] }): PrincipalComponent => ({
|
|
130
|
+
loadings: claimIds.map((claimId, j) => ({ claimId, loading: raw.loadings[j] })),
|
|
131
|
+
scores: vendors.map((vendorId, i) => ({ vendorId, score: raw.scores[i] })),
|
|
132
|
+
});
|
|
133
|
+
return { vendors, pc1: shape(first), pc2: shape(second) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// The axes report: positions, triangulation vs PCA, orthogonality screen.
|
|
138
|
+
|
|
139
|
+
export type AxisVendorPosition = { vendorId: string; position: number | null };
|
|
140
|
+
|
|
141
|
+
export type AxisAssessment = {
|
|
142
|
+
axis: MarketAxis;
|
|
143
|
+
positions: AxisVendorPosition[];
|
|
144
|
+
/** Standard deviation of placeable vendor positions — does the axis separate anyone? */
|
|
145
|
+
spread: number;
|
|
146
|
+
rVsPc1: number;
|
|
147
|
+
rVsPc2: number;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export type AxisPairing = {
|
|
151
|
+
aId: string;
|
|
152
|
+
bId: string;
|
|
153
|
+
r: number;
|
|
154
|
+
verdict: "near-orthogonal" | "correlated — weak pair" | "redundant — same axis twice";
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type AxesReport = {
|
|
158
|
+
vendors: string[];
|
|
159
|
+
pc1: PrincipalComponent;
|
|
160
|
+
pc2: PrincipalComponent;
|
|
161
|
+
assessments: AxisAssessment[];
|
|
162
|
+
/** Includes the derived breadth axis in pairings. */
|
|
163
|
+
pairings: AxisPairing[];
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export function pairingVerdict(r: number): AxisPairing["verdict"] {
|
|
167
|
+
const magnitude = Math.abs(r);
|
|
168
|
+
if (magnitude < 0.4) return "near-orthogonal";
|
|
169
|
+
if (magnitude < 0.75) return "correlated — weak pair";
|
|
170
|
+
return "redundant — same axis twice";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function assessAxes(config: MarketConfig, set: ObservationSet): AxesReport {
|
|
174
|
+
const { vendors, pc1, pc2 } = pcaTop2(config, set);
|
|
175
|
+
const pcScore = (pc: PrincipalComponent) => new Map(pc.scores.map((entry) => [entry.vendorId, entry.score]));
|
|
176
|
+
const pc1ByVendor = pcScore(pc1);
|
|
177
|
+
const pc2ByVendor = pcScore(pc2);
|
|
178
|
+
|
|
179
|
+
const axes = config.axes ?? [];
|
|
180
|
+
const positionsById = new Map<string, Map<string, number>>();
|
|
181
|
+
const assessments: AxisAssessment[] = axes.map((axis) => {
|
|
182
|
+
const positions: AxisVendorPosition[] = vendors.map((vendorId) => ({
|
|
183
|
+
vendorId,
|
|
184
|
+
position: axisPosition(vendorId, axis.claimScores, set.observations),
|
|
185
|
+
}));
|
|
186
|
+
const placeable = positions.filter((entry): entry is { vendorId: string; position: number } => entry.position !== null);
|
|
187
|
+
positionsById.set(axis.id, new Map(placeable.map((entry) => [entry.vendorId, entry.position])));
|
|
188
|
+
const values = placeable.map((entry) => entry.position);
|
|
189
|
+
const mean = values.reduce((sum, x) => sum + x, 0) / Math.max(values.length, 1);
|
|
190
|
+
const spread = Math.sqrt(values.reduce((sum, x) => sum + (x - mean) ** 2, 0) / Math.max(values.length, 1));
|
|
191
|
+
const aligned = placeable.filter((entry) => pc1ByVendor.has(entry.vendorId));
|
|
192
|
+
return {
|
|
193
|
+
axis,
|
|
194
|
+
positions,
|
|
195
|
+
spread,
|
|
196
|
+
rVsPc1: pearson(aligned.map((entry) => entry.position), aligned.map((entry) => pc1ByVendor.get(entry.vendorId) as number)),
|
|
197
|
+
rVsPc2: pearson(aligned.map((entry) => entry.position), aligned.map((entry) => pc2ByVendor.get(entry.vendorId) as number)),
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Derived breadth axis joins the orthogonality screen (it's free and often
|
|
202
|
+
// the only near-orthogonal partner early on).
|
|
203
|
+
const breadthPositions = new Map<string, number>();
|
|
204
|
+
for (const vendorId of vendors) {
|
|
205
|
+
const { breadth } = messageBreadth(vendorId, set.observations);
|
|
206
|
+
if (breadth !== null) breadthPositions.set(vendorId, breadth);
|
|
207
|
+
}
|
|
208
|
+
positionsById.set("breadth", breadthPositions);
|
|
209
|
+
|
|
210
|
+
const ids = [...axes.map((axis) => axis.id), "breadth"];
|
|
211
|
+
const pairings: AxisPairing[] = [];
|
|
212
|
+
for (let i = 0; i < ids.length; i += 1) {
|
|
213
|
+
for (let j = i + 1; j < ids.length; j += 1) {
|
|
214
|
+
const a = positionsById.get(ids[i]) as Map<string, number>;
|
|
215
|
+
const b = positionsById.get(ids[j]) as Map<string, number>;
|
|
216
|
+
const shared = vendors.filter((vendorId) => a.has(vendorId) && b.has(vendorId));
|
|
217
|
+
const r = pearson(shared.map((vendorId) => a.get(vendorId) as number), shared.map((vendorId) => b.get(vendorId) as number));
|
|
218
|
+
pairings.push({ aId: ids[i], bId: ids[j], r, verdict: pairingVerdict(r) });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { vendors, pc1, pc2, assessments, pairings };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function axesReportToText(report: AxesReport): string {
|
|
226
|
+
const lines: string[] = [];
|
|
227
|
+
for (const [label, pc] of [
|
|
228
|
+
["PC1", report.pc1],
|
|
229
|
+
["PC2", report.pc2],
|
|
230
|
+
] as const) {
|
|
231
|
+
lines.push(`=== ${label} — claim loadings (extremes; sign is arbitrary, read the poles) ===`);
|
|
232
|
+
const ordered = [...pc.loadings].sort((a, b) => a.loading - b.loading);
|
|
233
|
+
for (const entry of ordered.slice(0, 5)) {
|
|
234
|
+
lines.push(` ${entry.loading >= 0 ? "+" : ""}${entry.loading.toFixed(2)} ${entry.claimId}`);
|
|
235
|
+
}
|
|
236
|
+
lines.push(" ...");
|
|
237
|
+
for (const entry of ordered.slice(-5)) {
|
|
238
|
+
lines.push(` ${entry.loading >= 0 ? "+" : ""}${entry.loading.toFixed(2)} ${entry.claimId}`);
|
|
239
|
+
}
|
|
240
|
+
lines.push(
|
|
241
|
+
` vendor scores: ${[...pc.scores]
|
|
242
|
+
.sort((a, b) => a.score - b.score)
|
|
243
|
+
.map((entry) => `${entry.vendorId}=${entry.score >= 0 ? "+" : ""}${entry.score.toFixed(2)}`)
|
|
244
|
+
.join(" ")}`,
|
|
245
|
+
);
|
|
246
|
+
lines.push("");
|
|
247
|
+
}
|
|
248
|
+
if (report.assessments.length > 0) {
|
|
249
|
+
lines.push("=== configured axes vs PCA (triangulation: a real axis is derivable from the data) ===");
|
|
250
|
+
for (const assessment of report.assessments) {
|
|
251
|
+
lines.push(
|
|
252
|
+
` ${assessment.axis.id.padEnd(20)} spread=${assessment.spread.toFixed(3)} r(PC1)=${assessment.rVsPc1 >= 0 ? "+" : ""}${assessment.rVsPc1.toFixed(2)} r(PC2)=${assessment.rVsPc2 >= 0 ? "+" : ""}${assessment.rVsPc2.toFixed(2)} [${assessment.axis.status ?? ""}]`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
lines.push("");
|
|
256
|
+
lines.push("=== orthogonality screen (|r|>0.75 = redundant pair) ===");
|
|
257
|
+
for (const pairing of report.pairings) {
|
|
258
|
+
const flag = pairing.verdict === "redundant — same axis twice" ? " <-- redundant" : "";
|
|
259
|
+
lines.push(
|
|
260
|
+
` ${pairing.aId.padEnd(18)} x ${pairing.bId.padEnd(18)} r=${pairing.r >= 0 ? "+" : ""}${pairing.r.toFixed(2)}${flag}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
lines.push("No axes configured. Read the PC loadings above, name the two directions, and add them");
|
|
265
|
+
lines.push("to market.config.json as axes: [{ id, label, negativePole, positivePole, rubric, claimScores }].");
|
|
266
|
+
}
|
|
267
|
+
return `${lines.join("\n")}\n`;
|
|
268
|
+
}
|
package/src/marketReport.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ObservationSet,
|
|
7
7
|
} from "./market.ts";
|
|
8
8
|
import { computeFrontStates } from "./market.ts";
|
|
9
|
+
import { assessAxes, messageBreadth, type AxesReport } from "./marketAxes.ts";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Render a market map as a client-ready deliverable: markdown for terminals
|
|
@@ -104,6 +105,127 @@ export function marketMapToMarkdown(config: MarketConfig, set: ObservationSet):
|
|
|
104
105
|
return `${lines.join("\n")}\n`;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
type ScatterPoint = { vendorId: string; name: string; x: number; y: number; loud: number };
|
|
109
|
+
type ScatterAxis = { label: string; negativePole: string; positivePole: string; signed: boolean };
|
|
110
|
+
|
|
111
|
+
function svgScatter(
|
|
112
|
+
points: ScatterPoint[],
|
|
113
|
+
ax: ScatterAxis,
|
|
114
|
+
ay: ScatterAxis,
|
|
115
|
+
anchor: string | undefined,
|
|
116
|
+
mini: boolean,
|
|
117
|
+
): string {
|
|
118
|
+
const W = mini ? 330 : 700;
|
|
119
|
+
const H = mini ? 250 : 460;
|
|
120
|
+
const PAD = mini ? 34 : 56;
|
|
121
|
+
const range = (axis: ScatterAxis, values: number[]): [number, number] => {
|
|
122
|
+
if (axis.signed) return [-1.1, 1.1];
|
|
123
|
+
if (values.length === 0) return [0, 1];
|
|
124
|
+
return [Math.min(0, Math.min(...values) - 0.05), Math.max(...values) + 0.08];
|
|
125
|
+
};
|
|
126
|
+
const [xLo, xHi] = range(ax, points.map((p) => p.x));
|
|
127
|
+
const [yLo, yHi] = range(ay, points.map((p) => p.y));
|
|
128
|
+
const sx = (x: number) => PAD + ((x - xLo) / (xHi - xLo)) * (W - 2 * PAD);
|
|
129
|
+
const sy = (y: number) => H - PAD - ((y - yLo) / (yHi - yLo)) * (H - 2 * PAD);
|
|
130
|
+
const fsLabel = mini ? 8.5 : 10.5;
|
|
131
|
+
const fsAx = mini ? 8 : 10;
|
|
132
|
+
const e = escapeHtml;
|
|
133
|
+
const dots = points
|
|
134
|
+
.map((p) => {
|
|
135
|
+
const r = mini ? 3 + p.loud * 0.8 : 6 + p.loud * 1.6;
|
|
136
|
+
const cls = p.vendorId === anchor ? "dot-anchor" : "dot";
|
|
137
|
+
return (
|
|
138
|
+
`<circle class="${cls}" cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}"/>` +
|
|
139
|
+
`<text class="dot-label" style="font-size:${fsLabel}px" x="${sx(p.x).toFixed(1)}" y="${(sy(p.y) - r - 4).toFixed(1)}">${e(p.name)}</text>`
|
|
140
|
+
);
|
|
141
|
+
})
|
|
142
|
+
.join("");
|
|
143
|
+
const midX = ax.signed ? `<line class="axis-mid" x1="${sx(0).toFixed(0)}" y1="${PAD}" x2="${sx(0).toFixed(0)}" y2="${H - PAD}"/>` : "";
|
|
144
|
+
const midY = ay.signed ? `<line class="axis-mid" x1="${PAD}" y1="${sy(0).toFixed(0)}" x2="${W - PAD}" y2="${sy(0).toFixed(0)}"/>` : "";
|
|
145
|
+
return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${e(ax.label)} vs ${e(ay.label)}">
|
|
146
|
+
<line class="axis" x1="${PAD}" y1="${H - PAD}" x2="${W - PAD}" y2="${H - PAD}"/>
|
|
147
|
+
<line class="axis" x1="${PAD}" y1="${PAD}" x2="${PAD}" y2="${H - PAD}"/>${midX}${midY}
|
|
148
|
+
<text class="ax-label" style="font-size:${fsAx}px" x="${PAD}" y="${H - 14}">← ${e(ax.negativePole)}</text>
|
|
149
|
+
<text class="ax-label" style="font-size:${fsAx}px" x="${W - PAD}" y="${H - 14}" text-anchor="end">${e(ax.positivePole)} →</text>
|
|
150
|
+
<text class="ax-label" style="font-size:${fsAx}px" x="${PAD}" y="${PAD - 10}">↑ ${e(ay.positivePole)}${ay.signed ? ` · ↓ ${e(ay.negativePole)}` : ""}</text>
|
|
151
|
+
${dots}</svg>`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function axisSectionsHtml(
|
|
155
|
+
config: MarketConfig,
|
|
156
|
+
set: ObservationSet,
|
|
157
|
+
): { strategicMap: string; report: AxesReport | null } {
|
|
158
|
+
const axes = config.axes ?? [];
|
|
159
|
+
if (axes.length === 0) return { strategicMap: "", report: null };
|
|
160
|
+
const e = escapeHtml;
|
|
161
|
+
const report = assessAxes(config, set);
|
|
162
|
+
const vendorNames = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
|
|
163
|
+
const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
|
|
164
|
+
|
|
165
|
+
const breadthAxis: ScatterAxis & { id: string } = {
|
|
166
|
+
id: "breadth",
|
|
167
|
+
label: "Message breadth",
|
|
168
|
+
negativePole: "FOCUSED",
|
|
169
|
+
positivePole: "BROAD (share of claims voiced)",
|
|
170
|
+
signed: false,
|
|
171
|
+
};
|
|
172
|
+
const axisInfo = new Map<string, ScatterAxis & { id: string }>([
|
|
173
|
+
...axes.map((axis) => [axis.id, { id: axis.id, label: axis.label, negativePole: axis.negativePole, positivePole: axis.positivePole, signed: true }] as const),
|
|
174
|
+
[breadthAxis.id, breadthAxis],
|
|
175
|
+
]);
|
|
176
|
+
const positions = new Map<string, Map<string, number>>();
|
|
177
|
+
for (const assessment of report.assessments) {
|
|
178
|
+
positions.set(
|
|
179
|
+
assessment.axis.id,
|
|
180
|
+
new Map(
|
|
181
|
+
assessment.positions
|
|
182
|
+
.filter((entry): entry is { vendorId: string; position: number } => entry.position !== null)
|
|
183
|
+
.map((entry) => [entry.vendorId, entry.position]),
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const breadthMap = new Map<string, number>();
|
|
188
|
+
for (const vendorId of report.vendors) {
|
|
189
|
+
const { breadth } = messageBreadth(vendorId, set.observations);
|
|
190
|
+
if (breadth !== null) breadthMap.set(vendorId, breadth);
|
|
191
|
+
}
|
|
192
|
+
positions.set("breadth", breadthMap);
|
|
193
|
+
|
|
194
|
+
const pointsFor = (xId: string, yId: string): ScatterPoint[] => {
|
|
195
|
+
const xs = positions.get(xId);
|
|
196
|
+
const ys = positions.get(yId);
|
|
197
|
+
if (!xs || !ys) return [];
|
|
198
|
+
return report.vendors
|
|
199
|
+
.filter((vendorId) => xs.has(vendorId) && ys.has(vendorId))
|
|
200
|
+
.map((vendorId) => ({
|
|
201
|
+
vendorId,
|
|
202
|
+
name: vendorNames.get(vendorId) ?? vendorId,
|
|
203
|
+
x: xs.get(vendorId) as number,
|
|
204
|
+
y: ys.get(vendorId) as number,
|
|
205
|
+
loud: loudCounts.get(vendorId) ?? 0,
|
|
206
|
+
}));
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const [px, py] = config.primaryAxes ?? [axes[0].id, axes[1]?.id ?? "breadth"];
|
|
210
|
+
const axInfo = axisInfo.get(px) as ScatterAxis & { id: string };
|
|
211
|
+
const ayInfo = axisInfo.get(py) as ScatterAxis & { id: string };
|
|
212
|
+
const statusOf = (id: string) => axes.find((axis) => axis.id === id)?.status ?? (id === "breadth" ? "derived" : "");
|
|
213
|
+
const strategicMap = `<section>
|
|
214
|
+
<h2><span class="no">03</span> Strategic map — ${e(axInfo.label)} × ${e(ayInfo.label)}</h2>
|
|
215
|
+
<figure>${svgScatter(pointsFor(px, py), axInfo, ayInfo, config.anchorVendor, false)}
|
|
216
|
+
<figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
|
|
217
|
+
in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=½) of the claims it
|
|
218
|
+
voices. Dot size = LOUD count. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
|
|
219
|
+
</figure>
|
|
220
|
+
</section>`;
|
|
221
|
+
|
|
222
|
+
// Deliberately no axis-pairing gallery here: the report is the client-facing
|
|
223
|
+
// artifact, best foot forward — one earned 2x2. Axis exploration (PCA,
|
|
224
|
+
// triangulation, the orthogonality screen over every pairing) lives in
|
|
225
|
+
// `market axes` for the analyst or agent doing the iterating.
|
|
226
|
+
return { strategicMap, report };
|
|
227
|
+
}
|
|
228
|
+
|
|
107
229
|
export function marketMapToHtml(config: MarketConfig, set: ObservationSet): string {
|
|
108
230
|
const model = buildModel(config, set);
|
|
109
231
|
const stateByClaim = new Map(model.fronts.map((front) => [front.claimId, front.state]));
|
|
@@ -113,6 +235,8 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
|
|
|
113
235
|
const unobservable = set.observations.filter((obs) => obs.intensity === "unobservable").length;
|
|
114
236
|
const anchor = config.anchorVendor;
|
|
115
237
|
const e = escapeHtml;
|
|
238
|
+
const axisHtml = axisSectionsHtml(config, set);
|
|
239
|
+
const appendixNo = axisHtml.report ? "04" : "03";
|
|
116
240
|
|
|
117
241
|
const matrixRows = model.orderedClaimIds
|
|
118
242
|
.map((claimId) => {
|
|
@@ -223,6 +347,14 @@ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
|
|
|
223
347
|
.ev-head { font-size:10.5px; letter-spacing:.1em; color:var(--accent); }
|
|
224
348
|
.ev blockquote { font-style:italic; margin:6px 0; font-size:13.5px; line-height:1.5; }
|
|
225
349
|
.ev-src { font-size:10px; color:var(--ink-soft); word-break:break-all; }
|
|
350
|
+
figure { margin-top:22px; border:1px solid var(--line); background:rgba(255,255,255,.35); }
|
|
351
|
+
.axis { stroke:var(--ink); stroke-width:1.5; }
|
|
352
|
+
.axis-mid { stroke:var(--line); stroke-dasharray:3 5; }
|
|
353
|
+
.ax-label { letter-spacing:.16em; fill:var(--ink-soft); font-family:"SF Mono",Menlo,Consolas,monospace; }
|
|
354
|
+
.dot { fill:rgba(33,29,22,.78); }
|
|
355
|
+
.dot-anchor { fill:var(--green); stroke:var(--ink); stroke-width:1.5; }
|
|
356
|
+
.dot-label { fill:var(--ink); text-anchor:middle; letter-spacing:.04em; font-family:"SF Mono",Menlo,Consolas,monospace; }
|
|
357
|
+
figcaption { font-size:12px; color:var(--ink-soft); padding:12px 16px 14px; font-style:italic; border-top:1px solid var(--line); line-height:1.5; }
|
|
226
358
|
footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; font-size:11px; color:var(--ink-soft);
|
|
227
359
|
display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
|
|
228
360
|
@media print { body { max-width:none; padding:0 8mm; background:white; } section { break-inside:avoid-page; } tr { break-inside:avoid; } }
|
|
@@ -260,8 +392,9 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
|
|
|
260
392
|
<tbody>${matrixRows}</tbody>
|
|
261
393
|
</table>
|
|
262
394
|
</section>
|
|
395
|
+
${axisHtml.strategicMap}
|
|
263
396
|
<section>
|
|
264
|
-
<h2><span class="no"
|
|
397
|
+
<h2><span class="no">${appendixNo}</span> Evidence appendix</h2>
|
|
265
398
|
${appendix}
|
|
266
399
|
</section>
|
|
267
400
|
<footer>
|