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 CHANGED
@@ -5,6 +5,39 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.18.0] — 2026-06-11
9
+
10
+ Axis discovery: earn a strategic 2×2 from the observations instead of
11
+ asserting one.
12
+
13
+ ### Added
14
+
15
+ - **Axes as config** — `axes` in `market.config.json`: each axis is a
16
+ claim-scoring rubric (`{ id, label, poles, rubric, status, claimScores }`,
17
+ null = axis doesn't apply to that claim); a vendor's position is the
18
+ intensity-weighted mean (loud=1, quiet=½) of the claims it voices.
19
+ `primaryAxes: [x, y]` picks the report's strategic map. Config validation
20
+ rejects axes scoring unknown claims.
21
+ - **`fullstackgtm market axes`** — the discovery math, pure and
22
+ dependency-free: PCA (power iteration) over the vendor × claim intensity
23
+ matrix — PC1 is the category's own primary axis, PC2 the
24
+ maximum-differentiation direction orthogonal to it; triangulation of every
25
+ configured axis against the PCs (a real axis is *derivable* from the data,
26
+ not just felt); and an orthogonality screen (|r| ≥ 0.75 = one axis twice —
27
+ sometimes the finding: the category couples the ideas and the empty
28
+ quadrant is the white space). Fully-unobservable vendors are excluded,
29
+ never zeroed.
30
+ - **Report: strategic map** — section 03 renders the primary 2×2 (positions
31
+ computed, not asserted; dot size = LOUD count; axis status in the caption)
32
+ when axes are configured; the evidence appendix renumbers accordingly. The
33
+ report deliberately carries only the one earned 2×2 — best foot forward
34
+ for the client; axis exploration (every pairing, r, verdicts) is `market
35
+ axes` territory for the analyst or agent doing the iterating.
36
+ - **Golden regression**: the 280-cell creative-intelligence validation
37
+ dataset ships as a test fixture — PCA must recover the buyer axis as PC1
38
+ (|r| ≥ 0.9) and value-mode as PC2 (|r| ≥ 0.85), and flag the documented
39
+ buyer × operating-model redundancy.
40
+
8
41
  ## [0.17.0] — 2026-06-11
9
42
 
10
43
  Market map classification: intensity readings become a one-command step, and
@@ -49,11 +49,16 @@ In an agent sandbox, prefer rung 1 or 2. Never echo tokens into argv —
49
49
  environments — login flows then print verification URLs instead of opening
50
50
  the OS browser.
51
51
 
52
- LLM calls (`call parse`, `call score`): set `ANTHROPIC_API_KEY` or
53
- `OPENAI_API_KEY` in the environment, or have the human run
54
- `echo "$KEY" | fullstackgtm login anthropic` once. Without a key, use
55
- `call parse --deterministic` (free keyword baseline, no prompt). In
56
- non-interactive contexts the CLI never prompts it fails with this guidance.
52
+ LLM calls (`call parse`, `call score`, `market classify`): set
53
+ `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in the environment, or have the human
54
+ run `echo "$KEY" | fullstackgtm login anthropic` once. Without a key, use
55
+ `call parse --deterministic` (free keyword baseline, no prompt) — or, for the
56
+ market map, classify it yourself: `fullstackgtm market worksheet --vendor <id>`
57
+ returns the claims, judging rules, and captured page texts; submit your
58
+ readings via `market observe --from <file>`. Quote evidence VERBATIM from the
59
+ page texts — every span is checked character-for-character against the stored
60
+ capture, and paraphrased quotes are rejected. In non-interactive contexts the
61
+ CLI never prompts — it fails with this guidance.
57
62
 
58
63
  Provider prerequisites (what the human must create, and which scopes) are in
59
64
  the README's **"Connect your CRM"** section: HubSpot needs a private app with
package/README.md CHANGED
@@ -109,6 +109,23 @@ npx fullstackgtm report --provider hubspot --client "Acme" --out acme-health.htm
109
109
 
110
110
  `report` renders the same audit as a deliverable — severity counts up front, a prose summary, per-rule detail with example records, and next steps — as markdown or self-contained HTML (printable, emailable, no external assets).
111
111
 
112
+ ## The market map: the category, observed
113
+
114
+ Your CRM records what happened in your own deals; nothing records the shape of the category you sell into. The **market map** does: vendors and a claim taxonomy live in a reviewable `market.config.json`, vendor pages are captured into a content-addressed cache, every vendor × claim cell gets a messaging-intensity reading (LOUD / QUIET / ABSENT — with UNOBSERVABLE for failed captures, never a fake absence), and deterministic front states fall out per claim: open, contested, owned, saturated.
115
+
116
+ ```bash
117
+ fullstackgtm market init --category creative-intelligence # seed vendors + claims, edit by hand
118
+ fullstackgtm market capture # fetch pages → content-addressed captures
119
+ fullstackgtm market classify # LLM readings (BYO key), every quote verified
120
+ fullstackgtm market fronts --diff run-1 # what changed since last run
121
+ fullstackgtm market report --format html --out map.html # the client-ready field report
122
+ fullstackgtm market refresh # all of the above, weekly, one command
123
+ ```
124
+
125
+ The discipline matches the rest of the tool. Intensity readings are *proposals* — from the LLM (`classify`, same bring-your-own-key seam as `call parse`, provenance-marked) or from any agent/human (`market worksheet` → `market observe`) — and **every quoted evidence span is verified character-for-character against the stored capture it cites** before an observation is accepted. Quotes that aren't on the page bounce. Everything downstream of the store is deterministic: same observations, same map.
126
+
127
+ `market axes` is for earning a strategic 2×2 instead of asserting one: PCA over the intensity matrix (PC1 = the category's own primary axis, PC2 = the most differentiating direction orthogonal to it), triangulation of your configured axes against the data, and an orthogonality screen that flags two axes that are secretly one. Axes are claim-scoring rubrics in the config; the report renders the primary pair as the strategic map. Captures and observations are profile-scoped (`~/.fullstackgtm/market/<category>`), so one client's category intel never bleeds into another's.
128
+
112
129
  ### Working across organizations
113
130
 
114
131
  Consultants and fractional operators hold credentials for several CRMs at once. A profile scopes stored logins *and* stored plans to one organization:
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { builtinAuditRules } from "./rules.js";
19
19
  import { sampleSnapshot } from "./sampleData.js";
20
20
  import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
21
21
  import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
22
+ import { assessAxes, axesReportToText } from "./marketAxes.js";
22
23
  import { buildWorksheet, classifyMarket } from "./marketClassify.js";
23
24
  import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
24
25
  import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
@@ -64,6 +65,7 @@ Usage:
64
65
  fullstackgtm market worksheet --vendor <id> [--out <path>]
65
66
  fullstackgtm market observe --from <observations.json> [--unverified]
66
67
  fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
68
+ fullstackgtm market axes [--run <label>] [--json]
67
69
  fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
68
70
  fullstackgtm market refresh [--run <label>] [--model m]
69
71
  the live competitive map: capture vendor pages (content-addressed),
@@ -757,9 +759,17 @@ market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model
757
759
  market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
758
760
  market observe --from <observations.json> [--unverified]
759
761
  market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
762
+ market axes [--config <path>] [--run <label>] [--json]
760
763
  market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
761
764
  market refresh [--run <label>] [--model m] capture → classify → fronts drift → HTML report
762
765
 
766
+ axes runs the axis-discovery math: PCA over the vendor × claim intensity
767
+ matrix (PC1 = the category's primary axis, PC2 = the max-differentiation
768
+ direction orthogonal to it), triangulation of configured axes against the
769
+ PCs, and an orthogonality screen (|r|>0.75 = one axis twice). Axes live in
770
+ the config as claim-scoring rubrics; the report's strategic map and axis
771
+ lab render from them.
772
+
763
773
  classify uses your Anthropic/OpenAI key (like call parse) to read the stored
764
774
  captures and propose intensity readings; worksheet is the no-key path (an
765
775
  agent or human fills it, submits via observe). Either way, every quoted span
@@ -947,7 +957,17 @@ recomputed deterministically on every invocation — never stored.`);
947
957
  }
