Skip to content

Feature/benchmarks - #47

Draft
multiarc wants to merge 156 commits into
mainfrom
feature/benchmarks
Draft

Feature/benchmarks#47
multiarc wants to merge 156 commits into
mainfrom
feature/benchmarks

Conversation

@multiarc

@multiarc multiarc commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Related issue

How was this tested?

Checklist

  • I have read, understood, and tested this change myself (not unverified tool output).
  • Tests were added/updated and dotnet test src/Heddle.Tests passes locally.
  • My changes follow the project's .editorconfig / surrounding code style.
  • All commits are signed off for the DCO (git commit -s).
  • If AI/code-generation tools were used, I disclose it here and take responsibility for the result.

AI-assistance disclosure (if any)

multiarc and others added 30 commits July 21, 2026 22:26
Phase 1: five new workloads + 20 twins, parity contract v2 gates (N3b),
golden corpus with export-corpus/verify-corpus verbs, five new benchmark
suites. Phases 2-6: Rust (Askama/Tera + Criterion), JVM (JTE/Thymeleaf +
JMH), JS (Handlebars/Eta + mitata), Python (Jinja2/Mako + pyperf), Go
(text/html/template + templ) harnesses under benchmarks/<eco>/ - each
with all 32 controlled+idiomatic cells passing the byte gate/verifier.
Phase 8: linux-crosscheck tooling (validate.py, launchers, env capture);
full gate sweep green on WSL Ubuntu 24.04 at exact toolchain pins.

Measurement/publication runs deferred to a dedicated session; Phase 7
awaits published runs per its go/no-go gate. Errata E1-E3 recorded in
docs/spec/records.md.

Bug fixes related to type resolution

Signed-off-by: Aliaksandr Kukrash <multiarc@gmail.com>
Signed-off-by: Aliaksandr Kukrash <multiarc@gmail.com>
Signed-off-by: Aliaksandr Kukrash <multiarc@gmail.com>
Signed-off-by: Aliaksandr Kukrash <multiarc@gmail.com>
Two strands of work, both driven by gaps the 2026-07-25 run exposed.

## The report (complete)

Publish docs/benchmarks/2026-07-25 and withdraw 2026-07-22, whose JS render
figures were invalidated by the ConsString rope defect.

Present workloads in two tiers rather than protocol order. The boundary is the
CLR's 85,000-byte Large Object Heap threshold: .NET strings are UTF-16, so a
page crosses it at 42,500 characters, and past it copy throughput collapses 4.5x
(measured: 40.3 B/ns at 84,800 B, 8.9 B/ns at 108,680 B) with every render
driving a full Gen2 collection. Five workloads sit below it, three above. No
other ecosystem has this cliff. Tier order is DERIVED and owned by
consolidate.py -- no table in a published directory is ordered by hand.

Fix the implied-throughput numerator: it divided by the golden byteLength, which
is the NORMALIZED form, understating composed-page by 1.56x and hiding a cell
above the plausibility ceiling. consolidate.py now emits summary-tables.md as a
second generated artifact, which index.md embeds, so the narrative page
transcribes no measured figure by hand.

## The new .NET harness (W1-W7 of 12)

benchmarks/dotnet/, structured like the other five ecosystems, replacing
src/Heddle.Performance as the cross-stack .NET leg. The old project is still
intact and still builds; it is not retired until the remaining work items land.

- Corpus relocated to benchmarks/dotnet/GoldenCorpus and all six harnesses plus
  consolidate.py repointed. Bytes and SHA-256s unchanged; every gate re-verified.
- Razor now covers ALL EIGHT workloads, up from one. The "1.37x faster than
  Razor" claim previously rested on the single least favourable workload, and
  the gap had to be published as a caveat.
- Idiomatic track added for .NET (51 templates, 6 engines), discharging Phase 1
  D15 by its own recorded revisit trigger.
- All six Heddle render techniques implemented and gated: runtime and
  precompiled backends across string, TextWriter and UTF-8 IBufferWriter sinks.
  Only the runtime UTF-8 sink is wired into the cross-stack sweep -- one engine
  contributes one row -- and it is the fairest row, since the other five
  ecosystems all emit UTF-8/Latin-1 and never cross the LOH boundary.

Three things the harness makes explicit rather than assuming:

- Precompiled coverage is DISCOVERED from the manifest. The generator silently
  leaves uncovered templates un-precompiled and the engine serves them from the
  dynamic path, so an assuming harness would time the runtime backend under the
  precompiled name. Real coverage is 4/8 and the gate prints it.
- Sinks CHECKSUM rather than count. A counting writer proves a length was
  computed, not that characters were produced -- the defect class behind both
  the retired sink suite and the JS rope artifact. Verified by injecting a
  count-only body and watching the differential fail.
