Close the benchmark dark spots and collect the declaration tax - #185
Conversation
The six legacy adapters total 1,520 lines with zero benchmark coverage (build-warnings.md §19): every load benchmark parsed RSS 2.0 or Atom 1.0, so the RDF-shaped parse, the ISO-8859-1 + DOCTYPE wire shape real 0.91 feeds still ship, and namespace-sniffed version dispatch had no price. LegacyFeedCorpus generates all six formats with element mixes modelled on the FeedTestData fixtures and the real 1999-2005 corpus documents; LegacyFormatLoadBenchmarks prices them at 15 items (RSS 0.91's own DTD cap) and 100. Setup guards are structural because version identity is not observable (RssFeed.Version is a fixed 2.0): the PICS rating only the 0.91 adapter reads, cloud+enclosure for 0.92, an accented title a wrong decode cannot reproduce for the ISO-8859-1 arm, attached Dublin Core for the RDF item parse, and namespace-differential entry counts for Atom 0.3. A version="0.94" arm is recorded as rejected: the dispatch throws for it, so it would measure an exception and its guard could never pass. Smoke: 28 cases executed, zero guard failures, zero exceptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
Podcasting 2.0 - declared by 62% of 1,934 censused feeds - had zero benchmark coverage, and the existing extension corpus emits six families' simplest elements, leaving nine families and every rich element mix dark (build-warnings.md §19). ExtensionFeedCorpus adds three generators with cited realism sources: - GeneratePodcastFeedUtf8: census-ratio Podcasting 2.0 (§2.50) with transcripts cycling the three dominant MIME types including the unregistered application/srt, the misused copyright-notice license value publishers actually write, and iTunes at The Daily's measured per-item density including the nested category and owner that light ITunesCategory/ITunesOwner. - GenerateDeclaredOnlyProductionUtf8: the ~96% production shape - nine namespaces declared, five families used (627-feed census: geo 121 declared/5 used, georss 144/4). Detection answers on the prefix alone, so unused declarations force extension loads that find nothing; this is the shape most production loads actually take. - GenerateMaximalExtensionFeedUtf8: the ceiling document - every family, every collection-bearing element, with honesty labels for the spec-example-only families and LiveJournal's URI mismatch. PodcastFeedLoadBenchmarks prices load and the aggregator-shaped read separately; ExtensionElementMixBenchmarks prices the four density points, with guards that encode arm meaning: declared-only asserts Dublin Core IS attached and geo/GeoRSS/CreativeCommons are NOT, so a detection-semantics change fails the guard instead of silently inverting the declaration-tax arm. Smoke: both classes executed with zero guard failures and zero exceptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
The four Google sitemap extension families total 930 lines with zero benchmark coverage - SitemapVideo alone is 519 - and no sitemap writer had a benchmark at all (build-warnings.md §19). The §13.5 defect (video elements emitted in an order Google's schema rejects, green through every round-trip test) shipped in exactly this territory. SitemapExtensionCorpus generates the four families at real publisher ratios: Yoast's 14-child video shape with intra-document optionality (content_loc 1-in-3, view_count 2-in-3, non-standard width/height a parser must skip), BBC/NYT/Guardian news shapes cycling, the bare image:loc pairs that are all the wild still uses post-2022-deprecation, and GitLab's attribute-only hreflang links - the opposite parse profile from every other family. SitemapExtensionLoadBenchmarks prices each family over the plain-urlset floor at 100/1,000 URLs; SitemapSaveBenchmarks prices the writers, including the WriteXsiSchemaLocation arm (SitemapSchemaLocations was at zero) and the schema-order-pinned video save. Guards verify counts, per-arm extension attachment, and that the video optionality cycling survived generation. Smoke: 18 cases executed, zero guard failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
…L/RSD The committed samples are loaded once by FormatBreadthLoadBenchmarks, so BlogML and APML had token presence but no cost-vs-size curve, and the save table had two formats in it (build-warnings.md §19). The §17 RSS save regression was a save-vs-load asymmetry that went unpriced until its benchmark existed; this puts numbers on the remaining writers before their regression rather than after. SpecializedDocumentCorpus scales the committed samples' shapes: the approval-status mix an importer must not get wrong, category hierarchy with parentref, comments including unapproved ones, trackbacks, an embedded ~1 KB base64 attachment on every sixteenth post (where the decode cost lives), and APML's twin implicit/explicit sub-trees with a meaningful defaultprofile. SpecializedDocumentScaleBenchmarks prices both formats at 10/100/1,000; FormatSaveBreadthBenchmarks prices the four writers at fixed realistic sizes (299 outlines = the real Power BI Weekly subscription list). Smoke: 10 cases executed, zero guard failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
Every per-format LoadAsync/CreateAsync state machine sat at zero benchmark coverage except the two the polling class exercises (build-warnings.md §19). AsyncLoadBenchmarks prices CreateAsync - and through its delegation, LoadAsync - for AtomFeed, AtomEntry, Opml, Apml, BlogML, Rsd, Sitemap, SitemapIndex, AtomServiceDocument, AtomCategoryDocument and AtomEntryResource, against a sync Load anchor on the same document so the a->b delta is the async-over-sync overhead with no network in it. Transport is a stub HttpMessageHandler serving preloaded committed samples, honouring the recorded PollingSteadyStateBenchmarks decision against loopback sockets; RssFeed/GenericSyndicationFeed async is deliberately absent as already priced there. Smoke: 12 cases executed, zero failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
RealWorldExtensionLoadBenchmarks (opt-in, machine-local, per the RealWorldLoadBenchmarks precedent) loads the documents that calibrate the gapfill generators: simplecast-thedaily.xml (18.5 MB, 2,939-item podcast), gitlab-pages.xml (17,854 attribute-only hreflang links), and every rss09x/rss10/atom03 corpus document. Two documents the dispatch rejects by design (version="0.94"; the atom draft-04) are skip-listed with a guard asserting they STILL throw - if the library ever gains support, the guard fails and the skip list is the thing to delete. The §20 coverage re-run (recorded in build-warnings.md) surfaced two generator omissions, fixed here: the APML corpus lacked Author-in-Source and Applications (215 dark lines), and the maximal extension feed lacked the RFC 5005 atom:link rels that FeedHistoryLinkRelation parses (79). Both are now emitted and guard-asserted. Result: 333 benchmark cases (was 261); every class §19 named has left the zero-coverage list; Data 21->57%, Extensions 17->31%, Syndication 29->40% benchmark line coverage. What remains dark is recorded with reasons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
The gapfill baseline's top finding (§21): on the shape most production feeds take - namespaces stamped into the root and never used, a 96% declared-vs-used gap in the live census - every declaration-matched family still constructed a fresh extension and ran a full context Load per entity to find nothing, costing more than parsing six families' real payloads. FillCore now adds a presence layer after declaration matching: one walk over the entity's child elements ORs per-namespace probe masks, and only families with in-namespace content proceed to construct-and-load. Skipping is observationally identical to a Load returning false (the instance was fresh; nothing could observe its Loaded event). Prefix rebinding is honoured - the declaration walk records each matched prefix's document binding in C# InlineArray stack storage, so feeds binding "media" or "lj" to variant URIs keep attaching exactly as before; the corpus's squarespace and lj-brad documents are the proof. The sole cross-namespace family (FeedHistory reads RFC 5005 atom:link relations) is covered by a content-map-only override, deliberately kept out of the declaration map so Atom documents gain no phantom candidate. Declared-but-unused @100 items: 3,105 KB -> 880 KB (-72%), 2,188 us -> 981 us (-55%); the declaration tax (over the extension-free floor) falls 85%. Six-families-in-use -6.5%; extension-free and maximal arms unchanged; wikihow 50K sitemap unchanged at 63.50 MB. Extension-content inventory byte-identical across all 115,417 corpus records. Detail and the three instrument catches: build-warnings.md §22. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
There was a problem hiding this comment.
Pull request overview
This PR extends the benchmark suite to cover previously unmeasured hot paths (especially extension-heavy and legacy formats) and introduces a load-side optimization in SyndicationExtensionAdapter to avoid paying per-entity extension Load costs when namespaces are declared but unused.
Changes:
- Add a “content presence” filtering layer to framework extension auto-detection to skip extension probes when no in-namespace child elements exist for the current entity.
- Add new corpus generators for specialized documents (BlogML/APML) and sitemap extensions (video/news/image/hreflang).
- Add multiple new BenchmarkDotNet benchmark classes for load/save breadth, scaling, async surfaces, real-world calibration, and extension density mixes.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| Solutions/Argotic.Extensions/SyndicationExtensionAdapter.cs | Adds namespace-content presence filtering to reduce extension detection overhead on declared-but-unused feeds. |
| Solutions/Argotic.Benchmarks/SpecializedDocumentCorpus.cs | Generates scalable BlogML/APML benchmark inputs. |
| Solutions/Argotic.Benchmarks/SitemapExtensionCorpus.cs | Generates extension-bearing sitemap inputs (video/news/image/hreflang). |
| Solutions/Argotic.Benchmarks/Saving/SitemapSaveBenchmarks.cs | Benchmarks sitemap save paths including schemaLocation and extensions. |
| Solutions/Argotic.Benchmarks/Saving/FormatSaveBreadthBenchmarks.cs | Benchmarks save breadth for OPML/APML/BlogML/RSD. |
| Solutions/Argotic.Benchmarks/Loading/SpecializedDocumentScaleBenchmarks.cs | Benchmarks BlogML/APML load scaling across sizes. |
| Solutions/Argotic.Benchmarks/Loading/SitemapExtensionLoadBenchmarks.cs | Benchmarks per-sitemap-extension load deltas over plain urlset. |
| Solutions/Argotic.Benchmarks/Loading/RealWorldExtensionLoadBenchmarks.cs | Adds opt-in real-world calibration benchmark loads (podcast/hreflang/legacy sets). |
| Solutions/Argotic.Benchmarks/Loading/PodcastFeedLoadBenchmarks.cs | Benchmarks podcast feed load and a consumer-shaped “load + read” composite. |
| Solutions/Argotic.Benchmarks/Loading/LegacyFormatLoadBenchmarks.cs | Benchmarks legacy RSS variants, Atom 0.3, and RSD 0.6 loading. |
| Solutions/Argotic.Benchmarks/Loading/ExtensionElementMixBenchmarks.cs | Benchmarks extension-density scenarios including declared-but-unused “production shape”. |
| Solutions/Argotic.Benchmarks/Loading/AsyncLoadBenchmarks.cs | Benchmarks async CreateAsync surfaces with stubbed HTTP transport. |
| Solutions/Argotic.Benchmarks/LegacyFeedCorpus.cs | Generates legacy-format corpora (RSS 0.90–0.92, RSS 1.0 RDF, Atom 0.3, RSD 0.6). |
| Solutions/Argotic.Benchmarks/ExtensionFeedCorpus.cs | Generates extension-focused corpora (podcast, declared-only, maximal every-family). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| this.handler = new SampleServingHandler(); | ||
| this.client = new HttpClient(this.handler, disposeHandler: false); |
There was a problem hiding this comment.
Fixed in c96d71d — Setup() now disposes the field-initializer placeholder instances before reassigning (the HttpClient disposes its inner handler; the standalone stub is disposed explicitly). All gates and the class smoke re-verified.
…ransport Review on #184 found that MatchFrameworkProbes read ProbeMasks.Value after moving the navigator onto the namespace axis - the lazy initializer is the one thing on that path that can throw (the >64-probe guard), and a throw there would strand the navigator on a namespace node, contradicting the method's restore contract. Rather than the suggested try/finally, both walks (MatchFrameworkProbes and FilterByPresentContent, which inherited the shape) now resolve the lazy BEFORE the first move, making the documented "nothing that can throw runs between move and restore" invariant true again at zero cost. Review on #185 found AsyncLoadBenchmarks.Setup replacing the field-initializer HttpClient/handler instances without disposing them; they are now disposed before reassignment. Verified: extension inventory byte-identical (115,417 records), wikihow 50K and plain-RSS allocations unchanged, all five gates green, async class smoke clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
The four generators built their documents from hundreds of StringBuilder.Append calls full of escaped quotes and \n suffixes; the XML was unreadable as XML. They now use C# raw string literals - static sections as plain blocks, per-item sections as interpolated blocks with CultureInfo.InvariantCulture passed to the handler overload (CA1305) - so the emitted document reads directly off the source. One idiom is documented in each file header: every block ends with a blank line, because that final empty line is what encodes the trailing newline the closing delimiter would otherwise swallow. Byte-identity is proven, not assumed: a hash sweep of all 17 generators across 11 counts chosen to exercise every shape-cycle branch (%2 %3 %4 %8 %10 %16 %33) - 187 SHA-256 hashes - is identical before and after. The sweep caught one real slip during the rewrite (a 2-bytes-per-item indent loss in the podcast episode ternary), which is exactly the instrument working. Committed baselines and calibration numbers therefore remain valid unchanged. All five gates green; the full gapfill suite executes with zero guard failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp
1ea18be
into
perf/extension-adapter-allocations
Summary
Stacked on #184. Two deliverables: the benchmark suite now covers every hot-path-relevant area §19's coverage analysis found dark (with realistic, census-calibrated corpora), and the first optimisation driven by the new data — a 72% allocation cut on the feed shape most production RSS actually takes.
Part 1 — the gapfill benchmark suite (6 commits)
Four corpus generator files and nine benchmark classes (category
gapfill), 333 cases total (was 261). Every element mix cites its realism source: the 627-feed and 1,934-feed censuses, the corpus manifests, the committed samples and scenario fixtures. Every generator is guard-verified in setup, several encoding arm meaning (the declared-but-unused arm asserts Dublin Core IS attached and geo/GeoRSS are NOT, so a detection-semantics change fails loudly instead of silently inverting the arm).Covered: legacy formats (RSS 0.90–1.0 incl. the real ISO-8859-1+DOCTYPE wire shape, Atom 0.3, RSD 0.6), Podcasting 2.0 at census element ratios with iTunes at real podcast density, the declared-but-unused production shape, a maximal every-family ceiling, Google sitemap extensions (video/news/image/hreflang) for load AND save, BlogML/APML scaling curves, save breadth for OPML/APML/BlogML/RSD, the per-format async surface (stub-handler transport per the recorded no-sockets decision), and opt-in real-corpus calibration arms.
Coverage proof (in-process instrumented run, §20): benchmark line coverage Data 21→57.5%, Extensions 17→31%, Syndication 29→40%; every class named in the plan left the zero list. Baseline dataset (full BenchmarkDotNet JSON + markdown, quiet-window certified by a control arm) preserved for future regression analysis; both synthetic-vs-real calibration gates passed (podcast 1.27× vs an 18.5 MB real feed, hreflang 1.72× vs GitLab's).
Part 2 — the declaration tax, collected (1 commit)
The baseline's top finding: on feeds that declare extension namespaces without using them — a 96% declared-vs-used gap in the live census, i.e. the WordPress shape — every declaration-matched family still constructed a fresh extension and ran a full context
Loadper entity to find nothing, costing more than parsing six families' real payloads.SyndicationExtensionAdapter.FillCorenow adds a presence layer: one walk over the entity's child elements ORs per-namespace probe masks (C#[InlineArray]stack buffers,FrozenDictionarymaps), and only families with in-namespace content proceed. Prefix rebinding is honoured via tracked document bindings (real feeds bindmediato at least three URIs); FeedHistory's RFC 5005 cross-namespace reads are covered by a content-map-only override.Verification
🤖 Generated with Claude Code
https://claude.ai/code/session_01CBG52K8XTzAPk4Vat1pnhp