~bigbes/sr-ht-spec

ref: 824788ab8269bd6c58de5848bc4545450fac7aaf sr-ht-spec/prosediff/SPIKE.md -rw-r--r-- 15.0 KiB
824788ab — Eugene Blikh mcpsrv: mark /mcp uncacheable, fail closed on origin, split tool errors from faults 2 days ago

#Phase 0 spike: does a prose diff read well enough to review AI-written specs in a browser?

Status: run, with a verdict.

Corpus: this repository's own docs/DESIGN.md — twelve revisions, be096fcdb7d6c0, 336 → 1139 lines of heavily reworked English prose with genuine reflow, written by an agent and repeatedly corrected by review. Extracted with git show <rev>:docs/DESIGN.md. Every number and excerpt below is real output from prosediff/cmd/prosediff; revisions are pinned to SHAs rather than HEAD because other agents were committing to this branch while the spike ran.

#Verdict

Yes. Build the review UI on this. The failure mode the design feared — "a one-word edit shows as a whole-paragraph replace" — does not occur at all, and the common cases are not merely tolerable but pleasant to read. Roughly 4 in 5 real modifications read as what they are: a substitution, an added clause, a deleted sentence.

There is one real weak spot — paragraphs that were substantively rewritten shred into interleaved fragments (13% of real prose modifications) — but it is a presentation problem with a fix the data structure already supports, not a defect in the approach. Details and the recommended fix below.

Two limitations to carry into Phase 4, both honest rather than papered over:

  • Move detection needs a verbatim anchor. A section that moved and was rewritten throughout still shows as add+remove. A section that moved with an edit or two inside it is handled correctly.
  • A changed-blocks-only view loses structural context. The reviewer needs the heading a change lives under; the plain renderer prints a @@ heading › path @@ banner as a stand-in and the web UI will need something better.

#The headline measurement

Rewrapping the whole document from ~80 to 58 columns, changing not one word:

line differ prosediff
db7d6c0 vs. rewrapped db7d6c0 1563 changed lines 0 changes — 307 blocks, all equal

That is the entire premise of the package, and it holds on a real 1139-line document containing paragraphs, nested lists, block quotes, tables, YAML and ASCII-art code fences. It is not an artefact of the test fixtures; the equivalent synthetic check is TestWholeDocumentReflowIsSilent.

#The eleven real commit pairs

Adjacent commits are what a review actually looks like, so these matter more than the endpoints. git --numstat on the same pair for comparison:

be096fc→c945f38    95 equal,  1 modified, 27 added,  0 removed, 0 moved | git +86  -1
c945f38→72f5894   113 equal,  8 modified, 11 added,  2 removed, 0 moved | git +76  -26
72f5894→fc49924   117 equal, 13 modified, 32 added,  2 removed, 0 moved | git +165 -46
fc49924→1e7e4b1   130 equal, 23 modified, 60 added,  9 removed, 0 moved | git +296 -89
1e7e4b1→f0dcfb4   210 equal,  2 modified,  6 added,  1 removed, 0 moved | git +35  -19
f0dcfb4→12e043e   185 equal, 18 modified, 32 added, 15 removed, 0 moved | git +125 -101
12e043e→a733623   223 equal,  5 modified, 53 added,  7 removed, 0 moved | git +242 -49
a733623→ba61af7   280 equal,  1 modified,  1 added,  0 removed, 0 moved | git +11  -1
ba61af7→7c82786   280 equal,  2 modified,  6 added,  0 removed, 0 moved | git +28  -4
7c82786→d148a66   283 equal,  4 modified,  9 added,  1 removed, 0 moved | git +42  -11
d148a66→db7d6c0   294 equal,  2 modified, 11 added,  0 removed, 0 moved | git +48  -4

The equal counts are the important column: in every pair the aligner keeps the untouched bulk of the document silent, including across the two commits that rewrote the largest sections (fc49924→1e7e4b1, f0dcfb4→12e043e). No content-specific heuristics or tuning were needed to get that.

Zero moves across the whole history is a property of this corpus, not a broken detector: the document grew by insertion and rewriting rather than relocation, and where sections were reorganised they were also rewritten, which removes the verbatim anchor moves are keyed on. Move detection is therefore exercised against constructed moves below.

#Does a reflowed paragraph with a small edit show as a small edit?

Yes. This is the strongest result. Appending one clause and rewrapping the paragraph (a 7-line change to git):

@@ … › Merge model: no text merge, ever @@
~ L404 paragraph modified
  **Verified constraint:** `go-git` v5.19.1 implements only `FastForwardMerge`
  (`repository.go:1800`; anything else returns `ErrUnsupportedMergeStrategy`). There is no
  three-way merge available in-process{+, and none is planned upstream+}.

