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/CHANGELOG.md +72 -0
- package/INSTALL_FOR_AGENTS.md +10 -5
- package/README.md +17 -0
- package/dist/bulkUpdate.d.ts +37 -0
- package/dist/bulkUpdate.js +315 -0
- package/dist/cli.js +93 -2
- package/dist/connector.d.ts +6 -0
- package/dist/connector.js +158 -17
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/market.d.ts +16 -0
- package/dist/market.js +27 -0
- package/dist/marketAxes.d.ts +77 -0
- package/dist/marketAxes.js +199 -0
- package/dist/marketReport.js +114 -1
- package/dist/mcp.js +13 -2
- package/dist/types.d.ts +44 -0
- package/docs/api.md +29 -2
- package/llms.txt +16 -0
- package/package.json +1 -1
- package/src/bulkUpdate.ts +375 -0
- package/src/cli.ts +97 -2
- package/src/connector.ts +169 -23
- package/src/index.ts +15 -0
- package/src/market.ts +41 -0
- package/src/marketAxes.ts +268 -0
- package/src/marketReport.ts +134 -1
- package/src/mcp.ts +15 -2
- package/src/types.ts +39 -0
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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")
|
|
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
|
+
}
|