@tduyng/pi-harness-loop 0.0.0-stage → 0.0.2

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/src/corpus.ts ADDED
@@ -0,0 +1,641 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
3
+ import { basename, dirname, join } from 'node:path'
4
+
5
+ import { Validator, type Infer } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+
9
+ import { writeFileAtomic } from './atomic.js'
10
+ import { failureSignature, usableSignature } from './findings.js'
11
+ const isString = new Validator(t.string()).isValidObject
12
+
13
+ export const MAX_CACHED_SESSIONS = 400
14
+ export const MAX_SESSION_BYTES = 24_000_000
15
+ export const MAX_NEW_SESSION_BYTES = 8_000_000
16
+ export const MIN_SESSIONS = 2
17
+ export const MIN_FAILURE_OCCURRENCES = 2
18
+ export const MIN_HABIT_OCCURRENCES = 3
19
+ export const DEAD_SKILL_SESSIONS = 20
20
+
21
+ const CorpusSessionSchema = t.object({
22
+ file: t.string(),
23
+ prompts: t.array(t.string()),
24
+ errors: t.array(
25
+ t.object({
26
+ signature: t.string(),
27
+ tool: t.string(),
28
+ body: t.string(),
29
+ command: t.optional(t.string())
30
+ })
31
+ ),
32
+ commands: t.array(t.string()),
33
+ reads: t.array(t.string()),
34
+ skills: t.array(t.string()),
35
+ /** Missing in caches written before edits were counted, so an old cache reads as unknown. */
36
+ edits: t.optional(t.array(t.string())),
37
+ /** Missing in caches written before tool use was counted, so an old cache reads as unknown. */
38
+ tools: t.optional(t.record(t.number()))
39
+ })
40
+
41
+ const CorpusCacheSchema = t.object({
42
+ files: t.array(
43
+ t.object({
44
+ path: t.string(),
45
+ mtime: t.number(),
46
+ size: t.number(),
47
+ session: CorpusSessionSchema
48
+ })
49
+ )
50
+ })
51
+ const isCorpusCacheSchema = new Validator(CorpusCacheSchema).isValidObject
52
+
53
+ export interface Recurrence {
54
+ id: string
55
+ /** The raw signature, so an install can be measured against the sessions after it. */
56
+ key: string
57
+ kind: 'error' | 'command' | 'read'
58
+ title: string
59
+ sessions: number
60
+ occurrences: number
61
+ tool: string
62
+ sample: string
63
+ command?: string
64
+ /** Why the loop will not act on it, when the evidence comes from one piece of work. */
65
+ blocked?: string
66
+ evidence: string[]
67
+ }
68
+
69
+ export interface SkillUse {
70
+ id: string
71
+ name: string
72
+ sessions: number
73
+ opens: number
74
+ }
75
+
76
+ export interface CorpusSession {
77
+ file: string
78
+ prompts: string[]
79
+ errors: { signature: string; tool: string; body: string; command?: string }[]
80
+ commands: string[]
81
+ reads: string[]
82
+ skills: string[]
83
+ edits: string[]
84
+ tools?: Record<string, number>
85
+ }
86
+
87
+ const Block = t.object({
88
+ type: t.string(),
89
+ id: t.optional(t.string()),
90
+ name: t.optional(t.string()),
91
+ arguments: t.optional(t.unknown())
92
+ })
93
+
94
+ const AssistantMessage = t.object({
95
+ type: t.literal('message'),
96
+ message: t.object({ role: t.literal('assistant'), content: t.array(Block) })
97
+ })
98
+ const isAssistantMessage = new Validator(AssistantMessage).isValidObject
99
+
100
+ const ResultBlock = t.object({ type: t.string(), text: t.optional(t.string()) })
101
+ type ResultBlock = Infer<typeof ResultBlock>
102
+
103
+ const ToolMessage = t.object({
104
+ type: t.literal('message'),
105
+ message: t.object({
106
+ role: t.literal('toolResult'),
107
+ toolCallId: t.optional(t.string()),
108
+ toolName: t.string(),
109
+ isError: t.boolean(),
110
+ content: t.union([t.string(), t.array(ResultBlock)])
111
+ })
112
+ })
113
+ const isToolMessage = new Validator(ToolMessage).isValidObject
114
+
115
+ const UserMessage = t.object({
116
+ type: t.literal('message'),
117
+ message: t.object({
118
+ role: t.literal('user'),
119
+ content: t.union([t.string(), t.array(ResultBlock)])
120
+ })
121
+ })
122
+ const isUserMessage = new Validator(UserMessage).isValidObject
123
+
124
+ const CommandArguments = t.object({ command: t.string() })
125
+ const isCommandArguments = new Validator(CommandArguments).isValidObject
126
+ const PathArguments = t.object({ path: t.string() })
127
+ const isPathArguments = new Validator(PathArguments).isValidObject
128
+
129
+ export function sessionFiles(dir: string): string[] {
130
+ if (!existsSync(dir)) return []
131
+ try {
132
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
133
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
134
+ .map((entry) => {
135
+ const file = join(entry.parentPath, entry.name)
136
+ return { file, at: statOf(file).mtime }
137
+ })
138
+ .toSorted((a, b) => b.at - a.at)
139
+ .map((entry) => entry.file)
140
+ } catch {
141
+ return []
142
+ }
143
+ }
144
+
145
+ function resultBody(content: string | ResultBlock[]): string {
146
+ if (isString(content)) return content
147
+ return content.map((block) => block.text ?? '').join('\n')
148
+ }
149
+
150
+ function skillNamesIn(command: string): string[] {
151
+ const out: string[] = []
152
+ for (const match of command.matchAll(/([\w.-]+)\/SKILL\.md/g)) {
153
+ const name = match[1]
154
+ if (name !== undefined) out.push(name)
155
+ }
156
+ return out
157
+ }
158
+
159
+ export function readCorpusSession(file: string): CorpusSession {
160
+ const session: CorpusSession = {
161
+ file,
162
+ prompts: [],
163
+ errors: [],
164
+ commands: [],
165
+ reads: [],
166
+ skills: [],
167
+ edits: [],
168
+ tools: {}
169
+ }
170
+ const commands = new Map<string, string>()
171
+ let text: string
172
+ try {
173
+ if (statSync(file).size > MAX_SESSION_BYTES) return session
174
+ text = readFileSync(file, 'utf8')
175
+ } catch {
176
+ return session
177
+ }
178
+ for (const line of text.split('\n')) {
179
+ if (line.trim() === '') continue
180
+ let parsed: unknown
181
+ try {
182
+ parsed = JSON.parse(line)
183
+ } catch {
184
+ continue
185
+ }
186
+ if (isUserMessage(parsed)) {
187
+ const body = resultBody(parsed.message.content)
188
+ for (const match of body.matchAll(/\/skill:([\w.-]+)/g)) {
189
+ const name = match[1]
190
+ if (name !== undefined) session.skills.push(name)
191
+ }
192
+ if (body.trim() !== '' && !body.trimStart().startsWith('<')) session.prompts.push(body)
193
+ continue
194
+ }
195
+ if (isAssistantMessage(parsed)) {
196
+ for (const block of parsed.message.content) {
197
+ if (block.type !== 'toolCall') continue
198
+ if (block.name !== undefined) {
199
+ session.tools ??= {}
200
+ session.tools[block.name] = (session.tools[block.name] ?? 0) + 1
201
+ }
202
+ if (block.name === 'bash' && isCommandArguments(block.arguments)) {
203
+ const command = block.arguments.command
204
+ session.commands.push(command)
205
+ if (block.id !== undefined) commands.set(block.id, command)
206
+ for (const name of skillNamesIn(command)) session.skills.push(name)
207
+ }
208
+ if (block.name === 'read' && isPathArguments(block.arguments)) {
209
+ const path = block.arguments.path
210
+ session.reads.push(path)
211
+ if (basename(path) === 'SKILL.md') session.skills.push(basename(dirname(path)))
212
+ }
213
+ if ((block.name === 'edit' || block.name === 'write') && isPathArguments(block.arguments)) {
214
+ session.edits.push(basename(block.arguments.path))
215
+ }
216
+ }
217
+ continue
218
+ }
219
+ if (!isToolMessage(parsed)) continue
220
+ if (!parsed.message.isError) continue
221
+ const body = resultBody(parsed.message.content)
222
+ const signature = failureSignature(body)
223
+ if (signature === '') continue
224
+ const command = parsed.message.toolCallId === undefined ? undefined : commands.get(parsed.message.toolCallId)
225
+ const error = { signature, tool: parsed.message.toolName, body: body.slice(0, 400), command }
226
+ session.errors.push(error)
227
+ }
228
+ session.edits = [...new Set(session.edits)]
229
+ return session
230
+ }
231
+
232
+ export function idFor(key: string): string {
233
+ return createHash('sha1').update(key).digest('hex').slice(0, 8)
234
+ }
235
+
236
+ function tally(
237
+ sessions: CorpusSession[],
238
+ pick: (session: CorpusSession) => string[]
239
+ ): Map<string, { files: Set<string>; count: number }> {
240
+ const out = new Map<string, { files: Set<string>; count: number }>()
241
+ for (const session of sessions) {
242
+ for (const value of pick(session)) {
243
+ if (value === '') continue
244
+ const found = out.get(value) ?? { files: new Set<string>(), count: 0 }
245
+ found.files.add(session.file)
246
+ found.count += 1
247
+ out.set(value, found)
248
+ }
249
+ }
250
+ return out
251
+ }
252
+
253
+ function collect(
254
+ kind: Recurrence['kind'],
255
+ sessions: CorpusSession[],
256
+ pick: (session: CorpusSession) => string[],
257
+ label: (key: string, files: number) => string,
258
+ minOccurrences: number
259
+ ): Recurrence[] {
260
+ const out: Recurrence[] = []
261
+ for (const [key, found] of tally(sessions, pick)) {
262
+ if (found.files.size < MIN_SESSIONS || found.count < minOccurrences) continue
263
+ out.push({
264
+ id: idFor(`${kind}:${key}`),
265
+ key,
266
+ kind,
267
+ title: label(key, found.files.size),
268
+ sessions: found.files.size,
269
+ occurrences: found.count,
270
+ tool: '',
271
+ sample: '',
272
+ evidence: [...found.files].map((file) => basename(file)).slice(0, 4)
273
+ })
274
+ }
275
+ return out
276
+ }
277
+
278
+ function collectErrors(sessions: CorpusSession[]): Recurrence[] {
279
+ const byKey = new Map<
280
+ string,
281
+ {
282
+ files: Set<string>
283
+ edits: Map<string, Set<string>>
284
+ count: number
285
+ tool: string
286
+ sample: string
287
+ command?: string
288
+ }
289
+ >()
290
+ for (const session of sessions) {
291
+ for (const error of session.errors) {
292
+ if (!usableSignature(error.signature)) continue
293
+ const found = byKey.get(error.signature) ?? {
294
+ files: new Set<string>(),
295
+ edits: new Map<string, Set<string>>(),
296
+ count: 0,
297
+ tool: error.tool,
298
+ sample: error.body,
299
+ command: error.command
300
+ }
301
+ if (found.command === undefined && error.command !== undefined) found.command = error.command
302
+ found.files.add(session.file)
303
+ found.edits.set(session.file, new Set(session.edits))
304
+ found.count += 1
305
+ byKey.set(error.signature, found)
306
+ }
307
+ }
308
+ const out: Recurrence[] = []
309
+ for (const [key, found] of byKey) {
310
+ if (found.files.size < MIN_SESSIONS || found.count < MIN_FAILURE_OCCURRENCES) continue
311
+ out.push({
312
+ id: idFor(`error:${key}`),
313
+ key,
314
+ kind: 'error',
315
+ title: `the same failure shows up in ${found.files.size} sessions: ${key}`,
316
+ sessions: found.files.size,
317
+ occurrences: found.count,
318
+ tool: found.tool,
319
+ sample: found.sample,
320
+ command: found.command,
321
+ blocked: onePieceOfWork(found.edits) ? 'the sessions that hit it edited the same files' : undefined,
322
+ evidence: [...found.files].map((file) => basename(file)).slice(0, 4)
323
+ })
324
+ }
325
+ return out
326
+ }
327
+
328
+ /**
329
+ * Are the sessions that hit this failure one piece of work? They are when every one of them
330
+ * edited a file and every pair shares one. A session that edited nothing met the failure
331
+ * outside the edit loop, so its evidence is independent.
332
+ */
333
+ function onePieceOfWork(work: Map<string, Set<string>>): boolean {
334
+ const sets = [...work.values()]
335
+ if (sets.some((edits) => edits.size === 0)) return false
336
+ for (let i = 0; i < sets.length; i += 1) {
337
+ for (let j = i + 1; j < sets.length; j += 1) {
338
+ const left = sets[i]
339
+ const right = sets[j]
340
+ if (left === undefined || right === undefined) continue
341
+ let shared = false
342
+ for (const file of left) {
343
+ if (right.has(file)) {
344
+ shared = true
345
+ break
346
+ }
347
+ }
348
+ if (!shared) return false
349
+ }
350
+ }
351
+ return true
352
+ }
353
+
354
+ export function findRecurrences(sessionDir: string): Recurrence[] {
355
+ return scanCorpus(sessionDir, []).recurrences
356
+ }
357
+
358
+ export interface CorpusScan {
359
+ recurrences: Recurrence[]
360
+ skills: SkillUse[]
361
+ sessions: number
362
+ available: number
363
+ prompts: PromptWords
364
+ }
365
+
366
+ export interface PromptWords {
367
+ count: number
368
+ words: Map<string, number>
369
+ }
370
+
371
+ /**
372
+ * One pass over the session files, because the corpus is the biggest thing the
373
+ * loop reads and reading it twice for two answers is the same work twice.
374
+ */
375
+ export function scanCorpus(sessionDir: string, names: string[], cachePath?: string, skipSuffix?: string): CorpusScan {
376
+ const cache = cachePath === undefined ? new Map<string, CorpusCacheEntry>() : readCorpusCache(cachePath)
377
+ const files = sessionFiles(sessionDir)
378
+ const kept = new Map<string, CorpusCacheEntry>()
379
+ let parsedBytes = 0
380
+ let parsed = 0
381
+ let changed = false
382
+ for (const file of files) {
383
+ if (kept.size >= MAX_CACHED_SESSIONS) break
384
+ if (skipSuffix !== undefined && file.endsWith(skipSuffix)) continue
385
+ const stat = statOf(file)
386
+ const hit = cache.get(file)
387
+ if (
388
+ hit !== undefined &&
389
+ hit.mtime === stat.mtime &&
390
+ hit.size === stat.size &&
391
+ hit.session.edits !== undefined
392
+ ) {
393
+ kept.set(file, hit)
394
+ continue
395
+ }
396
+ if (parsed > 0 && parsedBytes + stat.size > MAX_NEW_SESSION_BYTES) {
397
+ if (hit !== undefined && hit.session.edits !== undefined) kept.set(file, hit)
398
+ continue
399
+ }
400
+ if (stat.size > MAX_SESSION_BYTES) {
401
+ if (hit !== undefined && hit.session.edits !== undefined) kept.set(file, hit)
402
+ continue
403
+ }
404
+ const session = readCorpusSession(file)
405
+ kept.set(file, { path: file, mtime: stat.mtime, size: stat.size, session })
406
+ parsedBytes += stat.size
407
+ parsed += 1
408
+ changed = true
409
+ }
410
+ if (cache.size !== kept.size) changed = true
411
+ if (cachePath !== undefined && changed) writeCorpusCache(cachePath, [...kept.values()])
412
+ const sessions: CorpusSession[] = [...kept.values()].map(({ session }) => ({
413
+ ...session,
414
+ edits: session.edits ?? []
415
+ }))
416
+ const available = files.length
417
+ if (sessions.length < MIN_SESSIONS) {
418
+ return {
419
+ recurrences: [],
420
+ skills: [],
421
+ sessions: sessions.length,
422
+ available,
423
+ prompts: { count: 0, words: new Map() }
424
+ }
425
+ }
426
+ return {
427
+ recurrences: recurrencesFrom(sessions),
428
+ skills: skillUseFrom(sessions, names),
429
+ sessions: sessions.length,
430
+ available,
431
+ prompts: promptWords(sessions)
432
+ }
433
+ }
434
+
435
+ interface CorpusCacheEntry {
436
+ path: string
437
+ mtime: number
438
+ size: number
439
+ /** Older caches have no `edits`, which reads as unknown and forces a reparse of that file. */
440
+ session: Infer<typeof CorpusSessionSchema>
441
+ }
442
+
443
+ function readCorpusCache(path: string): Map<string, CorpusCacheEntry> {
444
+ const cache = new Map<string, CorpusCacheEntry>()
445
+ if (!existsSync(path)) return cache
446
+ let parsed: unknown
447
+ try {
448
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
449
+ } catch {
450
+ return cache
451
+ }
452
+ if (!isCorpusCacheSchema(parsed)) return cache
453
+ for (const entry of parsed.files) cache.set(entry.path, entry)
454
+ return cache
455
+ }
456
+
457
+ function writeCorpusCache(path: string, files: CorpusCacheEntry[]): void {
458
+ try {
459
+ mkdirSync(dirname(path), { recursive: true })
460
+ writeFileAtomic(path, `${JSON.stringify({ files })}\n`)
461
+ } catch {
462
+ return
463
+ }
464
+ }
465
+
466
+ export function corpusCachePath(cwd: string): string {
467
+ return join(cwd, '.pi', 'harness', 'corpus-cache.json')
468
+ }
469
+
470
+ export interface SignatureUse {
471
+ /** When the session started, from the name of the session file. */
472
+ at: number
473
+ count: number
474
+ }
475
+
476
+ let historyCache: {
477
+ path: string
478
+ mtime: number
479
+ size: number
480
+ value: History
481
+ } | null = null
482
+
483
+ export interface History {
484
+ /** Failures per session, for each signature. */
485
+ signatures: Map<string, SignatureUse[]>
486
+ /** Calls per session, for each tool name. */
487
+ tools: Map<string, SignatureUse[]>
488
+ /** Calls of every tool per session, which is how much work the session did. */
489
+ calls: SignatureUse[]
490
+ }
491
+
492
+ interface SessionHistory {
493
+ at: number
494
+ counts: Map<string, number>
495
+ tools: Record<string, number>
496
+ }
497
+
498
+ /**
499
+ * The session file is named after the run's start, and its mtime is when the file
500
+ * was last copied, so the name is the date the failures happened on.
501
+ */
502
+ export function sessionStartedAt(file: string, fallback: number): number {
503
+ const match = /(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/.exec(basename(file))
504
+ if (match === null) return fallback
505
+ const [year, month, day, hour, minute, second] = match.slice(1).map(Number)
506
+ const at = Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 1, hour ?? 0, minute ?? 0, second ?? 0)
507
+ return Number.isNaN(at) || at === 0 ? fallback : at
508
+ }
509
+
510
+ /**
511
+ * What each failure signature did per session, read from the parsed corpus
512
+ * instead of the transcripts. It is what an install can be measured against:
513
+ * the sessions before it and the sessions after it, including the ones that
514
+ * never hit the failure, because a session that did not fail is the evidence.
515
+ */
516
+ export function signatureHistory(cachePath: string): Map<string, SignatureUse[]> {
517
+ return historyOf(cachePath).signatures
518
+ }
519
+
520
+ /**
521
+ * A part can lower the rate of a failure without preventing it, by making the agent do less
522
+ * work. The calls of every tool per session is what tells the two apart, so it is read from
523
+ * the same parse as the signatures.
524
+ */
525
+ export function callHistory(cachePath: string): SignatureUse[] {
526
+ return historyOf(cachePath).calls
527
+ }
528
+
529
+ export function historyOf(cachePath: string): History {
530
+ const stat = statOf(cachePath)
531
+ if (
532
+ historyCache !== null &&
533
+ historyCache.path === cachePath &&
534
+ historyCache.mtime === stat.mtime &&
535
+ historyCache.size === stat.size
536
+ ) {
537
+ return historyCache.value
538
+ }
539
+ const sessions: SessionHistory[] = []
540
+ for (const entry of readCorpusCache(cachePath).values()) {
541
+ const counts = new Map<string, number>()
542
+ for (const error of entry.session.errors) {
543
+ if (!usableSignature(error.signature)) continue
544
+ counts.set(error.signature, (counts.get(error.signature) ?? 0) + 1)
545
+ }
546
+ sessions.push({
547
+ at: sessionStartedAt(entry.path, entry.mtime),
548
+ counts,
549
+ tools: entry.session.tools ?? {}
550
+ })
551
+ }
552
+ sessions.sort((a, b) => a.at - b.at)
553
+ const signatures = new Set<string>()
554
+ const tools = new Set<string>()
555
+ for (const session of sessions) {
556
+ for (const signature of session.counts.keys()) signatures.add(signature)
557
+ for (const tool of Object.keys(session.tools)) tools.add(tool)
558
+ }
559
+ const series = (keys: Set<string>, count: (session: SessionHistory, key: string) => number) => {
560
+ const out = new Map<string, SignatureUse[]>()
561
+ for (const key of keys) {
562
+ out.set(
563
+ key,
564
+ sessions.map((session) => ({ at: session.at, count: count(session, key) }))
565
+ )
566
+ }
567
+ return out
568
+ }
569
+ const value: History = {
570
+ signatures: series(signatures, (session, key) => session.counts.get(key) ?? 0),
571
+ tools: series(tools, (session, key) => session.tools[key] ?? 0),
572
+ calls: sessions.map((session) => ({
573
+ at: session.at,
574
+ count: Object.values(session.tools).reduce((total, count) => total + count, 0)
575
+ }))
576
+ }
577
+ historyCache = { path: cachePath, mtime: stat.mtime, size: stat.size, value }
578
+ return value
579
+ }
580
+
581
+ function statOf(file: string) {
582
+ try {
583
+ const stat = statSync(file)
584
+ return { mtime: stat.mtimeMs, size: stat.size }
585
+ } catch {
586
+ return { mtime: 0, size: 0 }
587
+ }
588
+ }
589
+
590
+ function recurrencesFrom(sessions: CorpusSession[]): Recurrence[] {
591
+ const errors = collectErrors(sessions)
592
+ const commands = collect(
593
+ 'command',
594
+ sessions,
595
+ (session) => session.commands,
596
+ (key, files) => `the same command runs in ${files} sessions: ${key.slice(0, 100)}`,
597
+ MIN_HABIT_OCCURRENCES
598
+ )
599
+ const reads = collect(
600
+ 'read',
601
+ sessions,
602
+ (session) => session.reads,
603
+ (key, files) => `the same file is read in ${files} sessions: ${key}`,
604
+ MIN_HABIT_OCCURRENCES
605
+ )
606
+ return [...errors, ...commands, ...reads].toSorted(
607
+ (a, b) => b.sessions - a.sessions || b.occurrences - a.occurrences
608
+ )
609
+ }
610
+
611
+ export function findSkillUse(sessionDir: string, names: string[]): SkillUse[] {
612
+ return scanCorpus(sessionDir, names).skills
613
+ }
614
+
615
+ function promptWords(sessions: CorpusSession[]): PromptWords {
616
+ const words = new Map<string, number>()
617
+ let count = 0
618
+ for (const session of sessions) {
619
+ for (const prompt of session.prompts) {
620
+ count += 1
621
+ for (const word of new Set(prompt.toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g) ?? [])) {
622
+ words.set(word, (words.get(word) ?? 0) + 1)
623
+ }
624
+ }
625
+ }
626
+ return { count, words }
627
+ }
628
+
629
+ function skillUseFrom(sessions: CorpusSession[], names: string[]): SkillUse[] {
630
+ const wanted = new Set(names)
631
+ const out = new Map<string, SkillUse>()
632
+ for (const name of wanted) out.set(name, { id: `skill-${name}`, name, sessions: sessions.length, opens: 0 })
633
+ for (const session of sessions) {
634
+ for (const name of new Set(session.skills)) {
635
+ const found = out.get(name)
636
+ if (found === undefined) continue
637
+ found.opens += 1
638
+ }
639
+ }
640
+ return [...out.values()].toSorted((a, b) => a.opens - b.opens)
641
+ }
@@ -0,0 +1,73 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ export interface ProposalText {
5
+ id: string
6
+ text: string
7
+ }
8
+
9
+ export function proposalTexts(harnessDir: string): ProposalText[] {
10
+ if (!existsSync(harnessDir)) return []
11
+ const out: ProposalText[] = []
12
+ for (const name of readdirSync(harnessDir).toSorted()) {
13
+ const dir = join(harnessDir, name)
14
+ let entries: string[]
15
+ try {
16
+ entries = readdirSync(dir)
17
+ } catch {
18
+ continue
19
+ }
20
+ const text: string[] = []
21
+ for (const entry of entries.toSorted()) {
22
+ const path = join(dir, entry)
23
+ if (entry === 'BRIEF.md') continue
24
+ try {
25
+ text.push(readFileSync(path, 'utf8'))
26
+ } catch {
27
+ continue
28
+ }
29
+ }
30
+ if (text.length > 0) out.push({ id: name, text: text.join('\n') })
31
+ }
32
+ return out
33
+ }
34
+
35
+ function lines(text: string): Set<string> {
36
+ return new Set(
37
+ text
38
+ .split('\n')
39
+ .map((line) => line.trim())
40
+ .filter((line) => line !== '')
41
+ )
42
+ }
43
+
44
+ export function jaccard(left: string, right: string): number {
45
+ const a = lines(left)
46
+ const b = lines(right)
47
+ if (a.size === 0 || b.size === 0) return 0
48
+ let shared = 0
49
+ for (const line of a) if (b.has(line)) shared += 1
50
+ return shared / (a.size + b.size - shared)
51
+ }
52
+
53
+ export interface Collapse {
54
+ pair: [string, string]
55
+ similarity: number
56
+ }
57
+
58
+ /**
59
+ * The loop writing the same part twice is the failure of a self-improving loop
60
+ * that nobody watches, so the pair that says it is named.
61
+ */
62
+ export function findCollapse(proposals: ProposalText[], threshold = 0.8): Collapse | null {
63
+ let worst: Collapse | null = null
64
+ for (let left = 0; left < proposals.length; left += 1) {
65
+ for (let right = left + 1; right < proposals.length; right += 1) {
66
+ const similarity = jaccard(proposals[left]?.text ?? '', proposals[right]?.text ?? '')
67
+ if (similarity < threshold) continue
68
+ if (worst !== null && worst.similarity >= similarity) continue
69
+ worst = { pair: [proposals[left]?.id ?? '', proposals[right]?.id ?? ''], similarity }
70
+ }
71
+ }
72
+ return worst
73
+ }