diff --git a/src/attributionTracker.ts b/src/attributionTracker.ts new file mode 100644 index 0000000..0b95a23 --- /dev/null +++ b/src/attributionTracker.ts @@ -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); +} diff --git a/test/attributionTracker.test.ts b/test/attributionTracker.test.ts new file mode 100644 index 0000000..c590a8e --- /dev/null +++ b/test/attributionTracker.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { applyChange, coalesce, type LiveSpan, type TrackerCtx } from "../src/attributionTracker"; +import type { Provenance } from "../src/model"; + +const HUMAN: Provenance = { kind: "human", id: "ben" }; +const AGENT: Provenance = { + kind: "agent", id: "claude", + agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" }, +}; + +function ctx(turnId?: string): TrackerCtx { + let n = 0; + return { newId: () => `n${++n}`, now: () => "2026-06-10T00:00:00.000Z", turnId }; +} +function span(id: string, start: number, end: number, author: Provenance, turnId?: string): LiveSpan { + return { id, start, end, author, turnId, createdAt: "2026-06-09T00:00:00.000Z" }; +} +const ranges = (s: LiveSpan[]) => s.map((x) => [x.start, x.end]); +const authors = (s: LiveSpan[]) => s.map((x) => x.author.kind); + +describe("applyChange — insertions", () => { + it("insertion into empty span list creates one author span", () => { + const r = applyChange([], { start: 5, end: 5, newLength: 3 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[5, 8]]); + expect(r[0].author).toEqual(HUMAN); + }); + it("insertion before a span shifts it", () => { + const r = applyChange([span("a", 10, 14, AGENT)], { start: 0, end: 0, newLength: 2 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[0, 2], [12, 16]]); + expect(authors(r)).toEqual(["human", "agent"]); + }); + it("insertion after a span leaves it untouched", () => { + const r = applyChange([span("a", 0, 4, AGENT)], { start: 10, end: 10, newLength: 2 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[0, 4], [10, 12]]); + }); + it("PUC-3: insertion strictly inside a different-author span splits it char-precisely", () => { + const r = applyChange([span("a", 0, 10, AGENT)], { start: 4, end: 4, newLength: 3 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[0, 4], [4, 7], [7, 13]]); + expect(authors(r)).toEqual(["agent", "human", "agent"]); + }); + it("symmetric: agent insertion inside a human span splits it (INV-7)", () => { + const r = applyChange([span("a", 0, 10, HUMAN)], { start: 4, end: 4, newLength: 3 }, AGENT, ctx("t1")); + expect(authors(r)).toEqual(["human", "agent", "human"]); + expect(r[1].turnId).toBe("t1"); + }); + it("insertion inside a SAME-author span coalesces back to one span", () => { + const r = applyChange([span("a", 0, 10, HUMAN)], { start: 4, end: 4, newLength: 3 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[0, 13]]); + }); + it("insertion at a span boundary does not split; same author coalesces", () => { + const r = applyChange([span("a", 0, 5, HUMAN)], { start: 5, end: 5, newLength: 2 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[0, 7]]); + }); +}); + +describe("applyChange — deletions", () => { + it("deletion before a span shifts it left", () => { + const r = applyChange([span("a", 10, 14, HUMAN)], { start: 0, end: 4, newLength: 0 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[6, 10]]); + }); + it("deletion entirely inside a span shrinks it (no re-authoring, INV-6)", () => { + const r = applyChange([span("a", 0, 10, AGENT)], { start: 3, end: 6, newLength: 0 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[0, 7]]); + expect(authors(r)).toEqual(["agent"]); + }); + it("deletion covering a whole span removes it", () => { + const r = applyChange([span("a", 3, 6, AGENT)], { start: 0, end: 10, newLength: 0 }, HUMAN, ctx()); + expect(r).toEqual([]); + }); + it("deletion clipping a span head keeps the tail", () => { + const r = applyChange([span("a", 4, 10, AGENT)], { start: 0, end: 6, newLength: 0 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[0, 4]]); + }); + it("deletion clipping a span tail keeps the head", () => { + const r = applyChange([span("a", 0, 6, AGENT)], { start: 4, end: 10, newLength: 0 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[0, 4]]); + }); +}); + +describe("applyChange — replacements", () => { + it("replacement inside an agent span: human chars in the middle, agent halves keep ids/createdAt", () => { + const r = applyChange([span("a", 0, 12, AGENT)], { start: 4, end: 8, newLength: 2 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[0, 4], [4, 6], [6, 10]]); + expect(authors(r)).toEqual(["agent", "human", "agent"]); + expect(r[0].id).toBe("a"); + expect(r[0].createdAt).toBe("2026-06-09T00:00:00.000Z"); + }); + it("replacement spanning two spans clips both and inserts the author's span between", () => { + const r = applyChange( + [span("a", 0, 6, AGENT), span("b", 6, 12, HUMAN)], + { start: 4, end: 8, newLength: 5 }, AGENT, ctx("t2"), + ); + expect(ranges(r)).toEqual([[0, 4], [4, 9], [9, 13]]); + expect(authors(r)).toEqual(["agent", "agent", "human"]); + expect(r[1].turnId).toBe("t2"); + }); +}); + +describe("multi-edit sequences", () => { + it("type, agent-replace, type inside agent span — ends char-honest", () => { + const c = ctx(); + let s = applyChange([], { start: 0, end: 0, newLength: 20 }, HUMAN, c); + s = applyChange(s, { start: 5, end: 10, newLength: 8 }, AGENT, { ...c, turnId: "t1" }); + s = applyChange(s, { start: 9, end: 9, newLength: 1 }, HUMAN, c); + expect(authors(s)).toEqual(["human", "agent", "human", "agent", "human"]); + expect(ranges(s)).toEqual([[0, 5], [5, 9], [9, 10], [10, 14], [14, 24]]); + }); +}); + +describe("coalesce", () => { + it("merges adjacent same-author same-turn spans, keeps earliest createdAt", () => { + const a = { ...span("a", 0, 3, HUMAN), createdAt: "2026-06-09T00:00:00.000Z" }; + const b = { ...span("b", 3, 6, HUMAN), createdAt: "2026-06-08T00:00:00.000Z" }; + const r = coalesce([a, b]); + expect(ranges(r)).toEqual([[0, 6]]); + expect(r[0].id).toBe("a"); + expect(r[0].createdAt).toBe("2026-06-08T00:00:00.000Z"); + }); + it("does NOT merge different authors, different turnIds, or non-adjacent spans", () => { + expect(coalesce([span("a", 0, 3, HUMAN), span("b", 3, 6, AGENT)])).toHaveLength(2); + expect(coalesce([span("a", 0, 3, AGENT, "t1"), span("b", 3, 6, AGENT, "t2")])).toHaveLength(2); + expect(coalesce([span("a", 0, 3, HUMAN), span("b", 4, 6, HUMAN)])).toHaveLength(2); + }); + it("drops empty spans", () => { + expect(coalesce([span("a", 5, 5, HUMAN)])).toEqual([]); + }); +});