| id | 196 | |
|---|---|---|
| title | Lazy SectionParagraph text — defer ExtractPlainText until a caller asks | |
| status | ✅ | |
| model | opus | |
| depends-on |
|
|
| summary | Plan 195 cut the engine-bench allocs from 764 k to 635 k. The biggest remaining controllable allocator is the per-paragraph string materialised by astutil.buildSectionParagraphs — 1.3 M bytes.Buffer.String allocations across the 10-iteration BenchmarkCheckCorpusLarge run. paragraph-readability skips short paragraphs after counting their words, so the pre-allocated text is wasted for every paragraph under the minWords floor. Replace the eager Text field with a lazy computation keyed off a stored AST node reference, and add mdtext.CountWordsInNode so the gate runs without allocating. |
Move the per-paragraph ExtractPlainText call out of
astutil.buildSectionParagraphs. The eager call runs
for every paragraph today. The lazy call runs only when
the rule actually wants text.
On the engine bench this saves ~45 k allocations per iteration — every paragraph that paragraph-readability skips for being under minWords. On real prose the saving is smaller. Most prose paragraphs cross minWords. There the change is net-zero: the same ExtractPlainText runs, just later.
Plan 195 tightened the
per-rule alloc budget and cut the engine bench by 17 %.
The remaining top controllable allocator is the string
copy mdtext.ExtractPlainText produces per paragraph.
That copy accounts for 1.3 M objects out of 8.3 M total.
The cost lives in astutil.buildSectionParagraphs. The
plan-195 profile traces the call site to
paragraphreadability.Rule.Check via the
per-File memo.
paragraphreadability.Rule.Check reads the paragraph
text purely to compute its word count and ARI index. A
paragraph under minWords (default 20) is skipped
before the index runs, so its text is allocated and
discarded. The synthetic engine corpus' "This is a
synthetic sentence ..." block is 13 words long. Every
one of the 45 k paragraphs the bench parses falls
below the floor.
The other consumers of SectionParagraph.Text are all
opt-in:
- paragraphstructure (MDS024) reads
p.Textfor sentence segmentation. - requiredtextpatterns (MDS057) and
requiredmentions (MDS058) read
Textthrough theSectionBodyhelper. - duplicated-content (MDS037) is opt-in.
Eager materialisation made sense when every consumer walked the same text. Lazy materialisation is the right shape now that one consumer — the default-on paragraph-readability — only needs the word count.
Two changes in internal/rules/astutil/:
-
Add
Node ast.NodetoSectionParagraph. Stop computingTextinbuildSectionParagraphs; the field stays as a documented cache (callers can populate it for test construction) but the engine sets onlyLineandNode. -
Move text production to a method:
func (p SectionParagraph) ExtractText(source []byte) string { if p.Text != "" { return p.Text } return mdtext.ExtractPlainText(p.Node, source) }
The Text shortcut keeps existing test literals working without forcing them to construct AST nodes.
One change in internal/mdtext/:
- Add
CountWordsInNode(node ast.Node, source []byte) int— an AST-walking word counter that produces the same numberCountWords(ExtractPlainText(node, source))would, without materialising the string. The semantics across child nodes matchExtractPlainText's concat shape: a word boundary is a whitespace rune or the boundary between two text segments whose joined run does not contain whitespace. Verified by an equivalence harness over the existing fixture corpus (every paragraph ininternal/rules/MDS023-paragraph-readability/produces the same count both ways).
Per-rule wiring:
- paragraph-readability uses
CountWordsInNodefor the gate; callsExtractText(f.Source)only for paragraphs that passminWords. - paragraphstructure, requiredtextpatterns,
requiredmentions, duplicatedcontent call
ExtractText(f.Source)per paragraph. Net-zero versus today (they always materialise). SectionBodytakessource []byteand callsp.ExtractText(source)per paragraph.
- Add
mdtext.CountWordsInNode. Cover with a table-driven test that pins each case theextractTextswitch handles (Text, String, CodeSpan, Image, Link, Heading, nested emphasis, SoftLineBreak, HardLineBreak). - Add an equivalence harness that runs every
paragraph in
internal/rules/MDS023-paragraph-readability/good/andbad/through bothCountWords(ExtractPlainText(...))andCountWordsInNode(...); the two counts must agree for every paragraph. - Add
Node ast.Nodetoastutil.SectionParagraph. UpdatebuildSectionParagraphsto setNodeand not setText. Keep theTextfield on the struct so test literals continue to compile. - Add
(SectionParagraph).ExtractText(source []byte) stringthat returnsTextwhen non-empty and falls back toExtractPlainText(Node, source)otherwise. - Update
SectionBodyto take(paragraphs, source, start, end)and callExtractTextper matched paragraph. Update its tests and its two callers. - Update paragraph-readability to use
CountWordsInNodefor theminWordsgate andExtractTextfor the index calculation. - Update paragraphstructure to call
p.ExtractText(f.Source)rather than readingp.Textdirectly. - Update requiredtextpatterns and requiredmentions for the SectionBody signature change.
- [N/A] Update duplicatedcontent for the same change
(it also reads paragraph text). — Verified
inapplicable: MDS037's
extractParagraphswalks the AST directly vian.Lines()and reads raw source bytes; it does not useastutil.SectionParagraphorExtractPlainText. Nothing to change. - Re-run BenchmarkCheckCorpusLarge and
BenchmarkPerRuleAllocBudget. Expected on the
synthetic corpus: allocs/op drops from ~635 k to
~590 k (the ~45 k paragraph-readability skips), and
MDS023 paragraph-readability's gate number drops
from 10 to ~7. Update the engine-bench
Allocsbudget and the grandfather map accordingly. Measured: Large dropped from ~634 k to ~553 k (a bigger win than projected — every per-paragraph string allocation is gone for the synthetic corpus, not just the skipped ones), MDS023 from 10 to 8 allocs/op. Engine-bench budget tightened to 670 k / 70 k; no grandfather row needed (MDS023 stays under the ≤ 10 ceiling). - Run
go test ./...,go test -race ./...,go tool golangci-lint run,mdsmith check .. Non-race suite passes in full. Under-race,paragraphstructure.TestSentBufPool_ClearReleasesStringReferencesflakes — verified pre-existing bygit stash-ing the plan-196 diff and re-running; the same test still failed on the unmodified base. Plan-196 touched packages (mdtext, astutil, paragraphreadability, paragraphstructure, requiredtextpatterns, requiredmentions) pass-racecleanly when that one test is skipped via-runexclusion.
The Text shortcut keeps existing tests compiling. A
rule that constructs a SectionParagraph literal with
only Text set, no Node, still works. But the
shortcut also hides bugs: a caller that loses the
Node field can silently fall back to the cached
Text string. Mitigation: this only matters for test
code. Existing test literals assert on Text
directly, not via ExtractText. The plan reads every
SectionParagraph{...} literal in the test corpus
before landing the rule wiring.
CountWordsInNode has to match the existing
CountWords(ExtractPlainText(...)) chain byte-for-byte
on the corpus. The equivalence harness in task 2 is the
gate. Drift fails the test on the next run.
The signature change to SectionBody ripples through
three rule packages. Each one is small (single
function call), but a forgotten caller emits a build
error rather than a silent semantics drift.
-
BenchmarkCheckCorpusLargeallocs/op drops by at least 30 000 (the wasted-extract bound from the synthetic corpus). The new lower number is pinned in the engine-benchAllocsbudget. Measured drop ~81 000 (634 k → 553 k); budget moved from 760 k to 670 k. -
mdtext.CountWordsInNodematchesCountWords(ExtractPlainText(...))on every paragraph in the MDS023 fixture corpus. Pinned by theTestCountWordsInNode_EquivalentToCountWordsExtractPlainTextharness underinternal/rules/paragraphreadability/. -
BenchmarkPerRuleAllocBudgetreports MDS023 paragraph-readability at ≤ 10 allocs/op on the shared fixture without a grandfather row (its pre-plan-196 baseline of 10 was a "just barely" pass). Measured 8 allocs/op. -
mdsmith check .passes. -
go test ./...passes in full. -
go test -race ./...passes for every test this plan touched (mdtext, astutil, paragraphreadability, paragraphstructure, requiredtextpatterns, requiredmentions).paragraphstructure.TestSentBufPool_ClearReleasesStringReferencesflakes under-raceon main (verified pre-existing by an A/B withgit stash; see task 11 note above); the flake is unrelated to this plan and is left as-is. -
go tool golangci-lint runreports no issues.