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