fullstackgtm 0.17.0 → 0.19.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/src/connector.ts CHANGED
@@ -27,6 +27,12 @@ export type ApplyPatchPlanOptions = {
27
27
  * `readField`.
28
28
  */
29
29
  checkConflicts?: boolean;
30
+ /**
31
+ * For plans carrying a filter or guards: re-run the snapshot checks after
32
+ * the first applied write and then every N applied writes, so a record
33
+ * edited mid-apply is conflicted out instead of overwritten. Default 25.
34
+ */
35
+ recheckEvery?: number;
30
36
  };
31
37
 
32
38
  const FIELD_WRITE_OPERATIONS = new Set(["set_field", "clear_field", "link_record"]);
@@ -60,6 +66,129 @@ export async function applyPatchPlan(
60
66
  const results: PatchOperationResult[] = [];
61
67
  let attempted = 0;
62
68
  let applied = 0;
69
+ let appliedSinceRecheck = 0;
70
+
71
+ // Pass 0 — snapshot-backed checks: plan-level guards and per-record
72
+ // filter re-verification, both against a FRESH snapshot. Cross-record
73
+ // eligibility ("the account still has no open contractsent deal") cannot
74
+ // be checked through per-operation field reads.
75
+ //
76
+ // These checks are NOT one-shot: a concurrent edit can land mid-apply,
77
+ // after the initial verification but before later writes (the TOCTOU
78
+ // window). Providers offer no compare-and-swap, so the window cannot be
79
+ // closed — but it can be shrunk: re-run the snapshot checks after the
80
+ // first write and every `recheckEvery` writes, conflicting out any
81
+ // operation whose record went stale mid-run.
82
+ const needsSnapshot =
83
+ ((plan.guards && plan.guards.length > 0) || plan.filter) && connector.fetchSnapshot;
84
+ const recheckEvery = Math.max(1, options.recheckEvery ?? 25);
85
+ const staleIds = new Set<string>();
86
+ let guardFailure: string | null = null;
87
+ const refreshSnapshotChecks = async (): Promise<void> => {
88
+ if (!needsSnapshot) return;
89
+ const { evaluateGuard, eligibleIds } = await import("./bulkUpdate.ts");
90
+ const liveSnapshot = await connector.fetchSnapshot!();
91
+ if (plan.filter) {
92
+ const stillEligible = eligibleIds(liveSnapshot, plan.filter.objectType, plan.filter.where);
93
+ staleIds.clear();
94
+ for (const operation of plan.operations) {
95
+ if (!stillEligible.has(operation.objectId)) staleIds.add(operation.objectId);
96
+ }
97
+ }
98
+ for (const guard of plan.guards ?? []) {
99
+ const failure = evaluateGuard(liveSnapshot, guard);
100
+ if (failure) {
101
+ guardFailure = failure;
102
+ return;
103
+ }
104
+ }
105
+ };
106
+ await refreshSnapshotChecks();
107
+ if (guardFailure) {
108
+ for (const operation of plan.operations) {
109
+ results.push({
110
+ operationId: operation.id,
111
+ status: approved.has(operation.id) ? "conflict" : "skipped",
112
+ detail: approved.has(operation.id)
113
+ ? `${guardFailure} No operation in this plan was applied — re-plan against current data.`
114
+ : "Operation was not approved.",
115
+ });
116
+ }
117
+ return {
118
+ planId: plan.id,
119
+ provider: connector.provider,
120
+ startedAt,
121
+ finishedAt: new Date().toISOString(),
122
+ status: "rejected",
123
+ results,
124
+ };
125
+ }
126
+ const staleByFilter: Set<string> | null = plan.filter ? staleIds : null;
127
+
128
+ // Pass 1 — conflict detection. Re-read the written field (beforeValue
129
+ // compare-and-set) and any explicit preconditions. A conflicted operation
130
+ // never writes; if it carries a groupId, the whole group is poisoned so
131
+ // multi-record changes stay all-or-nothing.
132
+ const conflicts = new Map<string, PatchOperationResult>();
133
+ const poisonedGroups = new Set<string>();
134
+ if (staleByFilter && staleByFilter.size > 0) {
135
+ for (const operation of plan.operations) {
136
+ if (!approved.has(operation.id) || !staleByFilter.has(operation.objectId)) continue;
137
+ conflicts.set(operation.id, {
138
+ operationId: operation.id,
139
+ status: "conflict",
140
+ detail: `Record ${operation.objectType}/${operation.objectId} no longer matches the plan's filter [${plan.filter!.where.join(" AND ")}] — it changed since the plan was built. Re-plan against current data.`,
141
+ });
142
+ if (operation.groupId) poisonedGroups.add(operation.groupId);
143
+ }
144
+ }
145
+ if (checkConflicts && connector.readField) {
146
+ for (const operation of plan.operations) {
147
+ if (!approved.has(operation.id) || conflicts.has(operation.id)) continue;
148
+ let conflict: PatchOperationResult | null = null;
149
+ if (operation.field && FIELD_WRITE_OPERATIONS.has(operation.operation)) {
150
+ const current = await connector.readField(
151
+ operation.objectType,
152
+ operation.objectId,
153
+ operation.field,
154
+ );
155
+ const expected = normalizeForComparison(operation.beforeValue);
156
+ const found = normalizeForComparison(current);
157
+ if (expected !== found) {
158
+ conflict = {
159
+ operationId: operation.id,
160
+ status: "conflict",
161
+ detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
162
+ providerData: { currentValue: current ?? null },
163
+ };
164
+ }
165
+ }
166
+ if (!conflict && operation.preconditions) {
167
+ for (const precondition of operation.preconditions) {
168
+ const current = await connector.readField(
169
+ operation.objectType,
170
+ operation.objectId,
171
+ precondition.field,
172
+ );
173
+ const expected = normalizeForComparison(precondition.expectedValue);
174
+ const found = normalizeForComparison(current);
175
+ if (expected !== found) {
176
+ conflict = {
177
+ operationId: operation.id,
178
+ status: "conflict",
179
+ detail: `Precondition failed: ${precondition.field} was ${expected ?? "∅"} when the plan was built, found ${found ?? "∅"} now. The record changed — re-plan before writing.`,
180
+ providerData: { preconditionField: precondition.field, currentValue: current ?? null },
181
+ };
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ if (conflict) {
187
+ conflicts.set(operation.id, conflict);
188
+ if (operation.groupId) poisonedGroups.add(operation.groupId);
189
+ }
190
+ }
191
+ }
63
192
 
64
193
  for (const operation of plan.operations) {
65
194
  if (!approved.has(operation.id)) {
@@ -81,28 +210,35 @@ export async function applyPatchPlan(
81
210
  continue;
82
211
  }
83
212
 
84
- if (
85
- checkConflicts &&
86
- connector.readField &&
87
- operation.field &&
88
- FIELD_WRITE_OPERATIONS.has(operation.operation)
89
- ) {
90
- const current = await connector.readField(
91
- operation.objectType,
92
- operation.objectId,
93
- operation.field,
94
- );
95
- const expected = normalizeForComparison(operation.beforeValue);
96
- const found = normalizeForComparison(current);
97
- if (expected !== found) {
98
- results.push({
99
- operationId: operation.id,
100
- status: "conflict",
101
- detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
102
- providerData: { currentValue: current ?? null },
103
- });
104
- continue;
105
- }
213
+ if (guardFailure) {
214
+ results.push({
215
+ operationId: operation.id,
216
+ status: "conflict",
217
+ detail: `${guardFailure} Detected mid-apply — this and all remaining operations were not applied.`,
218
+ });
219
+ continue;
220
+ }
221
+ const conflict = conflicts.get(operation.id);
222
+ if (conflict) {
223
+ results.push(conflict);
224
+ continue;
225
+ }
226
+ if (staleByFilter && staleByFilter.has(operation.objectId)) {
227
+ results.push({
228
+ operationId: operation.id,
229
+ status: "conflict",
230
+ detail: `Record ${operation.objectType}/${operation.objectId} no longer matches the plan's filter [${plan.filter!.where.join(" AND ")}] (changed mid-apply). Not applied — re-plan against current data.`,
231
+ });
232
+ if (operation.groupId) poisonedGroups.add(operation.groupId);
233
+ continue;
234
+ }
235
+ if (operation.groupId && poisonedGroups.has(operation.groupId)) {
236
+ results.push({
237
+ operationId: operation.id,
238
+ status: "skipped",
239
+ detail: `Skipped: another operation in group ${operation.groupId} hit a conflict — the group applies all-or-nothing.`,
240
+ });
241
+ continue;
106
242
  }
107
243
 
108
244
  const resolved: PatchOperation =
@@ -112,7 +248,17 @@ export async function applyPatchPlan(
112
248
  try {
113
249
  const result = await connector.applyOperation(resolved);
114
250
  results.push(result);
115
- if (result.status === "applied") applied += 1;
251
+ if (result.status === "applied") {
252
+ applied += 1;
253
+ appliedSinceRecheck += 1;
254
+ // shrink the TOCTOU window: first write fires a re-check (a
255
+ // concurrent editor reacting to our changes shows up immediately),
256
+ // then re-check on a fixed cadence
257
+ if (applied === 1 || appliedSinceRecheck >= recheckEvery) {
258
+ appliedSinceRecheck = 0;
259
+ await refreshSnapshotChecks();
260
+ }
261
+ }
116
262
  } catch (error) {
117
263
  results.push({
118
264
  operationId: operation.id,
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.ts";
2
+ export { buildBulkUpdatePlan, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
2
3
  export {
3
4
  CONFIG_FILE_NAME,
4
5
  loadConfig,
@@ -118,6 +119,7 @@ export {
118
119
  DEFAULT_RUBRIC,
119
120
  detectProviderFromKey,
120
121
  extractInsightsLlm,
122
+ forcedToolCall,
121
123
  parseRubric,
122
124
  resolveLlmCredential,
123
125
  scoreCallLlm,
@@ -151,6 +153,7 @@ export {
151
153
  type ClaimIntensity,
152
154
  type FrontDrift,
153
155
  type FrontState,
156
+ type MarketAxis,
154
157
  type MarketClaim,
155
158
  type MarketConfig,
156
159
  type MarketObservation,
@@ -160,6 +163,18 @@ export {
160
163
  type ObservationStore,
161
164
  type SpanVerificationFailure,
162
165
  } from "./market.ts";
166
+ export {
167
+ assessAxes,
168
+ axesReportToText,
169
+ axisPosition,
170
+ messageBreadth,
171
+ pcaTop2,
172
+ pearson,
173
+ type AxesReport,
174
+ type AxisAssessment,
175
+ type AxisPairing,
176
+ type PrincipalComponent,
177
+ } from "./marketAxes.ts";
163
178
  export {
164
179
  buildWorksheet,
165
180
  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,30 @@ 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
+ if (!axis.negativePole || !axis.positivePole) {
176
+ throw new Error(`market config: axis "${axis.id}" needs negativePole and positivePole labels (the strategic map renders them as axis ends)`);
177
+ }
178
+ for (const claimId of Object.keys(axis.claimScores ?? {})) {
179
+ if (!claimIds.has(claimId)) {
180
+ throw new Error(`market config: axis "${axis.id}" scores unknown claim "${claimId}"`);
181
+ }
182
+ }
183
+ }
184
+ if (config.primaryAxes) {
185
+ if (config.primaryAxes.length !== 2 || config.primaryAxes.some((id) => !axisIds.has(id))) {
186
+ throw new Error(`market config: primaryAxes must name two configured axes (got ${JSON.stringify(config.primaryAxes)})`);
187
+ }
188
+ }
189
+ } else if (config.primaryAxes) {
190
+ throw new Error("market config: primaryAxes set but no axes configured");
191
+ }
151
192
  return config;
152
193
  }
153
194
 
@@ -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
+ }