fullstackgtm 0.16.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.
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ import { type LlmCallOptions } from "./llm.ts";
2
+ import { type CaptureEntry, type MarketClaim, type MarketConfig, type ObservationSet } from "./market.ts";
3
+ export type ClassifyMarketOptions = {
4
+ llm: LlmCallOptions;
5
+ /** Observation run label to produce; must be new (the store is append-only). */
6
+ runLabel: string;
7
+ /** Capture run to classify; defaults to the most recent run in the manifest. */
8
+ captureRun?: string;
9
+ /** Restrict to these vendor ids (e.g. one new vendor); defaults to all. */
10
+ vendors?: string[];
11
+ /** Captures directory override (tests); defaults to the profile market home. */
12
+ capturesDir?: string;
13
+ now?: () => Date;
14
+ };
15
+ export type ClassifyMarketResult = {
16
+ set: ObservationSet;
17
+ model: string;
18
+ /** Cells where the model's quote failed mechanical verification and the retry fixed it. */
19
+ retriedVendorIds: string[];
20
+ };
21
+ export declare function classifyMarket(config: MarketConfig, options: ClassifyMarketOptions): Promise<ClassifyMarketResult>;
22
+ /**
23
+ * The agent-driven alternative to LLM classification: a worksheet carrying
24
+ * everything needed to classify one vendor by hand or by an agent driving
25
+ * the CLI/MCP — claims with judging definitions, the surface rule, and the
26
+ * captured page texts. Submissions come back through `market observe`,
27
+ * which runs the same validation and span verification as `classify`.
28
+ */
29
+ export type MarketWorksheet = {
30
+ category: string;
31
+ captureRun: string;
32
+ surfaceRule?: string;
33
+ vendor: {
34
+ id: string;
35
+ name: string;
36
+ };
37
+ claims: MarketClaim[];
38
+ pages: Array<{
39
+ kind: CaptureEntry["kind"];
40
+ url: string;
41
+ captureHash: string;
42
+ text: string;
43
+ }>;
44
+ instructions: string;
45
+ };
46
+ export declare function buildWorksheet(config: MarketConfig, vendorId: string, options?: {
47
+ captureRun?: string;
48
+ capturesDir?: string;
49
+ }): MarketWorksheet;
@@ -0,0 +1,201 @@
1
+ import { DEFAULT_MODELS, forcedToolCall } from "./llm.js";
2
+ import { loadCaptureTexts, observationId, verifyEvidenceSpans, } from "./market.js";
3
+ /**
4
+ * LLM intensity classification for the market map — the same
5
+ * semi-deterministic posture as call extraction, with one upgrade calls
6
+ * can't have: because the source pages are stored captures, every quoted
7
+ * span is verified mechanically against the capture it cites before the
8
+ * observation is accepted. A reading whose quote isn't verbatim on the page
9
+ * bounces back to the model once with the failures named; if it still can't
10
+ * quote the page, classification fails rather than storing unverifiable
11
+ * evidence.
12
+ *
13
+ * Deterministic parts stay deterministic: vendors with no usable captures
14
+ * score UNOBSERVABLE on every claim without an LLM call, and front states
15
+ * downstream are computed from the store, never from model output.
16
+ */
17
+ // Bound cost and context: a vendor's pages are classified in one call.
18
+ const MAX_DOSSIER_CHARS = 48_000;
19
+ const CLASSIFY_INSTRUCTIONS = `Classify this vendor's messaging intensity for EVERY claim listed.
20
+ Rules:
21
+ - Judge ONLY from the captured pages below. Do not use outside knowledge of the vendor.
22
+ - intensity per the surface rule: "loud" = hero copy or a top-level-nav named product/program with a dedicated page; "quiet" = present on any page below that; "absent" = nowhere in the captures.
23
+ - evidence quotes MUST be verbatim spans copied exactly from the captured text (≤300 chars). Every loud or quiet reading needs at least one quote. If you cannot quote it, the reading is absent.
24
+ - An explicit disavowal ("we do not offer X", "call 988") is absent — put the disavowal quote in reason, it is informative signal.
25
+ - url must be the page the quote came from, exactly as given in the page headers below.
26
+ - reason: one reviewer-facing sentence.
27
+ - Return a reading for every claim id. Never invent claim ids.`;
28
+ const classifySchema = (claimIds) => ({
29
+ type: "object",
30
+ required: ["readings"],
31
+ properties: {
32
+ readings: {
33
+ type: "array",
34
+ items: {
35
+ type: "object",
36
+ required: ["claimId", "intensity", "confidence", "reason", "evidence"],
37
+ properties: {
38
+ claimId: { type: "string", enum: claimIds },
39
+ intensity: { type: "string", enum: ["loud", "quiet", "absent"] },
40
+ confidence: { type: "string", enum: ["high", "medium", "low"] },
41
+ reason: { type: "string", description: "One reviewer-facing sentence." },
42
+ evidence: {
43
+ type: "array",
44
+ items: {
45
+ type: "object",
46
+ required: ["quote", "url"],
47
+ properties: {
48
+ quote: { type: "string", description: "VERBATIM span copied exactly from the captured page text. Never paraphrase." },
49
+ url: { type: "string", description: "The page URL the quote came from, exactly as shown in the page header." },
50
+ },
51
+ },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ },
57
+ });
58
+ function buildDossier(entries, textByHash) {
59
+ const pages = entries
60
+ .filter((entry) => entry.captureHash && textByHash.has(entry.captureHash))
61
+ .map((entry) => ({ entry, text: textByHash.get(entry.captureHash) }));
62
+ if (pages.length === 0)
63
+ return "";
64
+ const budget = Math.floor(MAX_DOSSIER_CHARS / pages.length);
65
+ return pages
66
+ .map(({ entry, text }) => {
67
+ const body = text.length <= budget
68
+ ? text
69
+ : `${text.slice(0, budget / 2)}\n[... middle of page truncated ...]\n${text.slice(-budget / 2)}`;
70
+ return `=== PAGE (${entry.kind}) ${entry.url} ===\n${body}`;
71
+ })
72
+ .join("\n\n");
73
+ }
74
+ function claimsBlock(claims) {
75
+ return claims
76
+ .map((claim) => `- ${claim.id}: ${claim.capability}\n How to judge: ${claim.definition}`)
77
+ .join("\n");
78
+ }
79
+ export async function classifyMarket(config, options) {
80
+ const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
81
+ const { entries, textByHash } = loadCaptureTexts(config.category, options.capturesDir);
82
+ if (entries.length === 0) {
83
+ throw new Error(`No captures for ${config.category} — run \`market capture\` first`);
84
+ }
85
+ const captureRun = options.captureRun ?? entries[entries.length - 1].runLabel;
86
+ const runEntries = entries.filter((entry) => entry.runLabel === captureRun);
87
+ if (runEntries.length === 0) {
88
+ throw new Error(`No captures for run "${captureRun}" — available: ${[...new Set(entries.map((e) => e.runLabel))].join(", ")}`);
89
+ }
90
+ const observedAt = (options.now ?? (() => new Date()))().toISOString();
91
+ const vendorIds = options.vendors ?? config.vendors.map((vendor) => vendor.id);
92
+ const claimIds = config.claims.map((claim) => claim.id);
93
+ const observations = [];
94
+ const retriedVendorIds = [];
95
+ for (const vendorId of vendorIds) {
96
+ const vendor = config.vendors.find((candidate) => candidate.id === vendorId);
97
+ if (!vendor)
98
+ throw new Error(`Unknown vendor "${vendorId}"`);
99
+ const vendorEntries = runEntries.filter((entry) => entry.vendorId === vendorId);
100
+ const hashByUrl = new Map(vendorEntries.filter((entry) => entry.captureHash).map((entry) => [entry.url, entry.captureHash]));
101
+ const dossier = buildDossier(vendorEntries, textByHash);
102
+ if (!dossier) {
103
+ // Deterministic: no usable captures means UNOBSERVABLE everywhere — never
104
+ // ask a model to judge pages that were never read.
105
+ for (const claim of config.claims) {
106
+ observations.push({
107
+ id: observationId(config.category, options.runLabel, vendorId, claim.id),
108
+ vendorId,
109
+ claimId: claim.id,
110
+ observedAt,
111
+ intensity: "unobservable",
112
+ confidence: "high",
113
+ reason: `No usable captures for ${vendor.name} in run ${captureRun} — cannot judge.`,
114
+ evidence: [],
115
+ });
116
+ }
117
+ continue;
118
+ }
119
+ const prompt = (feedback) => `${CLASSIFY_INSTRUCTIONS}\n\nSurface rule for this category:\n${config.surfaceRule ?? "(default rule above)"}\n\nClaims to classify (all of them):\n${claimsBlock(config.claims)}\n${feedback}\nVendor: ${vendor.name}\nCaptured pages:\n${dossier}`;
120
+ const attempt = async (feedback) => {
121
+ const result = (await forcedToolCall(prompt(feedback), "classify_market_claims", classifySchema(claimIds), model, options.llm));
122
+ const readings = (result.readings ?? []).filter((reading) => claimIds.includes(reading.claimId));
123
+ const seen = new Set(readings.map((reading) => reading.claimId));
124
+ const problems = claimIds.filter((claimId) => !seen.has(claimId)).map((claimId) => `missing reading for ${claimId}`);
125
+ const candidate = readings.map((reading) => toObservation(reading, vendorId));
126
+ const failures = verifyEvidenceSpans(candidate, textByHash);
127
+ return { readings, problems, failures };
128
+ };
129
+ const toObservation = (reading, vendor) => ({
130
+ id: observationId(config.category, options.runLabel, vendor, reading.claimId),
131
+ vendorId: vendor,
132
+ claimId: reading.claimId,
133
+ observedAt,
134
+ intensity: reading.intensity,
135
+ confidence: reading.confidence,
136
+ reason: reading.reason,
137
+ evidence: (reading.evidence ?? []).map((item, index) => ({
138
+ id: `${observationId(config.category, options.runLabel, vendor, reading.claimId)}_ev${index}`,
139
+ sourceSystem: "web",
140
+ sourceObjectType: "page",
141
+ sourceObjectId: item.url,
142
+ text: item.quote,
143
+ observedAt,
144
+ metadata: { url: item.url, captureHash: hashByUrl.get(item.url) ?? "" },
145
+ })),
146
+ });
147
+ let outcome = await attempt("");
148
+ if (outcome.problems.length > 0 || outcome.failures.length > 0) {
149
+ retriedVendorIds.push(vendorId);
150
+ const failureLines = [
151
+ ...outcome.problems,
152
+ ...outcome.failures.map((failure) => `${failure.claimId}: ${failure.problem} (your quote: "${failure.quote.slice(0, 80)}")`),
153
+ ].join("\n- ");
154
+ outcome = await attempt(`\nYour previous answer had problems. Fix exactly these and answer again in full:\n- ${failureLines}\nQuotes must be copied character-for-character from the captured text.\n`);
155
+ }
156
+ if (outcome.problems.length > 0 || outcome.failures.length > 0) {
157
+ const detail = [...outcome.problems, ...outcome.failures.map((failure) => `${failure.claimId}: ${failure.problem}`)].slice(0, 10);
158
+ throw new Error(`Classification for ${vendor.name} failed mechanical verification after a retry:\n ${detail.join("\n ")}\nNothing was stored. Re-run, try another --model, or classify this vendor by hand via the worksheet.`);
159
+ }
160
+ for (const reading of outcome.readings)
161
+ observations.push(toObservation(reading, vendorId));
162
+ }
163
+ return {
164
+ set: {
165
+ id: `set_${config.category}_${options.runLabel}`,
166
+ category: config.category,
167
+ runLabel: options.runLabel,
168
+ runAt: observedAt,
169
+ extractor: `llm:${options.llm.provider}:${model}`,
170
+ observations,
171
+ },
172
+ model,
173
+ retriedVendorIds,
174
+ };
175
+ }
176
+ export function buildWorksheet(config, vendorId, options = {}) {
177
+ const vendor = config.vendors.find((candidate) => candidate.id === vendorId);
178
+ if (!vendor)
179
+ throw new Error(`Unknown vendor "${vendorId}"`);
180
+ const { entries, textByHash } = loadCaptureTexts(config.category, options.capturesDir);
181
+ const captureRun = options.captureRun ?? entries[entries.length - 1]?.runLabel;
182
+ if (!captureRun)
183
+ throw new Error(`No captures for ${config.category} — run \`market capture\` first`);
184
+ const pages = entries
185
+ .filter((entry) => entry.runLabel === captureRun && entry.vendorId === vendorId && entry.captureHash)
186
+ .map((entry) => ({
187
+ kind: entry.kind,
188
+ url: entry.url,
189
+ captureHash: entry.captureHash,
190
+ text: textByHash.get(entry.captureHash) ?? "",
191
+ }));
192
+ return {
193
+ category: config.category,
194
+ captureRun,
195
+ surfaceRule: config.surfaceRule,
196
+ vendor: { id: vendor.id, name: vendor.name },
197
+ claims: config.claims,
198
+ pages,
199
+ instructions: "Produce one observation per claim (intensity loud|quiet|absent from these pages only; unobservable only if a page you need failed to capture). Every loud/quiet reading must quote a verbatim span (≤300 chars) from a page's text, with that page's url and captureHash in evidence metadata. Submit as an ObservationSet via `market observe --from <file>` — quotes are mechanically verified against the captures.",
200
+ };
201
+ }
@@ -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>
package/dist/mcp.js CHANGED
@@ -47,6 +47,8 @@ import { builtinAuditRules } from "./rules.js";
47
47
  import { sampleSnapshot } from "./sampleData.js";
48
48
  import { normalizeTranscript, parseCall } from "./calls.js";
49
49
  import { extractInsightsLlm, resolveLlmCredential } from "./llm.js";
50
+ import { computeFrontStates, createFileObservationStore, loadCaptureTexts, loadMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
51
+ import { buildWorksheet } from "./marketClassify.js";
50
52
  import { resolveRecord } from "./resolve.js";
51
53
  import { suggestValues } from "./suggest.js";
52
54
  function content(value) {
@@ -244,6 +246,49 @@ export async function startMcpServer() {
244
246
  });
245
247
  return content(output === "markdown" ? formatPatchPlanRun(run) : run);
246
248
  });
249
+ server.registerTool("fullstackgtm_market_worksheet", {
250
+ title: "Market Map Classification Worksheet",
251
+ description: "Get everything needed to classify ONE vendor's messaging intensity for a market map: " +
252
+ "the claim taxonomy with judging definitions, the surface rule, and the captured page " +
253
+ "texts. Read each claim's definition, judge loud/quiet/absent from the page texts only, " +
254
+ "and quote verbatim spans (≤300 chars) for every loud/quiet reading. Submit the full " +
255
+ "ObservationSet via fullstackgtm_market_observe — quotes are verified character-for-" +
256
+ "character against the captures, so never paraphrase.",
257
+ inputSchema: {
258
+ vendorId: z.string(),
259
+ configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
260
+ captureRun: z.string().optional(),
261
+ },
262
+ }, async ({ vendorId, configPath, captureRun }) => {
263
+ const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
264
+ return content(buildWorksheet(config, vendorId, { captureRun }));
265
+ });
266
+ server.registerTool("fullstackgtm_market_observe", {
267
+ title: "Submit Market Map Observations",
268
+ description: "Submit a complete ObservationSet (every vendor × claim cell) for a market map run. " +
269
+ "Validates coverage, the verbatim-evidence rule, and mechanically verifies every quoted " +
270
+ "span against the stored capture it cites. Returns problems if rejected; nothing is " +
271
+ "stored unless the whole set passes. Observations are append-only — use a new runLabel.",
272
+ inputSchema: {
273
+ observationsPath: z.string().describe("Path to the ObservationSet JSON file"),
274
+ configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
275
+ },
276
+ }, async ({ observationsPath, configPath }) => {
277
+ const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
278
+ const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8"));
279
+ const problems = validateObservationSet(config, set);
280
+ const failures = verifyEvidenceSpans(set.observations, loadCaptureTexts(config.category).textByHash);
281
+ if (problems.length > 0 || failures.length > 0) {
282
+ return content({
283
+ accepted: false,
284
+ problems,
285
+ spanFailures: failures.map((failure) => `${failure.vendorId} × ${failure.claimId}: ${failure.problem}`),
286
+ });
287
+ }
288
+ await createFileObservationStore(config.category).append(set);
289
+ const fronts = computeFrontStates(config, set);
290
+ return content({ accepted: true, runLabel: set.runLabel, observations: set.observations.length, fronts });
291
+ });
247
292
  const transport = new StdioServerTransport();
248
293
  await server.connect(transport);
249
294
  }