backpass 0.1.11 → 0.1.13

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/sample.js CHANGED
@@ -1,5 +1,8 @@
1
+ import crypto from "node:crypto";
2
+
1
3
  import { parseMaxTranscripts, parseSince } from "./config.js";
2
4
  import { color, info } from "./logger.js";
5
+ import { transcriptIdentity } from "./transcript.js";
3
6
 
4
7
  /**
5
8
  * Recency-weighted capping of the discovered transcript set.
@@ -14,7 +17,22 @@ import { color, info } from "./logger.js";
14
17
  * Sampling uses the Efraimidis-Spirakis one-pass scheme: key = -ln(u) / weight with
15
18
  * u ~ U(0, 1), keep the N smallest keys. That is exactly weighted sampling without
16
19
  * replacement (an exponential race with rate = weight), needs no rejection loop, and is
17
- * O(n log n). The RNG is a seedable splitmix32 so runs are reproducible with `--seed`.
20
+ * O(n log n).
21
+ *
22
+ * `u` is NOT drawn from a shared PRNG stream stepped once per transcript - that would
23
+ * make every transcript's draw depend on how many other transcripts came before it in
24
+ * the array, so an unrelated insertion or a reordering would reshuffle everyone's draw
25
+ * (and, with `config.seed` defaulting to null, the CLI reseeded from `Math.random()` on
26
+ * every invocation, so even an unchanged rerun drew a different sample - see the
27
+ * `sample-reuse` regression tests). Instead each transcript's `u` is `sampleUnit`, a hash
28
+ * of that transcript's canonical discovery identity, never its array position, plus the
29
+ * configured seed. That makes sampling: (1)
30
+ * deterministic and sticky by default, with no persisted state - the same corpus and
31
+ * config always draw the same `u` per transcript; (2) stable under growth - a transcript
32
+ * already in the corpus keeps the exact same `u` (and so the same key, modulo its own
33
+ * weight) when other transcripts are added, removed, or reordered; new transcripts just
34
+ * compete for slots on the same footing. `--seed` still selects a different, equally
35
+ * reproducible hash input.
18
36
  *
19
37
  * This module is pure: it never touches the cache, so evidence for a sampled transcript
20
38
  * is reused by the analyzer exactly as before.
@@ -22,18 +40,19 @@ import { color, info } from "./logger.js";
22
40
 
23
41
  export const DEFAULT_SAMPLE_HALF_LIFE = "14d";
24
42
 
25
- /** splitmix32: small, seedable, and uniform enough for sampling keys. */
26
- export function seededRandom(seed) {
27
- let state = Number(seed) >>> 0 || 0x9e3779b9;
28
- return () => {
29
- state = (state + 0x9e3779b9) | 0;
30
- let t = state ^ (state >>> 16);
31
- t = Math.imul(t, 0x21f0aaad);
32
- t ^= t >>> 15;
33
- t = Math.imul(t, 0x735a2d97);
34
- t ^= t >>> 15;
35
- return (t >>> 0) / 4294967296;
36
- };
43
+ /**
44
+ * Deterministic draw in [0, 1) for one transcript: a SHA-256 of its durable identity
45
+ * and `seed` (or a fixed default when unseeded), so it depends only on that transcript and
46
+ * the configured seed - never on discovery order, the cap, or which other
47
+ * transcripts are present. Discovery removes duplicate canonical identities, while
48
+ * distinct identities that happen to draw equal keys are ordered by identity.
49
+ */
50
+ export function sampleUnit(transcript, seed) {
51
+ const digest = crypto
52
+ .createHash("sha256")
53
+ .update(`${seed ?? "default"} ${transcriptIdentity(transcript)}`, "utf8")
54
+ .digest();
55
+ return digest.readUInt32BE(0) / 0x100000000;
37
56
  }
38
57
 
39
58
  /** Epoch ms a transcript is dated to: the session start, else the file mtime. */
@@ -67,13 +86,16 @@ export function sampleTranscripts(
67
86
  { seed, now = Date.now(), halfLife = DEFAULT_SAMPLE_HALF_LIFE } = {},
68
87
  ) {
69
88
  if (count === null || transcripts.length <= count) return transcripts;
70
- const random = seededRandom(seed ?? Math.floor(Math.random() * 0xffffffff));
71
89
  const halfLifeMs = parseSince(halfLife) ?? Infinity;
72
90
  const keyed = transcripts.map((transcript, index) => {
73
- const u = random() || Number.EPSILON;
74
- return { index, key: -Math.log(u) / recencyWeight(transcript, { now, halfLifeMs }) };
91
+ const u = sampleUnit(transcript, seed) || Number.EPSILON;
92
+ return {
93
+ index,
94
+ identity: transcriptIdentity(transcript),
95
+ key: -Math.log(u) / recencyWeight(transcript, { now, halfLifeMs }),
96
+ };
75
97
  });
76
- keyed.sort((a, b) => a.key - b.key);
98
+ keyed.sort((a, b) => a.key - b.key || (a.identity < b.identity ? -1 : a.identity > b.identity ? 1 : 0));
77
99
  const kept = new Set(keyed.slice(0, count).map((k) => k.index));
78
100
  return transcripts.filter((_, index) => kept.has(index));
79
101
  }
package/src/state.js CHANGED
@@ -5,6 +5,7 @@ import crypto from "node:crypto";
5
5
  import { STATE_DIRNAME } from "./config.js";
6
6
  import { warn } from "./logger.js";
7
7
  import { ensureLocalExclude } from "./repo.js";
8
+ import { transcriptIdentity } from "./transcript.js";
8
9
 
9
10
  /** The line every command writes to the repo's local git exclude for the state dir. */
10
11
  export const STATE_EXCLUDE_LINE = `${STATE_DIRNAME}/`;
@@ -16,7 +17,7 @@ export const STATE_EXCLUDE_LINE = `${STATE_DIRNAME}/`;
16
17
  * the tracked `.gitignore`:
17
18
  *
18
19
  * scan-cache.json path+mtime+size -> association verdict (design section 2.2)
19
- * evidence/<id>.json per-transcript tier-1 analysis output (design section 3)
20
+ * evidence/<identity>.json per-transcript tier-1 analysis output (design section 3)
20
21
  * evidence-summary.json folded evidence (stage 2)
21
22
  * proposal.json latest parseable tier-2 synthesis; absent if none was produced (stage 3)
22
23
  * rejections.json edits the human rejected, and the evidence weight behind them
@@ -76,25 +77,58 @@ export class State {
76
77
  this.writeJsonFile(this.scanCachePath, cache);
77
78
  }
78
79
 
79
- evidencePath(transcriptId) {
80
- return path.join(this.evidenceDir, `${safeFileName(transcriptId)}.json`);
80
+ evidencePath(transcript) {
81
+ const identity = transcript && typeof transcript === "object" ? transcriptIdentity(transcript) : transcript;
82
+ return path.join(this.evidenceDir, `${safeFileName(identity)}.json`);
81
83
  }
82
84
 
83
- readEvidence(transcriptId) {
84
- return this.readJsonFile(this.evidencePath(transcriptId), null);
85
+ readEvidence(transcript) {
86
+ const currentPath = this.evidencePath(transcript);
87
+ const current = this.readJsonFile(currentPath, null);
88
+ if (!transcript || typeof transcript !== "object") return current;
89
+
90
+ const identity = transcriptIdentity(transcript);
91
+ const legacyPath = this.evidencePath(transcript.id);
92
+ if (legacyPath === currentPath) return current;
93
+ const legacy = this.readJsonFile(legacyPath, null);
94
+ const legacyMatches = legacy?.transcript && transcriptIdentity(legacy.transcript) === identity;
95
+ if (current) {
96
+ const currentMatches = current.transcript && transcriptIdentity(current.transcript) === identity;
97
+ const migrated = currentMatches ? migrateEvidenceRecord(current, transcript, identity) : current;
98
+ if (migrated !== current) this.writeJsonFile(currentPath, migrated);
99
+ if (legacyMatches) fs.rmSync(legacyPath, { force: true });
100
+ return migrated;
101
+ }
102
+ if (!legacyMatches) return null;
103
+
104
+ const migrated = migrateEvidenceRecord(legacy, transcript, identity);
105
+ // Publish the upgraded record atomically before removing the legacy path. If the
106
+ // process stops between these operations, the next read prefers the valid canonical
107
+ // record and merely cleans up the duplicate.
108
+ this.writeJsonFile(currentPath, migrated);
109
+ fs.rmSync(legacyPath, { force: true });
110
+ return migrated;
85
111
  }
86
112
 
87
- writeEvidence(transcriptId, evidence) {
88
- this.writeJsonFile(this.evidencePath(transcriptId), evidence);
113
+ writeEvidence(transcript, evidence) {
114
+ this.writeJsonFile(this.evidencePath(transcript), evidence);
89
115
  }
90
116
 
91
117
  listEvidence() {
92
118
  if (!fs.existsSync(this.evidenceDir)) return [];
93
- return fs
119
+ const records = fs
94
120
  .readdirSync(this.evidenceDir)
95
- .filter((f) => f.endsWith(".json"))
96
- .map((f) => this.readJsonFile(path.join(this.evidenceDir, f), null))
97
- .filter(Boolean);
121
+ .filter((file) => file.endsWith(".json"))
122
+ .map((file) => ({ file, record: this.readJsonFile(path.join(this.evidenceDir, file), null) }))
123
+ .filter(({ record }) => Boolean(record));
124
+ const unique = new Map();
125
+ for (const item of records) {
126
+ const identity = item.record.transcript ? transcriptIdentity(item.record.transcript) : `file:${item.file}`;
127
+ const canonical = item.record.transcript ? path.basename(this.evidencePath(item.record.transcript)) : item.file;
128
+ const existing = unique.get(identity);
129
+ if (!existing || item.file === canonical) unique.set(identity, item);
130
+ }
131
+ return [...unique.values()].map(({ record }) => record);
98
132
  }
99
133
 
100
134
  readSummary() {
@@ -158,12 +192,23 @@ export function sha256(text) {
158
192
  return crypto.createHash("sha256").update(text, "utf8").digest("hex");
159
193
  }
160
194
 
195
+ function migrateEvidenceRecord(record, transcript, identity) {
196
+ const legacyKey = `${transcript.mtimeMs}:${transcript.bytes}:${record.memoryHash}`;
197
+ const key = record.key === legacyKey ? evidenceKey(transcript, record.memoryHash) : record.key;
198
+ if (record.transcript?.identity === identity && record.key === key) return record;
199
+ return {
200
+ ...record,
201
+ transcript: { ...record.transcript, identity },
202
+ key,
203
+ };
204
+ }
205
+
161
206
  /**
162
207
  * Cache key for a transcript's analysis: the transcript's own content signature plus
163
208
  * the memory-file hash it was judged against. Either changing invalidates the evidence.
164
209
  */
165
210
  export function evidenceKey(transcript, memoryHash) {
166
- return `${transcript.mtimeMs}:${transcript.bytes}:${memoryHash}`;
211
+ return `${transcriptIdentity(transcript)}:${transcript.mtimeMs}:${transcript.bytes}:${memoryHash}`;
167
212
  }
168
213
 
169
214
  /**
package/src/synthesize.js CHANGED
@@ -70,10 +70,12 @@ function budgetRule(memoryFile, config, maxEdits) {
70
70
  `This file is ALREADY ${Math.abs(remaining)} tokens OVER budget, so this run is a SHRINK ` +
71
71
  `PLAN. You are NOT expected to reach ${config.budgetTokens} tokens in one run - the ` +
72
72
  `${maxEdits}-edit cap for this run makes that impossible and later runs continue the work. ` +
73
- `What is required is real progress: the edit set MUST be net-negative, so lead with the ` +
74
- `highest-cost instructions that have no positive evidence, and with skill extractions of ` +
75
- `long narrow sections. Any addition must name the removal that pays for it. Make the ` +
76
- `largest honest reduction you can justify from the evidence.`
73
+ `What is required is real progress: the edit set MUST be net-negative, so lead with skill ` +
74
+ `extractions of long, narrow, crisply-triggered sections - extraction frees the same ` +
75
+ `always-loaded tokens and loses nothing, and it never needs removal evidence. Deleting an ` +
76
+ `instruction outright still needs its harm-evidence floor; the budget never lowers that ` +
77
+ `bar. Any addition must name the removal or extraction that pays for it. Make the largest ` +
78
+ `honest reduction you can justify from the evidence.`
77
79
  );
78
80
  }
79
81
  if (remaining < config.budgetTokens * 0.15) {
@@ -0,0 +1,15 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export function transcriptIdentity(transcript) {
4
+ if (typeof transcript?.identity === "string" && transcript.identity) return transcript.identity;
5
+ const harness = String(transcript?.harness ?? "");
6
+ let nativeId = String(transcript?.nativeId ?? transcript?.id ?? "");
7
+ if (transcript?.nativeId == null && harness && nativeId.startsWith(`${harness}-`)) {
8
+ nativeId = nativeId.slice(harness.length + 1);
9
+ }
10
+ const source = String(transcript?.path ?? "");
11
+ return crypto
12
+ .createHash("sha256")
13
+ .update(JSON.stringify([harness, nativeId, source]), "utf8")
14
+ .digest("hex");
15
+ }
package/src/workspace.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
- import { anchoredHunks } from "./diff.js";
4
+ import { anchoredHunks, countOccurrences, span } from "./diff.js";
5
+ import { parseMemoryUnits } from "./memory.js";
5
6
  import { parseFrontmatter } from "./skills.js";
6
7
  import { sha256 } from "./state.js";
7
8
 
@@ -94,6 +95,112 @@ export function parseSkillFile(relative, text) {
94
95
  };
95
96
  }
96
97
 
98
+ /**
99
+ * One line's identity for extraction-recovery checks: verbatim modulo the noise a
100
+ * faithful move is allowed to make. Unicode dashes fold to "-" (house style normalizes
101
+ * them during moves) and interior whitespace collapses; anything more is a real change.
102
+ */
103
+ export function normalizeRecoveryLine(line) {
104
+ return String(line ?? "")
105
+ .replace(/[‐-―−]/g, "-")
106
+ .replace(/\s+/g, " ")
107
+ .trim();
108
+ }
109
+
110
+ /** The normalized non-blank lines of a set of file texts, with occurrence counts. */
111
+ export function recoveredLineCounts(texts) {
112
+ const counts = new Map();
113
+ for (const text of texts) {
114
+ for (const line of String(text ?? "").split("\n")) {
115
+ const normalized = normalizeRecoveryLine(line);
116
+ if (normalized) counts.set(normalized, (counts.get(normalized) || 0) + 1);
117
+ }
118
+ }
119
+ return counts;
120
+ }
121
+
122
+ /**
123
+ * Split one pure-removal memory-file hunk at the boundary between text that lands in a
124
+ * created skill and text that vanishes. Adjacent removals merge into one measured change
125
+ * (`anchoredHunks`), so without this split an extraction and an unrelated deletion that
126
+ * happen to sit next to each other in the file fuse into a single accept/reject decision.
127
+ * The boundary is a decision boundary, and it falls on instruction-unit edges because a
128
+ * skill carries whole sections.
129
+ *
130
+ * Returns the sub-hunks, or null when there is nothing to split (one kind only) or the
131
+ * sub-hunks cannot be given unique, non-overlapping spans, in which case the merged hunk
132
+ * is kept instead.
133
+ */
134
+ export function splitRemovalHunk(hunk, { oldText, oldLines, recovered }) {
135
+ // A removal that reaches the file's true tail cannot be split into independent
136
+ // sub-hunks: `span`'s tail rule gives the final sub-hunk a LEADING newline, and that
137
+ // is the same character its predecessor owns as its trailing newline. Applying one
138
+ // decision then consumes the separator the other one's `find` needs, so the pair
139
+ // composes in only one order - which breaks the any-subset-any-order contract the
140
+ // writer relies on. No non-overlapping anchoring exists at that seam; keep the
141
+ // merged hunk instead (a file ending in "\n" is unaffected: its final split("\n")
142
+ // element is the empty string, which no text removal reaches).
143
+ if (hunk.oldEnd === oldLines.length) return null;
144
+
145
+ const lineKinds = new Map();
146
+ for (let lineNo = hunk.oldStart; lineNo <= hunk.oldEnd; lineNo += 1) {
147
+ const normalized = normalizeRecoveryLine(oldLines[lineNo - 1]);
148
+ if (!normalized) continue;
149
+ const remaining = recovered.get(normalized) || 0;
150
+ lineKinds.set(lineNo, remaining > 0 ? "recovered" : "deleted");
151
+ if (remaining > 0) recovered.set(normalized, remaining - 1);
152
+ }
153
+
154
+ for (const unit of parseMemoryUnits(oldText)) {
155
+ const start = Math.max(unit.startLine, hunk.oldStart);
156
+ const end = Math.min(unit.endLine, hunk.oldEnd);
157
+ if (start > end) continue;
158
+ const kinds = new Set();
159
+ for (let lineNo = start; lineNo <= end; lineNo += 1) {
160
+ if (lineKinds.has(lineNo)) kinds.add(lineKinds.get(lineNo));
161
+ }
162
+ if (kinds.size > 1) return null;
163
+ }
164
+
165
+ for (let lineNo = hunk.oldStart; lineNo <= hunk.oldEnd; lineNo += 1) {
166
+ if (!/^#{1,6}\s+/.test(oldLines[lineNo - 1] || "")) continue;
167
+ let contentLine = lineNo + 1;
168
+ while (contentLine <= hunk.oldEnd && !normalizeRecoveryLine(oldLines[contentLine - 1])) contentLine += 1;
169
+ if (contentLine > hunk.oldEnd || /^#{1,6}\s+/.test(oldLines[contentLine - 1] || "")) continue;
170
+ if (lineKinds.get(lineNo) !== lineKinds.get(contentLine)) return null;
171
+ }
172
+
173
+ // Group the removed lines into maximal runs by recovery; blank lines never start a
174
+ // run and attach to whichever run surrounds them.
175
+ const runs = [];
176
+ for (let lineNo = hunk.oldStart; lineNo <= hunk.oldEnd; lineNo += 1) {
177
+ const kind = lineKinds.get(lineNo);
178
+ if (!kind) continue;
179
+ const current = runs[runs.length - 1];
180
+ if (current && current.kind === kind) current.last = lineNo;
181
+ else runs.push({ kind, first: lineNo, last: lineNo });
182
+ }
183
+ if (runs.length < 2) return null;
184
+
185
+ const subHunks = runs.map((run, index) => {
186
+ const start = index === 0 ? hunk.oldStart : runs[index - 1].last + 1;
187
+ const end = index === runs.length - 1 ? hunk.oldEnd : run.last;
188
+ const find = span(oldLines, start - 1, end);
189
+ return {
190
+ find,
191
+ replace: "",
192
+ oldStart: start,
193
+ oldEnd: end,
194
+ removed: end - start + 1,
195
+ added: 0,
196
+ lines: oldLines.slice(start - 1, end).map((text) => ({ type: "del", text })),
197
+ };
198
+ });
199
+
200
+ if (subHunks.some((sub) => !sub.find || countOccurrences(oldText, sub.find) !== 1)) return null;
201
+ return subHunks;
202
+ }
203
+
97
204
  /**
98
205
  * Everything that differs between the originals and the workspace now, as changes with
99
206
  * stable ids the annotate turn refers to:
@@ -138,6 +245,27 @@ export function measureWorkspace(workspace) {
138
245
  changes.push({ kind: "created", file: relative, text, skill: parseSkillFile(relative, text) });
139
246
  }
140
247
 
248
+ // With the created files known, split any memory-file removal that mixes extracted
249
+ // text (recovered in a created file) with deleted text, so the deletion is its own
250
+ // measured change and stays independently decidable.
251
+ const createdTexts = changes.filter((c) => c.kind === "created").map((c) => c.text);
252
+ if (createdTexts.length) {
253
+ const recovered = recoveredLineCounts(createdTexts);
254
+ const oldText = originals.get(memoryPath) ?? "";
255
+ const oldLines = oldText.split("\n");
256
+ const measured = [];
257
+ for (const change of changes) {
258
+ if (change.kind !== "hunk" || change.file !== memoryPath || !change.removed || change.added) {
259
+ measured.push(change);
260
+ continue;
261
+ }
262
+ const subHunks = splitRemovalHunk(change, { oldText, oldLines, recovered });
263
+ if (subHunks) measured.push(...subHunks.map((sub) => ({ kind: "hunk", file: memoryPath, ...sub })));
264
+ else measured.push(change);
265
+ }
266
+ changes.splice(0, changes.length, ...measured);
267
+ }
268
+
141
269
  changes.forEach((change, index) => {
142
270
  change.id = `H${index + 1}`;
143
271
  });