307 blocks: 306 equal, 1 modified, 0 added, 0 removed, 0 moved (+6/-0 words)

Real commits look the same. From 7c82786 (agent-token rework):

~ L546 paragraph modified
  [-Per-space (or per-project) tokens with a role — `reader` / `proposer` / `writer` —
  and-]{+One agent token (no roles, no per-space scopes — see above) plus+} a **required
  agent identity string**. Every commit records it in a way that survives clone:

and from the most recent pair, d148a66→db7d6c0:

~ L435→465 paragraph modified
  **Deletion and rename need an explicit surface.** The write plane is whole-document `PUT`,
  which gives an agent no way to express "delete this" or "move this". Two coherent answers;
  **the second is the v1 [-recommendation-]{+decision+}**:

A line differ shows each of these as several deleted and several inserted lines, with no indication of which words actually moved.

Across all 69 prose modifications in the eleven pairs:

shape of the inline diff count reads as
≤ 2 changed runs 27 (39%) one clean substitution — ideal
3–6 changed runs 26 (38%) a few separate edits in one paragraph — fine
7–8 changed runs 7 (10%) busy, still followable
≥ 9 changed runs 9 (13%) shredded — see below

Similarity of paired blocks: min 0.41, p10 0.46, p25 0.63, median 0.76, p75 0.86. Ten further modifications were code fences, diffed line-by-line.

#Where it is bad: substantively rewritten paragraphs shred

The worst case in the corpus (c945f38→72f5894, similarity 0.56, 18 changed runs):

  **[-Verified that opting out is-]{+Opting out, if we ever do, is verified+}
  safe.** `api.sr.ht` federates *every* config section ending in `.sr.ht` [-—
  it does not consult an-]{+with no+} allow-list, [-so our section does get
  added as a federated service pointed at `-]{+pointing at `api-origin` (else
  `+}origin{+`) `+} + "/query"`[-, which we will not serve-]. `updateSchema`
  (`api.sr.ht/main.go`) [-handles this explicitly:-]{+logs and **skips**+}
  services that are offline or [-have-]{+serve+} an invalid schema [-are
  logged and **skipped**-], and `BuildSchema` runs over [-only-] the healthy
  ones[-. So the effect is-]{+only — so a non-GraphQL service costs+} one
  `Unable to update service [-"spec.sr.ht"-]` log line, not a broken gateway.

This is genuinely unpleasant, and it is not fixable by tuning the word differ. The paragraph really was rewritten sentence by sentence, and interleaving two rewritten sentences is inherently hard to read no matter how the runs are computed. Absorbing tiny surviving fragments into the surrounding change (the package does this — smallEqualRun) helps, but cannot rescue a 70%-rewritten paragraph.

The fix belongs in the web layer, and the data already carries the signal. Every block with ≥ 9 changed runs has similarity ≤ 0.73; every block above 0.75 either reads cleanly or is a handful of clearly separate small edits. So BlockChange.Similarity is a usable switch: render the inline word diff above ~0.75 and fall back to a two-column old block / new block view below it, which is how a human wants to read a rewritten paragraph anyway. That is a Phase 4 rendering decision, not a redesign — the field is exported for exactly this.

Worth noting what is not happening even in the bad case: the surrounding 200+ blocks stay silent. The shredding is confined to the paragraph that was actually rewritten, so the reviewer's cost is bounded and local.

#Does a moved section explode into add+remove noise?

No, for moves with any verbatim content. Moving the whole "Open risks" section (6 blocks) ahead of "Repo layout" — git reports 34 changed lines:

@@ … › GraphQL: a read schema at our own `/query` in Phase 2 @@
> L836 h2 moved here (was line 1065)
@@ … › Open risks @@
> L838 list item moved here (was line 1067)
> L841 list item moved here (was line 1070)
…
< L1065 h2 moved away (now line 836)
< L1067 list item moved away (now line 838)
…

313 blocks: 301 equal, 0 modified, 0 added, 0 removed, 6 moved (+0/-0 words)

Zero adds, zero removes. The same move with one word changed inside it also holds together — the move group bridges a single edited block, so the reviewer sees the section relocate and the one thing that actually changed in it:

> L836 h2 moved here (was line 1065)
~ L1067→838 list item modified (moved from line 1067)
  **Prose diff quality (highest).** Everything downstream of the [-browser-review-]{+browser
  review+} decision assumes it reads well. No fallback is designed; Phase 0 exists to find
  out early rather than late.
> L841 list item moved here (was line 1070)

313 blocks: 301 equal, 1 modified, 0 added, 0 removed, 5 moved (+2/-1 words)

The honest limitation: every move must be anchored by at least one block that is byte-identical after normalization. A section that moved and was rewritten throughout falls through as a delete plus an insert; TestMovedAndEditedIsNotAMove pins that behaviour so it cannot regress silently. This is deliberate. Matching moves by similarity alone would claim relationships between blocks that merely share boilerplate, and a confidently wrong "moved from line 1065" costs a reviewer more than an honest add+remove.

#How do heavily-edited tables behave?

Well, because each table row is its own block. From ba61af7, one edited row of the reuse-inventory table with the other rows silent:

~ L574 table_row modified
  | `sr-ht-core` (fork) | config, crypto, auth, database, server, **gqlgen scaffolding +
  `webhooks`** | Pinned to `[-git.srht.bigb.es/~-]{+sourcecraft.dev/+}bigbes/[-core-go` via
  `replace`, as in-]{+sr-ht-core v0.0.0-20260718185800-dd418a200152`, byte-identical to+}
  both siblings{+. **No `replace` directive** — see the note below+}. Never `go get -u`. …

