F3 SLICE-2: AttributionTracker span algebra (INV-6/INV-7) (#6)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-10 09:44:29 -07:00
parent 4afe97c9a8
commit 363fd098fc
2 changed files with 212 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
/**
* AttributionTracker — pure span algebra over offset ranges (spec §6.2/§6.5).
* Char-honest (INV-7): attributes exactly the characters each author produced —
* split character-precisely, coalesce only adjacent same-author/same-turn
* spans. vscode-free; the controller converts vscode change events to TextEdit.
*/
import type { TextEdit } from "./anchorer";
import type { Provenance } from "./model";
export interface LiveSpan {
id: string;
/** half-open [start, end) character offsets in the live document. */
start: number;
end: number;
author: Provenance;
turnId?: string;
/** ISO-8601; survives splits/merges (earliest wins on merge). */
createdAt: string;
}
/** Injected effects so the algebra stays pure and deterministic in tests. */
export interface TrackerCtx {
newId: () => string;
now: () => string;
turnId?: string;
}
function sameAuthor(a: LiveSpan, b: LiveSpan): boolean {
return JSON.stringify(a.author) === JSON.stringify(b.author) && a.turnId === b.turnId;
}
/** Merge adjacent same-author/same-turn spans; drop empties; sort by start. */
export function coalesce(spans: LiveSpan[]): LiveSpan[] {
const sorted = spans.filter((s) => s.end > s.start).sort((a, b) => a.start - b.start);
const out: LiveSpan[] = [];
for (const s of sorted) {
const last = out[out.length - 1];
if (last && last.end === s.start && sameAuthor(last, s)) {
last.end = s.end;
if (s.createdAt < last.createdAt) last.createdAt = s.createdAt;
} else {
out.push({ ...s });
}
}
return out;
}
/**
* Apply one document edit (the half-open range [start,end) replaced by
* newLength chars) authored by `author`. Existing spans shift/split/clip;
* inserted chars become a new span of `author` (INV-7).
*/
export function applyChange(
spans: LiveSpan[],
edit: TextEdit,
author: Provenance,
ctx: TrackerCtx,
): LiveSpan[] {
const delta = edit.newLength - (edit.end - edit.start);
const out: LiveSpan[] = [];
for (const s of spans) {
if (s.end <= edit.start) {
out.push({ ...s });
} else if (s.start >= edit.end) {
out.push({ ...s, start: s.start + delta, end: s.end + delta });
} else {
const left = s.start < edit.start ? { ...s, end: edit.start } : null;
const right =
s.end > edit.end ? { ...s, start: edit.end + delta, end: s.end + delta } : null;
if (left) out.push(left);
if (right) out.push(left ? { ...right, id: ctx.newId() } : right);
}
}
if (edit.newLength > 0) {
out.push({
id: ctx.newId(),
start: edit.start,
end: edit.start + edit.newLength,
author,
turnId: ctx.turnId,
createdAt: ctx.now(),
});
}
return coalesce(out);
}