- Razor's default HtmlEncoder numeric-escapes non-ASCII, which N5 does not
  canonicalize; it inflated encoded-loop by 125,000 characters. Fixed by
  configuring the encoder (contract v2 D2's preferred remedy), not by widening
  the gate.

Gate: 140 passed, 0 failed. Selftest: 138 passed, 0 failed, including N3b byte
lengths matching the JS reference exactly on all eight workloads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves the duplication documented in docs/research/generator-code-sharing/:
every rule the source generator and the runtime engine maintained as two
hand-kept copies moves into one shared artifact, and the fifteen verified live
drifts are fixed.

Phase 0 (gate) — the suite can no longer pass through an unintended
precompiled→dynamic fallback: FallbackGuard sentinel + Strict policy,
RenderViaResolver in registry-only and file-backed modes, registry isolation
with a leakage canary, a gauntlet-crossing corpus sweep, and a seeded-mismatch
meta-suite whose negative control pins the pre-phase-0 world. Known drifts
landed as quarantined red fixtures handed to their owning phases.

Phase 5 — content hash, key↔path derivation, .heddle extension rule,
schema/engine versioning, option names/defaults. Fixes the hash-input mismatch
(BOM'd/UTF-16 templates were permanently StaleContent), the silent key
fallback, the fabricated engine version, and dead item metadata. Claims
HED7018/HED7019/HED7020; replaces the generator's blanket catch with a
per-template error under the Q2.2 fallback-legitimacy ruling.

Phase 2 — one implementation of every byte-affecting shaping machine. Fixes the
WidenToWholeLine clamp drift (the generator was wrong on all three sub-cases);
DocumentShaper.cs 398→118 lines.

Phase 4 — shared Roslyn-free rule tables for numeric kinds, operator legality,
member visibility, hop form, literal formatting and overload rank. Fixes the
ToString("R") round-trip (a build-machine-dependent value difference) and
unguarded binary emission.

Phase 6 — one diagnostic identity across build tier, run tier and editor:
shared catalog, projection, one line-index rule, one alias table. Fixes
forwarded-warning ID/Fix loss and the line-index \r mismatch; LSP gains full
options parity and its default profile aligns Text→Html.

Phase 3 — one rule-core each for extension identity/discovery, function
exports, prop layouts, assignability, member paths and model type names. Fixes
false HED7006, the nested/generic AQN mismatch (discovery *and* formatting) and
the BranchRole mirror. Claims HED7021/HED7023; fixes the runtime's
order-dependent short-name tie in lockstep per Q3.5.

Phase 1 — emitter↔runtime drift. Fixes the needsLocals scan and per-carrier
flags, unknown-@Profile silent acceptance, and DefaultConvertible gaps. Claims
HED7022/HED7024.

Quarantine register emptied: every red fixture earned green by its owning
phase, none deleted or weakened (phase 1's F11 reshaped, documented in place).
Suite 2630 → 4808 passing, 0 failing, 0 skipped on the legs that run.

Known outstanding items are recorded in docs/generator_plan/README.md
(post-implementation review findings + the compile-channel drain gap).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A review subagent reverted a mutation experiment with `git checkout` while the
whole program was still uncommitted, resetting PrecompiledGauntlet.cs to its
pre-program state. Three pieces were lost; all three are restored here from
their specs and red tests:

- Phase 3 OQ4 — the prop-layout fingerprint check in the extension-binding arm.
  The AQN check proves both sides name the same type, not that it still lays
  its [Prop] slots out the same way; without this a package that re-orders a
  slot renders into the wrong slots. Vacuous when the fingerprint is absent, per
  the additive-schema contract. Specified by PropLayoutFingerprintTests.
- Phase 5 D2 — both staleness path reconstitutions go back through the shared
  TemplateKey.ToPath instead of hand-rolled Path.Combine (behaviourally
  identical; the regression was to the shared-artifact claim, not behaviour).
- Phase 6 WI5 — the gauntlet's HED7101 const points at the shared
  HeddleDiagnosticIds row again, as its sibling in PrecompiledTemplates does.

HashFile's decode-then-hash and the AqnFormatter routing had already been
restored by the reviewer from its own context; both verified correct here.

Suite green: 4808 passed, 0 failed, 0 skipped on the legs that run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WI1-WI10 verified intact against source and the plan's Implementation record.
The hand-restored TemplateKey.ToPath call sites and HashFile body confirmed
faithful by their original author.

One residue found: 'using System.Globalization;' in PrecompiledGauntlet.cs,
whose only consumer was the CultureInfo.InvariantCulture inside the ToHex that
WI1 deleted. The revert restored the using without the code. Removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tring

Tranche A + B verified intact. The hand-restored prop-layout fingerprint check
was behaviourally faithful but wrong in its detail string:

- it broke the file's uniform "<Thing> 'name': manifest=X live=Y" shape; and
- it dropped the <none> sentinel. PropLayout.Fingerprint returns null when the
  live extension declares no [Prop] at all -- the package-removed-them case,
  the most diagnostic one -- and interpolating null rendered 'live=)', which
  reads as a formatting bug rather than a finding.

Root cause was in the original work, not the reconstruction: the test asserted
only Contains("prop layout"), so any message shape passed. The detail is now
pinned in full, and a new test covers the null-live-fingerprint path, which had
no coverage before.

Also reworded phase 3's Implementation record, whose '2 skipped' parenthetical
had gone stale once phase 1 un-skipped the last fixture, and added a
restoration note recording what was destroyed and why the drift was invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The audit ran under two transient API failures; its in-flight work is complete
and verified here.

D12.5 was the adversary reviewer's finding: the plan asserted that 'a host that
drops a channel turns the suite red', but no test compared the LSP drain
against the generator drain -- what shipped was unit tests of the drain rule
plus one single-template check. Now closed. DiagnosticCorpusVectors is one
shared table asserted by all three hosts (runtime, build tier, editor), so they
are compared to each other instead of to three sets of hand-written
expectations that can drift apart in exactly the way phase 6 exists to stop.

Also: catalog row count corrected 80 -> 82 in the README (the plan record had
already been corrected).

BOM investigation closed as a non-finding. Two goldens did lose a UTF-8 BOM
(Example5_DefinitionWithProps, Example7_FunctionShimCall), but the pre-program
baseline was itself inconsistent -- those two carried a BOM and the other six
did not. The program normalised all eight to no-BOM, which is homogeneous and
matches what Verify emits. Recorded rather than reverted.

Suite: 4930 passed, 0 failed, 0 skipped on the legs that run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lse rationale

Done inline: the phase-0 subagent died to four consecutive API 529s without
starting, and the queue should not stall on a server-side outage.

Coverage gates tightened -- both were reported by the verifying reviewer:
- The precompiled-corpus gate asserted '>= 25' against an actual 40, so fifteen
  templates could stop precompiling with nothing going red. Now an exact count,
  with a comment saying a change to it is a deliberate act.
- Five corpus tests carried 'if (dir == null) return;', which turns the whole
  gate into a silent no-op if the build layout ever changes -- zero signal,
  reported as a pass. All five now fail loudly with a message saying how to
  build the corpus.

D7's third seed added. The meta-suite had triples for content hash and
extension AQN but none for the options fingerprint -- gauntlet step 1, the
earliest check, previously unseeded. D7 calls this seed 'wrong fingerprint
arity'; a literal arity change would not compile the manifest, so what is
seeded is a wrong fingerprint *value*, the observable form of the same
condition. Strict throws OptionsMismatch, the sentinel fires, and the unguarded
negative control renders identical bytes.

D4's rationale corrected. It claimed 'the corpus is the union of the feature
templates', and that claim was load-bearing for the whole coverage argument.
It is false: feature suites build templates as inline strings, so ~130 feature
tests never cross the gauntlet. Criterion 2 is likewise restated as what the
suite actually asserts -- exact-count gauntlet coverage over the precompiled
corpus, plus byte parity on the model-less subset in both sub-modes -- rather
than the corpus-wide byte parity it claimed and never had. The residue is
recorded as open, not quietly closed.

Suite: 4936 passed, 0 failed, 0 skipped on the legs that run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…delivered

No restoration damage: DocumentShaping.cs verified byte-faithful to the
pre-program runtime method-by-method, with only the two licensed differences
(the safe ApplyRemove/Replace pair, and comments promoted to doc comments). The
clamp fix is intact across all three sub-cases, and the observer re-hosting
preserves the pre-extraction HED300x ordering exactly.

Pin 7 could not have caught the divergence it was written for. Inside the strip
machine, BranchKind.Participant and Other are extensionally equal -- both do
stripPrev = null -- so no assertion over the working document or the rebased
positions can separate them. Deleting the Participant arm reddens nothing. The
plan's risk row said as much, but the pin was recorded as though it discharged
the risk. The constraint now sits where it actually exists: enum arity pinned so
a backslide to four kinds is a red build, plus a recording observer asserting
that a [ScopeChannel] non-role chain is reported Participant rather than Other,
that no gap crosses it, and that events arrive classified -> gap -> completed
(the ordering every re-hosted diagnostic depends on, previously unpinned at
machine granularity). The residual unpinnable case is stated in the test's own
doc comment.

Nine surviving mutants closed. The landed pins covered the three classification
*classes* but never an *equality*: six boundary comparisons in
ShiftBySkippedTokens, two in ShiftListsAfter's chain-enclosing arm, and the
TrimHiddenRemnantLines already-removed-span guard. Pin 2's 'two comments on one
line' row did not constrain the guard it named. Two other survivors are
provably extensionally equal and are recorded as such rather than 'fixed'.

Q2.1's pin was half-missing: WI8's criterion demands red if *either* side
reintroduces the empty-default-chain skip, but the only test drove the generator
half. The runtime half is now pinned in the pass-order lockstep suite.

WI5's benchmark recorded as undelivered rather than argued. Its done-when
demanded allocation-neutrality 'proven, not argued'; it was never measured and
the omission was disclosed nowhere. A credible paired run needs a mutation held
across a multi-minute BDN run -- the posture that caused the contamination these
audits exist to clean up. The standing argument is now labelled as an argument,
and it corrects WI9's claim: render allocation is unchanged by construction, but
the compile path did gain three allocations per body (one display class, two
capturing delegates) that WI9 glossed as zero.

Suite: 4986 passed, 0 failed, 0 skipped on the legs that run (was 4936).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…le floor

Docs-only for the plan: phase 7 is authored as proposed, not started, because
its territory (src/Heddle.Tests/TestTemplate/) was under concurrent audit.

The principle lands as a standing rule, not just a plan's motivation:
testing-standards.md gains a Test-input single-sourcing section headed "a
duplicate test input is a duplicate rule one level up", with ledger entry E9
recording it as additive and as the mechanism E8's corpus-contribution bullet
had asked for without specifying.

Load-bearing design decisions: a shared MSBuild props file rather than a new
csproj or embedded resources (the contract under test is file-based -- RootPath,
the .heddle rule, HashFile's FileStream+BOM decode); a compile-checked intent
table on two orthogonal axes (Tier x Render) with mandatory justification and
bidirectional completeness gates; and set-equality gating rather than a count,
because a count is still rubber-stampable by editing one digit whereas set
equality can only be made green by naming the file and writing why.

Also tightened here, found by the phase-7 survey and missed by the phase-0
audit: CorpusDifferentialTests asserted a ">= 40" floor against an actual 62, so
a third of the corpus could disappear silently. Same anti-pattern as the
">= 25"-against-40 gate fixed in 45154fd, which is itself the argument for
phase 7's D5 replacing both with set equality.

Suite: 4986 passed, 0 failed, 0 skipped on the legs that run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ants

Note on provenance: part of this audit's work (the TemplateEmitter fold,
GeneratorNumericTableAdoptionTests, and an intermediate LiteralRoundTripTests)
was swept into commit 8f9c1fe by an orchestrator "git add -A" while the audit
was still running. No mutation residue reached any commit -- verified across all
eight. This commit carries the remainder.

The duplicate numeric table is folded. TemplateEmitter.IsImplicitNumericWidening
was a live second copy of the C# 10.2.3 widening table with five call sites and
ZERO test references -- the record's claim that "the lockstep test covers both
existing copies" was false, because that test transcribes the runtime's body
only and phase 1 never adopted the shared table. It now delegates to
NumericTable.IsImplicit via SymbolFacts.ToNumericKind, with the deleted body
kept verbatim as a characterization pin swept exhaustively over SpecialType
squared in three directions. Two record claims corrected in place.

Drift #9's guard strengthened, and its limit stated. Reverting G17/G9 to "R"
reddened exactly one test -- a literal string assertion -- while all four
round-trip legs (60,000 values plus corners) passed under the bug, because "R"
IS shortest-round-trippable on CoreCLR. The guard is now a format-identity
assertion, and the same mutation reddens 23 cases across 5 methods. What stays
unverifiable here is the defect itself: "R" only mis-round-trips under a .NET
Framework host, so G17's sufficiency can only be observed on net48. A revert is
caught everywhere; the Windows check remains the only thing that closes #9.

Four surviving mutants, each a real hole, each now killed and re-mutated dead:
- Cast-pinning deleted entirely reddened nothing. D10's whole mechanism was
  unobserved -- only the ranker's refusals were covered. Harmless for built-ins
  today, but phase 3 routed arbitrary host export signatures through the same
  path, where WI10's 0-of-480 measurement explicitly does not carry.
- Unary and ternary guards deleted together reddened nothing, despite the
  success criteria naming all three arities. Real divergences behind it,
  including a ternary that is CS0173 in the consumer's build.
- The shift row's degrade arm deleted reddened nothing. C# has no <<(int, long),
  so "Count << Big" was CS0019 in the consumer's build precompiled and rendered
  at run time -- precisely the class this phase exists to close, on a row no
  fixture reached.
- The string-+ row promoted to Supported reddened nothing, because every
  fixture partner's ToString happened to match C#'s overload choice.

Also: the OQ3 internal-property criterion rested on a fixture no test used
(Order.Secret was added for it and never referenced), so the criterion was
carried by a unit pin over a Heddle.Tests-internal type -- a different
accessibility situation from generated code in a third assembly. Now covered on
both tiers. And bool & bool? is confirmed contained, unchanged, and pinned for
the first time, with the contrast that bool == bool? does get positioned
HED1008.

Suite: 5108 passed, 0 failed, 0 skipped on the legs that run (was 4986).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Q7.* questions were written into the phase-7 plan and never reached
docs/generator_plan/open-questions.md, which is the authoritative register. That
bookkeeping failure is the reason this section exists.

Sweeps every unresolved question the two post-implementation reviews and the six
phase audits raised, not just phase 7's: Q7.1-Q7.4 (corpus scope, benchmark
convergence, written artifacts, stage-5 boundary) and Q8.1-Q8.8 (the overload-tie
silent degrade against the match principle, the P1 gate mechanism, the two
generator-only shared cores, [ExportExtensions], StripGlobal's hard-coded
assembly name, the compile-channel drain, native-expressions deviation 1, and
net48/net6.0 verification).

Each carries a stated default so work can proceed under a named assumption if a
ruling does not arrive -- the convention the resolved sections already use. The
file header no longer claims everything is resolved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Q7.4: all migration stages including 5 land inside phase 7 -- the D4 coverage
residue is closed completely rather than left as a tail; each stage keeps its own
byte-neutral gate so the open-ended scope is bounded by per-stage acceptance.

Q8.1: the overload-tie silent degrade becomes a build error, HED7025. The
generator must not stay silent about an illegality it has already proved.
Accepted consequence: a project with an ambiguous overload call that builds green
today starts failing -- the same posture as phase 5's emitter fault and phase 3's
HED7021. The side condition is mandatory (report only when no argument estimate
is Unknown), and phase 4's fixture must stop pinning the silence.

Q8.2: raise MinSupportedSchemaVersion to 4. The break ships as declared at
version 2.1 with no compatibility shim, but the gate must reject cleanly rather
than advertise a support window the metadata cannot honour -- and the rejection
must be demonstrated by a manifest fixture built at the old schema, not asserted.

Q8.3: fold the runtime onto ExtensionRegistrationRules and TypeSpelling.
Acceptance is that mutating each shared rule reddens at least one RUNTIME test --
the property whose absence made them transcriptions rather than sources of truth.

Q8.4: model [ExportExtensions] in generator discovery. Acceptance requires a
fixture using an extension in an assembly without the attribute, since no test
uses such an assembly today.

Q8.5: fix StripGlobal's hard-coded assembly name, TDD -- the reproducing fixture
lands red first, because the defect's whole character is that no fixture reaches
it.

Unruled and standing at their defaults: Q7.1 (editor templates stay separate),
Q7.2 (props serves Heddle.Performance; template sets do not converge), Q7.3
(delete the written artifacts), Q8.6 (compile-channel drain stays unscheduled),
Q8.7 (native-expressions deviation 1 fixed in a docs pass), Q8.8 (drift #9 stays
unclosed until the Windows net48 run).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Q7.1: keep the editor-tier templates separate. No EditorOnly tier; the intent
table's three-value Tier axis stands.

Q7.2: leave Heddle.Performance alone entirely -- a benchmark effort is mid-flight
there. This overrides the plan's default, which had the shared props file serving
it so the path-traversal helper would die everywhere. It now serves the four test
projects only, and the benchmark project keeps its traversal helper. Recorded as
ACCEPTED RESIDUE in both the register and the plan's non-goals, explicitly so a
later reader does not mistake it for an oversight and 'helpfully' fix it: the
failure class D2 eliminates is gone from the test suites and survives in the
benchmark project until the benchmark work settles.

Q7.3: relocate the six written artifacts rather than delete them. Also overrides
the recorded default. WI4's done-when now requires the artifacts still to exist at
their new location, not merely to be absent from the glob.

With Q7.4 already ruled, phase 7's open-questions section is fully resolved and
now points at the register as authoritative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Q8.7's ruling, widened from one wrong sentence to a planned sweep. Docs-only;
proposed, not started.

The survey found the anchor defect was not alone. native-expressions.md carries
FOUR false normative claims, not one -- and because the authority convention
points at that document first, each is a latent bug rather than a typo:
- user-defined operators are documented as honored; they are honored for
  arithmetic/relational/equality/coalesce and NOT AT ALL for bitwise, shifts or
  any unary, where FailBinary/FailUnary fire before the factory;
- the shift row documents an int right operand, but any integral is accepted and
  converted, so a long-shift expression compiles here and is CS0019 in C# -- the
  doc UNDERSTATES, so matching code to it would break working templates;
- lifted operands are documented as covering relational AND equality exactly as
  C#, but lifting is numeric-path only.

The sharpest inversion: the tree's most accurate description of
native-expression semantics is NativeOperatorRules.cs, whose comments document
every divergence correctly beside the verdicts that encode them -- while the
document the authority convention designates is the least accurate. Hence D3
narrows the convention: a normative doc outranks code only for claims that are
gated or dated-verified; an ungated claim is evidence of intent, not authority.

Diagnostics are the clearest split. All 24 HED70xx are triple-gated and
drift-free across three phases, while 20 of 82 shipped IDs appear in no
published document and HED1xxx -- whose registry-designated owner IS
native-expressions.md -- is 2 of 17. HED1008, the error the anchor defect is
about, is documented nowhere.

Stale citations are not where anyone expected: all 830 file-and-line references
sit in generator_plan and research (86 past EOF), and the published docs and
specs carry zero. So class S is scoped as a gate only, never a hand-fix.

Three code defects escalated rather than fixed, per D2 (the sweep corrects
documents, never code): the id-less nullable-bool bitwise error, floor of an int
reaching HED1013, and a shipped sample still passing the removed Name item
metadata.

Version sweep assigned to Q8.2, not here: 2.1 IS the declaration of the binary
break and must ship atomically with MinSupportedSchemaVersion = 4 (verified
still 1) and the old-schema fixture. The surface is 13 must-change files plus 5
coupled, not nine, and the riskiest is the VS Code extension's PINNED_VERSION,
outside any check today.

Four new open questions recorded with defaults: Q8.9 (convention placement and
retroactivity), Q8.10 (phase 7 slip), Q8.11 (centralise the version elements),
Q8.12 (the sample's stale metadata).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sions]

Both ruled by the user after the post-implementation reviews. Phase 1's audit is
editing this tree concurrently; its files are deliberately excluded here and land
separately.

Q8.3 -- the two "shared" cores had no runtime caller, so they were transcriptions
rather than sources of truth. Baseline reproduced before fixing: mutating
ExtensionRegistrationRules.Resolve's Conflict verdict killed only the direct table
assertion and no behavioural runtime test, exactly as the reviewer reported.

Now TemplateFactory.AddExtensions resolves every name collision through Resolve
and switches on the verdict, LoadExtensions sorts by OrderingKey, and
ReflectionHelper.ResolveType drives TypeSpelling.TryResolve through a new
reflection ITypeLookup adapter -- deleting its duplicate ExtractGenericArguments,
TryFindMatchingAngleBracket, SplitTopLevelArguments, ResolveGenericType,
ResolveArrayType and the tuple regex (-185 lines). ResolveSimpleType stays
deliberately: the assembly-scan index, the dot-to-plus retry ladder and the
ambiguity rule are the reflection tier's own universe, not grammar.

Acceptance was that mutating each shared rule must redden a RUNTIME test. Nine
mutations, nine runtime deaths, all reverted -- table in the plan record. The new
ExtensionRegistrationPrecedenceTests assert the three verdicts through the public
AddExtensions seam; their absence is why the transcription was invisible.
ExportBookkeeping had no test at all and now has nine, placed in Heddle.Tests on
purpose so mutating it reddens a runtime leg.

The fold surfaced two real divergences, both fixed in the shared file so the tiers
move together: a one-element tuple is legal and reflection has always resolved it,
so the parser's two-element floor was a build-tier-only refusal the extraction
itself introduced; and a whitespace-padded top-level spelling now resolves on the
run tier where its own dispatch threw.

Q8.4 -- the generator scanned all referenced assemblies while the runtime only
scans those carrying [ExportExtensions], so it precompiled extensions the live
registry can never resolve. ExtensionBinder.CollectExported now reproduces
TemplateFactory.ObtainExtensions: the engine assembly whole and unconditional, any
other assembly only what the attribute names or the whole assembly for the
parameterless All form (short-circuiting the rest exactly as the runtime's break
does), and nothing without the attribute. Nested-container discovery survives on
both paths -- asserted, because Q8.4 must not undo F1.

TDD: the fixture went red first and for the right reason -- exactly the three
narrowing assertions failed while the eight must-still-work assertions already
passed.

No rendered byte changes and nothing that renders today stops rendering: a
bound-but-unexported extension produced a manifest row the gauntlet already
rejected on every request, so the dynamic tier was already serving it. The change
removes dead precompiled output and a permanent per-request fallback. Two verdict
changes: a bodied call to an unexported name is now HED7006 at Error, converting a
first-render failure into a build failure; a bodiless call degrades quietly with
identical bytes.

Two fixture debts fell out and were paid, both making an implicit declaration
explicit: probe compilations declare their exports through a single-sourced
harness helper rather than copying the attribute into ten probe strings (testing
standards, test-input single-sourcing), and the integration suite declared nine
extension types its own tests exercise and never exported.

Neither fix is window-gated; both judgements are recorded in breaking-windows.md.
Q8.4 is defect repair under the match principle -- no correct build regresses.
Q8.3's two items are widenings: one repairs build-tier drift, the other widens
accepted input with no resolved type changing, and depending on ResolveType
throwing for padded input is not a dependency the contract offers.

The phase-3 record's B4 row is corrected: TypeSpelling was a re-implementation at
landing, not the split it claimed.

Suite: 5206 passed, 0 failed, 0 skipped on the legs that run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…closed

24 mutants, 24 killed, 0 survivors. The record's factual claims all hold; what did
not hold was the coverage two of them implied.

BodyModelRules had no Release enforcement -- the emitter's only link to it was a
Debug.Assert, and the table's Chained column had NO consumer anywhere. The emitter
now DERIVES each nested body's build context from the table row, so it is
load-bearing in Release, and the tautological adoption theory (which asserted the
table against its own InlineData and never ran the emitter) is replaced by tests
that run the generator and read the consequence off emitted source. Byte-neutral.

Two tests pinned method NAMES, so a re-implementation under another name passed.
Replaced with pins on the INPUTS a re-implementation must read -- proven by adding
a renamed leftmost-only probe and watching it go red.

The participant-scan lockstep was 6 hand-picked rows where the matrix promised a
whole-corpus sweep; it now sweeps all 62 corpus templates, pins by name the six
that provision a frame as an anti-vacuity check, and carries an empty named
over-provision allow-list. Exact agreement on all 62 today.

The test matrix's six "corpus guardrail entries" had zero fixture files, and the
claim was wrong twice over: CorpusDifferentialTests has no fixture set of its own,
and none of the six shapes was ever added to the corpus it sweeps. Corrected rather
than backfilled -- each guardrail now names the differential or lockstep asset that
actually pins it (rendered bytes on both tiers, a stronger gate than the corpus's
classification pin), and three of the five are stated as unable to be corpus files
at all. Eleven further rows naming never-created files now name what exists. Root
cause was a naming class of error, now stated at the top of the matrix: the
integration suite holds no .heddle files, so a "fixture" there is an inline string
keyed by a views/<stem>.heddle path.

Q8.5 is closed as phase 1's own D14 artifact: StripGlobal is deleted and the branch
arm's manifest row takes Info.BareTypeName/Info.AssemblyName. Byte-neutral, and
honestly labelled -- the defect is unreachable by test because the arm gates on
IsEngineAssembly, so the guard is a source-shape pin rather than behavioural
coverage.

WI7's actual fix had no test at all; the new CallTargetAdoptionTests exports a
function named raw from a synthetic compilation and requires the emitter to bind
the extension and emit nothing into the container. Swapping the classifier's arms
reddens it.

The F11 fixture is narrowed honestly: clause 3 was a verbatim duplicate of a
lockstep test and is dropped; clause 2 was decorative (leftmost participants the
old buggy probe already handled) and is replaced by the observable half of the same
drift, the per-carrier flag asymmetry. Reverting the flag OR now reddens this
fixture, not only its sibling suite.

Five mutants were killed only by rule-level tests with every differential and
golden suite green. Recorded in the plan because it locates blind spots rather than
closing them: no fixture inherits out:: from a base layer, no fixture has a
foreign-origin region-fill candidate, and NO extension anywhere carries
[EncodeOutput] and [NotEncode] together.

Q1.2 confirmed: no present coercion-rail mismatch. Both additive APIs are in the
public-API golden and now genuinely exercised -- BindDefinitionOverloadTests is the
first test of the "existing overloads unchanged, both flags equal implies identical
binding" claim, which the golden only covered at signature level.

Suite: 5208 passed, 0 failed, 0 skipped on the legs that run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Q8.13 the value-path coercion rail has no byte-level fixture (shape plus
render-path only); Q8.14 RenderTypeRules' fourth truth-table row is unreachable
from any real extension, so [EncodeOutput]+[NotEncode] is either meaningful and
needs a fixture or incoherent and should be an error on both tiers; Q8.15 two
generator tests fail intermittently under concurrent multi-TFM runs, which matters
because every mutation result in these audits rested on a test going red for the
right reason; Q8.16 RegionTests.LocationOffsetOf returns a hard-coded 0, so it
reads as a position assertion while asserting nothing; Q8.17 SymbolTypeIndex.Cache
pins every Compilation for the process lifetime.

Each carries a stated default. Q8.15's default is to stabilise before further
mutation work, on the grounds that a flaky gate is a broken gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twenty of twenty-one post-implementation questions are now ruled. Only Q8.6 (the
compile-channel drain) stands at its default, deliberately.

The substantive correction is Q8.12. The register recorded Q5.1 as "Name removed
per the recommendation" and phase 5 implemented that removal. The user's
instruction is that removal was never the ask -- only wiring Precompile was. So
Name is being restored as real optional custom key mapping. Name was dead code
(CompilerVisibleItemMetadata, never read), so removing it changed no behaviour and
the sample's Name="BuildReport" was always ignored; the defect was that the
feature was never wired, not that the metadata existed. Restoring it is the
smaller fix and closes Q8.12 as a side effect.

Q8.10 rejects phase 8's D9 as designed: documentation has a different job from a
test fixture, and byte-identity with a corpus entry is not a property worth buying.
No @include: from corpus templates, no corpus intent rows for doc examples, and
phase 8 no longer blocks on phase 7. The false doc/test coupling that already
exists gets deleted rather than formalised.

Q8.14 opens a genuinely new surface: a declaration-side analyzer, so an extension
author sees an incoherent [EncodeOutput]+[NotEncode] pairing in their OWN build
(warning, HED7027) rather than only their consumers seeing it in theirs (error,
HED7026). That is Q6.1's early-surfacing principle applied to authorship.

Q8.15 forbids the easy answer explicitly: a retry attribute is not an acceptable
resolution for the two flaky generator tests, because it preserves exactly the
property that makes a surviving mutant invisible -- and every mutation result in
these audits rested on a test going red for the right reason.

Q8.17 asks for an operational contract rather than a size cap: a real operation
API, observable capacity, and eviction in which AGE participates, motivated by the
edit-and-edit-back case where a cached entry is identical yet old.

Q8.11 adds strong-naming for our own unsigned assemblies alongside centralised
versioning, so the CS8002 warnings stop; third-party unsigned references are
handled explicitly rather than blanket-suppressed. Q8.9 puts the documentation
mapping into cross-cutting-decisions. Q8.8 and Q8.13/Q8.16 take their defaults or
straightforward yes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four ruled items plus one build-order fix, landed by three concurrent agents on
disjoint file sets. Committed together because they share the diagnostics
registry and docs surfaces, and splitting those by hunk would risk a broken
intermediate commit.

THE MOST IMPORTANT FINDING IS NOT ANY OF THE RULED ITEMS.

Wiring Name revealed that NONE of the three HeddleTemplate item metadata worked
from a real project. Heddle.Generator.targets restated each one as
<Key>%(HeddleTemplate.Key)</Key> inside an Include="@(HeddleTemplate)" transform.
The transform already copies every metadatum, and outside a target a cross-item
%() reference evaluates to empty -- so each element OVERWROTE the copied value
with "". Key was inert. Precompile="false" was inert. Nothing noticed because
every test suite injects build_metadata.* directly and never crosses that file.

That means phase 5's Q5.1 deliverable -- "wire Precompile properly" -- did not
work end to end, and its acceptance tests could not have shown it. The user's
instinct that Name should never have been removed was right for a deeper reason
than the one recorded: the feature was not broken because the metadata existed,
it was broken because the targets file nulled it. Fixed by deleting the
restatements, gated structurally by a set-equality test that also refuses any
future restatement, and behaviourally by the sample.

Q8.12 -- Name restored as optional custom key mapping, implemented as a second
spelling of Key so it shares every downstream rule rather than acquiring parallel
ones. Full participation in HED7002 duplicate and HED7003 case-only-twin checks;
HED7018 out-of-root deliberately SUPPRESSED by an explicit Key or Name, because
that warning's premise is that the flattened key was not asked for. HED7028 was
NOT claimed: both new faults are instances of HED7004's existing fault class at
the same severity, position and remediation, so its message was generalised
instead. A second fix was needed to keep the sample's golden to one line: emitted
#line directives named the registration key, so an explicit Name pointed every
mapped span at a path that exists nowhere. #line now names the root-relative
path -- byte-identical where no explicit key is set, which is why no Verify
snapshot moved.

Q8.2 -- MinSupportedSchemaVersion = 4, and the rejection is DEMONSTRATED. The
fixture compiles a manifest against a reference facade declaring the
pre-schema-4 surface under the real assembly's identity, public-signed, with the
real Heddle excluded from both compilations' reference sets -- so the IL genuinely
carries a MemberRef to .ctor(string, string) that cannot bind to the current
3-arg form. That is exactly what the old test could not be: writing
new PrecompiledExtensionBinding("a","b") binds to the 3-arg ctor and passes null,
producing a new-schema call wearing an old-schema shape. Three arms, including a
control that admits the SAME BYTES at schema Min and observes the
MissingMethodException -- so "the gate prevents a startup crash" is evidence
rather than narration, and 4 is shown to be the exact boundary.

Q8.11 -- one VersionPrefix (2.1.0) replacing nine per-project elements, plus
fourteen kinds of version statement now gated by 17 cases. Two things the survey
had missed: LspServer.InformationalVersion was a live drift ("1.0.0" reported by
heddle-lsp --version for the whole 2.0 line, guarded only by a test comparing it
against itself), and the beta job's --version-suffix had to lose its leading dash
or compose 2.1.0--beta.N. All CS8002 warnings gone: Demo.Models, Demo.Wasm and --
found by the new gate rather than the warning -- LanguageServices.Tests.Corpus,
whose csproj already carried a comment claiming it was signed. Scriban is handled
by a declared accepted-unsigned list, with the honest bound recorded: Roslyn has
no per-reference CS8002 suppression, measured, so the mechanism is keyed on the
reference rather than scoped to it.

Q8.1 -- HED7025, the overload-ambiguity build error. BindOutcome is propagated
out of both binders instead of collapsing to null, and the side condition is
implemented as an early return placed BEFORE OverloadRank.Bind runs, so no
verdict is ever computed for untypeable arguments and then suppressed. Three
tests pin the silent-degrade half. One residue found and pinned: the name-keyed
model answers IsReferenceAssignable false by construction, which was harmless
while it could only degrade and is sound now only because no shipped built-in
parameter type is a reference type other than String/Object -- so a test pins
that parameter-type set. The quarantine fixture is reshaped a second time, and
this reshape REVERSES an assertion the first added: phase 4 pinned the build
tier's silence as though silence were the fix.

Q8.16 -- RegionTests.LocationOffsetOf no longer returns a hard-coded 0. It
recomputes the anchor from what the diagnostic reports, asserting the right file
and that line/column agrees with the raw span. Mutation-proven: span shift, line
drift and wrong-file all redden it. A second decorative assertion in the same
fixture (Assert.NotNull after an Assert.Single that already guaranteed it) was
replaced with a real both-tier offset twin.

Q8.20 -- the missing build-order edge. CorpusResolverSweepTests reads
src/Heddle.Tests/bin/** with no ProjectReference ordering it, which was
reproduced as a real failure. Added with ReferenceOutputAssembly="false" --
build order only, deliberately not a compile-time reference, because that suite
hands hand-filtered reference sets to compilations it creates and loads the DLL
by path at run time.

Mutation testing across all three items: 27 mutants, 26 killed, 1 survivor
classified as provably extensionally equal (a null-coalesce whose two orderings
are reachable only when the operands are Ordinal-equal). The load-bearing mutant
is the one that turns the old-schema fixture INTO the optional-parameter
substitution -- it reddens the suite, which is what proves the fixture is not
that substitution.

Breaking-window dispositions recorded for Q8.2, Q8.11 and Q8.12, plus a 2.1
as-shipped record. Q8.2's is the interesting one: the break ALREADY SHIPPED in
2.0.0, because schema 4's optional parameter removed the 2-arg ctor then. What
2.1 changes is only whether the engine says so or crashes.

Build: 0 errors, 0 CS8002 (was 8). Suite: 5312 passed, 0 failed, 0 skipped on
the legs that run. All 10 samples run and compare-golden clean, with only
codegen-t4-successor's golden changed and reviewed line by line. The eight Verify
snapshots differ by exactly the engineVersion line. net6.0 and net48 remain
unrun on this box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promoted from the phase-5 plan's implementation record to the README findings,
because it invalidates a DELIVERED acceptance claim rather than an unverified one.

Heddle.Generator.targets restated each HeddleTemplate metadatum inside an
Include="@(HeddleTemplate)" transform, and outside a target a cross-item %()
reference evaluates to empty -- so each element overwrote the copied value with
"". Key, Precompile and Name were all inert. Phase 5's Q5.1 deliverable did not
work end to end, and its acceptance tests could not have shown it: every suite
injects build_metadata.* directly and no test crosses the targets file.

Also reframes Q8.12 -- the record said Name was dead code and removal was
harmless, which was true as far as it went, but all three were dead because of
the targets file, not because the metadata existed.

Standing lesson recorded: a build-surface contract verified only through injected
analyzer-config values is unverified. Where a claim depends on MSBuild
evaluation, something must actually evaluate MSBuild -- which is why the fix is
gated both structurally and through the sample gallery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Q8.18 dissolves: verified in source that precompiled function calls are
statically bound (NativeExpressionWriter emits a direct shim call from a
build-time table; PrecompiledRuntime never consults options.Functions at
render). So the build-time inventory is the only scope that CAN be correct for
what precompiles, and a host adding overloads is handled by the gauntlet
degrading to the dynamic tier. The escape hatch was verified real:
Precompile="false" continues before key derivation and emit.

Q8.24 closed as invalid -- precompilation shipped in 2.0.0, so no 1.x manifest
has ever existed and the reason-code change has no population to affect. Also
recorded plainly: no option was added for manifest rejection.
PrecompiledMismatchPolicy is pre-existing 2.0 API and phase 0 depends on Strict;
the entry read as though proposing a switch, which it was not.

Q8.25 is a CORRECTION to work landed in daf7f91. Name was implemented as a
second spelling of Key -- an override -- so a named template stopped resolving
by its path. It must be ADDITIVE: the path-derived key is kept and the
registered name is added, so imports by either spelling resolve, with a warning
(HED7028) where a named template is imported by path. This also invalidates the
HED7018 suppression recorded under Q8.12, which must be re-derived rather than
assumed to carry over.

Q8.19 collect all errors if contained; stop and report the cost if it needs the
body walk restructured. Q8.26 leave as is, no TreatWarningsAsErrors. Q8.27 prefer
absolute paths and mark relativity where relative is genuinely right, rather than
converting everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corrects the Name implementation landed in daf7f91, which made Name "a second
spelling of Key" -- an override, so a named template stopped resolving by its
path and drew HED7011. The ruling is that Name is an additional name register for
imports, not an override: nothing that resolved before may stop resolving.

Additivity is now structural rather than conditional. The import map is built in
two passes and the ORDER is the mechanism: pass 1 registers every template's key,
pass 2 adds each Name alias on top, skipping any spelling already present. A
registered name therefore cannot displace a key. Name was removed from DeriveKey
entirely and a new DeriveName owns it.

TDD: ANamedTemplateIsStillImportableByItsPath was written first and reddened with
exactly the HED7011 the defect produces. BothSpellingsResolveInOneCompilation then
pins that both resolve in one run, so an implementation that merely swapped which
spelling works cannot pass. Resolution is asserted by the imported definition body
turning up inlined in the importer's pieces -- @<< pulls in definitions, not text,
so "no HED7011" alone would also be satisfied by silently dropping the import.

HED7028 (Warning) advises where an import resolves through the key of a template
that has a registered name. A new id was right here where landing 1 was right to
decline one: every other HED70xx key diagnostic reports something UNUSABLE, and
this reports something that works, at a different severity and position.

Four of six Q8.12 decisions changed once re-derived off the additive premise
rather than adjusted:
- HED7018 out-of-root: Key only. An additive Name leaves the path-derived key in
  place and still out of root, so the warning is still about something real.
- HED7002 duplicate and HED7003 case-only twin: keys only. A name registers no
  manifest row and is never a runtime registry lookup.
- Key+Name is no longer a conflict at all -- two names for one template. That
  HED7004 arm is deleted; the name arms that remain cover the two ways a name is
  genuinely unusable.
The #line key/file separation stands, and the sample changed (below).

The breaking-window disposition was corrected IN PLACE rather than appended to, so
the normative document does not carry two dispositions: the rename clause now
applies to Key alone. Name moves nothing -- not the key, the manifest row, the
class name or #line -- so it cannot break a call site or stop an import resolving.

Q8.27: absolute where it costs nothing, relative-and-labelled where it does.
Outside HeddleTemplateRoot there is no anchor and the old fallback was the bare
filename -- unopenable and colliding across directories -- so that now emits the
template's own absolute path, at zero snapshot cost (the affected snapshots use
synthetic relative paths and gained the views/ prefix they should always have
had). Under the root the form stays root-relative, because absolute would bake
this machine's layout into the sample golden and any rooted snapshot. Every
generated file now states which form its #line directives use, directly under the
auto-generated marker.

The sample now demonstrates Name the way it actually works. Name="BuildReport" on
report.heddle became decorative once the key survived, so the entry class reverts
to Templates_Report and a new import-only partial carries Precompile="false"
Name="Banner", imported as @<<{{Banner}}. Rendered output is byte-identical. This
also repairs a gate that had silently died: finding 10's behavioural half was
"Name renames the class Program.cs calls", which stopped gating anything the
moment Name went additive. Proven by deleting Name="Banner" and watching the real
MSBuild build fail with HED7011.

Mutation testing: 16 mutants, 15 killed, 1 survivor classified extensionally
equal with proof (an early-out whose absence routes through the collision guard to
the same observable state). Restoring the override kills 12 tests. One real gap
was found by mutation and closed: dropping the HED7028 per-import dedup survived,
because no test imported one spelling twice.

Harness note recorded for whoever mutation-tests next: rsync -a preserves mtimes,
so a reverted source can look older than the mutant's build output and MSBuild
skips the rebuild. That produced one false failure, traced and fixed by touching
after revert.

Suite: 5348 passed, 0 failed, 0 skipped on the legs that run. All 10 samples run
and compare-golden clean. README intro refreshed to the real count and to the
HED7018-HED7028 range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Q8.28/Q8.29 are one defect from either end: an opted-out template is still a
participant in the import graph, so its key and name must be derived, validated
and advised even though it contributes no entry point or manifest entry. The
population whose faults are currently unreportable is exactly the population the
Name feature is for.

Q8.30 REVERSES the import-only scope the Q8.25 landing assumed. The reasoning
recorded: if Name is a useful key for imports it is a useful key full stop, so a
named template should resolve by that name at run time too, and the asymmetry was
an artifact of the wiring rather than a designed boundary. The manifest carries
the name, the runtime registry consults it, and the assertion asked for is of
that behaviour -- not, as the question originally proposed, that a runtime name
lookup misses.

Q8.31 moves the #line path-form choice from prose in generated code into the
manifest as machine-readable data, and deletes the comment.

Q8.30 and Q8.31 both add manifest fields, so they share one schema bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lidation

Completes the work checkpointed in d1fa89f. Suite green again: 5414 passed,
0 failed, 0 skipped -- the one red test that WIP commit recorded is reconciled.

Q8.30 -- PrecompiledTemplateInfo.RegisteredName, and the runtime resolves by it.
The generator emits only a name that actually registered at build time, so both
tiers hold the same spellings. PrecompiledTemplates gains a second index and
TryGet consults keys then names, so TryResolve, TemplateResolver and
PrecompiledRuntime all get name search without their own changes.

KEYS WIN, always, independent of registration order. Three grounds: additivity
(an addition that displaced an existing spelling IS the override Q8.25 corrected);
the match principle (the build-time import map is already keys-first, so the tiers
cannot disagree about what a spelling means); and determinism (one merged
dictionary would make the winner depend on which assembly loaded first, which is
the host's business, not ours). Mechanically that is two indexes plus a
disjointness invariant enforced from BOTH directions -- a name whose spelling a key
owns is refused at insert, and a key arriving later evicts the name shadowing it.
Both directions are needed because either can happen first across assemblies.

The cross-assembly collision gets a RUNTIME id, HED7104
(PrecompiledFallbackReason.RegisteredNameUnavailable), because the build tier
structurally cannot see it: a referenced manifest's rows live in a GetTemplates
method body -- IL, not symbol metadata. It deliberately does not join the
duplicate-key throw, since two templates claiming one key is unresolvable whereas a
name/key collision is already resolved by the ordering rule.

Q8.31 -- LinePathForm on the manifest as an enum rather than a bool, because a
fallback-marker row has no generated source and must be able to claim nothing
(Unspecified). The enum file is linked into the generator so a rename breaks its
compile instead of emitting a manifest that will not compile downstream. The prose
comment is gone from generated code, asserted together with the field's presence so
an implementation leaving two carriers cannot pass.

Q8.28/Q8.29 -- key and name derivation moved ahead of the Precompile gate, so a
malformed Key, a malformed Name or a taken Name now reports HED7004 on the item
that is actually wrong rather than surfacing as HED7011 at some importer. Opted-out
items parse in advisory-only mode so their own imports draw HED7028. HED7029 was
not needed: every fault had a home. A "name is build-time-only" warning was
declined because it would fire on the feature's intended shape. Opted-out items
still contribute no entry point and no manifest entry -- pinned, and verified end to
end from the real sample csproj.

Schema stays at Min = Max = Current = 3. The 11-arg PrecompiledTemplateInfo ctor is
kept as a REAL overload rather than defaulted parameters, deliberately not
repeating the mistake that caused the whole schema episode; the public-API diff
confirms it survives.

Mutation testing: 17 run, 16 killed. M6 was a real gap -- checking names against
pre-registration keys survived, because the name landed in the index violating
disjointness and the lookup still returned the key owner (two redundant guards
masking each other). Fixed by asserting the HED7104 report, not just the
resolution. The surviving mutant is proved extensionally equal against the code:
one insertion site each, byKey never removes, and pass 2 only inserts spellings
absent from the complete staged key set, so at most one dictionary holds any
spelling and lookup order cannot matter.

Q8.2's breaking-window disposition is REPLACED, not softened -- its ground (a),
that the break already shipped in 2.0.0, is withdrawn outright. Q8.30 is recorded
honestly as a widening taken anyway: scoped to opt-in data no 2.0 project can have
(the metadata was inert until 2.1), nothing that resolved before stops resolving
structurally, and the shadowing hazard is reported rather than silent.

Five new questions recorded, the sharpest being that RegisteredName has no gauntlet
coverage (every other manifest field is validated at request time) and that
Min == Max makes the support window a point, so every future manifest change
becomes a whole-assembly rejection for the previous version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
multiarc and others added 30 commits August 1, 2026 14:43
Two probe compilations graft a tree onto the consumer's own compilation — the `@using`
"does this directive compile" check and the embedded-C# expression typer. Both parsed with
the defaults. Roslyn requires every tree in a compilation to have been parsed alike and
throws `ArgumentException: Inconsistent syntax tree features` on the graft otherwise, so the
emitter reported HED7020 and nothing precompiled for any consumer whose project sets a
language version, a preprocessor symbol or a compiler feature. That is not an exotic project:
a plain `dotnet build` on the current SDK already passes
`/features:InterceptorsNamespaces=…`, and it took two shipped samples down —
`codegen-t4-successor` and `precompiled-app`, both of which carry a `@using`.

Measured by building `samples/codegen-t4-successor` at each commit in the range:
clean at ec613c2 and 703567d, broken from 08872ff onward. ec613c2 introduced the typer's
graft and 08872ff the directive check's; the samples' templates carry a `@using` and no
embedded C#, so only the second is visible there. Both sites break on their own — reverting
either one alone reddens exactly its two rows of the new test.

Each probe tree now carries the parse options of a tree the compilation already holds, which
is what makes the graft legal and is also the only reading that asks the question the way the
consumer's own compiler will answer it: its language version decides whether an alias
directive binds, its preprocessor symbols decide what an expression contains. The defaults
stand in only where the compilation holds no trees at all, where there is nothing to be
inconsistent with. The generator takes no parse options from its pipeline, and adding
`ParseOptionsProvider` would be a second source that merely coincides with this one.

Nothing else in the repository builds a tree it grafts onto a compilation it did not parse:
the engine's two C# tier sites and the emitter's metadata probe each construct a fresh
compilation, one over its own single tree and one over references only.

No suite could see this — every generator harness built its compilation from zero trees or
from trees parsed with the defaults. `GeneratorHarness` now takes parse options and hands the
same ones to the driver, as the real build's does, and the new test runs both probes under a
consumer's options with a same-bytes neighbour under the defaults and a neighbour that must
still be refused.

Also measured, and left alone: rebuilding the typer's probe as a standalone compilation over
the references would close the internals-visibility gap recorded against it, and costs the
model type. A model declared in the consumer's source is in no reference at build time, so
every expression over it stops compiling and the template silently leaves the precompiled
tier. The generator integration suite stays 1017/1017 green under that mutation because its
harness seeds no consumer source; the new test is the first thing here that observes it.

Suites, Debug and Release, both TFMs where multi-targeted: Heddle.Tests 2043,
Heddle.Generator.Tests 586 (582 + 4), Heddle.Generator.IntegrationTests 1017,
LanguageServices 200, Tool 6. All 10 samples build and match their goldens. net6.0 was not
run: no .NET 6 runtime on this machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every regression check in the findings register is a filter command, and a
filter naming a test that has been renamed or deleted ran zero tests,
printed nothing at quiet verbosity, and exited 0. That is how two shipped
fixes came to be documented as pinned with nothing executing them, and how
renaming a test in one commit silently disarmed a check written in another.
TreatNoTestsAsError makes an empty run fail, which turns all 106 checks
from "green if the name still exists" into checks that actually run.

The defect this closes had the wrong diagnosis recorded against it. Both
instances it cited -- a declared framework with no runtime installed, and a
wrong-framework invocation -- exit 1 on this SDK and were never the fault.
The filter case is the live one and had never been written down.

The first version of the settings file broke every run: an XML comment
containing a double dash, which XML forbids, so the file was rejected and
real filters exited 1 as well. Caught only because the positive control ran
beside the negative one. A guard is not verified until both of its answers
are measured.

Also records the probe parse-options fix, whose reach was wider than the
two samples that exposed it: the SDK passes a features flag on a plain
build, so every consumer compilation carries non-default parse options and
every consumer with a using directive or an embedded expression was
breaking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Switching on the empty-run guard immediately failed 26 checks -- every one
written as Name~. The names were not stale: Name~ with the exact, correct,
full method name also matches nothing, because this xUnit adapter supports
only FullyQualifiedName. A quarter of the register's checks had never
executed anything and had passed every time they were run.

Two more named the wrong project. Both came from proposed deltas I merged
without verifying where the tests actually live.

The consolidation pass claimed to have validated all of these against
--list-tests. It did so by reimplementing the filter grammar and matching
against a list of names instead of invoking the tool, and its
reimplementation assumed Name worked -- the same defect class this register
keeps finding in the source, committed by the harness while verifying it.

All 74 unique checks now run and pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…by its element

Two seams where the emitter held a symbol and passed on a lossy summary of it.

The export overload binder was handed the shared OperandKind descriptor, which
names the numeric primitives, bool and string and nothing else. A struct, an
enum, a class and object therefore all arrived indistinguishable from an
argument nothing had resolved — and an unresolved argument is exactly what the
sole-candidate shortcut may bind past, since one candidate settles which
overload is meant. Three argument categories, measured: a Money member, an
OrderStatus member and a Manufacturer member each wrote a call the consumer's
compiler answers with CS1503, twice, off a .g.cs nobody can edit, where the
engine answers HED1012 before it renders a byte. The binder now takes the
symbol the writer already resolved, which is what NativeExpressionCompiler
hands OverloadRank on the other tier. That shrinks what "cannot say" covers
rather than widening what it may mean; deleting the shortcut instead was
measured and rejected, since it sends every single-params export down an
expanded tier this writer does not emit.

Widening the estimator re-opens the one position that spells a name with no
classifier in front of it: the per-argument cast target. It was safe only
because of what the estimator could reach, and an argument carrying its own
symbol reaches a host's whole signature set, where an [Obsolete(error: true)]
parameter type is CS0619 in the consumer's build. It goes through
ClassifyTypeName now and degrades where the name is out of reach.

An @list body was emitted on the dynamic tier although ListExtension.InitStart
gives the engine the collection's IEnumerable<T> argument and the engine
compiles the body in a scope of that type. The emitter computed that type —
every gate that types a call site inside the body already read it — and then
bound the body's own reads dynamically, which cost it in both directions.
A member the element type does not carry precompiled and threw
RuntimeBinderException at render where the engine refuses at compile; over an
element type of object it precompiled and RENDERED a page the engine refuses
outright. And a native expression, a function call or a ternary over the
element's own member degraded a template the engine renders. The body is typed
by the element now, and the element type passes the same name gate every other
spelled type passes before it reaches the file.

Corpus and samples: zero newly degrading, zero newly precompiling, zero moved
bytes — and zero moved generated source, because no corpus template pairs a
declared model with an @list, which is why this sat open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntegral tiers, and stop two path questions being answered by the wrong grammar

Six deferred engine defects, each closed against the thing that owns the rule
rather than against a recollection of it.

A bool/bool? bitwise pair is refused with the positioned diagnostic every other
illegal operand pair carries. VisitBitwise computes a lifted flag for all three
of its arms and spent it in only two; bool has no promotion to spend it on, so
the mismatch is refused instead. The shared operator table already published
that verdict, so the runtime and the table now agree.

round/floor/ceil carry the int and long tiers abs/min/max already carried. The
register named C# betterness as the fix; a compiled snippet says otherwise —
Math.Floor(3) is CS0121, because double and decimal are mutually
non-convertible and neither is closer. What ties an integral argument is the
overload set, not the ranker, and the ranker still refuses a genuine tie. The
betterness quantification moves with the table it measures: 62 combinations
ambiguous under both schemas become 38, and 82 become 100.

A using alias binds the head before the name index and commits, which is the
order C# reads a namespace-or-type-name in. Established by compiling and
executing the cases, and kept honest by a gate that asks the compiler what it
binds rather than restating a rule. The arms are mirrored on both tiers, and
the deviation this closes is removed from the candidate register.

A hosted GetTemplate can load the file its own search found. The search
inserted views/{controller}/ between root and file name and the options then
dropped exactly that segment. TemplateName means the path relative to RootPath
— which is what the precompiled partial path already assumed — and the search
now hands back the candidate it matched instead of it being re-derived. The
non-hosted arm had the same defect for a caller passing a nested path.

An import path is not a template key. The key grammar strips ~/, strips a
leading /, and appends .heddle, and each of those three names a different file
than Path.Combine does — which is the contract the documentation states and the
engine implements. The build tier now declines to rename a path spelling, and
still honours a spelling a Key or Name registration genuinely renames.

An import-only fragment no longer reds a build over a state no run of the
application can reach. The errors held are the ones an importer could have
satisfied — a base definition the file does not declare — so a syntax error,
which fails the same way in either setting, is unaffected; and they are only
withheld where something else in the compilation imports the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four of these were parked on a stated reason that measurement refuted. The
rounding cure would not have worked -- the same call is CS0121 in C# for the
same reason, so the gap was an overload set two tiers short, not a binder.
The import spellings the documentation was said to teach belong to a
different feature on a different code path, and for imports the docs state
the opposite. The fingerprint said to block the hosted-template fix does not
contain either field it was said to contain. And one entry had been fixed
incidentally eight cycles earlier; establishing that took 233 measured cells.

They survived because the review protocol said not to re-report a known-open,
which every reviewer read as permission to skip it, so the stated reasons
were never tested. The protocol now separates the two, and every remaining
known-open carries a runnable check, so a claim that has expired fails like
any other check.

Two entries understated what they described. The list-element body carried an
unrecorded silent-wrong-output cell, because object is a static type rather
than the absence of one. The export binder defect had three argument
categories, not one, and widening the estimator to fix it re-opened the
unchecked cast exactly as this register predicted it would.

Three defects found while closing these are recorded as open rather than
left in a report: a this argument in a native expression, a missing built-in
diagnostic, and a using-namespace exposing nested namespaces that C# does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…op a using reaching a nested namespace

`this` inside a native expression degraded on the precompiled tier where the engine renders it.
The expression writer had an arm for every node shape but that one, and a `this.`-rooted hop was
refused along with every other targeted path. It now emits the model local — which is what the
engine's compiler converts the model parameter to — and roots a member walk at the model, skipping
the prop layout exactly as the engine does for a path with a target. A body with no static model
still degrades, and `this` standing alone stays the model passthrough it always was, so the two
constructs keep their separate rules. The arm was added everywhere the writer and the emitter
dispatch on node shape: emission, the operand estimate, the argument's resolved type, the path
root, and the call-site value typing.

The built-in overload binder was fed only the shared operand descriptor, which calls an `object`
argument "cannot say" on purpose because an object-typed operand carries no facts an operator can
key on. That is not the same as having no type, and `min(1, Payload)` degraded in silence where the
engine refuses it by name. The binder now takes the writer's resolved symbol beside the descriptor
and names `System.Object` exactly — the one type the descriptor drops that a name-keyed rank model
can spell, and one the whole shipped candidate set decides without the reference conversion that
model must answer false to. Two functions that accept the same argument and used to degrade with
it, `str` and `format`, pre-compile again. The four other things the descriptor calls unknown —
dynamic, an error type, a type parameter, no symbol at all — keep the silent degrade they had.

A `using` namespace import exposed the namespaces nested inside it, which C# does not: `using A;`
plus `Sb.Deep` resolved here and is CS0246 there. Both tiers agreed with each other, so no
differential could see it; the rule was established by compiling and executing the spellings and
then followed on both tiers. A candidate counts only when its own namespace is the import, which
keeps a nested type reachable — it reports its outer type's namespace — and a nested namespace not.
This takes working templates off both tiers and is recorded as the narrowing it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
xUnit drops a theory row whose arguments duplicate another row's, logs
"Skipping test case with duplicate ID", and leaves the run green at a lower
count -- Skipped: still reads 0, because the row is dropped rather than
skipped. The empty-run guard cannot see it: the filter still matches tests,
so the run is not empty. The log line is the only signal, so CI now fails on
it. An agent introduced such a row this cycle and caught it only by reading
a Release log line by line.

Of the three defects recorded here, one was two constructs sharing a
spelling: this as a whole call parameter is the model passthrough and needed
no static type, which is why that path worked and this as an operand had no
arm at all. A golden had pinned the empty manifest that resulted, under a
name promising the call it was missing.

The built-in binder defect was wider than recorded -- two built-ins degraded
where the engine renders, not only the one that lost a diagnostic. The
narrowing to match C# on nested namespaces cost 0 templates, fixtures and
samples, and 1 test whose spelling moved to a nested type so it keeps making
the same claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sample metadata entry claimed the build no longer reads Name. It does:
the generator pulls it, uses it for the import advisory, and the props file
declares it visible with its contract stated alongside. The entry also
describes a template and a metadata pair the sample does not contain, and
the sample builds and matches its golden. Overturned.

The type-kind entry is the first parked reason in this sweep that survived
being tested. Structure is a compile-time alias of Struct so a second case
will not compile, and Extension is not declared by the Roslyn the generator
references; raising that reference raises the minimum SDK every consumer
needs, which is a cost rather than a defect. Its check now names the version
to watch, so the Extension half is re-tested if it ever moves.

That closes the sweep: of the sixteen known-opens standing when it began,
six were parked on a reason measurement refuted, one had been fixed eight
cycles earlier, and one more was fixed without the entry being updated. Six
remain, four of them blocked by this machine rather than by choice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… what closing it needed

The recorded residual said nothing drove a boxed non-string through the value
path on both tiers, and that closing it needed a host extension whose
ProcessData consumes its body's Execute result. The first half was already
stale — strategy-nonstring-value-bytes and the three-case theory byte-compare
the drop across tiers — and both redden with the rail mutated on either tier,
in Debug and Release, so they are load-bearing.

The second half is not buildable. AbstractExtension hard-codes typeof(string)
as the declared return of every extension that leaves InitStart alone, so the
engine faults on the returned type before its coercion runs; declaring anything
else means overriding InitStart, which is HED7015 for a roleless non-engine
extension; and a bodied custom call degrades unconditionally. Engine-assembly
extensions are the only vehicle.

New ValueRailCoercionDifferentialTests carries the host-extension half as far
as it goes, and pins the bound rather than working around it. The return-type
fault is #if DEBUG and the emitted Execute has no counterpart, so the tier
comparison is the Release leg and DEBUG pins the fault; the bodied degrade is
paired with the bodiless neighbour, which still precompiles and renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ostic ids

Eleven id-carrying warnings were added to CompileWarnings and reached no
build-time diagnostic. The recorded gap assumed closing it meant running
compile-channel stages in the generator; it did not. Ten of the eleven
conditions were already decided by the generator's own walk, and the eleventh
by a predicate it already supplied to the shaping pass.

The shaping-time warnings move into shared cores that both compilers drive,
rather than being decided twice. BranchSetLint is the observer StripBranchSets
always accepted and the generator always passed null for (HED3001, HED3002,
HED3004, HED3005); OutputLints holds the two scans that read nothing but text
and parse spans (HED4005, HED2004). Both were lifted out of HeddleCompiler,
which now drives them. The remaining five are decided per tier — reflection on
one side, Roslyn symbols on the other — but their text lives once, in
CompileWarningFactory, so a build report and a run report of the same mistake
are the same sentence. Mutating a sentence there reddens the runtime's own test
while the cross-tier parity test stays green, which is the demonstration that
there is one source and not two.

Three stay narrower at build time and say so: HED1016 sees the default function
table and each assembly's exports but not a registry the host fills at run time;
HED5011 answers for symbols the compilation can see; HED2003 reads the attribute
off a bound extension type. Narrower is the degrade direction, and
NothingIsForwardedThatTheRunTierWouldNotRaise gates the other one.

The corpus gains BuildForwarded beside ParseChannel — they stopped being the
same set — plus five fixtures, measured on all three hosts. The parity arm
compares both tiers' live output for the same bytes instead of restating either.
Every arm was rehearsed red: ten mutations, each reddening only its own id.

Measuring the error arm of the same event stream turned up a live divergence
that is not fixed here: an orphan branch terminal is a compile error the engine
refuses the template for, and the generator precompiles it and reports nothing.
Draining that arm fails a consumer build on the generator's own classification,
which is a different blast radius from eleven warnings. It is checked in as a
skipped red test with either half of a fix as its acceptance.

Also corrects three claims in the README that no longer held: two of the three
"shared cores with no runtime caller" have runtime callers and no surviving
second copy, and the mutation sentence that stood as their evidence is false in
both directions when measured; the generator has two blanket catch sites, not
three; the catalog is 91 rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a refusal

Two faults that both ended with a tier telling the caller something the engine
would not have said.

A model of the wrong type reached the compiled accessor's cast and escaped as a
raw InvalidCastException, which is not the shape any other render fault has. The
top-level render now checks the model against the scope type once per render --
outside the debug-only guard, and not per processor, so the recursive path is
untouched. The precompiled adapter has no compile-time model type to check
against and is skipped there, as it always has been.

The shared branch scan already knew when a body contained a terminal with no
opener; only its warning arm was wired up, so the build tier precompiled bytes
the engine refuses to render. Its error arm now reaches the emitter, which
declines the body and leaves the template to the dynamic tier.

branch-import-else.heddle is the corpus entry that proves the second one: a bare
@else continuation, previously declared as precompiling, now falls back. It was
also the only row that was both precompiling and excused from rendering, so the
theory reading that column lost its last case. xUnit errors rather than passing
on an empty MemberData, so the check is now a fact with the cardinality asserted
ahead of the loop -- the emptiness is the claim, and a new row reddens it.

TemplateOptions.ValidateModelType is left in place but no longer read; removing
it would be a break.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tributed ones on DCO

Three jobs built and shipped in one step, so a publish could begin from a tree
whose build had not finished proving itself, and the credentials to publish were
present for the whole build. Each is now a pack/build job that ends in an
artifact, and a deploy job that starts from one. Only the deploy half holds
publishing permissions.

  dotnet: prerelease -> prerelease-pack + prerelease-publish
          release    -> release-pack + release-publish
  npm:    publish    -> build + stage-prerelease + publish-release

The DCO workflow becomes callable as well as a required PR check, and the two
publishes that ship contributed code depend on it: an unsigned commit stops the
beta rather than staging it. Releases from main are deliberately not gated -- a
tag push carries no pull request to check, and they are the maintainer's own.

docs already had the split and deploys only from main, so it is unchanged.
The lsp and samples workflows upload artifacts rather than deploying.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed row

Two checks that could not fail.

The resolver sweep's file-backed half asserted only inside a loop over a set it
derived itself, so emptying that set left it green having rendered nothing. Its
twin, one method above, caught the same mutation on the first line of its body --
the difference between them was the count. Both now assert the swept set's size,
which is what the loop iterates; the existing count pinned the targets going in,
a weaker claim than the one the loop makes.

The duplicate-row guard wrapped the Debug run alone, so the two Release legs
could lose a theory row with nothing to notice. It was also bash inside a step
that runs under pwsh on the windows leg, where it would have failed rather than
guarded -- latent, because these workflows only trigger on main and this branch
has never run them. The guard is now one script behind every dotnet test call in
CI, including the two in the LSP workflow that had none, and both workflows pin
the shell instead of inheriting the runner's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.NET 6 is out of support and was the floor holding back everything else, so it
goes from every target framework, package group and conditional in the tree.

The two shipping libraries drop to netstandard2.0;net8.0;net10.0, the test
project to net8.0;net10.0 plus net48 on Windows, and the benchmark project to
net8.0;net10.0. Its net6-only debris goes with it: the ASP.NET Core 6.0.36
group, and the BenchmarkDotNet version split that existed only because 0.15.1+
had dropped net6.0, which collapses back to one unconditional reference.

41 `#if !NET6_0` guards across 18 benchmark files are now vacuous and removed --
four times what the first survey of them reported, so they were counted from
source rather than sampled. Each was checked for an `#else` branch first; none
had one. The `#if NET6_0_OR_GREATER` in the sink benchmark goes too, being
always true once the project floors at net8.0. The 17 other NET6_0_OR_GREATER
guards stay: they sit in projects that still target netstandard2.0 or net48,
where the symbol is genuinely undefined and the guard still selects behaviour.

The two package pins net48 consumers resolve transitively move from 6.x to 8.x
-- FileProviders.Embedded to 8.0.29 and Encodings.Web to 8.0.0 -- both confirmed
from their nuspecs to carry the netstandard2.0 asset, and Encodings.Web a net462
one, which is what net48 actually binds.

CheckEolTargetFramework stays but its comment no longer claims net6.0 is being
kept deliberately; it now records the real reason, which is that net8.0's own
LTS window closes in November.

Suites unchanged at 2134/606/1115/205/6 in Debug and Release, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sweep that removed the vacuous net6 guards only looked for `#if !NET6_0` and
`#if NET6_0_OR_GREATER`; it never checked for a bare `#if NET6_0`. There are
none -- confirmed over every source file in the tree, not just the benchmark
project the earlier survey covered -- so the 41 removed were the whole set.

What remained were 16 NET6_0_OR_GREATER guards in 12 files, all still live: they
sit in projects that also target netstandard2.0 or net48, where the symbol is
undefined and the fallback arm is the one that compiles. Live but misleading,
since they name a framework this repo no longer supports while actually meaning
"not netstandard2.0, not net48". With the floor at net8.0 the two symbols select
identical TFM sets -- false on netstandard2.0 and net48, true on net8.0 and
net10.0 -- so the rename preserves behaviour exactly.

The netstandard2.0 leg was built on its own, since that is the leg whose
fallback arm the guards exist for and a whole-solution build would not
distinguish it.

Suites unchanged at 2134/606/1115/205/6 in Debug and Release, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h context

Two tests grew a System.Runtime.Loader dependency in late July and broke the
net48 leg, which is Windows-only and had not run since. .NET Framework has no
AssemblyLoadContext at all; the engine's own AssemblyHelper already guards its
ALC arm for the same reason.

The using, both facts and the shared helper go behind `#if !NETFRAMEWORK` -- the
helper in the same guard as its only caller. This is the file-level convention
already in use: NetFrameworkDegradePathTests wraps its net48-only fact in the
positive form of the same symbol, and no csproj in this repo conditions a
Compile item. Each guarded test says in its doc comment that the engine rule it
pins is not framework-specific; only the probe's mechanism is.

Verified by compiling the net8.0 leg with NETFRAMEWORK forced on, which is the
branch net48 takes and the only way to reach it from Linux. That alone would be
a weak check -- the ALC type still exists on net8.0, so an unguarded use could
compile regardless -- so it was rehearsed the other way: removing the helper's
guard fails with CS0246 on AssemblyLoadContext, the same root cause CI reported
as CS0234 on the namespace. The first attempt at that rehearsal reddened on an
orphaned #endif instead, which proved nothing and was redone.

Heddle.Tests unchanged at 2134 in Debug and Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every test project becomes a stand-alone executable hosting its own runner:
xunit 2.9.3 -> xunit.v3 3.2.2, Verify.Xunit -> Verify.XunitV3 31.28.0, and the
VSTest host and adapter drop out entirely. Verify.XunitV3 pins the same 3.2.2
extensibility package, and xunit.v3 ships net472 alongside net8.0, so the net48
leg keeps its runner.

The cutover had to be all five suites at once: global.json selects the runner
for the whole repo and refuses a mix, which one suite proved before the rest
were touched. That smoke test also caught the worst failure mode first --
removing the VSTest adapter without selecting MTP leaves `dotnet test` printing
nothing and exiting 0.

Three code changes, which is all v3 asked for: the one Xunit.Abstractions
import, IAsyncLifetime returning ValueTask, and reading InlineDataAttribute.Data
rather than a GetData overload that is now asynchronous.

tests.runsettings is deleted. Its TreatNoTestsAsError is native here and
stronger: --minimum-expected-tests exits 9 below a floor, and a filter matching
nothing exits 8 rather than 0, which retires a way checks have died in this
repo. The CI wrapper stops scraping the log for a v2 message that no longer
exists and passes each suite's real size instead; every suite now has its own
named leg, since one aggregate floor cannot see a single suite going quiet. All
three arms of the wrapper were measured, not assumed.

Randomised test order immediately found a genuine order dependency: two tests
built probe assemblies whose names were unique but whose model type was not, so
whichever ran first decided whether the engine could see the type. Both now
carry a per-probe namespace, which is what "not loaded yet" needed to be a fact.

It also exposed a real tier divergence, checked in skipped: an assembly-qualified
model version ahead of the assembly's own is ignored at build time and refused
by the engine, so the same template precompiles and throws. The old host
satisfied the request from the loaded assembly whatever version was asked for,
which hid it. Left for its own change rather than folded into a runner move.

2134/606/1115/205/6 in Debug and Release, one deliberate skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ys there

Zero in Debug and Release, across the solution and the twelve projects it does
not contain -- the samples, the dotnet benchmark harness and the ThirdParty
comparison project, none of which a solution build reports on at all.

The v3 analyzers found real assertion defects, and those are fixed rather than
suppressed: Assert.Single and Assert.Empty over a Where clause both discard the
predicate from the failure message, so a red row said only that a count was
wrong and not which element was missing. Same for a collection size compared
with Assert.Equal. An obsolete model type had a pragma covering its construction
but not the typeof one line below, which is the same deliberate use.

Two package references are deleted rather than pinned: System.Net.Http has no
usage anywhere in the tests, and System.Text.RegularExpressions is in the box on
every framework still targeted, net48 included.

Three suppressions are kept, each with its reason next to it. A concurrency test
joins its writer thread synchronously because the assertions are meaningless
until it stops. The Razor harness uses runtime compilation because that is the
mode it exists to benchmark. Vector tables carry a row label their bodies never
read, whose job is naming the failing row out of dozens.

The largest rule, xUnit1051, is turned off per test project with its reasoning:
it wants a cancellation token threaded through 46 synchronous Roslyn calls in a
suite whose tests finish in milliseconds, so there is no hang for a token to
shorten. Recorded as a decision, revisited when a cancellable test appears.

The practice itself now lives in the coding standards: sweep per change while
the context is fresh, build stays permissive on purpose so a warning never
blocks running the tests, sweep both configurations and the projects outside the
solution, and record anything deliberately kept instead of silencing it.

An XML comment written with a double hyphen broke all four project files at once
-- the same way it broke the run settings earlier in this work -- so the csproj
files are now parsed as XML before the build is trusted.

2134/606/1115/205/6 in Debug and Release, one deliberate skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each was written as a test and run before being believed. Three outcomes from
four reports, which is the reason for measuring rather than transcribing.

Confirmed, checked in skipped. A type nested in a generic outer: the engine
counts GetGenericArguments().Length, which includes the outer's parameters, and
the build tier counts Arity, which does not, so a spelling the engine binds is
silently left on the dynamic tier. A dotted spelling reachable through two
imports: both tiers let @using declaration order pick a winner where C# raises
CS0104 and refuses the spelling outright.

Overturned, and kept open and green. The keyword table really is consulted at
opposite ends of the two walks -- the engine's alias arm claims the head before
its table, the build tier reads its table before the index carrying aliases --
but the difference is unreachable. It needs an alias whose name is a keyword,
and C# permits that only for `dynamic`, every other name in the table being
reserved. One name is the whole population and both tiers agree on it, so the
row stays as the pin that keeps it that way.

The first version of that row proved less than it looked: equal bytes are also
what a build-time degrade produces, so it now demands the template precompile.
The arity row was wrong too, spelled with a literal backtick the grammar builds
for itself, and failed on the engine before reaching the arm it names.

The fourth report, an import-versus-static CS0104 bucket recorded as both done
and not done, has one mention left in the docs and no contradiction to fix; the
behaviour it described is the dotted-import row above.

Both spec documents gain the missing branch. A finding becomes a test whichever
way it lands: red and reproducing is skipped with its owner, green because the
claim was wrong stays open as a regression pin, and green where the intended
behaviour is unclear is a question for the maintainer, not a guess to assert.

1119 in the integration suite, 3 skipped, zero warnings, Debug and Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t replaces

Maintainer ruling: src/Heddle.Performance is decommissioned for comparative
benchmarking. The .NET leg of the cross-stack program is rebuilt under
benchmarks/dotnet/, structured like the other five ecosystem harnesses, and
nothing in the tree references the old project's code. Recorded as ledger E12.

Why a rebuild and not a move. The old project was a SIGNED src/ assembly and a
friend of the engine, so it could and did reach internals no caller can -- and a
benchmark that needs private access to the thing it measures is measuring
something no user can reach. It also multi-targeted three frameworks for an
intra-.NET historical question this program does not ask, stored its templates as
escaped C# literals rather than reviewable files, measured one fairness track,
and covered Razor on one workload of eight.

## The harness (W8-W12 of 12; W1-W7 landed in 02405ba)

One unsigned net10.0 project, verb-dispatching like every other harness:

  gate              143 cells: byte gate, verifier, security floor, plus the
                    materialisation trailer and a stated precompiled coverage
  selftest          141 checks incl. the six-technique differential
  export-corpus     regenerate from the Heddle oracles
  verify-corpus     freshness + verifier calibration
  bench-crossstack  8 workload-named suites x 6 engines x BOTH fairness tracks
  bench-techniques  Heddle's six render techniques against each other
  bench-cold        cold parse/compile, per engine
  bench-internal    props, branching, language-service metadata

Bench verbs forward their remaining arguments to BenchmarkDotNet, so a master
runner selects one suite per step and layers the measurement budget on top.

The corpus is what proves the replacement faithful rather than merely new:
re-exporting reproduces all sixteen committed artifacts -- eight goldens and
eight .verify.json sidecars -- byte for byte.

Three things are new rather than ported. The default job moved out of a
[ShortRunJob] attribute into configuration: an attribute job cannot be REPLACED
from the command line, only added to, so `--job Dry` used to run the full
measurement as well as the dry one. A filter matching nothing now exits 1
instead of reporting a green step over zero rows. And the internal suites are
measured per operation rather than over a 10,000-iteration loop, which only
multiplied the number it was meant to expose.

The internal suites' models are TOP-LEVEL types. A nested type's reflection name
carries a '+' that the definition `::` clause cannot lex, so a suite whose models
were nested inside it would fail to compile its own templates.

## The decommissioning

The project is deleted. With it go the engine's InternalsVisibleTo grant, the
solution entry, the .gitignore line, and Directory.Build.targets' CS8002
acceptance mechanism -- which existed solely because one signed project under
src/ referenced unsigned Scriban, and no project under src/ references a
competitor engine any more. VersionConsistencyTests keeps the property that
mechanism protected: no project buys silence with a project-wide NoWarn.

The vendored third-party Fluid harness inside it is PRESERVED, not deleted: it is
an independent deliverable with its own upstream obligation. It moves intact to
benchmarks/third-party/, renamespaced, and still passes its parity check.

Both master runners, the Phase 8 launcher and consolidate.py are cut over.
consolidate.py maps artifacts by workload-named suite class, reads the Track
column so the idiomatic tables finally have a .NET row to anchor to, and filters
the ratio baseline to the controlled track -- an unfiltered comprehension would
have let whichever track appeared last become the baseline every ratio divides by.

Two consequences worth stating rather than discovering: Phase 1 D15's
controlled-track-only scope for .NET is DISCHARGED, and the .NET measure phase
roughly doubles, so ledger E6's ~9/~21 min figures for that leg are now low.

## Documentation

DocumentationLinkTests enforces link integrity, so leaving the ratified specs'
citations dangling was not an option: every link across 31 documents is
repointed at the replacement file. The surrounding prose stands as ratified and
still names the old project where it records what was researched at authoring
time; the full mapping is ledger E12, with banners in the plan and spec indexes
pointing at it. docs/plan/README.md is restated end to end -- the leg is
complete, and measurement is the only work left.

## Verified

Solution and both harnesses build clean, 0 warnings, Debug and Release.
gate 143/143, selftest 141/141, verify-corpus verified.
run-all.ps1 -Smoke -Ecosystem dotnet: 15/15 steps green, gates through artifact
copy. consolidate.py against that run: 96 cross-stack cells, both tracks, eight
workloads, six cold cells, 49 internal rows, anchor complete.
Tests 10,027 passed / 22 failed -- down from 25, and every remaining failure is a
pre-existing Windows or net48 issue untouched by this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pled

Ledger E6 set "one uniform measurement budget of ~10 min per ecosystem", and
every leg it was sized against carries two engines -- Rust, JVM, JS, Python --
or three (Go). For those legs the leg total and the per-engine share are nearly
the same number, so the distinction never surfaced. The .NET leg carries SIX:
Heddle, Fluid, Scriban, DotLiquid, Handlebars.Net and Razor. Holding it to the
same LEG figure divides one ecosystem's budget across three times as many
engines, so every .NET cell is sampled about a third as well as a Rust cell --
and the cross-engine comparison this program exists to make would rest on its
worst-sampled rows.

The budget unit becomes ONE ENGINE. short and baseline keep their ~10 and ~30
minute sizes; what they size is an engine's share. A leg's total is its engine
count times that, so the .NET leg is longer than the others by construction
rather than by accident.

## Launches, not iterations

Measured on this repo, one 12-cell suite, quiet machine:

  LaunchCount 1   139 s    11.6 s/cell   (BenchmarkDotNet's stock ShortRun)
  LaunchCount 2   316 s    26.3 s/cell   (the new committed short shape)
  LaunchCount 5   704 s    58.7 s/cell

About 10.8 s per cell per additional launch across L2..L5 -- very nearly linear,
which is what makes the knob predictable. Raising the iteration counts instead
runs through BenchmarkDotNet's pilot stage and does not: a trial at
--launchCount 4 --warmupCount 5 --iterationCount 8 overshot its projection by
more than 2x and was abandoned.

That is the practical reason. The substantive one is that LaunchCount 1 samples
the within-process term ONLY -- one process, one JIT, one heap layout -- and
never samples the cross-process term at all. E6 itself established this is the
term worth paying for: it kept JMH's forks plural after measuring fork-to-fork
RSD at 1.18% median against a within-fork 0.18%, and re-spent the whole JS
budget on independent processes for the same reason. The .NET leg was the one
that had never bought it.

  short     ShortRun + LaunchCount 2   66 min leg   11 min/engine   (measured)
  baseline  --launchCount 6           ~175 min leg  ~29 min/engine  (projected,
            one launch past the last measured point -- replace it with the real
            duration after the first protocol run)

The leg is 151 cells: 96 cross-stack (8 workloads x 6 engines x 2 tracks) plus
the techniques, cold and internal sidebars.

Verified after the change that the committed default really is LaunchCount 2 and
that --launchCount 6 MUTATES that job rather than adding a second one -- the
report CSV carries two rows at L6, not four.

## Recorded and deliberately not acted on

The other five legs are still sized per ecosystem, which leaves them at roughly
3-5 min per engine against .NET's 11. Making the program uniform per engine
means roughly doubling every one of them, and that is a session-length decision
for the maintainer rather than a consequence of this amendment.

Filed as ledger E13, which amends E6's unit and its .NET row, the metrics
protocol's .NET harness row, and the benchmarks/README budget tables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hmetic

E13 changed the budget's unit from one ecosystem to one engine, resized the .NET
leg, and recorded -- without acting on it -- that the other five legs were still
sized per ecosystem and therefore sat at roughly 3-5 min per engine. This acts
on it: every engine in the program now gets ~10 min at short and ~30 min at
baseline, wherever it lives.

## An engine is 16 cells, and that corrects E13

Eight workloads times two fairness tracks. Fixing the definition is what makes
the budget checkable rather than a matter of taste -- and it shows E13 got its
own .NET figure wrong. E13 divided the whole 151-cell leg by six engines and
reported 11 min per engine. That over-credits: 55 of those cells are the
Heddle-only techniques, cold and internal sidebars, which belong to no engine's
comparison share. The comparable rows are the 96 cross-stack cells, so the real
figure at E13's LaunchCount 2 was 7.0 min per engine -- under budget, not over.

LaunchCount 3 puts it at ~9.9 min and LaunchCount 10 at ~30.1 min; the sidebars
ride at the same job shape on top of that.

## The knobs, all six

  .NET     ShortRun + LaunchCount 3        --launchCount 10
  Rust     warm-up 5 s, measure 26 s       --warm-up-time 10 --measurement-time 84
  JVM      @fork(5), @measurement(5x1s)    -f 5 -wi 2 -i 17
  JS       38 aggregated passes            114 passes
  Python   --values 6 --warmups 1          --values 20 --warmups 2
  Go       -count 28                       --count 84

Each ecosystem's committed source or script default remains its short shape, so
a bare single-harness invocation is still the budgeted regime. Where a leg had a
choice about where to spend the increment it went to the term its own variance
decomposition says is dominant: JVM buys forks rather than iterations, .NET buys
launches for the same reason (E13), JS buys independent processes (E6).

## Cost, stated rather than discovered

Session totals become ~3.2 h short and ~9.7 h baseline, against ~1 h and ~2.5 h
under E6's per-ecosystem unit and 5.45 h before E6. That is what sampling
seventeen engines' worth of comparison rows equally costs, instead of sampling
six of them a third as well as the rest.

EVERY duration in the resized table is PROJECTED -- E6's measured per-leg
durations scaled by the knob change -- with the single exception of .NET's
per-cell cost, measured here at 139 s / 316 s / 704 s for one 12-cell suite at
LaunchCount 1, 2 and 5. The first protocol run replaces the whole column with
real durations, and the projections should be checked against it before any of
these numbers is quoted.

Verified that the new .NET default really is LaunchCount 3, that the gate is
still 143/143, and that every documentation link still resolves.

Filed as ledger E14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant