@m8i-51/shoal 0.1.7 → 0.1.8

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.
@@ -167,6 +167,86 @@ describe("computeWeightedSummary", () => {
167
167
  expect(result.formatted).toContain("By scenario");
168
168
  });
169
169
 
170
+ it("14日以内に同じレンズが複数 run に登場するとボーナスが乗る", () => {
171
+ const now = Date.now();
172
+ // 同じ Accessibility レンズが2回登場 → bonus = 1 + (2-1)*0.5 = 1.5
173
+ setupMockCoverage({
174
+ entries: [
175
+ makeEntry({
176
+ runId: "run_1",
177
+ timestamp: new Date(now - 1000).toISOString(),
178
+ findingsCount: 2,
179
+ byLens: { Accessibility: 2 },
180
+ }),
181
+ makeEntry({
182
+ runId: "run_2",
183
+ timestamp: new Date(now).toISOString(),
184
+ findingsCount: 2,
185
+ byLens: { Accessibility: 2 },
186
+ }),
187
+ ],
188
+ });
189
+
190
+ const resultWithRepeat = computeWeightedSummary();
191
+
192
+ // 1回しか登場しない場合と比較
193
+ setupMockCoverage({
194
+ entries: [
195
+ makeEntry({
196
+ runId: "run_1",
197
+ timestamp: new Date(now).toISOString(),
198
+ findingsCount: 2,
199
+ byLens: { Accessibility: 2 },
200
+ }),
201
+ ],
202
+ });
203
+ const resultSingle = computeWeightedSummary();
204
+
205
+ // 繰り返しありのほうが lens の重みが高いはず
206
+ expect(resultWithRepeat.byLens["Accessibility"]).toBeGreaterThan(resultSingle.byLens["Accessibility"]);
207
+ });
208
+
209
+ it("14日より古いエントリーは繰り返しカウントに含まれない", () => {
210
+ const now = Date.now();
211
+ const oldMs = 15 * 24 * 60 * 60 * 1000; // 15日前
212
+ setupMockCoverage({
213
+ entries: [
214
+ makeEntry({
215
+ runId: "run_old",
216
+ timestamp: new Date(now - oldMs).toISOString(),
217
+ findingsCount: 2,
218
+ byLens: { Security: 2 },
219
+ }),
220
+ makeEntry({
221
+ runId: "run_new",
222
+ timestamp: new Date(now).toISOString(),
223
+ findingsCount: 2,
224
+ byLens: { Security: 2 },
225
+ }),
226
+ ],
227
+ });
228
+
229
+ const result = computeWeightedSummary();
230
+ // 古いエントリーはウィンドウ外なのでボーナスなし(繰り返し回数=1 → bonus=1.0)
231
+ // ボーナスなしの場合: weight≈1.0*2 + 15日前のdecay*2 ≈ 2.06
232
+ expect(result.formatted).not.toContain("Repeated lenses");
233
+ });
234
+
235
+ it("繰り返しレンズが formatted に含まれる", () => {
236
+ const now = Date.now();
237
+ setupMockCoverage({
238
+ entries: [
239
+ makeEntry({ runId: "r1", timestamp: new Date(now - 1000).toISOString(), findingsCount: 1, byLens: { "UI design": 1 } }),
240
+ makeEntry({ runId: "r2", timestamp: new Date(now).toISOString(), findingsCount: 1, byLens: { "UI design": 1 } }),
241
+ ],
242
+ });
243
+
244
+ const result = computeWeightedSummary();
245
+ expect(result.formatted).toContain("Repeated lenses");
246
+ expect(result.formatted).toContain("UI design");
247
+ expect(result.formatted).toContain("×2");
248
+ });
249
+
170
250
  it("MAX_ENTRIES を超えると最新30件に切り捨てる", () => {
171
251
  const entries = Array.from({ length: 35 }, (_, i) =>
172
252
  makeEntry({
@@ -27,6 +27,9 @@ export interface WeightedSummary {
27
27
  const COVERAGE_PATH = path.join(process.cwd(), "coverage", "coverage.json");
28
28
  const MAX_ENTRIES = 30;
29
29
  const HALF_LIFE_DAYS = 7;
30
+ const REPETITION_WINDOW_DAYS = 14;
31
+ const REPETITION_BONUS = 0.005;
32
+ const REPETITION_EXPONENT = 3;
30
33
 
31
34
  export function loadCoverage(): Coverage {
32
35
  try {
@@ -98,6 +101,20 @@ export function computeWeightedSummary(): WeightedSummary {
98
101
 
99
102
  const now = Date.now();
100
103
  const halfLifeMs = HALF_LIFE_DAYS * 24 * 60 * 60 * 1000;
104
+ const windowMs = REPETITION_WINDOW_DAYS * 24 * 60 * 60 * 1000;
105
+
106
+ // 14日以内の run で各 lens/scenario が何回登場したかを数える
107
+ const lensRepeat: Record<string, number> = {};
108
+ const scenarioRepeat: Record<string, number> = {};
109
+ for (const entry of coverage.entries) {
110
+ if (now - new Date(entry.timestamp).getTime() > windowMs) continue;
111
+ for (const lens of Object.keys(entry.byLens)) {
112
+ lensRepeat[lens] = (lensRepeat[lens] ?? 0) + 1;
113
+ }
114
+ for (const title of Object.keys(entry.byScenario ?? {})) {
115
+ scenarioRepeat[title] = (scenarioRepeat[title] ?? 0) + 1;
116
+ }
117
+ }
101
118
 
102
119
  const byCategory: Record<string, number> = {};
103
120
  const byLens: Record<string, number> = {};
@@ -106,18 +123,21 @@ export function computeWeightedSummary(): WeightedSummary {
106
123
 
107
124
  for (const entry of coverage.entries) {
108
125
  const age = now - new Date(entry.timestamp).getTime();
109
- const weight = Math.pow(0.5, age / halfLifeMs);
126
+ const decay = Math.pow(0.5, age / halfLifeMs);
110
127
 
111
128
  for (const [cat, count] of Object.entries(entry.byCategory)) {
112
- byCategory[cat] = (byCategory[cat] ?? 0) + count * weight;
129
+ byCategory[cat] = (byCategory[cat] ?? 0) + count * decay;
113
130
  }
114
131
  for (const [lens, count] of Object.entries(entry.byLens)) {
115
- byLens[lens] = (byLens[lens] ?? 0) + count * weight;
132
+ // 繰り返し呼ばれるほど「必要」とみなしてボーナスを加算
133
+ const bonus = 1 + Math.pow((lensRepeat[lens] ?? 1) - 1, REPETITION_EXPONENT) * REPETITION_BONUS;
134
+ byLens[lens] = (byLens[lens] ?? 0) + count * decay * bonus;
116
135
  }
117
136
  for (const [title, count] of Object.entries(entry.byScenario ?? {})) {
118
- byScenario[title] = (byScenario[title] ?? 0) + count * weight;
137
+ const bonus = 1 + Math.pow((scenarioRepeat[title] ?? 1) - 1, REPETITION_EXPONENT) * REPETITION_BONUS;
138
+ byScenario[title] = (byScenario[title] ?? 0) + count * decay * bonus;
119
139
  }
120
- totalWeighted += entry.findingsCount * weight;
140
+ totalWeighted += entry.findingsCount * decay;
121
141
  }
122
142
 
123
143
  // 小数点1桁に丸める
@@ -142,12 +162,27 @@ export function computeWeightedSummary(): WeightedSummary {
142
162
  ? `By scenario: ${sortedScenario.map(([t, c]) => `"${t}" (${c})`).join(", ")}`
143
163
  : null;
144
164
 
165
+ const repeatedLenses = Object.entries(lensRepeat)
166
+ .filter(([, n]) => n > 1)
167
+ .sort((a, b) => b[1] - a[1])
168
+ .map(([l, n]) => `${l} (×${n})`);
169
+ const repeatedScenarios = Object.entries(scenarioRepeat)
170
+ .filter(([, n]) => n > 1)
171
+ .sort((a, b) => b[1] - a[1])
172
+ .map(([t, n]) => `"${t}" (×${n})`);
173
+
145
174
  const formatted = [
146
- `Coverage summary (half-life: ${HALF_LIFE_DAYS} days, ${coverage.entries.length} run(s) tracked):`,
175
+ `Coverage summary (half-life: ${HALF_LIFE_DAYS} days, repetition window: ${REPETITION_WINDOW_DAYS} days, ${coverage.entries.length} run(s) tracked):`,
147
176
  `Total weighted findings: ${totalWeighted}`,
148
177
  `By lens: ${sortedLens.map(([l, c]) => `${l} (${c})`).join(" > ") || "(none)"}`,
149
178
  scenarioLine,
150
179
  `By category: ${sortedCategory.map(([c, n]) => `${c} (${n})`).join(" > ") || "(none)"}`,
180
+ repeatedLenses.length > 0
181
+ ? `Repeated lenses (bonus applied): ${repeatedLenses.join(", ")}`
182
+ : null,
183
+ repeatedScenarios.length > 0
184
+ ? `Repeated scenarios (bonus applied): ${repeatedScenarios.join(", ")}`
185
+ : null,
151
186
  underrepresented.length > 0
152
187
  ? `Underrepresented lenses: ${underrepresented.join(", ")} — consider recruiting agents with these perspectives`
153
188
  : "All lenses have comparable coverage",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m8i-51/shoal",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "Multi-agent web exploration framework — finds bugs, UX issues, and missing features by running AI agents against your app",
6
6
  "repository": {