# ADR 008: jsdiff source alignment without outcome inference

- HTML version: https://robbiepalmer.me/projects/agent-first-writing/adrs/008-jsdiff-source-alignment-without-outcome-inference
- Project: Agent-first Writing Editor (https://robbiepalmer.me/projects/agent-first-writing.md)
- Status: Accepted
- Date: 2026-09-11
- Initiatives: Semi-autonomous Software Development (https://robbiepalmer.me/initiatives/semi-autonomous-software-development.md)

# Context

[ADR 003](/projects/agent-first-writing/adrs/003-versioned-editor-record-contract)
separates detection-only findings from suggestions that contain a replacement.
The DVC pipeline in
[ADR 004](/projects/agent-first-writing/adrs/004-dvc-writing-evaluation-pipeline)
contains source and published revisions plus stable Vale findings. It does not
contain rewrite suggestions or the author's accept, reject, and change
decisions. The pipeline can still establish whether the published document
changed around each finding, but that observation is not a decision outcome.

The matcher needs exact positions despite Markdown markup and multibyte text.
Line diffs are too coarse for a finding inside one sentence. Word tokenization
can hide punctuation and markup changes. The current corpus contains files up
to 98 KB, so the matcher also needs a work limit for a pair with little text in
common.

# Decision

Use [jsdiff](https://github.com/kpdecker/jsdiff) and its `diffChars` function to
compare every frozen source revision with its published revision. `diffChars`
treats Unicode code points as tokens and returns ordered unchanged, inserted,
and removed runs. Its `maxEditLength` option lets the pipeline stop a costly
comparison instead of blocking indefinitely. Version 9 also includes its own
TypeScript definitions.

Pin jsdiff in the workspace lockfile. Read the installed package version at
runtime and record it in every match run. Set `maxEditLength` through
`params.yaml` so a limit change invalidates the DVC stage.

Convert the ordered diff into edit hunks. Every hunk records:

* a stable ID derived from the algorithm version, source and published hashes,
  and both byte ranges;
* a half-open UTF-8 byte span and exact text from the source; and
* the corresponding half-open UTF-8 byte span and exact text from the
  published revision.

Classify each finding as follows:

* `changed` when at least one edit overlaps the finding and every overlapping
  edit is contained by its source span;
* `unchanged` when no edit overlaps or touches the finding; or
* `manual-adjudication-required` when an edit crosses the finding boundary or
  an insertion occurs exactly on it.

For an unambiguous match, map the complete finding span into the published
revision and retain its exact observed text. For an ambiguous match, retain the
relevant hunk IDs and reason without inventing a published span.

Before matching, verify both content hashes against the frozen cohort, require
the Vale producer run to name that cohort, and verify every finding against the
source bytes. Stable match-run identity covers the cohort, producer run,
algorithm parameters, hunks, and classifications.

Do not infer accepted, rejected, or changed suggestion outcomes from this
alignment. A finding has no replacement, and a Git diff cannot prove why an
author made an edit. Outcome scoring begins only when the dataset contains a
proposal and an explicit decision record.

# Alternatives

| Option                                                         | Useful properties                                                                                                         | Why it lost here                                                                                                                                                                 |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [jsdiff](https://github.com/kpdecker/jsdiff)                   | Unicode code-point tokens, ordered change objects, a configurable edit-distance limit, and bundled TypeScript definitions | Chosen. Its output maps directly to the byte-range adapter, and the work limit is part of the experiment parameters.                                                             |
| [fast-diff](https://github.com/jhchen/fast-diff)               | Small API, bundled TypeScript definitions, and Unicode cleanup around a Myers diff                                        | It deliberately removes advanced diff options and exposes no configurable edit-distance or timeout limit. That is a poor fit for unattended DVC runs over growing documents.     |
| [Diff Match Patch](https://github.com/google/diff-match-patch) | Mature diff, fuzzy match, and patch algorithms with a configurable timeout                                                | The Google repository is archived, its match and patch features are unused, and the JavaScript npm package needs separate community type definitions.                            |
| Git `--word-diff-regex=.`                                      | Uses a tool already required by corpus extraction and produces character-like patch output                                | Parsing display-oriented CLI output back into exact ranges adds an error-prone process boundary. Git's diff heuristics and output format would become part of the data contract. |
| A local Myers implementation                                   | No production dependency and complete control over limits and output                                                      | The project would own Unicode handling, algorithm tests, and performance fixes for infrastructure that does not distinguish the editor.                                          |

# Consequences

The pipeline can trace every Vale finding to an observed published change or a
manual queue without choosing a rewrite model. Later producers can reuse the
same evidence while adding their own proposals and explicit decisions.

Character diffs may split a rewrite into many small hunks when old and new text
share isolated characters. The DVC output is therefore larger than a
display-oriented patch. Boundary-crossing rewrites remain unresolved by design
instead of becoming misleading automatic matches.

---

Markdown index of this site: https://robbiepalmer.me/llms.txt