f0dcfb4→12e043e adds six rows to the decisions table; they render as six clean + table_row entries and the surrounding rows do not move. A new table appears as a + table_header followed by + table_row entries, which reads correctly.

Two caveats:

  • The alignment row is invisible. goldmark folds |---|---| into the table node, so changing it to |:--|--:| reports no change at all — measured, not assumed. Acceptable (it is presentation, not content) but a real blind spot to record.
  • Word diffing can straddle a cell boundary — the git.srht.bigb.es/~sourcecraft.dev/ fragment above splits mid-path because the row is one token stream rather than a list of cells. Legible, but cell-aware diffing would be better and is cheap to add later: the block already records its column count.

#How do heavily-edited lists behave?

Well, and this is the second-strongest result. Each list item is its own block, so editing one item leaves its siblings silent, adding an item is an insert rather than a whole-list replace, and nesting depth is part of block identity — re-indenting an item is reported (as a modification with no word changes, flagged StructureOnly) rather than hidden.

Real evidence: 1e7e4b1→f0dcfb4 adds two list items and removes one, and the render is exactly two + list item blocks and one - list item, each shown in full, with the rest of their lists silent. d148a66→db7d6c0 adds a five-row table and six paragraphs inside existing sections without disturbing one neighbouring block.

#Smaller findings

  • Renaming a heading does not dirty its section. HeadingPath is context for the reviewer, never part of a block's hash. Including it would have made a section rename rewrite every block underneath — a trap worth naming, because the comment-anchoring design in DESIGN.md wants that same tuple.
  • A soft line break is a space, and the differ is right to say so. An early rewrap run broke logout-everywhere across two lines; prosediff flagged it, because in rendered markdown logout-\neverywhere really is "logout- everywhere". Correct behaviour, initially mistaken for a bug.
  • Two-word blocks below the similarity floor become add+remove. ## Old name## New name scores 0.5, under the 0.6 floor that applies to short blocks, so it renders as a removal plus an addition rather than a word diff. For a two-word heading both readings are equally clear, so the floor stays where it is rather than being tuned to a fixture (TestShortBlockRenameFallsBackToAddRemove).
  • Within one changed region, entries are emitted old-side first. A block inserted at line 433 can therefore print after a modification at line 465. The plain renderer accepts this; the web UI should order the new-side entries by new-document position.
  • The heading-path banner flaps when a section heading is itself added or deleted: the blocks around it briefly display the old path. Cosmetic, and it disappears once blocks are rendered structurally under their headings.
  • Fast enough to ignore. The widest pair (336 vs 1139 lines, 307 blocks, 211 of them added) compares in 12 ms in-process. Typical adjacent-commit pairs are far cheaper. No caching needed on the review page.

#What would have made this a negative verdict

Recorded so the verdict is falsifiable rather than a vibe. The spike would have failed if:

  • a pure rewrap had produced any change at all — it produced zero;
  • the median modification had been shredded rather than roughly one in eight;
  • the block aligner had needed content-specific heuristics to keep the "equal" set stable across the eleven real commits — it needed none;
  • code fences had had to be diffed as prose, or prose as lines, to make either read acceptably — they did not.

None of those happened, so the browser-review decision and Phase 4 stand.

#Recommendations for Phase 4

  1. Switch presentation on Similarity. Inline word diff at ≥ ~0.75; two-column old/new block below it. This removes the one genuinely bad case.
  2. Render ChangeMoveOut as a collapsed one-line marker and ChangeMoveIn in full at the new position.
  3. Show unchanged blocks collapsed behind an expander rather than hidden — the @@ banner is a poor substitute for structural context in a browser.
  4. Order new-side entries by new-document position (see "smaller findings").
  5. Consider cell-aware diffing for table rows before shipping, using the column count already on the block.
  6. Block.Hash + HeadingPath + Ordinal are exactly the comment-anchor tuple DESIGN.md specifies. Phase 5 can test anchor survival against this same corpus rather than guessing.