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

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/effect.ts ADDED
@@ -0,0 +1,134 @@
1
+ import type { SignatureUse } from './corpus.js'
2
+ import type { Install } from './types.js'
3
+
4
+ export interface Window {
5
+ sessions: number
6
+ occurrences: number
7
+ perSession: number
8
+ /** Median calls of every tool per session. Absent when the cache never counted tool calls. */
9
+ callsPerSession?: number
10
+ }
11
+
12
+ export type Verdict = 'held' | 'flat' | 'worse' | 'early' | 'out-of-window' | 'confounded' | 'avoided'
13
+
14
+ export interface Effect {
15
+ install: string
16
+ finding: string
17
+ key: string
18
+ before: Window
19
+ after: Window
20
+ verdict: Verdict
21
+ commit?: string
22
+ }
23
+
24
+ export type Confound = (key: string, since: number) => string | null
25
+
26
+ export const HELD_RATIO = 0.75
27
+ export const WORSE_RATIO = 1.25
28
+ export const MIN_SESSIONS_AFTER = 3
29
+ /** The session has to do at least this much of its old work for the failure's fall to be the part's. */
30
+ export const USED_RATIO = 0.5
31
+
32
+ /** A session can do no work at all, so one session does not move the middle of the window. */
33
+ function median(counts: number[]): number {
34
+ const sorted = counts.toSorted((a, b) => a - b)
35
+ const middle = Math.floor(sorted.length / 2)
36
+ if (sorted.length % 2 === 1) return sorted[middle] ?? 0
37
+ return ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
38
+ }
39
+
40
+ function windowOf(uses: SignatureUse[], at: number, side: 'before' | 'after', calls?: SignatureUse[]): Window {
41
+ const picked = uses.filter((use) => (side === 'before' ? use.at < at : use.at >= at))
42
+ const occurrences = picked.reduce((total, use) => total + use.count, 0)
43
+ const callPicked = calls?.filter((use) => (side === 'before' ? use.at < at : use.at >= at))
44
+ const window: Window = {
45
+ sessions: picked.length,
46
+ occurrences,
47
+ perSession: picked.length === 0 ? 0 : occurrences / picked.length
48
+ }
49
+ if (callPicked !== undefined && callPicked.length > 0) {
50
+ window.callsPerSession = median(callPicked.map((use) => use.count))
51
+ }
52
+ return window
53
+ }
54
+
55
+ /**
56
+ * A failure that stopped because the agent stopped working was not prevented. The rate of the
57
+ * failure fell, and the work the session did fell with it, so the part is not what carried.
58
+ */
59
+ function avoided(before: Window, after: Window): boolean {
60
+ const was = before.callsPerSession
61
+ const now = after.callsPerSession
62
+ if (was === undefined || now === undefined || was === 0) return false
63
+ return now < was * USED_RATIO
64
+ }
65
+
66
+ /**
67
+ * A part is a claim about the future. The sessions after it are the only evidence
68
+ * the loop has, so the claim is read against them: fewer failures a session is a
69
+ * part that held, and the same rate or worse is a part that did not.
70
+ */
71
+ export function verdictOf(before: Window, after: Window): Verdict {
72
+ if (before.sessions === 0) return 'out-of-window'
73
+ if (after.sessions < MIN_SESSIONS_AFTER) return 'early'
74
+ if (before.perSession === 0) return after.occurrences === 0 ? 'held' : 'worse'
75
+ const ratio = after.perSession / before.perSession
76
+ if (ratio > HELD_RATIO) return ratio >= WORSE_RATIO ? 'worse' : 'flat'
77
+ return avoided(before, after) ? 'avoided' : 'held'
78
+ }
79
+
80
+ export function effectFor(
81
+ installs: Install[],
82
+ history: Map<string, SignatureUse[]>,
83
+ finding: string,
84
+ key: string,
85
+ confound?: Confound,
86
+ calls?: SignatureUse[]
87
+ ): Effect | null {
88
+ const install = installs
89
+ .filter((entry) => entry.ok && !entry.reverted && entry.finding === finding)
90
+ .toSorted((a, b) => b.at - a.at)[0]
91
+ if (install === undefined) return null
92
+ const uses = history.get(install.key ?? key)
93
+ if (uses === undefined) return null
94
+ const before = windowOf(uses, install.at, 'before', calls)
95
+ const after = windowOf(uses, install.at, 'after', calls)
96
+ const verdict = verdictOf(before, after)
97
+ const commit =
98
+ verdict === 'early' || verdict === 'out-of-window' ? null : confound?.(install.key ?? key, install.at)
99
+ return {
100
+ install: install.id,
101
+ finding,
102
+ key: install.key ?? key,
103
+ before,
104
+ after,
105
+ verdict: commit === null || commit === undefined ? verdict : 'confounded',
106
+ commit: commit ?? undefined
107
+ }
108
+ }
109
+
110
+ /**
111
+ * One effect per finding, from the newest install. `keys` carries the signature the
112
+ * finding is about, for the installs written before the key was recorded.
113
+ */
114
+ export function effectsOf(
115
+ installs: Install[],
116
+ history: Map<string, SignatureUse[]>,
117
+ keys: Map<string, string>,
118
+ confound?: Confound,
119
+ calls?: SignatureUse[]
120
+ ): Effect[] {
121
+ const findings = new Set(installs.filter((entry) => entry.ok && !entry.reverted).map((entry) => entry.finding))
122
+ const out: Effect[] = []
123
+ for (const finding of findings) {
124
+ const key = keys.get(finding)
125
+ if (key === undefined) continue
126
+ const effect = effectFor(installs, history, finding, key, confound, calls)
127
+ if (effect !== null) out.push(effect)
128
+ }
129
+ return out.toSorted((a, b) => a.verdict.localeCompare(b.verdict) || a.finding.localeCompare(b.finding))
130
+ }
131
+
132
+ export function rate(window: Window): string {
133
+ return `${window.perSession.toFixed(1)}/session`
134
+ }
@@ -0,0 +1,81 @@
1
+ import { corpusCachePath, signatureHistory, callHistory } from './corpus.js'
2
+ import { effectsOf, rate, type Effect } from './effect.js'
3
+ import { commitInWindow } from './producer.js'
4
+ import { readScan } from './auto.js'
5
+ import { revertApplied } from './apply.js'
6
+ import { installRoots } from './install.js'
7
+ import { appendInstall, failLesson, readInstalls, readLessons } from './store.js'
8
+ import { appendSpend } from './ledger.js'
9
+ import type { InstallContext } from './apply.js'
10
+ import type { Install } from './types.js'
11
+
12
+ export function failLessonsFor(cwd: string, effect: Effect, installs: Install[]): void {
13
+ const linked = installs
14
+ .filter((install) => install.finding === effect.finding)
15
+ .map((install) => install.lesson)
16
+ .filter((id): id is string => id !== undefined)
17
+ const prefixed = readLessons(cwd)
18
+ .filter((lesson) => lesson.id.startsWith(effect.finding))
19
+ .map((lesson) => lesson.id)
20
+ for (const id of new Set([...linked, ...prefixed])) failLesson(cwd, id)
21
+ }
22
+
23
+ export function evaluateInstalls(cwd: string): Effect[] {
24
+ const scan = readScan(cwd)
25
+ const keys = new Map(scan?.recurrences.map((recurrence) => [recurrence.id, recurrence.key]) ?? [])
26
+ const installs = readInstalls(cwd)
27
+ const effects = effectsOf(
28
+ installs,
29
+ signatureHistory(corpusCachePath(cwd)),
30
+ keys,
31
+ (key, since) => commitInWindow(cwd, key, since),
32
+ callHistory(corpusCachePath(cwd))
33
+ )
34
+ for (const effect of effects) {
35
+ if (effect.verdict === 'flat' || effect.verdict === 'worse' || effect.verdict === 'avoided') {
36
+ failLessonsFor(cwd, effect, installs)
37
+ }
38
+ }
39
+ return effects
40
+ }
41
+
42
+ /**
43
+ * A part that did not lower the rate it was written for is taken back out. The install is
44
+ * always reversible because it records the file it replaced, so a rule nobody can show a use
45
+ * for does not stay in the always-on context.
46
+ */
47
+ export function autoRevert(cwd: string, context: InstallContext, effects: Effect[]): string[] {
48
+ const installs = readInstalls(cwd)
49
+ const lines: string[] = []
50
+ for (const effect of effects) {
51
+ if (effect.verdict !== 'flat' && effect.verdict !== 'worse' && effect.verdict !== 'avoided') continue
52
+ const install = installs.find((entry) => entry.id === effect.install)
53
+ if (install === undefined || install.reverted) continue
54
+ const result = revertApplied(install.files, installRoots(context))
55
+ const kept = result.refused.length > 0
56
+ appendInstall(cwd, {
57
+ ...install,
58
+ at: Date.now(),
59
+ ok: false,
60
+ reverted: true,
61
+ note: kept
62
+ ? `the file changed since the install: ${result.refused[0]}`
63
+ : `auto-reverted, the part was ${effect.verdict} at ${rate(effect.after)}`
64
+ })
65
+ appendSpend(cwd, {
66
+ at: Date.now(),
67
+ target: effect.finding,
68
+ turns: 0,
69
+ input: 0,
70
+ output: 0,
71
+ cost: 0,
72
+ outcome: 'auto-reverted'
73
+ })
74
+ lines.push(
75
+ kept
76
+ ? `harness: kept ${effect.finding}, the file changed since the install`
77
+ : `harness: reverted ${effect.finding}, the part was ${effect.verdict} at ${rate(effect.after)}`
78
+ )
79
+ }
80
+ return lines
81
+ }
@@ -0,0 +1,141 @@
1
+ import type { Transcript } from './transcript.js'
2
+ import { idFor } from './corpus.js'
3
+
4
+ export type FindingKindName = 'error' | 'command' | 'read' | 'no-change' | 'unanswered'
5
+
6
+ export interface Finding {
7
+ id: string
8
+ kind: FindingKindName
9
+ title: string
10
+ count: number
11
+ evidence: string[]
12
+ }
13
+
14
+ const REPEAT_THRESHOLD = 3
15
+ const MIN_SIGNATURE_TOKENS = 1
16
+ const GENERIC_SIGNATURE =
17
+ /^(script failed|command failed|error|failed|ok|done|total n|command aborted|command timed out after n seconds|traceback \(most recent call last\))[:!.]?$/i
18
+ const TRACEBACK = /^Traceback \(most recent call last\)/
19
+ const NOT_AN_ERROR = /not an error/i
20
+ const EXIT_LINE = /\n*command exited with code \d+\s*$/i
21
+
22
+ export function usableSignature(value: string): boolean {
23
+ if (GENERIC_SIGNATURE.test(value.trim())) return false
24
+ const tokens = value
25
+ .split(/\s+/)
26
+ .filter(
27
+ (token) => token !== '' && token !== '<path>' && !/^['"]?<path>['"]?$/.test(token) && !/^N+$/.test(token)
28
+ )
29
+ if (tokens.length < MIN_SIGNATURE_TOKENS) return false
30
+ return tokens.some((token) => /[a-zA-Z]{4,}/.test(token))
31
+ }
32
+
33
+ export function signature(body: string): string {
34
+ const lines = body.split('\n').filter((line) => line.trim() !== '')
35
+ const head = lines[0] ?? ''
36
+ const pick = TRACEBACK.test(head) ? (lines[lines.length - 1] ?? head) : head
37
+ return pick
38
+ .replace(/[^\s'"]*\/[^\s'"]*/g, '<path>')
39
+ .replace(/\d+/g, 'N')
40
+ .replace(/\s+/g, ' ')
41
+ .trim()
42
+ .slice(0, 90)
43
+ }
44
+
45
+ export function failureSignature(body: string): string {
46
+ if (NOT_AN_ERROR.test(body)) return ''
47
+ const output = body.replace(EXIT_LINE, '').trim()
48
+ if (output === '' || output === '(no output)') return ''
49
+ const key = signature(output)
50
+ return usableSignature(key) ? key : ''
51
+ }
52
+
53
+ function topRepeat(values: string[]): { key: string; count: number } | null {
54
+ const counts = new Map<string, number>()
55
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1)
56
+ let best: { key: string; count: number } | null = null
57
+ for (const [key, count] of counts) {
58
+ if (!best || count > best.count) best = { key, count }
59
+ }
60
+ return best
61
+ }
62
+
63
+ export function findFindings(transcript: Transcript): Finding[] {
64
+ const findings: Finding[] = []
65
+
66
+ const errors = transcript.results.filter((result) => result.isError)
67
+ const repeatedError = topRepeat(errors.map((result) => failureSignature(result.body)).filter((body) => body !== ''))
68
+ if (repeatedError && repeatedError.count >= REPEAT_THRESHOLD) {
69
+ findings.push({
70
+ id: idFor(`error:${repeatedError.key}`),
71
+ kind: 'error',
72
+ title: `the same failure came back ${repeatedError.count} times: ${repeatedError.key}`,
73
+ count: repeatedError.count,
74
+ evidence: errors
75
+ .filter((result) => failureSignature(result.body) === repeatedError.key)
76
+ .slice(0, 4)
77
+ .map((result) => `${result.toolName}: ${result.body.split('\n')[0]?.slice(0, 120) ?? ''}`)
78
+ })
79
+ }
80
+
81
+ const repeatedCommand = topRepeat(
82
+ transcript.calls
83
+ .filter((call) => call.name === 'bash')
84
+ .map((call) => call.command)
85
+ .filter((command) => command !== '')
86
+ )
87
+ if (repeatedCommand && repeatedCommand.count >= REPEAT_THRESHOLD) {
88
+ findings.push({
89
+ id: idFor(`command:${repeatedCommand.key}`),
90
+ kind: 'command',
91
+ title: `the same command ran ${repeatedCommand.count} times: ${repeatedCommand.key.slice(0, 120)}`,
92
+ count: repeatedCommand.count,
93
+ evidence: repeatedCommand.key
94
+ .split('\n')
95
+ .slice(0, 3)
96
+ .map((line) => `bash: ${line}`)
97
+ })
98
+ }
99
+
100
+ const repeatedRead = topRepeat(
101
+ transcript.calls
102
+ .filter((call) => call.name === 'read')
103
+ .map((call) => call.path)
104
+ .filter((path) => path !== '')
105
+ )
106
+ if (repeatedRead && repeatedRead.count >= REPEAT_THRESHOLD) {
107
+ findings.push({
108
+ id: idFor(`read:${repeatedRead.key}`),
109
+ kind: 'read',
110
+ title: `the same file was read ${repeatedRead.count} times: ${repeatedRead.key}`,
111
+ count: repeatedRead.count,
112
+ evidence: [`read: ${repeatedRead.key}`]
113
+ })
114
+ }
115
+
116
+ const changed = transcript.calls.some((call) => call.name === 'edit' || call.name === 'write')
117
+ const failed = transcript.results.some((result) => result.isError)
118
+ if (!changed && failed && transcript.calls.length >= 8) {
119
+ findings.push({
120
+ id: idFor('no-change'),
121
+ kind: 'no-change',
122
+ title: `the run made ${transcript.calls.length} tool calls, hit a failure and changed nothing`,
123
+ count: transcript.calls.length,
124
+ evidence: transcript.calls.slice(-4).map((call) => `${call.name} ${call.command || call.path}`)
125
+ })
126
+ }
127
+
128
+ const answered = new Set(transcript.results.map((result) => result.toolCallId))
129
+ const unanswered = transcript.calls.filter((call) => !answered.has(call.id))
130
+ if (unanswered.length > 0) {
131
+ findings.push({
132
+ id: idFor('unanswered'),
133
+ kind: 'unanswered',
134
+ title: `${unanswered.length} tool calls have no result in the transcript`,
135
+ count: unanswered.length,
136
+ evidence: unanswered.slice(0, 3).map((call) => `${call.name} ${call.command || call.path}`)
137
+ })
138
+ }
139
+
140
+ return findings
141
+ }