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/dist/connector.js CHANGED
@@ -23,6 +23,124 @@ export async function applyPatchPlan(connector, plan, options) {
23
23
  const results = [];
24
24
  let attempted = 0;
25
25
  let applied = 0;
26
+ let appliedSinceRecheck = 0;
27
+ // Pass 0 — snapshot-backed checks: plan-level guards and per-record
28
+ // filter re-verification, both against a FRESH snapshot. Cross-record
29
+ // eligibility ("the account still has no open contractsent deal") cannot
30
+ // be checked through per-operation field reads.
31
+ //
32
+ // These checks are NOT one-shot: a concurrent edit can land mid-apply,
33
+ // after the initial verification but before later writes (the TOCTOU
34
+ // window). Providers offer no compare-and-swap, so the window cannot be
35
+ // closed — but it can be shrunk: re-run the snapshot checks after the
36
+ // first write and every `recheckEvery` writes, conflicting out any
37
+ // operation whose record went stale mid-run.
38
+ const needsSnapshot = ((plan.guards && plan.guards.length > 0) || plan.filter) && connector.fetchSnapshot;
39
+ const recheckEvery = Math.max(1, options.recheckEvery ?? 25);
40
+ const staleIds = new Set();
41
+ let guardFailure = null;
42
+ const refreshSnapshotChecks = async () => {
43
+ if (!needsSnapshot)
44
+ return;
45
+ const { evaluateGuard, eligibleIds } = await import("./bulkUpdate.js");
46
+ const liveSnapshot = await connector.fetchSnapshot();
47
+ if (plan.filter) {
48
+ const stillEligible = eligibleIds(liveSnapshot, plan.filter.objectType, plan.filter.where);
49
+ staleIds.clear();
50
+ for (const operation of plan.operations) {
51
+ if (!stillEligible.has(operation.objectId))
52
+ staleIds.add(operation.objectId);
53
+ }
54
+ }
55
+ for (const guard of plan.guards ?? []) {
56
+ const failure = evaluateGuard(liveSnapshot, guard);
57
+ if (failure) {
58
+ guardFailure = failure;
59
+ return;
60
+ }
61
+ }
62
+ };
63
+ await refreshSnapshotChecks();
64
+ if (guardFailure) {
65
+ for (const operation of plan.operations) {
66
+ results.push({
67
+ operationId: operation.id,
68
+ status: approved.has(operation.id) ? "conflict" : "skipped",
69
+ detail: approved.has(operation.id)
70
+ ? `${guardFailure} No operation in this plan was applied — re-plan against current data.`
71
+ : "Operation was not approved.",
72
+ });
73
+ }
74
+ return {
75
+ planId: plan.id,
76
+ provider: connector.provider,
77
+ startedAt,
78
+ finishedAt: new Date().toISOString(),
79
+ status: "rejected",
80
+ results,
81
+ };
82
+ }
83
+ const staleByFilter = plan.filter ? staleIds : null;
84
+ // Pass 1 — conflict detection. Re-read the written field (beforeValue
85
+ // compare-and-set) and any explicit preconditions. A conflicted operation
86
+ // never writes; if it carries a groupId, the whole group is poisoned so
87
+ // multi-record changes stay all-or-nothing.
88
+ const conflicts = new Map();
89
+ const poisonedGroups = new Set();
90
+ if (staleByFilter && staleByFilter.size > 0) {
91
+ for (const operation of plan.operations) {
92
+ if (!approved.has(operation.id) || !staleByFilter.has(operation.objectId))
93
+ continue;
94
+ conflicts.set(operation.id, {
95
+ operationId: operation.id,
96
+ status: "conflict",
97
+ 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.`,
98
+ });
99
+ if (operation.groupId)
100
+ poisonedGroups.add(operation.groupId);
101
+ }
102
+ }
103
+ if (checkConflicts && connector.readField) {
104
+ for (const operation of plan.operations) {
105
+ if (!approved.has(operation.id) || conflicts.has(operation.id))
106
+ continue;
107
+ let conflict = null;
108
+ if (operation.field && FIELD_WRITE_OPERATIONS.has(operation.operation)) {
109
+ const current = await connector.readField(operation.objectType, operation.objectId, operation.field);
110
+ const expected = normalizeForComparison(operation.beforeValue);
111
+ const found = normalizeForComparison(current);
112
+ if (expected !== found) {
113
+ conflict = {
114
+ operationId: operation.id,
115
+ status: "conflict",
116
+ detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
117
+ providerData: { currentValue: current ?? null },
118
+ };
119
+ }
120
+ }
121
+ if (!conflict && operation.preconditions) {
122
+ for (const precondition of operation.preconditions) {
123
+ const current = await connector.readField(operation.objectType, operation.objectId, precondition.field);
124
+ const expected = normalizeForComparison(precondition.expectedValue);
125
+ const found = normalizeForComparison(current);
126
+ if (expected !== found) {
127
+ conflict = {
128
+ operationId: operation.id,
129
+ status: "conflict",
130
+ detail: `Precondition failed: ${precondition.field} was ${expected ?? "∅"} when the plan was built, found ${found ?? "∅"} now. The record changed — re-plan before writing.`,
131
+ providerData: { preconditionField: precondition.field, currentValue: current ?? null },
132
+ };
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ if (conflict) {
138
+ conflicts.set(operation.id, conflict);
139
+ if (operation.groupId)
140
+ poisonedGroups.add(operation.groupId);
141
+ }
142
+ }
143
+ }
26
144
  for (const operation of plan.operations) {
27
145
  if (!approved.has(operation.id)) {
28
146
  results.push({
@@ -41,30 +159,53 @@ export async function applyPatchPlan(connector, plan, options) {
41
159
  });
42
160
  continue;
43
161
  }
44
- if (checkConflicts &&
45
- connector.readField &&
46
- operation.field &&
47
- FIELD_WRITE_OPERATIONS.has(operation.operation)) {
48
- const current = await connector.readField(operation.objectType, operation.objectId, operation.field);
49
- const expected = normalizeForComparison(operation.beforeValue);
50
- const found = normalizeForComparison(current);
51
- if (expected !== found) {
52
- results.push({
53
- operationId: operation.id,
54
- status: "conflict",
55
- detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
56
- providerData: { currentValue: current ?? null },
57
- });
58
- continue;
59
- }
162
+ if (guardFailure) {
163
+ results.push({
164
+ operationId: operation.id,
165
+ status: "conflict",
166
+ detail: `${guardFailure} Detected mid-apply this and all remaining operations were not applied.`,
167
+ });
168
+ continue;
169
+ }
170
+ const conflict = conflicts.get(operation.id);
171
+ if (conflict) {
172
+ results.push(conflict);
173
+ continue;
174
+ }
175
+ if (staleByFilter && staleByFilter.has(operation.objectId)) {
176
+ results.push({
177
+ operationId: operation.id,
178
+ status: "conflict",
179
+ 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.`,
180
+ });
181
+ if (operation.groupId)
182
+ poisonedGroups.add(operation.groupId);
183
+ continue;
184
+ }
185
+ if (operation.groupId && poisonedGroups.has(operation.groupId)) {
186
+ results.push({
187
+ operationId: operation.id,
188
+ status: "skipped",
189
+ detail: `Skipped: another operation in group ${operation.groupId} hit a conflict — the group applies all-or-nothing.`,
190
+ });
191
+ continue;
60
192
  }
61
193
  const resolved = override === undefined ? operation : { ...operation, afterValue: override };
62
194
  attempted += 1;
63
195
  try {
64
196
  const result = await connector.applyOperation(resolved);
65
197
  results.push(result);
66
- if (result.status === "applied")
198
+ if (result.status === "applied") {
67
199
  applied += 1;
200
+ appliedSinceRecheck += 1;
201
+ // shrink the TOCTOU window: first write fires a re-check (a
202
+ // concurrent editor reacting to our changes shows up immediately),
203
+ // then re-check on a fixed cadence
204
+ if (applied === 1 || appliedSinceRecheck >= recheckEvery) {
205
+ appliedSinceRecheck = 0;
206
+ await refreshSnapshotChecks();
207
+ }
208
+ }
68
209
  }
69
210
  catch (error) {
70
211
  results.push({
package/dist/index.d.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 { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, type FullstackgtmConfig, type LoadedConfig, } from "./config.ts";
3
4
  export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
4
5
  export { createHubspotConnector, type HubspotConnectorOptions } from "./connectors/hubspot.ts";
@@ -17,9 +18,10 @@ export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mapp
17
18
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.ts";
18
19
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, type CallDealSuggestion, type CallInsightType, type ExtractedCallInsight, type ParsedCall, type ParsedTranscriptSegment, } from "./calls.ts";
19
20
  export { sampleSnapshot } from "./sampleData.ts";
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
+ export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
21
22
  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";
23
+ 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";
24
+ export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, type AxesReport, type AxisAssessment, type AxisPairing, type PrincipalComponent, } from "./marketAxes.ts";
23
25
  export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type ClassifyMarketResult, type MarketWorksheet, } from "./marketClassify.ts";
24
26
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
25
27
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { auditSnapshot, defaultPolicy } from "./audit.js";
2
+ export { buildBulkUpdatePlan, parseWhere } from "./bulkUpdate.js";
2
3
  export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, } from "./config.js";
3
4
  export { applyPatchPlan } from "./connector.js";
4
5
  export { createHubspotConnector } from "./connectors/hubspot.js";
@@ -17,9 +18,10 @@ export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mapp
17
18
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.js";
18
19
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, } from "./calls.js";
19
20
  export { sampleSnapshot } from "./sampleData.js";
20
- export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
21
+ export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
21
22
  export { resolveRecord } from "./resolve.js";
22
23
  export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
24
+ export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, } from "./marketAxes.js";
23
25
  export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
24
26
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
25
27
  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,33 @@ 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
+ if (!axis.negativePole || !axis.positivePole) {
62
+ throw new Error(`market config: axis "${axis.id}" needs negativePole and positivePole labels (the strategic map renders them as axis ends)`);
63
+ }
64
+ for (const claimId of Object.keys(axis.claimScores ?? {})) {
65
+ if (!claimIds.has(claimId)) {
66
+ throw new Error(`market config: axis "${axis.id}" scores unknown claim "${claimId}"`);
67
+ }
68
+ }
69
+ }
70
+ if (config.primaryAxes) {
71
+ if (config.primaryAxes.length !== 2 || config.primaryAxes.some((id) => !axisIds.has(id))) {
72
+ throw new Error(`market config: primaryAxes must name two configured axes (got ${JSON.stringify(config.primaryAxes)})`);
73
+ }
74
+ }
75
+ }
76
+ else if (config.primaryAxes) {
77
+ throw new Error("market config: primaryAxes set but no axes configured");
78
+ }
52
79
  return config;
53
80
  }
54
81
  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
+ }