948
958
  return;
949
959
  }
950
- throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, report, refresh)`);
960
+ if (subcommand === "axes") {
961
+ const set = await loadSet();
962
+ const report = assessAxes(config, set);
963
+ if (rest.includes("--json")) {
964
+ console.log(JSON.stringify(report, null, 2));
965
+ return;
966
+ }
967
+ console.log(axesReportToText(report));
968
+ return;
969
+ }
970
+ throw new Error(`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, axes, report, refresh)`);
951
971
  }
952
972
  /**
953
973
  * The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
package/dist/index.d.ts CHANGED
@@ -19,7 +19,8 @@ export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, s
19
19
  export { sampleSnapshot } from "./sampleData.ts";
20
20
  export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
21
21
  export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
22
- export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type CaptureEntry, type CaptureOptions, type ClaimFront, type ClaimIntensity, type FrontDrift, type FrontState, type MarketClaim, type MarketConfig, type MarketObservation, type MarketVendor, type ObservationConfidence, type ObservationSet, type ObservationStore, type SpanVerificationFailure, } from "./market.ts";
22
+ export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type CaptureEntry, type CaptureOptions, type ClaimFront, type ClaimIntensity, type FrontDrift, type FrontState, type MarketAxis, type MarketClaim, type MarketConfig, type MarketObservation, type MarketVendor, type ObservationConfidence, type ObservationSet, type ObservationStore, type SpanVerificationFailure, } from "./market.ts";
23
+ export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, type AxesReport, type AxisAssessment, type AxisPairing, type PrincipalComponent, } from "./marketAxes.ts";
23
24
  export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type ClassifyMarketResult, type MarketWorksheet, } from "./marketClassify.ts";
24
25
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
25
26
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ export { sampleSnapshot } from "./sampleData.js";
20
20
  export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
21
21
  export { resolveRecord } from "./resolve.js";
22
22
  export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
23
+ export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, } from "./marketAxes.js";
23
24
  export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
24
25
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
25
26
  export { suggestValues } from "./suggest.js";
package/dist/market.d.ts CHANGED
@@ -41,6 +41,18 @@ export type MarketVendor = {
41
41
  };
42
42
  notes?: string;
43
43
  };
44
+ export type MarketAxis = {
45
+ id: string;
46
+ label: string;
47
+ negativePole: string;
48
+ positivePole: string;
49
+ /** How a human scores a claim on this axis — the axis IS this rubric. */
50
+ rubric: string;
51
+ /** e.g. "validated", "proposal", "proposal (PC2-validated)". Reviewer-facing. */
52
+ status?: string;
53
+ /** claimId → score in [-1, 1]; null = the axis does not apply to this claim. */
54
+ claimScores: Record<string, number | null>;
55
+ };
44
56
  export type MarketConfig = {
45
57
  category: string;
46
58
  anchorVendor?: string;
@@ -48,6 +60,10 @@ export type MarketConfig = {
48
60
  claims: MarketClaim[];
49
61
  /** The LOUD/QUIET/ABSENT/UNOBSERVABLE judging rule, stated for reviewers. */
50
62
  surfaceRule?: string;
63
+ /** Strategic axes as claim-scoring rubrics — config, not code. */
64
+ axes?: MarketAxis[];
65
+ /** [xAxisId, yAxisId] for the report's strategic map. */
66
+ primaryAxes?: [string, string];
51
67
  };
52
68
  export type MarketObservation = {
53
69
  /** stableHash(category, runLabel, vendorId, claimId) — deterministic. */
package/dist/market.js CHANGED
@@ -49,6 +49,30 @@ export function parseMarketConfig(raw) {
49
49
  if (config.anchorVendor && !config.vendors.some((v) => v.id === config.anchorVendor)) {
50
50
  throw new Error(`market config: anchorVendor "${config.anchorVendor}" is not in vendors`);
51
51
  }
52
+ if (config.axes) {
53
+ const claimIds = new Set(config.claims.map((claim) => claim.id));
54
+ const axisIds = new Set();
55
+ for (const axis of config.axes) {
56
+ if (!axis.id)
57
+ throw new Error("market config: axis missing id");
58
+ if (axisIds.has(axis.id))
59
+ throw new Error(`market config: duplicate axis id "${axis.id}"`);
60
+ axisIds.add(axis.id);
61
+ for (const claimId of Object.keys(axis.claimScores ?? {})) {
62
+ if (!claimIds.has(claimId)) {
63
+ throw new Error(`market config: axis "${axis.id}" scores unknown claim "${claimId}"`);
64
+ }
65
+ }
66
+ }
67
+ if (config.primaryAxes) {
68
+ if (config.primaryAxes.length !== 2 || config.primaryAxes.some((id) => !axisIds.has(id))) {
69
+ throw new Error(`market config: primaryAxes must name two configured axes (got ${JSON.stringify(config.primaryAxes)})`);
70
+ }
71
+ }
72
+ }
73
+ else if (config.primaryAxes) {
74
+ throw new Error("market config: primaryAxes set but no axes configured");
75
+ }
52
76
  return config;
53
77
  }
54
78
  export function loadMarketConfig(path) {
@@ -0,0 +1,77 @@
1
+ import type { MarketAxis, MarketConfig, MarketObservation, ObservationSet } from "./market.ts";
2
+ /**
3
+ * Axis discovery for a market map — the method that earns a strategic 2x2
4
+ * instead of asserting one. Axes are claim-scoring rubrics in the config
5
+ * (reviewable, versioned); a vendor's position on an axis is the
6
+ * intensity-weighted mean of the scores of claims it voices. Two checks keep
7
+ * axes honest, both computed deterministically from the stored observations:
8
+ *
9
+ * 1. Triangulation — PCA over the vendor × claim intensity matrix gives the
10
+ * category's own top variance directions; a real axis correlates with a
11
+ * principal component (it is derivable from the data, not just felt).
12
+ * 2. Orthogonality — two configured axes that correlate ≥ ~0.75 at the
13
+ * vendor level are one axis twice. Sometimes that redundancy is the
14
+ * finding: the category couples the two ideas, and the empty quadrant is
15
+ * the strategic white space.
16
+ *
17
+ * Everything here is pure math over the store: same observations, same map.
18
+ */
19
+ export declare const VOICE_WEIGHT: Record<string, number>;
20
+ /**
21
+ * Intensity-weighted mean of claim scores over claims the vendor voices.
22
+ * Claims scored null on the axis are excluded; returns null if the vendor
23
+ * voices nothing scoreable (e.g. fully unobservable).
24
+ */
25
+ export declare function axisPosition(vendorId: string, claimScores: Record<string, number | null>, observations: MarketObservation[]): number | null;
26
+ /** Share of the claim space voiced (loud + half-weight quiet) over observable claims. */
27
+ export declare function messageBreadth(vendorId: string, observations: MarketObservation[]): {
28
+ breadth: number | null;
29
+ loudCount: number;
30
+ };
31
+ export declare function pearson(xs: number[], ys: number[]): number;
32
+ export type PrincipalComponent = {
33
+ /** claimId → loading. Sign is arbitrary; read poles from the extremes. */
34
+ loadings: Array<{
35
+ claimId: string;
36
+ loading: number;
37
+ }>;
38
+ /** vendorId → score on this component. */
39
+ scores: Array<{
40
+ vendorId: string;
41
+ score: number;
42
+ }>;
43
+ };
44
+ export declare function pcaTop2(config: MarketConfig, set: ObservationSet): {
45
+ vendors: string[];
46
+ pc1: PrincipalComponent;
47
+ pc2: PrincipalComponent;
48
+ };
49
+ export type AxisVendorPosition = {
50
+ vendorId: string;
51
+ position: number | null;
52
+ };
53
+ export type AxisAssessment = {
54
+ axis: MarketAxis;
55
+ positions: AxisVendorPosition[];
56
+ /** Standard deviation of placeable vendor positions — does the axis separate anyone? */
57
+ spread: number;
58
+ rVsPc1: number;
59
+ rVsPc2: number;
60
+ };
61
+ export type AxisPairing = {
62
+ aId: string;
63
+ bId: string;
64
+ r: number;
65
+ verdict: "near-orthogonal" | "correlated — weak pair" | "redundant — same axis twice";
66
+ };
67
+ export type AxesReport = {
68
+ vendors: string[];
69
+ pc1: PrincipalComponent;
70
+ pc2: PrincipalComponent;
71
+ assessments: AxisAssessment[];
72
+ /** Includes the derived breadth axis in pairings. */
73
+ pairings: AxisPairing[];
74
+ };
75
+ export declare function pairingVerdict(r: number): AxisPairing["verdict"];
76
+ export declare function assessAxes(config: MarketConfig, set: ObservationSet): AxesReport;
77
+ export declare function axesReportToText(report: AxesReport): string;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Axis discovery for a market map — the method that earns a strategic 2x2
3
+ * instead of asserting one. Axes are claim-scoring rubrics in the config
4
+ * (reviewable, versioned); a vendor's position on an axis is the
5
+ * intensity-weighted mean of the scores of claims it voices. Two checks keep
6
+ * axes honest, both computed deterministically from the stored observations:
7
+ *
8
+ * 1. Triangulation — PCA over the vendor × claim intensity matrix gives the
9
+ * category's own top variance directions; a real axis correlates with a
10
+ * principal component (it is derivable from the data, not just felt).
11
+ * 2. Orthogonality — two configured axes that correlate ≥ ~0.75 at the
12
+ * vendor level are one axis twice. Sometimes that redundancy is the
13
+ * finding: the category couples the two ideas, and the empty quadrant is
14
+ * the strategic white space.
15
+ *
16
+ * Everything here is pure math over the store: same observations, same map.
17
+ */
18
+ export const VOICE_WEIGHT = { loud: 1.0, quiet: 0.5 };
19
+ /**
20
+ * Intensity-weighted mean of claim scores over claims the vendor voices.
21
+ * Claims scored null on the axis are excluded; returns null if the vendor
22
+ * voices nothing scoreable (e.g. fully unobservable).
23
+ */
24
+ export function axisPosition(vendorId, claimScores, observations) {
25
+ let num = 0;
26
+ let den = 0;
27
+ for (const obs of observations) {
28
+ if (obs.vendorId !== vendorId)
29
+ continue;
30
+ const score = claimScores[obs.claimId];
31
+ if (score === null || score === undefined)
32
+ continue;
33
+ const weight = VOICE_WEIGHT[obs.intensity] ?? 0;
34
+ if (weight > 0) {
35
+ num += score * weight;
36
+ den += weight;
37
+ }
38
+ }
39
+ return den > 0 ? num / den : null;
40
+ }
41
+ /** Share of the claim space voiced (loud + half-weight quiet) over observable claims. */
42
+ export function messageBreadth(vendorId, observations) {
43
+ let voiced = 0;
44
+ let observable = 0;
45
+ let loudCount = 0;
46
+ for (const obs of observations) {
47
+ if (obs.vendorId !== vendorId)
48
+ continue;
49
+ if (obs.intensity === "unobservable")
50
+ continue;
51
+ observable += 1;
52
+ voiced += VOICE_WEIGHT[obs.intensity] ?? 0;
53
+ if (obs.intensity === "loud")
54
+ loudCount += 1;
55
+ }
56
+ return { breadth: observable > 0 ? voiced / observable : null, loudCount };
57
+ }
58
+ export function pearson(xs, ys) {
59
+ const n = xs.length;
60
+ if (n < 3)
61
+ return 0;
62
+ const mx = xs.reduce((sum, x) => sum + x, 0) / n;
63
+ const my = ys.reduce((sum, y) => sum + y, 0) / n;
64
+ const sx = Math.sqrt(xs.reduce((sum, x) => sum + (x - mx) ** 2, 0));
65
+ const sy = Math.sqrt(ys.reduce((sum, y) => sum + (y - my) ** 2, 0));
66
+ if (!sx || !sy)
67
+ return 0;
68
+ return xs.reduce((sum, x, i) => sum + (x - mx) * (ys[i] - my), 0) / (sx * sy);
69
+ }
70
+ export function pcaTop2(config, set) {
71
+ const claimIds = config.claims.map((claim) => claim.id);
72
+ const byCell = new Map(set.observations.map((obs) => [`${obs.vendorId}|${obs.claimId}`, obs]));
73
+ // Exclude fully-unobservable vendors: they carry no information, only zeros.
74
+ const vendors = config.vendors
75
+ .map((vendor) => vendor.id)
76
+ .filter((vendorId) => claimIds.some((claimId) => {
77
+ const obs = byCell.get(`${vendorId}|${claimId}`);
78
+ return obs !== undefined && obs.intensity !== "unobservable";
79
+ }));
80
+ const matrix = vendors.map((vendorId) => claimIds.map((claimId) => VOICE_WEIGHT[byCell.get(`${vendorId}|${claimId}`)?.intensity ?? ""] ?? 0));
81
+ const means = claimIds.map((_, j) => matrix.reduce((sum, row) => sum + row[j], 0) / vendors.length);
82
+ const centered = matrix.map((row) => row.map((value, j) => value - means[j]));
83
+ const component = (deflate) => {
84
+ let v = new Array(claimIds.length).fill(1 / Math.sqrt(claimIds.length));
85
+ for (let iteration = 0; iteration < 300; iteration += 1) {
86
+ if (deflate) {
87
+ const dot = v.reduce((sum, x, k) => sum + x * deflate[k], 0);
88
+ v = v.map((x, k) => x - dot * deflate[k]);
89
+ }
90
+ const scores = centered.map((row) => row.reduce((sum, x, j) => sum + x * v[j], 0));
91
+ v = claimIds.map((_, j) => centered.reduce((sum, row, i) => sum + row[j] * scores[i], 0));
92
+ const norm = Math.sqrt(v.reduce((sum, x) => sum + x * x, 0)) || 1;
93
+ v = v.map((x) => x / norm);
94
+ }
95
+ return { loadings: v, scores: centered.map((row) => row.reduce((sum, x, j) => sum + x * v[j], 0)) };
96
+ };
97
+ const first = component();
98
+ const second = component(first.loadings);
99
+ const shape = (raw) => ({
100
+ loadings: claimIds.map((claimId, j) => ({ claimId, loading: raw.loadings[j] })),
101
+ scores: vendors.map((vendorId, i) => ({ vendorId, score: raw.scores[i] })),
102
+ });
103
+ return { vendors, pc1: shape(first), pc2: shape(second) };
104
+ }
105
+ export function pairingVerdict(r) {
106
+ const magnitude = Math.abs(r);
107
+ if (magnitude < 0.4)
108
+ return "near-orthogonal";
109
+ if (magnitude < 0.75)
110
+ return "correlated — weak pair";
111
+ return "redundant — same axis twice";
112
+ }
113
+ export function assessAxes(config, set) {
114
+ const { vendors, pc1, pc2 } = pcaTop2(config, set);
115
+ const pcScore = (pc) => new Map(pc.scores.map((entry) => [entry.vendorId, entry.score]));
116
+ const pc1ByVendor = pcScore(pc1);
117
+ const pc2ByVendor = pcScore(pc2);
118
+ const axes = config.axes ?? [];
119
+ const positionsById = new Map();
120
+ const assessments = axes.map((axis) => {
121
+ const positions = vendors.map((vendorId) => ({
122
+ vendorId,
123
+ position: axisPosition(vendorId, axis.claimScores, set.observations),
124
+ }));
125
+ const placeable = positions.filter((entry) => entry.position !== null);
126
+ positionsById.set(axis.id, new Map(placeable.map((entry) => [entry.vendorId, entry.position])));
127
+ const values = placeable.map((entry) => entry.position);
128
+ const mean = values.reduce((sum, x) => sum + x, 0) / Math.max(values.length, 1);
129
+ const spread = Math.sqrt(values.reduce((sum, x) => sum + (x - mean) ** 2, 0) / Math.max(values.length, 1));
130
+ const aligned = placeable.filter((entry) => pc1ByVendor.has(entry.vendorId));
131
+ return {
132
+ axis,
133
+ positions,
134
+ spread,
135
+ rVsPc1: pearson(aligned.map((entry) => entry.position), aligned.map((entry) => pc1ByVendor.get(entry.vendorId))),
136
+ rVsPc2: pearson(aligned.map((entry) => entry.position), aligned.map((entry) => pc2ByVendor.get(entry.vendorId))),
137
+ };
138
+ });
139
+ // Derived breadth axis joins the orthogonality screen (it's free and often
140
+ // the only near-orthogonal partner early on).
141
+ const breadthPositions = new Map();
142
+ for (const vendorId of vendors) {
143
+ const { breadth } = messageBreadth(vendorId, set.observations);
144
+ if (breadth !== null)
145
+ breadthPositions.set(vendorId, breadth);
146
+ }
147
+ positionsById.set("breadth", breadthPositions);
148
+ const ids = [...axes.map((axis) => axis.id), "breadth"];
149
+ const pairings = [];
150
+ for (let i = 0; i < ids.length; i += 1) {
151
+ for (let j = i + 1; j < ids.length; j += 1) {
152
+ const a = positionsById.get(ids[i]);
153
+ const b = positionsById.get(ids[j]);
154
+ const shared = vendors.filter((vendorId) => a.has(vendorId) && b.has(vendorId));
155
+ const r = pearson(shared.map((vendorId) => a.get(vendorId)), shared.map((vendorId) => b.get(vendorId)));
156
+ pairings.push({ aId: ids[i], bId: ids[j], r, verdict: pairingVerdict(r) });
157
+ }
158
+ }
159
+ return { vendors, pc1, pc2, assessments, pairings };
160
+ }
161
+ export function axesReportToText(report) {
162
+ const lines = [];
163
+ for (const [label, pc] of [
164
+ ["PC1", report.pc1],
165
+ ["PC2", report.pc2],
166
+ ]) {
167
+ lines.push(`=== ${label} — claim loadings (extremes; sign is arbitrary, read the poles) ===`);
168
+ const ordered = [...pc.loadings].sort((a, b) => a.loading - b.loading);
169
+ for (const entry of ordered.slice(0, 5)) {
170
+ lines.push(` ${entry.loading >= 0 ? "+" : ""}${entry.loading.toFixed(2)} ${entry.claimId}`);
171
+ }
172
+ lines.push(" ...");
173
+ for (const entry of ordered.slice(-5)) {
174
+ lines.push(` ${entry.loading >= 0 ? "+" : ""}${entry.loading.toFixed(2)} ${entry.claimId}`);
175
+ }
176
+ lines.push(` vendor scores: ${[...pc.scores]
177
+ .sort((a, b) => a.score - b.score)
178
+ .map((entry) => `${entry.vendorId}=${entry.score >= 0 ? "+" : ""}${entry.score.toFixed(2)}`)
179
+ .join(" ")}`);
180
+ lines.push("");
181
+ }
182
+ if (report.assessments.length > 0) {
183
+ lines.push("=== configured axes vs PCA (triangulation: a real axis is derivable from the data) ===");
184
+ for (const assessment of report.assessments) {
185
+ lines.push(` ${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 ?? ""}]`);
186
+ }
187
+ lines.push("");
188
+ lines.push("=== orthogonality screen (|r|>0.75 = redundant pair) ===");
189
+ for (const pairing of report.pairings) {
190
+ const flag = pairing.verdict === "redundant — same axis twice" ? " <-- redundant" : "";
191
+ lines.push(` ${pairing.aId.padEnd(18)} x ${pairing.bId.padEnd(18)} r=${pairing.r >= 0 ? "+" : ""}${pairing.r.toFixed(2)}${flag}`);
192
+ }
193
+ }
194
+ else {
195
+ lines.push("No axes configured. Read the PC loadings above, name the two directions, and add them");
196
+ lines.push("to market.config.json as axes: [{ id, label, negativePole, positivePole, rubric, claimScores }].");
197
+ }
198
+ return `${lines.join("\n")}\n`;
199
+ }
@@ -1,4 +1,5 @@
1
1
  import { computeFrontStates } from "./market.js";
2
+ import { assessAxes, messageBreadth } from "./marketAxes.js";
2
3
  /**
3
4
  * Render a market map as a client-ready deliverable: markdown for terminals
4
5
  * and PRs, and a self-contained printable HTML "field report" — front
@@ -77,6 +78,107 @@ export function marketMapToMarkdown(config, set) {
77
78
  }
78
79
  return `${lines.join("\n")}\n`;
79
80
  }
81
+ function svgScatter(points, ax, ay, anchor, mini) {
82
+ const W = mini ? 330 : 700;
83
+ const H = mini ? 250 : 460;
84
+ const PAD = mini ? 34 : 56;
85
+ const range = (axis, values) => {
86
+ if (axis.signed)
87
+ return [-1.1, 1.1];
88
+ if (values.length === 0)
89
+ return [0, 1];
90
+ return [Math.min(0, Math.min(...values) - 0.05), Math.max(...values) + 0.08];
91
+ };
92
+ const [xLo, xHi] = range(ax, points.map((p) => p.x));
93
+ const [yLo, yHi] = range(ay, points.map((p) => p.y));
94
+ const sx = (x) => PAD + ((x - xLo) / (xHi - xLo)) * (W - 2 * PAD);
95
+ const sy = (y) => H - PAD - ((y - yLo) / (yHi - yLo)) * (H - 2 * PAD);
96
+ const fsLabel = mini ? 8.5 : 10.5;
97
+ const fsAx = mini ? 8 : 10;
98
+ const e = escapeHtml;
99
+ const dots = points
100
+ .map((p) => {
101
+ const r = mini ? 3 + p.loud * 0.8 : 6 + p.loud * 1.6;
102
+ const cls = p.vendorId === anchor ? "dot-anchor" : "dot";
103
+ return (`<circle class="${cls}" cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}"/>` +
104
+ `<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>`);
105
+ })
106
+ .join("");
107
+ const midX = ax.signed ? `<line class="axis-mid" x1="${sx(0).toFixed(0)}" y1="${PAD}" x2="${sx(0).toFixed(0)}" y2="${H - PAD}"/>` : "";
108
+ const midY = ay.signed ? `<line class="axis-mid" x1="${PAD}" y1="${sy(0).toFixed(0)}" x2="${W - PAD}" y2="${sy(0).toFixed(0)}"/>` : "";
109
+ return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${e(ax.label)} vs ${e(ay.label)}">
110
+ <line class="axis" x1="${PAD}" y1="${H - PAD}" x2="${W - PAD}" y2="${H - PAD}"/>
111
+ <line class="axis" x1="${PAD}" y1="${PAD}" x2="${PAD}" y2="${H - PAD}"/>${midX}${midY}
112
+ <text class="ax-label" style="font-size:${fsAx}px" x="${PAD}" y="${H - 14}">&#8592; ${e(ax.negativePole)}</text>
113
+ <text class="ax-label" style="font-size:${fsAx}px" x="${W - PAD}" y="${H - 14}" text-anchor="end">${e(ax.positivePole)} &#8594;</text>
114
+ <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>
115
+ ${dots}</svg>`;
116
+ }
117
+ function axisSectionsHtml(config, set) {
118
+ const axes = config.axes ?? [];
119
+ if (axes.length === 0)
120
+ return { strategicMap: "", report: null };
121
+ const e = escapeHtml;
122
+ const report = assessAxes(config, set);
123
+ const vendorNames = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
124
+ const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
125
+ const breadthAxis = {
126
+ id: "breadth",
127
+ label: "Message breadth",
128
+ negativePole: "FOCUSED",
129
+ positivePole: "BROAD (share of claims voiced)",
130
+ signed: false,
131
+ };
132
+ const axisInfo = new Map([
133
+ ...axes.map((axis) => [axis.id, { id: axis.id, label: axis.label, negativePole: axis.negativePole, positivePole: axis.positivePole, signed: true }]),
134
+ [breadthAxis.id, breadthAxis],
135
+ ]);
136
+ const positions = new Map();
137
+ for (const assessment of report.assessments) {
138
+ positions.set(assessment.axis.id, new Map(assessment.positions
139
+ .filter((entry) => entry.position !== null)
140
+ .map((entry) => [entry.vendorId, entry.position])));
141
+ }
142
+ const breadthMap = new Map();
143
+ for (const vendorId of report.vendors) {
144
+ const { breadth } = messageBreadth(vendorId, set.observations);
145
+ if (breadth !== null)
146
+ breadthMap.set(vendorId, breadth);
147
+ }
148
+ positions.set("breadth", breadthMap);
149
+ const pointsFor = (xId, yId) => {
150
+ const xs = positions.get(xId);
151
+ const ys = positions.get(yId);
152
+ if (!xs || !ys)
153
+ return [];
154
+ return report.vendors
155
+ .filter((vendorId) => xs.has(vendorId) && ys.has(vendorId))
156
+ .map((vendorId) => ({
157
+ vendorId,
158
+ name: vendorNames.get(vendorId) ?? vendorId,
159
+ x: xs.get(vendorId),
160
+ y: ys.get(vendorId),
161
+ loud: loudCounts.get(vendorId) ?? 0,
162
+ }));
163
+ };
164
+ const [px, py] = config.primaryAxes ?? [axes[0].id, axes[1]?.id ?? "breadth"];
165
+ const axInfo = axisInfo.get(px);
166
+ const ayInfo = axisInfo.get(py);
167
+ const statusOf = (id) => axes.find((axis) => axis.id === id)?.status ?? (id === "breadth" ? "derived" : "");
168
+ const strategicMap = `<section>
169
+ <h2><span class="no">03</span> Strategic map — ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
170
+ <figure>${svgScatter(pointsFor(px, py), axInfo, ayInfo, config.anchorVendor, false)}
171
+ <figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
172
+ in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=&#189;) of the claims it
173
+ voices. Dot size = LOUD count. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
174
+ </figure>
175
+ </section>`;
176
+ // Deliberately no axis-pairing gallery here: the report is the client-facing
177
+ // artifact, best foot forward — one earned 2x2. Axis exploration (PCA,
178
+ // triangulation, the orthogonality screen over every pairing) lives in
179
+ // `market axes` for the analyst or agent doing the iterating.
180
+ return { strategicMap, report };
181
+ }
80
182
  export function marketMapToHtml(config, set) {
81
183
  const model = buildModel(config, set);
82
184
  const stateByClaim = new Map(model.fronts.map((front) => [front.claimId, front.state]));
@@ -87,6 +189,8 @@ export function marketMapToHtml(config, set) {
87
189
  const unobservable = set.observations.filter((obs) => obs.intensity === "unobservable").length;
88
190
  const anchor = config.anchorVendor;
89
191
  const e = escapeHtml;
192
+ const axisHtml = axisSectionsHtml(config, set);
193
+ const appendixNo = axisHtml.report ? "04" : "03";
90
194
  const matrixRows = model.orderedClaimIds
91
195
  .map((claimId) => {
92
196
  const claim = claimsById.get(claimId);
@@ -184,6 +288,14 @@ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
184
288
  .ev-head { font-size:10.5px; letter-spacing:.1em; color:var(--accent); }
185
289
  .ev blockquote { font-style:italic; margin:6px 0; font-size:13.5px; line-height:1.5; }
186
290
  .ev-src { font-size:10px; color:var(--ink-soft); word-break:break-all; }
291
+ figure { margin-top:22px; border:1px solid var(--line); background:rgba(255,255,255,.35); }
292
+ .axis { stroke:var(--ink); stroke-width:1.5; }
293
+ .axis-mid { stroke:var(--line); stroke-dasharray:3 5; }
294
+ .ax-label { letter-spacing:.16em; fill:var(--ink-soft); font-family:"SF Mono",Menlo,Consolas,monospace; }
295
+ .dot { fill:rgba(33,29,22,.78); }
296
+ .dot-anchor { fill:var(--green); stroke:var(--ink); stroke-width:1.5; }
297
+ .dot-label { fill:var(--ink); text-anchor:middle; letter-spacing:.04em; font-family:"SF Mono",Menlo,Consolas,monospace; }
298
+ 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; }
187
299
  footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; font-size:11px; color:var(--ink-soft);
188
300
  display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
189
301
  @media print { body { max-width:none; padding:0 8mm; background:white; } section { break-inside:avoid-page; } tr { break-inside:avoid; } }
@@ -221,8 +333,9 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
221
333
  <tbody>${matrixRows}</tbody>
222
334
  </table>
223
335
  </section>
336
+ ${axisHtml.strategicMap}
224
337
  <section>
225
- <h2><span class="no">03</span> Evidence appendix</h2>
338
+ <h2><span class="no">${appendixNo}</span> Evidence appendix</h2>
226
339
  ${appendix}
227
340
  </section>
228
341
  <footer>