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/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`, `rules`, `profiles`, `doctor`.
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`). Input schemas are stable.
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.17.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
+ }
@@ -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}">&#8592; ${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)} &#8594;</text>
150
+ <text class="ax-label" style="font-size:${fsAx}px" x="${PAD}" y="${PAD - 10}">&#8593; ${e(ay.positivePole)}${ay.signed ? ` &#183; &#8595; ${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)} &#215; ${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=&#189;) 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">03</span> Evidence appendix</h2>
397
+ <h2><span class="no">${appendixNo}</span> Evidence appendix</h2>
265
398
  ${appendix}
266
399
  </section>
267
400
  <footer>