feat(splice): a run's ranged replace pasted over a selection, worked out on the codec's runs (Phase 3 slice 6a) #104
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/ranged-splice"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Phase 3's slice 6a. The domain gains the splice that a replace commit will land: a run's output pasted over a selection, worked out on the codec's runs (ADR 0030). Nothing calls it yet. The commit modes come with slice 6b.
What changes
RangedSplice.Plan(covered, start, end, output)pastes output paragraphs over a selection. The selection runs from a visible offset in the first covered segment to one in the last.ModelParagraphmoves into the domain, where both the append and the splice land it.ADR 0030 records what its bullets left open. It also names the one id it gives against a bullet's parenthetical: with one covered paragraph and a scene break first, Oₙ + suffix keeps the paragraph's id, because the id follows the paragraph's words. The plan's slice 6a is ticked.
Verification
Tests. All six projects pass under TZ=UTC and in the local zone:
The new tests are 35 example rows and 5 FsCheck properties, each run 2000 times over generated chapters. The properties:
Revert run itself, over the store and the journal, is 6b's to prove.
Coverage of the changed lines: 74/74 lines and 68/68 branches.
Mutation probes: 26 of 27 killed: 20 on the slice and 6 of 7 on the round-0 fixes. The survivor is equivalent. It drops the suffix's whitespace check, but stored text is end-trimmed, so a non-empty suffix always holds words. Among the probes:
Removedmissing a segment;Reviews. The counts come from the saved review JSON.
ModelParagraph: the type moves unchanged, and no caller builds that case;commit.🤖 Generated with Claude Code
The domain gains the splice a replace commit will land (ADR 0030); nothing calls it yet, as the commit modes come with slice 6b. - `RangedSplice.Plan(covered, start, end, output)` pastes output paragraphs over a selection from a visible offset in the first covered segment to one in the last: - the prefix joins the first output paragraph and the suffix the last, one block when there is one paragraph; - an output scene break joins nothing, and a prefix or suffix beside it stays a block of its own when it holds words; whitespace alone is no words; - the first block keeps the first segment's id, and the last of several blocks the last segment's when the selection covers more than one; over one segment with a scene break first, the block holding its words after the selection keeps its id; a scene break keeps no prose id, and every other covered segment goes; - each block says whether it holds the run's words and whose words before or after the selection it holds, so the commit can credit it. - It refuses no covered segment, ends that are not prose, a segment given twice, an offset outside its text or inside a character, an end before the start, and no output paragraph. - `ModelParagraph` moves into the domain, where the append and the splice both land it. - Property tests over generated chapters: no word lost or invented and every mark kept; one block per output paragraph in order, each naming whose old words it holds; the id rules; pasting the selection's own paragraphs gives the covered text back; and a journal written from the splice's report takes the chapter back word for word. Revert run itself, over the store, is the commit's to prove in slice 6b. - ADR 0030 records what its bullets left open and the one id they gave otherwise; the plan's slice 6a is ticked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A pure splice —
Plan(covered, start, end, output)in the domain, worked entirely onInlineMarkupruns, never once touching a stored string. That is exactly what ADR 0030's "The splice happens in the codec's runs, never by cutting stored strings" bullet demands, and theRangedSplice/SplicedBlockreport withHoldsRunWords/OldWordsOfis precisely the shape slice 6b's commit needs for its crediting rules. I read every line of the 142, traced all 18 example rows by hand, and matched them against the amended ADR's id rules — the one-segment-scene-break-first case (Oₙ + suffixkeeps C₁'s id because the id follows the words) is implemented exactly as amended, including the deliberate walk against the original bullet's parenthetical. ♪Verdict: ✅ Looks good to me~
✅ What I liked~
Distinct().Count() != Count); an offset outside its text or inside a surrogate pair; an end before the start in one segment; no output paragraph. All seven tested withParamNameassertions, plus nulls. I couldn't find a door I could sneak a bad splice through~HasWords("an indent before the selection is not the paragraph's words") gates the standalone prefix/suffix blocks beside scene breaks andOldWordsOf, so whitespace alone neither stands as a block nor marks a block edited — exactly the amendment. And the suffix keeping its trailing whitespace inside a block is right, since storing trims at a block's end only.keptis aHashSetbuilt from kept ids andRemovediscoveredminus kept, in reading order — so a scene break block keeping no id correctly forces every covered segment whose id it would have taken intoRemoved, C₂…Cₘ₋₁ included. The double-assignment in the one-segment case is beautifully harmless:blocks[0]andblocks[^1]are the same object, andOldWordsOfmatchingfirst.Idafter a leading scene break is exactly the right branch condition.Pasting_the_selections_own_paragraphs_gives_the_covered_text_back— the round-trip property. That is the one that catches off-by-ones silently, and it holds.befores,Removedbefores, blockafters), not from the test's own bookkeeping — the round-0 fix, still in place. The revert model then restores the chapter word for word including re-inserting removed rows atoriginal.IndexOf. ♡ModelParagraph's move into the domain is clean:AppendRunText,CommitExecutor.Paragraphs,ModelOutput.Readand both test projects all reference the new location (namespace only, record body byte-identical), and placing it besideInlineMarkupis right since both the append and the splice land it.RangedSpliceProperties.cscarryingSpliceCase+SpliceArbitrariesmirrorsInlineMarkupProperties.cs'sGeneratedMarkup+MarkupArbitraries, and the arbitraries reuseMarkupArbitraries.Generated()rather than duplicating the generator. DRY where it matters.Check) duplicateInlineMarkup.CheckOffset's logic — but as a private helper with its own refusal tests ("A𝒜" 2 3 → start,1 2 → end), soPlan's contract stands on its own rather than reaching intoSlice'sArgumentOutOfRangeExceptionfor itsArgumentExceptionpromise. Reasonable.Automated review by Jibril · 2026-10-10
CI: red — but read the fine print: all six suites passed on the head SHA (Domain 557, Flow 1289, OpenRouter 608+5 skipped, UseCases 762, BlazorAdapter 1166, Integration 1105 — zero failures); the
testjob died only after the last suite when the runner's cgroup hit its 1.5 GB cap (oom_kill 2, sock_throttled 5468), so the step exited 1. That is a runner memory flake, not this code — but as long as it stands red, I'm noting it, not blessing it. Re-run the job, fufu~ · Mutation probes: 2 run (prefix&&→||inwithPrefix/withSuffix— killed; missing-witness edge in the one-segment id rule — killed), 0 survived · Local suite: skipped per CI-green rule — the failing step's own log shows every suite green at the head SHA; targeted RangedSplice tests re-run locally all 40 + 5 properties green