impact-compass 0.2.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/DOCUMENTATION.md +47 -0
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/SKILLS.md +52 -0
- package/assets/top-banner-readme.png +0 -0
- package/example-coffee.json +15 -0
- package/example-input.json +15 -0
- package/example-output.json +352 -0
- package/example-react.json +15 -0
- package/package.json +52 -0
- package/src/cli.ts +219 -0
- package/src/domain/evidence.test.ts +93 -0
- package/src/domain/evidence.ts +99 -0
- package/src/domain/queryBundle.test.ts +67 -0
- package/src/domain/queryBundle.ts +116 -0
- package/src/domain/scoring.test.ts +184 -0
- package/src/domain/scoring.ts +322 -0
- package/src/services/comparison.test.ts +89 -0
- package/src/services/comparison.ts +84 -0
- package/src/services/demoReport.test.ts +37 -0
- package/src/services/demoReport.ts +32 -0
- package/src/services/publicEvidenceReport.test.ts +66 -0
- package/src/services/publicEvidenceReport.ts +82 -0
- package/src/services/queryDerivedReport.test.ts +68 -0
- package/src/services/queryDerivedReport.ts +227 -0
- package/src/services/reportBuilder.test.ts +90 -0
- package/src/services/reportBuilder.ts +219 -0
- package/src/services/reportInsights.ts +23 -0
- package/src/services/reportStorage.test.ts +77 -0
- package/src/services/reportStorage.ts +113 -0
- package/src/services/reportTypes.ts +49 -0
- package/src/services/sources/extendedAdapters.ts +218 -0
- package/src/services/sources/githubSource.test.ts +48 -0
- package/src/services/sources/githubSource.ts +63 -0
- package/src/services/sources/hackerNewsSource.test.ts +80 -0
- package/src/services/sources/hackerNewsSource.ts +117 -0
- package/src/services/sources/itunesSource.ts +62 -0
- package/src/services/sources/npmSource.ts +65 -0
- package/src/services/sources/redditSource.ts +68 -0
- package/src/services/sources/sourceAdapter.ts +12 -0
- package/src/services/sources/stackExchangeSource.ts +62 -0
- package/src/services/sources/wikipediaSource.ts +62 -0
- package/src/services/therapySeed.ts +183 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
calculateCompassScore,
|
|
4
|
+
calculateConfidence,
|
|
5
|
+
calculateActivityScore,
|
|
6
|
+
calculateCompetitionFitScore,
|
|
7
|
+
calculateDemandScore,
|
|
8
|
+
calculateMomentumScore,
|
|
9
|
+
calculatePainScore,
|
|
10
|
+
calculateRecencyWeight,
|
|
11
|
+
calculateScoreRange,
|
|
12
|
+
rankIdeasByEvidence,
|
|
13
|
+
summarizeIntegrity,
|
|
14
|
+
} from "./scoring";
|
|
15
|
+
|
|
16
|
+
const balancedPillars = {
|
|
17
|
+
demand: 70,
|
|
18
|
+
pain: 80,
|
|
19
|
+
momentum: 60,
|
|
20
|
+
competitionFit: 65,
|
|
21
|
+
activity: 50,
|
|
22
|
+
channelFit: 75,
|
|
23
|
+
evidenceQuality: 60,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe("scoring methodology", () => {
|
|
27
|
+
it("calculates the Compass Score as a weighted pillar average", () => {
|
|
28
|
+
const result = calculateCompassScore(balancedPillars);
|
|
29
|
+
|
|
30
|
+
expect(result.score).toBe(67);
|
|
31
|
+
expect(result.weights).toEqual({
|
|
32
|
+
demand: 20,
|
|
33
|
+
pain: 20,
|
|
34
|
+
momentum: 15,
|
|
35
|
+
competitionFit: 15,
|
|
36
|
+
activity: 10,
|
|
37
|
+
channelFit: 10,
|
|
38
|
+
evidenceQuality: 10,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps uncertainty separate from the Compass Score", () => {
|
|
43
|
+
const highUncertainty = calculateCompassScore(balancedPillars, {
|
|
44
|
+
uncertainty: 22,
|
|
45
|
+
});
|
|
46
|
+
const lowUncertainty = calculateCompassScore(balancedPillars, {
|
|
47
|
+
uncertainty: 6,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(highUncertainty.score).toBe(lowUncertainty.score);
|
|
51
|
+
expect(highUncertainty.uncertainty).toBe(22);
|
|
52
|
+
expect(lowUncertainty.uncertainty).toBe(6);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("maps uncertainty bands to confidence labels", () => {
|
|
56
|
+
expect(calculateConfidence(7)).toBe("High");
|
|
57
|
+
expect(calculateConfidence(14)).toBe("Medium");
|
|
58
|
+
expect(calculateConfidence(24)).toBe("Low");
|
|
59
|
+
expect(calculateConfidence(25)).toBe("Very Low");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("clamps score ranges to 0-100", () => {
|
|
63
|
+
expect(calculateScoreRange({ score: 96, uncertainty: 9 })).toEqual({
|
|
64
|
+
lower: 87,
|
|
65
|
+
upper: 100,
|
|
66
|
+
});
|
|
67
|
+
expect(calculateScoreRange({ score: 4, uncertainty: 10 })).toEqual({
|
|
68
|
+
lower: 0,
|
|
69
|
+
upper: 14,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("ranks ideas by lower confidence bound, not raw score", () => {
|
|
74
|
+
const ranked = rankIdeasByEvidence([
|
|
75
|
+
{ id: "spiky", name: "Spiky idea", score: 74, uncertainty: 18 },
|
|
76
|
+
{ id: "steady", name: "Steady idea", score: 69, uncertainty: 5 },
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
expect(ranked.map((idea) => idea.id)).toEqual(["steady", "spiky"]);
|
|
80
|
+
expect(ranked[0].rankBasis).toBe(64);
|
|
81
|
+
expect(ranked[1].rankBasis).toBe(56);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("summarizes integrity caps when evidence quality is weak", () => {
|
|
85
|
+
const integrity = summarizeIntegrity({
|
|
86
|
+
evidenceQuality: 38,
|
|
87
|
+
sourceDiversity: 4,
|
|
88
|
+
relevancePrecision: 72,
|
|
89
|
+
relevantEvidenceCount: 50,
|
|
90
|
+
dominantSourceShare: 0.42,
|
|
91
|
+
queryLocked: true,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(integrity.finalScoreAvailable).toBe(true);
|
|
95
|
+
expect(integrity.confidenceCap).toBe("Low");
|
|
96
|
+
expect(integrity.warnings).toContain(
|
|
97
|
+
"Evidence Quality below 40 caps confidence at Low.",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("withholds final score when query bundle is not locked", () => {
|
|
102
|
+
const integrity = summarizeIntegrity({
|
|
103
|
+
evidenceQuality: 80,
|
|
104
|
+
sourceDiversity: 5,
|
|
105
|
+
relevancePrecision: 90,
|
|
106
|
+
relevantEvidenceCount: 100,
|
|
107
|
+
dominantSourceShare: 0.4,
|
|
108
|
+
queryLocked: false,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(integrity.finalScoreAvailable).toBe(false);
|
|
112
|
+
expect(integrity.warnings).toContain(
|
|
113
|
+
"Query bundle is not locked; only preview scoring is allowed.",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("uses deterministic recency weight boundaries", () => {
|
|
118
|
+
expect(calculateRecencyWeight(0)).toBe(1);
|
|
119
|
+
expect(calculateRecencyWeight(30)).toBe(1);
|
|
120
|
+
expect(calculateRecencyWeight(31)).toBe(0.75);
|
|
121
|
+
expect(calculateRecencyWeight(90)).toBe(0.75);
|
|
122
|
+
expect(calculateRecencyWeight(91)).toBe(0.45);
|
|
123
|
+
expect(calculateRecencyWeight(365)).toBe(0.45);
|
|
124
|
+
expect(calculateRecencyWeight(366)).toBe(0.2);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("calculates Demand from weighted normalized signals", () => {
|
|
128
|
+
expect(
|
|
129
|
+
calculateDemandScore({
|
|
130
|
+
volumePercentile: 80,
|
|
131
|
+
uniqueAuthorPercentile: 60,
|
|
132
|
+
questionPercentile: 70,
|
|
133
|
+
engagementPercentile: 50,
|
|
134
|
+
}),
|
|
135
|
+
).toBe(70);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("calculates Pain from weighted normalized signals", () => {
|
|
139
|
+
expect(
|
|
140
|
+
calculatePainScore({
|
|
141
|
+
painDensity: 90,
|
|
142
|
+
workaroundDensity: 80,
|
|
143
|
+
alternativeDensity: 50,
|
|
144
|
+
discussionDepthPercentile: 40,
|
|
145
|
+
}),
|
|
146
|
+
).toBe(70);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("clamps Momentum after spike penalties", () => {
|
|
150
|
+
expect(
|
|
151
|
+
calculateMomentumScore({
|
|
152
|
+
shortGrowth: 100,
|
|
153
|
+
mediumGrowth: 100,
|
|
154
|
+
sustainedGrowth: 100,
|
|
155
|
+
spikePenalty: 500,
|
|
156
|
+
}),
|
|
157
|
+
).toBe(40);
|
|
158
|
+
expect(
|
|
159
|
+
calculateMomentumScore({
|
|
160
|
+
shortGrowth: 0,
|
|
161
|
+
mediumGrowth: 0,
|
|
162
|
+
sustainedGrowth: 0,
|
|
163
|
+
spikePenalty: 500,
|
|
164
|
+
}),
|
|
165
|
+
).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("scores Competition Fit highest near moderate supply", () => {
|
|
169
|
+
expect(calculateCompetitionFitScore(60)).toBe(100);
|
|
170
|
+
expect(calculateCompetitionFitScore(0)).toBe(6);
|
|
171
|
+
expect(calculateCompetitionFitScore(100)).toBe(28);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("excludes non-relevant Activity metrics instead of treating them as zero", () => {
|
|
175
|
+
expect(
|
|
176
|
+
calculateActivityScore({
|
|
177
|
+
repoActivity: 80,
|
|
178
|
+
packageActivity: null,
|
|
179
|
+
launchRecency: 60,
|
|
180
|
+
discussionFreshness: 70,
|
|
181
|
+
}),
|
|
182
|
+
).toBe(72);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
export type ConfidenceLabel = "Very Low" | "Low" | "Medium" | "High";
|
|
2
|
+
|
|
3
|
+
export type PillarScores = {
|
|
4
|
+
demand: number;
|
|
5
|
+
pain: number;
|
|
6
|
+
momentum: number;
|
|
7
|
+
competitionFit: number;
|
|
8
|
+
activity: number;
|
|
9
|
+
channelFit: number;
|
|
10
|
+
evidenceQuality: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ScoreWeights = Record<keyof PillarScores, number>;
|
|
14
|
+
|
|
15
|
+
export type ScoreSummary = {
|
|
16
|
+
score: number;
|
|
17
|
+
uncertainty: number;
|
|
18
|
+
confidence: ConfidenceLabel;
|
|
19
|
+
weights: ScoreWeights;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type RankedIdea = {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
score: number;
|
|
26
|
+
uncertainty: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type RankedIdeaWithBasis = RankedIdea & {
|
|
30
|
+
rankBasis: number;
|
|
31
|
+
lower: number;
|
|
32
|
+
upper: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type IntegrityInput = {
|
|
36
|
+
evidenceQuality: number;
|
|
37
|
+
sourceDiversity: number;
|
|
38
|
+
relevancePrecision: number;
|
|
39
|
+
relevantEvidenceCount: number;
|
|
40
|
+
dominantSourceShare: number;
|
|
41
|
+
queryLocked: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type IntegritySummary = {
|
|
45
|
+
finalScoreAvailable: boolean;
|
|
46
|
+
confidenceCap?: ConfidenceLabel;
|
|
47
|
+
warnings: string[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type DemandSignals = {
|
|
51
|
+
volumePercentile: number;
|
|
52
|
+
uniqueAuthorPercentile: number;
|
|
53
|
+
questionPercentile: number;
|
|
54
|
+
engagementPercentile: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type PainSignals = {
|
|
58
|
+
painDensity: number;
|
|
59
|
+
workaroundDensity: number;
|
|
60
|
+
alternativeDensity: number;
|
|
61
|
+
discussionDepthPercentile: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type MomentumSignals = {
|
|
65
|
+
shortGrowth: number;
|
|
66
|
+
mediumGrowth: number;
|
|
67
|
+
sustainedGrowth: number;
|
|
68
|
+
spikePenalty: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type ActivitySignals = {
|
|
72
|
+
repoActivity: number | null;
|
|
73
|
+
packageActivity: number | null;
|
|
74
|
+
launchRecency: number | null;
|
|
75
|
+
discussionFreshness: number | null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const defaultWeights: ScoreWeights = {
|
|
79
|
+
demand: 20,
|
|
80
|
+
pain: 20,
|
|
81
|
+
momentum: 15,
|
|
82
|
+
competitionFit: 15,
|
|
83
|
+
activity: 10,
|
|
84
|
+
channelFit: 10,
|
|
85
|
+
evidenceQuality: 10,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const minimumEvidenceCount = 10;
|
|
89
|
+
|
|
90
|
+
function clampScore(value: number) {
|
|
91
|
+
return Math.min(100, Math.max(0, Math.round(value)));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function weightedAverage<T extends string>(
|
|
95
|
+
signals: Record<T, number | null>,
|
|
96
|
+
weights: Record<T, number>,
|
|
97
|
+
) {
|
|
98
|
+
let weightedTotal = 0;
|
|
99
|
+
let totalWeight = 0;
|
|
100
|
+
|
|
101
|
+
for (const key of Object.keys(weights) as T[]) {
|
|
102
|
+
const value = signals[key];
|
|
103
|
+
|
|
104
|
+
if (value === null) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
weightedTotal += value * weights[key];
|
|
109
|
+
totalWeight += weights[key];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return totalWeight === 0 ? 0 : weightedTotal / totalWeight;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function lowestConfidenceCap(
|
|
116
|
+
current: ConfidenceLabel | undefined,
|
|
117
|
+
next: ConfidenceLabel,
|
|
118
|
+
) {
|
|
119
|
+
const order: ConfidenceLabel[] = ["Very Low", "Low", "Medium", "High"];
|
|
120
|
+
|
|
121
|
+
if (!current) {
|
|
122
|
+
return next;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return order.indexOf(next) < order.indexOf(current) ? next : current;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function calculateConfidence(uncertainty: number): ConfidenceLabel {
|
|
129
|
+
if (uncertainty <= 7) {
|
|
130
|
+
return "High";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (uncertainty <= 14) {
|
|
134
|
+
return "Medium";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (uncertainty <= 24) {
|
|
138
|
+
return "Low";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return "Very Low";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function calculateScoreRange(params: { score: number; uncertainty: number }) {
|
|
145
|
+
return {
|
|
146
|
+
lower: clampScore(params.score - params.uncertainty),
|
|
147
|
+
upper: clampScore(params.score + params.uncertainty),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function calculateRecencyWeight(ageInDays: number) {
|
|
152
|
+
if (ageInDays <= 30) {
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (ageInDays <= 90) {
|
|
157
|
+
return 0.75;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (ageInDays <= 365) {
|
|
161
|
+
return 0.45;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return 0.2;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function calculateDemandScore(signals: DemandSignals) {
|
|
168
|
+
return clampScore(
|
|
169
|
+
weightedAverage(
|
|
170
|
+
{
|
|
171
|
+
volumePercentile: signals.volumePercentile,
|
|
172
|
+
uniqueAuthorPercentile: signals.uniqueAuthorPercentile,
|
|
173
|
+
questionPercentile: signals.questionPercentile,
|
|
174
|
+
engagementPercentile: signals.engagementPercentile,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
volumePercentile: 0.45,
|
|
178
|
+
uniqueAuthorPercentile: 0.25,
|
|
179
|
+
questionPercentile: 0.2,
|
|
180
|
+
engagementPercentile: 0.1,
|
|
181
|
+
},
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function calculatePainScore(signals: PainSignals) {
|
|
187
|
+
return clampScore(
|
|
188
|
+
weightedAverage(
|
|
189
|
+
{
|
|
190
|
+
painDensity: signals.painDensity,
|
|
191
|
+
workaroundDensity: signals.workaroundDensity,
|
|
192
|
+
alternativeDensity: signals.alternativeDensity,
|
|
193
|
+
discussionDepthPercentile: signals.discussionDepthPercentile,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
painDensity: 0.35,
|
|
197
|
+
workaroundDensity: 0.25,
|
|
198
|
+
alternativeDensity: 0.2,
|
|
199
|
+
discussionDepthPercentile: 0.2,
|
|
200
|
+
},
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function calculateMomentumScore(signals: MomentumSignals) {
|
|
206
|
+
return clampScore(
|
|
207
|
+
0.4 * signals.shortGrowth +
|
|
208
|
+
0.3 * signals.mediumGrowth +
|
|
209
|
+
0.2 * signals.sustainedGrowth -
|
|
210
|
+
0.1 * signals.spikePenalty,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function calculateCompetitionFitScore(supplyPercentile: number) {
|
|
215
|
+
const variance = 2 * 25 ** 2;
|
|
216
|
+
const rawScore = 100 * Math.exp(-((supplyPercentile - 60) ** 2) / variance);
|
|
217
|
+
|
|
218
|
+
return clampScore(rawScore);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function calculateActivityScore(signals: ActivitySignals) {
|
|
222
|
+
return clampScore(
|
|
223
|
+
weightedAverage(
|
|
224
|
+
{
|
|
225
|
+
repoActivity: signals.repoActivity,
|
|
226
|
+
packageActivity: signals.packageActivity,
|
|
227
|
+
launchRecency: signals.launchRecency,
|
|
228
|
+
discussionFreshness: signals.discussionFreshness,
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
repoActivity: 0.35,
|
|
232
|
+
packageActivity: 0.25,
|
|
233
|
+
launchRecency: 0.2,
|
|
234
|
+
discussionFreshness: 0.2,
|
|
235
|
+
},
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function calculateCompassScore(
|
|
241
|
+
pillars: PillarScores,
|
|
242
|
+
options: { uncertainty?: number; weights?: ScoreWeights } = {},
|
|
243
|
+
): ScoreSummary {
|
|
244
|
+
const weights = options.weights ?? defaultWeights;
|
|
245
|
+
const totalWeight = Object.values(weights).reduce((sum, weight) => sum + weight, 0);
|
|
246
|
+
let weightedScore =
|
|
247
|
+
Object.entries(weights).reduce((sum, [pillar, weight]) => {
|
|
248
|
+
return sum + pillars[pillar as keyof PillarScores] * weight;
|
|
249
|
+
}, 0) / totalWeight;
|
|
250
|
+
const uncertainty = options.uncertainty ?? 10;
|
|
251
|
+
|
|
252
|
+
// Red Ocean Saturation Penalty
|
|
253
|
+
// High demand but very low Competition Fit (meaning massive competition volume)
|
|
254
|
+
let saturationPenalty = 0;
|
|
255
|
+
if (pillars.demand > 70 && pillars.competitionFit < 30) {
|
|
256
|
+
// Dock up to 15 extra points for extreme saturation
|
|
257
|
+
saturationPenalty = (30 - pillars.competitionFit) * 0.5;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
weightedScore -= saturationPenalty;
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
score: clampScore(weightedScore),
|
|
264
|
+
uncertainty,
|
|
265
|
+
confidence: calculateConfidence(uncertainty),
|
|
266
|
+
weights,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function rankIdeasByEvidence(ideas: RankedIdea[]): RankedIdeaWithBasis[] {
|
|
271
|
+
return ideas
|
|
272
|
+
.map((idea) => {
|
|
273
|
+
const range = calculateScoreRange(idea);
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
...idea,
|
|
277
|
+
...range,
|
|
278
|
+
rankBasis: range.lower,
|
|
279
|
+
};
|
|
280
|
+
})
|
|
281
|
+
.sort((a, b) => b.rankBasis - a.rankBasis || b.score - a.score);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function summarizeIntegrity(input: IntegrityInput): IntegritySummary {
|
|
285
|
+
const warnings: string[] = [];
|
|
286
|
+
let confidenceCap: ConfidenceLabel | undefined;
|
|
287
|
+
|
|
288
|
+
if (!input.queryLocked) {
|
|
289
|
+
warnings.push("Query bundle is not locked; only preview scoring is allowed.");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (input.evidenceQuality < 40) {
|
|
293
|
+
warnings.push("Evidence Quality below 40 caps confidence at Low.");
|
|
294
|
+
confidenceCap = lowestConfidenceCap(confidenceCap, "Low");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (input.sourceDiversity < 2) {
|
|
298
|
+
warnings.push("Source diversity below 2 caps confidence at Low.");
|
|
299
|
+
confidenceCap = lowestConfidenceCap(confidenceCap, "Low");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (input.relevancePrecision < 50) {
|
|
303
|
+
warnings.push("Relevance precision below 50 caps confidence at Low.");
|
|
304
|
+
confidenceCap = lowestConfidenceCap(confidenceCap, "Low");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (input.relevantEvidenceCount < minimumEvidenceCount) {
|
|
308
|
+
warnings.push("Relevant evidence count is below the minimum confidence threshold.");
|
|
309
|
+
confidenceCap = lowestConfidenceCap(confidenceCap, "Low");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (input.dominantSourceShare > 0.7) {
|
|
313
|
+
warnings.push("One source contributes over 70% of evidence; confidence caps at Medium.");
|
|
314
|
+
confidenceCap = lowestConfidenceCap(confidenceCap, "Medium");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
finalScoreAvailable: input.queryLocked,
|
|
319
|
+
confidenceCap,
|
|
320
|
+
warnings,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createLockedQueryBundle } from "../domain/queryBundle";
|
|
3
|
+
import { buildCompassReport } from "./reportBuilder";
|
|
4
|
+
import { buildComparisonRows, buildSnapshotComparisonRows } from "./comparison";
|
|
5
|
+
import { createReportSnapshot } from "./reportStorage";
|
|
6
|
+
import { therapyEvidenceSeed, therapyPillarScores } from "./therapySeed";
|
|
7
|
+
|
|
8
|
+
function report(name: string, scoreShape: typeof therapyPillarScores, uncertainty: number) {
|
|
9
|
+
return buildCompassReport({
|
|
10
|
+
idea: {
|
|
11
|
+
name,
|
|
12
|
+
problem: `${name} problem`,
|
|
13
|
+
targetUser: "Indie builders",
|
|
14
|
+
lens: "Productivity / Prosumer SaaS",
|
|
15
|
+
},
|
|
16
|
+
queryBundle: createLockedQueryBundle({
|
|
17
|
+
problemKeywords: name,
|
|
18
|
+
solutionKeywords: `${name} tool`,
|
|
19
|
+
audienceKeywords: "indie builders",
|
|
20
|
+
competitorKeywords: "",
|
|
21
|
+
exclusions: "jobs",
|
|
22
|
+
}),
|
|
23
|
+
evidence: therapyEvidenceSeed,
|
|
24
|
+
pillarScores: scoreShape,
|
|
25
|
+
uncertainty,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("comparison service", () => {
|
|
30
|
+
it("ranks reports by lower confidence bound", () => {
|
|
31
|
+
const rows = buildComparisonRows([
|
|
32
|
+
report(
|
|
33
|
+
"Spiky high score",
|
|
34
|
+
{ ...therapyPillarScores, demand: 95, pain: 95, evidenceQuality: 70 },
|
|
35
|
+
25,
|
|
36
|
+
),
|
|
37
|
+
report(
|
|
38
|
+
"Steady evidence",
|
|
39
|
+
{ ...therapyPillarScores, demand: 72, pain: 74, evidenceQuality: 80 },
|
|
40
|
+
6,
|
|
41
|
+
),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
expect(rows.map((row) => row.ideaName)).toEqual([
|
|
45
|
+
"Steady evidence",
|
|
46
|
+
"Spiky high score",
|
|
47
|
+
]);
|
|
48
|
+
expect(rows[0].rankBasis).toBeGreaterThan(rows[1].rankBasis);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("includes comparison diagnostics for each idea", () => {
|
|
52
|
+
const rows = buildComparisonRows([
|
|
53
|
+
report("Therapy notes", therapyPillarScores, 11),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
expect(rows[0]).toMatchObject({
|
|
57
|
+
ideaName: "Therapy notes",
|
|
58
|
+
strongestPillar: "Pain",
|
|
59
|
+
weakestPillar: "Activity",
|
|
60
|
+
bestChannel: "Reddit",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("ranks saved snapshots without seeded fake alternatives", () => {
|
|
65
|
+
const rows = buildSnapshotComparisonRows([
|
|
66
|
+
createReportSnapshot(
|
|
67
|
+
report(
|
|
68
|
+
"Spiky high score",
|
|
69
|
+
{ ...therapyPillarScores, demand: 95, pain: 95, evidenceQuality: 70 },
|
|
70
|
+
25,
|
|
71
|
+
),
|
|
72
|
+
"2026-05-24T04:00:00.000Z",
|
|
73
|
+
),
|
|
74
|
+
createReportSnapshot(
|
|
75
|
+
report(
|
|
76
|
+
"Steady evidence",
|
|
77
|
+
{ ...therapyPillarScores, demand: 72, pain: 74, evidenceQuality: 80 },
|
|
78
|
+
6,
|
|
79
|
+
),
|
|
80
|
+
"2026-05-24T05:00:00.000Z",
|
|
81
|
+
),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
expect(rows.map((row) => row.ideaName)).toEqual([
|
|
85
|
+
"Steady evidence",
|
|
86
|
+
"Spiky high score",
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { rankIdeasByEvidence } from "../domain/scoring";
|
|
2
|
+
import { findBestEvidenceSource, summarizeEvidenceGap } from "./reportInsights";
|
|
3
|
+
import type { ReportSnapshot } from "./reportStorage";
|
|
4
|
+
import type { CompassReportModel } from "./reportTypes";
|
|
5
|
+
|
|
6
|
+
export type ComparisonRow = {
|
|
7
|
+
ideaName: string;
|
|
8
|
+
score: number;
|
|
9
|
+
confidence: string;
|
|
10
|
+
range: string;
|
|
11
|
+
rankBasis: number;
|
|
12
|
+
strongestPillar: string;
|
|
13
|
+
weakestPillar: string;
|
|
14
|
+
bestChannel: string;
|
|
15
|
+
evidenceGap: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function buildComparisonRows(
|
|
19
|
+
reports: CompassReportModel[],
|
|
20
|
+
): ComparisonRow[] {
|
|
21
|
+
const ranked = rankIdeasByEvidence(
|
|
22
|
+
reports.map((report) => ({
|
|
23
|
+
id: report.idea.name,
|
|
24
|
+
name: report.idea.name,
|
|
25
|
+
score: report.summary.score,
|
|
26
|
+
uncertainty: report.summary.uncertainty,
|
|
27
|
+
})),
|
|
28
|
+
);
|
|
29
|
+
const reportByName = new Map(reports.map((report) => [report.idea.name, report]));
|
|
30
|
+
|
|
31
|
+
return ranked.map((rankedIdea) => {
|
|
32
|
+
const report = reportByName.get(rankedIdea.name);
|
|
33
|
+
|
|
34
|
+
if (!report) {
|
|
35
|
+
throw new Error(`Missing report for ${rankedIdea.name}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
ideaName: report.idea.name,
|
|
40
|
+
score: report.summary.score,
|
|
41
|
+
confidence: report.summary.confidence,
|
|
42
|
+
range: `${report.summary.range.lower}-${report.summary.range.upper}`,
|
|
43
|
+
rankBasis: rankedIdea.rankBasis,
|
|
44
|
+
strongestPillar: report.strongestPillar.label,
|
|
45
|
+
weakestPillar: report.weakestPillar.label,
|
|
46
|
+
bestChannel: findBestEvidenceSource(report),
|
|
47
|
+
evidenceGap: summarizeEvidenceGap(report),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildSnapshotComparisonRows(
|
|
53
|
+
snapshots: ReportSnapshot[],
|
|
54
|
+
): ComparisonRow[] {
|
|
55
|
+
const ranked = rankIdeasByEvidence(
|
|
56
|
+
snapshots.map((snapshot) => ({
|
|
57
|
+
id: snapshot.id,
|
|
58
|
+
name: snapshot.ideaName,
|
|
59
|
+
score: snapshot.score,
|
|
60
|
+
uncertainty: snapshot.uncertainty,
|
|
61
|
+
})),
|
|
62
|
+
);
|
|
63
|
+
const snapshotById = new Map(snapshots.map((snapshot) => [snapshot.id, snapshot]));
|
|
64
|
+
|
|
65
|
+
return ranked.map((rankedIdea) => {
|
|
66
|
+
const snapshot = snapshotById.get(rankedIdea.id);
|
|
67
|
+
|
|
68
|
+
if (!snapshot) {
|
|
69
|
+
throw new Error(`Missing snapshot for ${rankedIdea.id}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
ideaName: snapshot.ideaName,
|
|
74
|
+
score: snapshot.score,
|
|
75
|
+
confidence: snapshot.confidence,
|
|
76
|
+
range: snapshot.range,
|
|
77
|
+
rankBasis: rankedIdea.rankBasis,
|
|
78
|
+
strongestPillar: snapshot.strongestPillar,
|
|
79
|
+
weakestPillar: snapshot.weakestPillar,
|
|
80
|
+
bestChannel: snapshot.bestChannel,
|
|
81
|
+
evidenceGap: snapshot.evidenceGap,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createDemoReport } from "./demoReport";
|
|
3
|
+
|
|
4
|
+
describe("demo report fixture", () => {
|
|
5
|
+
it("builds a locked public-evidence report from deterministic scoring", () => {
|
|
6
|
+
const report = createDemoReport();
|
|
7
|
+
|
|
8
|
+
expect(report.idea.name).toBe("Privacy-safe session note drafts");
|
|
9
|
+
expect(report.queryBundle.locked).toBe(true);
|
|
10
|
+
expect(report.methodologyVersion).toBe("0.1");
|
|
11
|
+
expect(report.summary.score).toBe(66);
|
|
12
|
+
expect(report.summary.uncertainty).toBe(11);
|
|
13
|
+
expect(report.summary.confidence).toBe("Medium");
|
|
14
|
+
expect(report.summary.range).toEqual({ lower: 55, upper: 77 });
|
|
15
|
+
expect(report.integrity.finalScoreAvailable).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("keeps evidence ledger entries auditable", () => {
|
|
19
|
+
const report = createDemoReport();
|
|
20
|
+
|
|
21
|
+
expect(report.evidence).toHaveLength(12);
|
|
22
|
+
expect(report.evidence.some((item) => item.included)).toBe(true);
|
|
23
|
+
expect(report.evidence.some((item) => !item.included)).toBe(true);
|
|
24
|
+
expect(report.evidence[0]).toMatchObject({
|
|
25
|
+
source: "Reddit",
|
|
26
|
+
metricContribution: "Pain",
|
|
27
|
+
query: "therapist paperwork",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("identifies strongest and weakest pillars", () => {
|
|
32
|
+
const report = createDemoReport();
|
|
33
|
+
|
|
34
|
+
expect(report.strongestPillar.label).toBe("Pain");
|
|
35
|
+
expect(report.weakestPillar.label).toBe("Activity");
|
|
36
|
+
});
|
|
37
|
+
});
|