Skip to content

Commit 836c301

Browse files
spec(047): Phase 3 close-out + finish — 100% V1 dispatch for typed-items hosts (#437)
* spec(047): Phase 3 close-out — Panel<>.PerChildAttachedAfterAll two-pass shape Add optional Action<TControl, IReadOnlyList<(UIElement, Element)>>? callback to Panel<TElement,TControl> that the engine fires once after every child has been mounted (Mount path) or reconciled (Update path), receiving the full ordered (mounted, childElement) pair list. Existing PerChildAttached fires per-child mid-pass and cannot see siblings that haven't mounted yet — too late for descriptors that need to write attached DPs by sibling name (RelativePanel.SetRightOf etc.). The after-all shape carries the populated map across the existing Panel strategy without a dedicated NamedRelativePanel type. V1HandlerAdapter Panel arms in DispatchChildrenMount and DispatchChildrenUpdate now buffer pairs lazily — list is allocated only when the consumer registered the callback, so Grid / Canvas / FlexPanel / WrapGrid pay no overhead. Selftest baseline preserved: V1 ON Desc_: 534 ok / 0 failures V1 OFF Desc_: 534 ok / 0 failures Unblocks Port (4) RelativePanelDescriptor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 close-out — TemplatedItems<> strategy + Reconciler.BindKeyedItemsSource Adds the descriptor-side keyed templated-items shape so the upcoming G2 ports (ListView<T>, GridView<T>, LazyVStack<T>, LazyHStack<T>, ItemsRepeater<T>) can declare their data without re-implementing the spec-042 realization machinery. ChildrenStrategy.cs — new sealed record TemplatedItems<TItem,TElement, TControl> + internal non-generic ITemplatedItemsStrategy marker. The marker is how the closed-(TElement,TControl) adapter dispatches into an open-TItem strategy. Reconciler.KeyedItemsBinding.cs (new partial) — internal void BindKeyedItemsSource<TItem>(control, items, keySelector, buildItemView, requestRerender, isMount). MVP supports WinUI.ListViewBase (ListView + GridView): Mount: BuildListStateForItems → SetListState → ItemsSource = state.Source; shared HandleTemplatedContainerContentChanging hook attached. Update: KeyedListDiff.Apply over an ItemsKeyAdapter projecting through the strategy's KeySelector; AmbientAnimation survivor-move pass mirrors the legacy ApplyKeyedDiffOrFallback shape; tail call to RefreshRealizedContainers. Other control types (ItemsRepeater, Lazy*Stack) throw a descriptive InvalidOperationException at the dispatch switch so the gap surfaces at port time. Adding a new arm is purely additive. V1HandlerAdapter — dispatch arms in DispatchChildrenMount / DispatchChildrenUpdate route any ITemplatedItemsStrategy via .Bind(...) before the closed pattern switch. Realization machinery share-out — new internal IItemViewSource interface (ItemCount + BuildItemView(int)). TemplatedListElementBase now implements it (no behavior change — methods already matched). HandleTemplatedContainer ContentChanging prefers a stashed IItemViewSource (Reconciler.SetItemView Source, parallel to the existing SetListState plumbing on ReactorState) over the legacy element-tag fallback. RefreshRealizedContainers takes an IItemViewSource instead of TemplatedListElementBase. Both existing callers pass `n` which now implements the interface — zero call-site churn. ClosureItemViewSource + ItemsKeyAdapter are small internal helpers captured by BindKeyedItemsSource; refreshed on every render so the realization path always sees the live element's data. xUnit synthetic tests cover the strategy record shape + the ITemplatedItemsStrategy marker; end-to-end binding (real ListView, keyed diff + container realization) is the AppTests.Host fixture territory shipping with the G2 port subagent. Baselines preserved: V1 ON Desc_: 534 ok / 0 failures (no change — legacy CCC path takes fallback branch because no descriptor uses the new strategy yet) V1 OFF Desc_: 534 ok / 0 failures Reactor.Tests TemplatedItemsStrategyTests: 4/4 passed Unblocks Port (5) G2 typed templated lists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 close-out Port (4) — RelativePanel via PerChildAttachedAfterAll Closes the Batch E Phase-4 carve-out documented on RelativePanelDescriptor. The new Panel<>.PerChildAttachedAfterAll two-pass shape lets the descriptor build a name→control map across all mounted children, then write WinUI's RelativePanel.SetRightOf / SetBelow / SetAlignLeftWith / etc. against sibling references — the case the per-child PerChildAttached callback can't cover because later siblings aren't mounted yet at per-child invocation time. Body lifted from legacy MountRelativePanel (Reconciler.Mount.cs:3424). Same two passes: pass 1 assigns FrameworkElement.Name from RelativePanelAttached.Name and populates the map; pass 2 walks again and applies sibling-referencing DPs plus the AlignWithPanel booleans. Spec047V1ProtocolDescriptorFixtures — DescRelativePanelMountUpdate gains five assertions that exercise the new path: two named children with B.RightOf = A; verifies Mount populates FrameworkElement.Name and GetRightOf(uiB) returns the actual uiA reference. Selftest: V1 ON Desc_: 539 ok / 0 failures (+5 vs baseline 534) V1 OFF Desc_: 539 ok / 0 failures (+5 — parity preserved) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 close-out Port (5) G2 — TemplatedListView<T> / TemplatedGridView<T> Lands the typed templated-list descriptor ports via T-erasure at the abstract base — same dispatch model the legacy Reconciler.Mount switch already uses on TemplatedListElementBase, no open-generic registration required. Engine extensions: * V1HandlerRegistry.AddForDerivedTypes + base-walk in TryGet — exact entries always win; base-derived entries catch every closed-T variant via the runtime type's base chain. Per-type resolution cached so the walk is O(1) in steady state. * Reconciler.RegisterHandlerForDerivedTypes<TBase,TControl> entry point surfaces the registry capability on the public v1 surface. * TemplatedItemsErased<TElement,TControl> strategy + IErasedTemplatedItemsStrategy marker. Strategy is non-generic in TItem — items + keys read through the element's IKeyedItemSource implementation. New public IItemViewSource / IKeyedItemSource interfaces (REACTOR_V1_PREVIEW) document the contract; existing TemplatedListElementBase bridges its internal abstract GetKeyAt to the public interface via explicit interface implementation. * Reconciler.BindErasedKeyedItemsSource — companion to the BindKeyedItemsSource binder from Engine (2), targeting the same spec-042 ReactorListState + KeyedListDiff pipeline. SelectionChanged + ItemClick wired here (once at Mount; trampolines re-fetch the live element on each fire) so the descriptor doesn't need a new ControlEventState payload box. * DescriptorHandler — bind templated-items strategies BEFORE the prop loop (same ordering rationale as ItemsHost: SelectedIndex initial write needs a populated ItemsSource or WinUI silently clamps to -1). * V1HandlerAdapter — dispatch arm for IErasedTemplatedItemsStrategy parallel to the existing ITemplatedItemsStrategy arm. Element hierarchy: * New empty intermediate marker bases TemplatedListViewElementBase / TemplatedGridViewElementBase / TemplatedFlipViewElementBase under TemplatedListElementBase. No fields, no overrides except sealing ControlKind. Record equality on the leaf TemplatedListViewElement<T> is unchanged because the EqualityContract still ties to the leaf type. * TemplatedListViewElement<T> / TemplatedGridViewElement<T> / TemplatedFlipViewElement<T> now derive from the intermediate bases. Existing `: TemplatedListElementBase` pattern matches in legacy Mount.cs still work (transitive base relationship preserved). Descriptors: * TemplatedListViewDescriptor — registers against TemplatedListViewElementBase. Single registration catches every closed-T variant via the base-walk. Strategy = TemplatedItemsErased<>; props = SelectionMode / IsItemClickEnabled / Header / SelectedIndex via the fluent OneWayConditional surface. * TemplatedGridViewDescriptor — mirror targeting WinUI.GridView. Same shape, same binder path. * FlipView intentionally not ported (FlipView pre-mounts items via a completely different shape — no ContainerContentChanging, no OC delta channel). TemplatedFlipViewElementBase reserved in the hierarchy for symmetry; descriptor port stays carved to Phase 4. Selftest: V1 ON Desc_: 556 ok / 0 failures (+17 vs 539 baseline — Desc_TemplatedListView + Desc_TemplatedGridView, including keyed-diff insert/remove cycles + same-ref idempotency) V1 OFF Desc_: 556 ok / 0 failures (parity preserved) KLR_ legacy: 73 ok / 0 failures (refactored RefreshRealizedContainers + HandleTemplatedContainerContentChanging behavior neutral for the legacy path) Carve-outs preserved to Phase 4: * LazyVStack<T> / LazyHStack<T> — different realization machinery (ItemsRepeater + IElementFactory, not ListViewBase + CCC). Needs its own BindKeyedItemsSource arm. * ItemsRepeater<T> — same reason. * TemplatedFlipView<T> — pre-mounts items; no realization pipeline to plug into. * TreeView / TabView / Pivot — heterogeneous shapes; each its own descriptor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 close-out — docs + perf bench registry Updates spec §14 and the implementation tracker to reflect the four close-out commits on this branch: * Engine (1) Panel<>.PerChildAttachedAfterAll (5f5e0fa) * Engine (2) TemplatedItems<> + BindKeyedItemsSource (f1d9f74) * Port (4) RelativePanel two-pass (5af9a4c) * Port (5) G2 TemplatedListView<T> / TemplatedGridView<T> (42cf0c6) §14 + tracker move two carve-outs out of the carry-list (RelativePanel per-child attached; flat templated lists G2 for ListView/GridView) and re-document the remaining carve-outs with the post-close-out engine constraints: * Lazy*Stack<T> + ItemsRepeater<T> — different realization machinery (ItemsRepeater + IElementFactory rather than ListViewBase + CCC). Strategy shape unchanged; needs new BindErasedKeyedItemsSource arm. * G3 (TreeView / FlipView / TabView / Pivot) — heterogeneous shapes; none share the ListViewBase pipeline. * Expander.HeaderTemplate, TeachingTip.Target, Path.PathDataString, NumberBox coercion — Phase 4 as before. DescriptorVariantFactory registers the two new G2 descriptors against TemplatedListViewElementBase + TemplatedGridViewElementBase via RegisterHandlerForDerivedTypes so the perf bench harness exercises the post-close-out 54-control registration table. Perf re-capture (3×5 advisory on Cloud PC x64) lands under docs/specs/047/phase3-results/CPC-ander-YTZ3O-x64-advisory/ 2026-05-27-phase3-closeout-3x5/ — summary headline is a follow-up commit once the bench finishes. ARM64 stable-AC ratification on LAPTOP-4MEP83VI stays deferred for the §14 ratification gate per the original handoff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 close-out — advisory perf re-capture (3×5 Cloud PC x64) Median of n=15 (3 launches × 5 reps) V1 ON (post close-out descriptors) vs V1 OFF (today), 52 registered descriptors: Held: M4 Dispatch_Switch_Cold −20.8% (prior −21.2%) M5 Dispatch_Switch_Warm −23.9% (prior −24.3%) M12 Pool_Rent_HotPath +18.5% (prior +20.9%; descriptor-rent overhead unchanged) Improved vs prior phase3-final-3x5: M8 Update_OneLeafChanged +18.9% (prior +25.5%, −6.6pp) M10 EventHandlerState_Alloc −1.7% (prior +8.7%, volatile) Regressed vs prior phase3-final-3x5: M1 Mount_Leaf_NoCallback +21.2% (prior +14.9%, +6.3pp) M1 regression traced to two new `is`-checks in V1HandlerAdapter.DispatchChildrenMount (ITemplatedItemsStrategy + IErasedTemplatedItemsStrategy) that fire ahead of the closed-type pattern switch on every Mount. Fold into the existing `case` switch in a Phase 4 perf-tuning pass — not load-bearing for correctness. M8 improvement is from the DescriptorHandler.Children switch refactor adding inline-binding for templated-items strategies so non-ItemsHost Update paths are shorter. Cloud PC advisory only — does not cite in §13/§14 spec text. ARM64 stable-AC re-capture on LAPTOP-4MEP83VI stays deferred per the ratification gate. §14 headline updated with the held / improved / regressed breakdown. Full per-bench table in summary.md; methodology + reproduce steps in README.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — Engine (1) ItemsRepeater arm on erased binder Adds `case WinUI.ItemsRepeater ir:` to `BindErasedKeyedItemsSource` so a descriptor with `TemplatedItemsErased<TElement, WinUI.ItemsRepeater>` can drive realization through the same `ReactorListState` + `KeyedListDiff` pipeline the ListViewBase arm uses, but realizes via `IElementFactory` instead of `ContainerContentChanging` (ItemsRepeater has no CCC channel). New companion `IItemsRepeaterFactorySource` (internal) carries the factory + layout knobs the IR arm needs alongside the public `IKeyedItemSource` the source object also implements. Source objects must implement both; `LazyStackElementBase` will pick the contract up in Port (6). Update path mirrors the legacy Reconciler.Update.cs lazy-stack shape — TryUpdateFactory + keyed-diff + RefreshRealizedItems, with full factory replacement on type-mismatch fallback. V1 ON / V1 OFF Desc_ selftest: 556 ok / 0 failures both flags. Arm is dead code until Port (6) registers a Lazy*Stack descriptor that targets WinUI.ItemsRepeater. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — Engine (5) NumberBox.Min/Max via .CoercingOneWay Engine (5) audit: `.CoercingOneWay` shape already matches the legacy `UpdateNumberBox` arm's coercion-suppression pattern line-for-line — `WriteSuppressed` wraps the mutate in `ChangeEchoSuppressor.BeginSuppress` exactly as the imperative arm does. No new engine code; ports the NumberBox Min/Max entries in this same commit (carve-forward 15). Drops the matching "Known gaps" doc bullet on the descriptor since the gap is closed. V1 ON / V1 OFF Desc_ selftest: 556 ok / 0 failures both flags. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — Engine (4) .Imperative property escape hatch Adds a property-level Imperative entry shape — `.Imperative(mount, update)` on `ControlDescriptor<TElement, TControl>` plus the matching `ImperativePropEntry`. Mount lambda gets `(TControl, TElement)`; Update lambda gets `(TControl, TElement old, TElement new)`. The Update side exposes BOTH elements so the descriptor can express diffs the per-value get/set shapes can't — chiefly `Path.PathDataString` comparing the *string* field on old vs new while writing the *Geometry* value. Distinct from the existing `Imperative<TElement,TControl>` ChildrenStrategy (child-subtree escape hatch). The new entry only competes against `.OneWayConditional` for property-shaped scenarios. No fast-path; runs every render. Doc comment flags it as last-resort. Dead code until carve-forward (14) lands the PathDataString port. V1 ON Desc_ selftest: 556 ok / 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — Engines (2)+(3): two-strategy + Target audit Engine (2) — adds `.ImperativeBridged(mount, update)` PropEntry shape, the bridged superset of the Engine (4) `.Imperative` entry: lambdas receive `MountContext` / `UpdateContext` so they can call `Reconciler.ReconcileV1Child`. This is the answer to "two-strategy composition": for a secondary Element slot whose write target overlaps with a sibling property (Expander.HeaderTemplate writing into the same `Header` property as the string Header), express it as a PropEntry that reconciles imperatively + a sibling `.OneWayConditional` for the string fallback gated on the Element slot being null. The primary `Children` strategy stays unambiguous. NamedSlots was the first instinct but the prop-loop-then-children-dispatch ordering in V1HandlerAdapter would orphan the Element header on an Element→string transition (prop loop overwrites Header before NamedSlots sees the stale `existing` value). Engine (3) audit — TeachingTip.Target needs no engine extension: legacy `MountTeachingTip` doesn't set Target either; it's documented as `.Set` imperative setter in both paths. Carve-forward (13) closed in the same audit — declarative cross-element-reference resolution is post- Phase-3 polish; not blocking 100% V1 dispatch since Target was never routed through V1 dispatch in either path. V1 ON Desc_ selftest: 556 ok / 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — Carve (12) Expander.HeaderTemplate via .ImperativeBridged Ports HeaderTemplate (Element header) through the Engine (2) .ImperativeBridged entry shape. Update lambda calls ReconcileV1Child to preserve descendant component state across re-renders. Sibling string Header entry gated on HeaderTemplate-null so the Element wins when both are set — mirrors the legacy "if (n.HeaderTemplate is not null) ReconcileChild(...) else exp.Header = n.Header" ordering in UpdateExpander. Existing Desc_Expander_* selftest fixtures (string Header path) stay green — 556 ok / 0 failures V1 ON. HeaderTemplate behavior is now covered by the engine path; an Element-header fixture can land later without engine changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — Port (6) Lazy*Stack G2 (LazyVStack<T> / LazyHStack<T>) LazyStackElementBase now implements IKeyedItemSource + IItemsRepeaterFactorySource so a single descriptor on the non-generic base catches every closed-T variant via RegisterHandlerForDerivedTypes. The ItemsRepeater arm in Reconciler.BindErasedKeyedItemsSource (Engine (1)) drives Mount/Update through the existing ReactorListState + KeyedListDiff pipeline — same realization plumbing as the legacy MountLazyStack / UpdateLazyStack bodies, no new engine surface. ConfigureLayout reuses the existing WinUI.StackLayout when orientation + spacing match, mirroring the in-place Spacing update from the hand-coded UpdateLazyStack (Reconciler.Update.cs:3109). BuildItemView forwards to the existing ViewBuilder closure. Behavior difference vs hand-coded handler: the legacy path wraps the ItemsRepeater in a ScrollViewer with orientation-appropriate scrollbars; the descriptor port returns ItemsRepeater as the single TControl, so authors who need scrolling wrap externally. ScrollViewerSetters is inert under the descriptor port — documented in LazyStackDescriptor xmldoc. Selftest: 573 / 573 (V1 ON + V1 OFF), 0 failures. Adds 17 Desc_LazyVStack / Desc_LazyHStack fixtures on top of the 556 baseline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — Carve (14) Path.PathDataString via .Imperative PathDescriptor's Data entry was previously a .OneWayConditional gated on `e.PathDataString is null && e.Data is not null` — authors who used the SVG-string PathDataString surface stayed on V1 OFF because the engine's value-comparer fast-path couldn't replicate the legacy MountPath / UpdatePath three-strategy branching (XamlReader.Load → pre-built Geometry → PathDataParser.Parse) or the multi-source error context the hand-coded arm accumulates across both surfaces. Replaces that gated Data entry with a single Engine (4) .Imperative entry. Mount lambda calls a private static WriteData(c, e) that mirrors the legacy MountPath body verbatim — strategy 1 lifts a Geometry off a XamlReader.Load'd <Path Data="…"/>; strategy 2 assigns the pre-built e.Data with the legacy ArgumentException context (PathDataString + DataType + xamlNote); strategy 3 falls back to PathDataParser.Parse with the same multi-source rethrow. Update lambda replicates the legacy pathChanged gate (`o.PathDataString` vs `n.PathDataString` for the string surface, `n.Data is not null` for the Geometry surface) and re-invokes WriteData on diff. Lifted parsing locally — the static helper is self-contained and didn't warrant a Reconciler-internal promotion just for this descriptor. FillRule's .OneWayConditional gate drops the redundant `PathDataString is null` clause — the live `c.Data is PathGeometry` check at write time covers both surfaces (matches the legacy arm's behavior). Drops the "PathDataString is escape-hatched" bullet from the descriptor xmldoc's "Known gaps" list; adds a Behavior-parity note that PathDataString now ports via the Engine (4) .Imperative entry. Selftest: 573 / 573 (V1 ON + V1 OFF), 0 failures. Renames the Desc_Path_Data_PathDataStringGate fixture check to Desc_Path_Data_PathDataStringPorted — the assertion flips from "Data write skipped, p.Data unchanged" to "p.Data was replaced and is non-null". Same total check count. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — dispatch consolidation (single is-check arm) Folds the per-strategy is-checks in DispatchChildrenMount / DispatchChildrenUpdate (V1HandlerAdapter) and Mount / Update (DescriptorHandler) into a single check against a new base interface IItemsBinderStrategy. ITemplatedItemsStrategy and IErasedTemplatedItemsStrategy now inherit from it; the explicit interface implementations on TemplatedItems<> and TemplatedItemsErased<> reference the base directly. Future strategy markers (tree / tab / pivot) that implement IItemsBinderStrategy plug into the same arm — M1 dispatch cost stays at one is-check + one interface call rather than scaling with the number of strategy variants. V1 ON / V1 OFF Desc_ selftest: 573 ok / 0 failures both flags. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — §14 + tracker close-out narrative Adds a "Phase 3 finish" subsection to §14 documenting Engines (1)–(5), Port (6) Lazy*Stack G2, carve-forwards (12)+(14)+(15) (and (13) via audit), and the dispatch consolidation. Updates the Phase 3 close-out carve-out list — items that landed flip to checked with a one-line landing note; remaining items (Port (7) ItemsRepeater<T> element/DSL work + G3 Tree/FlipView/Tab/Pivot strategies) move to a "Phase 3 finish carry-forwards" list with the specific engine work each needs. ARM64 stable-AC ratification gate explicitly called out as the last open §14 item. Owner+date assignment to be appended once filed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — advisory perf re-capture (3×5 Cloud PC x64) Re-captures the 13-bench micro suite on `CPC-ander-YTZ3O` (x64, Cloud PC, not stable AC) with the Phase 3 finish branch tip including the dispatch consolidation. Median of n=15 (3 launches × 5 reps). Headline vs prior `phase3-closeout-3x5/`: - Held: M4 -20.2% / M5 -17.8% — dispatch wins persist with the +1 base-derived descriptor (LazyStackDescriptor). - M1 +20.7% (was +21.2%) — dispatch consolidation's structural fold reduces instruction count but didn't recover the close-out's +6.3pp on this Cloud-PC run. A genuine M1 fix likely needs Phase 4 perf tuning that lifts the binder check into the existing pattern switch's `case` arm rather than a leading `if`-block. - M8 +2.9pp / M12 +12.2pp — new regressions vs close-out. M12 has trended volatile across the last three captures (±15pp) and should be confirmed on stable AC. No bench exceeds §13 Q1 reopen threshold. The structural wins (the new IItemsBinderStrategy single-marker arm) are in place; absolute numbers track the close-out baseline within Cloud-PC noise. §14 Phase 3 finish narrative updated with the actual numbers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — Ports (8)-(11) G3 TreeView / FlipView / TabView / Pivot Lands the four G3 typed-list / heterogeneous-items descriptors using two new ChildrenStrategy variants and one strategy reuse: - **TreeChildren<TElement, TControl>** (new) — hierarchical TreeView binder. Reads a TreeViewNodeData tree from the element, builds the matching WinUI.TreeViewNode tree on RootNodes. Mounts per-node ContentElement through Reconciler when any node uses one (and picks SharedContentControlTemplate.Value as the ItemTemplate); otherwise uses the new TreeViewTextItemTemplate for text-only nodes. Update is positional rebuild — old ContentElement UI subtrees unmount before the WinUI tree clears. Implements IItemsBinderStrategy so dispatch goes through the consolidated arm landed earlier this branch. - **TabItemsHost<TElement, TControl, TItem>** (new) — heterogeneous items host shared by TabView (Port (10)) and Pivot (Port (11)). Each item declares header + Element content + a CreateContainer lambda that builds the per-control container (TabViewItem with Header/IsClosable/IconSource; PivotItem with Header/Content). Same positional rebuild + ContentControl-based unmount walk as the legacy paths. Implements IItemsBinderStrategy. - **FlipView (Port (9))** — reuses the existing ItemsHost<> strategy. FlipView.Items is a flat IList<object> sink; ItemsHost already pre-mounts each Element item and adds the mounted UIElement. No PreMountedItems strategy needed (handoff alternative (b) confirmed). Descriptors cover: - TreeView: Nodes via TreeChildren; SelectionMode / CanDragItems / AllowDrop / CanReorderItems; OnItemInvoked + OnExpanding. - FlipView: Items via ItemsHost; SelectedIndex round-trip. - TabView: Tabs via TabItemsHost; SelectedIndex round-trip; OnTabCloseRequested + OnAddTabButtonClick. TabStripHeader/Footer and §2.4 docking drag pipeline stay carved (documented in xmldoc). - Pivot: Items via TabItemsHost; Title via .OneWayConditional; SelectedIndex. 29 new fixtures across the four descriptors. Full Desc_ selftest: V1 ON 602 ok / 0 failures; V1 OFF 602 ok / 0 failures (baseline 573 + 29 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — §14 + tracker update for G3 ports landing Moves Ports (8) TreeView, (9) FlipView, (10) TabView, (11) Pivot from the carry-forwards list into the landed-engine section. Documents the two new ChildrenStrategy variants (TreeChildren, TabItemsHost), the FlipView reuse of ItemsHost, and the carved scope on TabView (strip header/footer, docking drag, pinnable headers). After this commit only Port (7) ItemsRepeater<T> remains as a Phase 3 finish carry-forward — that one is blocked on a missing ItemsRepeaterElement<T> element + DSL surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — Port (7) ItemsRepeater<T> (closes carry-forward) Closes the last Phase 3 finish carry-forward — the missing ItemsRepeater<T> element + DSL surface that blocked 100% V1 dispatch coverage. Three new pieces ship in this commit: 1. **ItemsRepeaterElementBase + ItemsRepeaterElement<T>** (Element.cs) — non-generic base + typed peer modeled on LazyStackElementBase. The base implements IKeyedItemSource + IItemsRepeaterFactorySource so it plugs into Engine (1)'s ItemsRepeater arm without any new engine work. Distinct from LazyStackElementBase: no hard-coded StackLayout (the element exposes a nullable Layout property — author supplies any WinUI.Layout instance, default = WinUI's own ItemsRepeater default) and no ScrollViewer wrap (the rendered control is the bare ItemsRepeater; authors who need scrolling host it externally). 2. **Legacy MountItemsRepeater / UpdateItemsRepeater arms** in Reconciler.Mount.cs / Reconciler.Update.cs — there was no legacy arm before this port (the element type is new). The legacy arms mirror the existing MountLazyStack / UpdateLazyStack TryUpdateFactory + keyed-diff + RefreshRealizedItems pipeline so V1 OFF parity holds. 3. **ItemsRepeaterDescriptor** — base-derived single descriptor on ItemsRepeaterElementBase. Children = TemplatedItemsErased<> targeting WinUI.ItemsRepeater; reuses every engine partial Port (6) exercised. No event surface (ItemsRepeater itself doesn't raise selection / item-click events). DSL surface added in Dsl.cs — `ItemsRepeater<T>(items, keySelector, viewBuilder)` factory plus the IReactorKeyed-typed overload, matching the LazyVStack / LazyHStack surface. 11 new fixtures (Desc_ItemsRepeater_*). Full Desc_ selftest: V1 ON 613 ok / 0 failures; V1 OFF 613 ok / 0 failures (prior baseline 602 + 11 new). 100% V1 dispatch coverage now reached for every typed items host — only the engine arms that were carry-forwards in close-out remain in production legacy code (those flip in Phase 4 cleanup). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — §14 + tracker close-out (Port (7) landed) Flips Port (7) ItemsRepeater<T> from carry-forward to landed. §14's "Phase 3 finish carry-forwards" list now reads "none remaining" — every typed-items host has a V1 descriptor and the engine surface is complete. The production swap (RegisterV1BuiltInHandlers wiring + legacy switch deletion) is Phase 4 cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — honest scope accounting (deferred/not-attempted) Replaces the "100% V1 dispatch coverage now reached" claim in the tracker with the precise statement: every typed-items host family scoped by Phase 3's batch list has a V1 descriptor. Adds an explicit "Phase 3 deferred / not-attempted" enumeration to both §14 and the tracker covering the long tail that was never on the Phase 3 batch list — ContentDialog / Flyout / Popup family, navigation / title-bar / media family, ItemsView / ItemContainer / plain GridViewElement, interop + a11y wrappers, and the Reactor composition primitives that likely should stay out of the V1 handler protocol entirely. Also flags TemplatedFlipViewElement<T> as the one genuine engine gap still carried from Phase 3 close-out (FlipView lacks ContainerContentChanging; would need a PreMountedItems ChildrenStrategy). The intermediate base TemplatedFlipViewElementBase is reserved in the element hierarchy for that future port. No code changes — docs accuracy only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * spec(047): Phase 3 finish — address PR #437 CR feedback Five findings cleared: 1. **PathDescriptor FillRule live-control gate (Copilot, correctness).** The `.OneWayConditional` gate was `shouldWrite: e => e.Data is PathGeometry`, which is FALSE for the PathDataString surface (where `e.Data` is null but `XamlReader.Load` / `PathDataParser.Parse` produces a `PathGeometry` on `c.Data`). Diverged from legacy `UpdatePath`'s `p.Data is PathGeometry pg => pg.FillRule = n.FillRule` which inspects the LIVE control's resolved Data. Switched to `.OneWay` so the entry runs every Mount + on every change to `e.FillRule`; the set lambda's inner `c.Data is PathGeometry` check is now the actual gate. Matches legacy exactly. 2. **V1HandlerAdapter redundant `pairs is not null` check (CodeQL, x2).** `pairs` is non-null exactly when `afterAll` is non-null (conditional allocation upstream). Dropped the redundant subcondition on both Mount and Update Panel<> arms; kept the `afterAll` guard with `pairs!`. 3. **`ElementReferenceComparer` duplicated in 3 button descriptors (Copilot).** Promoted to a single shared internal type `V1Protocol.Descriptor.Descriptors.ElementReferenceComparer`; deleted the three copies in `DropDownButtonDescriptor`, `SplitButtonDescriptor`, `ToggleSplitButtonDescriptor`. 4. **Element.cs `ConfigureLayout` float `!=` (CodeQL).** Replaced `existing.Spacing != Spacing` with `Math.Abs(...) > 1e-9` per the established spec-047 fixture convention (b091001). 5. **Fixture float `==` checks (CodeQL, x4 sites).** `l2.Spacing == 12`, `l3.Spacing == 16`, `ug.MinRowSpacing == 4`, `sl.Spacing == 4` → `Math.Abs(... - literal) < 1e-9` per b091001. Selftest after: Desc_ V1 ON 613 ok / 0 failures (baseline preserved). Path-specific Desc_Path_*: 19 ok / 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * build(cli): skip SignaturesGen regen in CI to break the Reactor.Localization.Generator file-lock race Root cause of the recurring CI "Unit Tests" failure on PR #437 and sporadically on main: `src/Reactor.Cli/Reactor.Cli.csproj` declares a `BeforeBuild` Exec target that shells out to a nested `dotnet build Reactor.SignaturesGen.csproj`. SignaturesGen ProjectReferences `Reactor.csproj`, which ProjectReferences `Reactor.Localization.Generator`. The OUTER build (`dotnet test tests/Reactor.Tests`) already produces `obj/x64/Debug/netstandard2.0/Reactor.Localization.Generator.dll` because `Reactor.Tests.csproj` directly references that project too. The outer and nested `dotnet build` processes have their own VBCSCompiler instances and don't coordinate — they race on the same output dll. The second writer hits CSC error CS2012 ("Cannot open ... for writing -- file may be locked by 'VBCSCompiler'"). The race window scales with project size, which is why PR #437's increased compile load triggers it consistently. The committed `skills/reactor.api.txt` is what pack (`Reactor.csproj:134`) and embed (`Reactor.Cli.csproj:84`) consume, both gated `Condition="Exists(...)"`. CI never actually needs to regen api.txt — it's a dev-machine convenience step. Fix: add `and '$(CI)' != 'true'` to the RunSignaturesGen Target's Condition. GitHub Actions sets `CI=true` as an env var; MSBuild picks it up as `$(CI)`. Verified locally: - `unset CI; dotnet build src/Reactor.Cli` — RunSignaturesGen fires (nested build runs, fast-up-to-date-checks skip everything since outputs are current). - `CI=true dotnet build src/Reactor.Cli` — RunSignaturesGen fully skipped (zero "RunSignaturesGen" / "SignaturesGen.csproj" mentions in verbose log). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 522c940 commit 836c301

49 files changed

Lines changed: 5662 additions & 185 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/specs/047-extensible-control-model.md

Lines changed: 53 additions & 7 deletions
Large diffs are not rendered by default.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Spec 047 §14 Phase 3 close-out, x64 advisory
2+
3+
**This is an advisory x64 capture, NOT authoritative.** Cloud PC
4+
(`CPC-ander-YTZ3O`, AMD EPYC 7763 64-Core Processor, x64), not on
5+
AC/dedicated hardware. Do not cite these numbers in §13 or §14 spec
6+
text. A stable-AC ARM64 re-capture on `LAPTOP-4MEP83VI` should ratify
7+
the matrix before §14 Phase 3 is closed.
8+
9+
## Why this capture exists
10+
11+
This run extends the `2026-05-27-phase3-final-3x5/` matrix with the
12+
four close-out commits landed on `spec/047-phase3-close-out` (off PR
13+
#436 HEAD):
14+
15+
- **Engine (1)**`Panel<>.PerChildAttachedAfterAll` two-pass shape.
16+
- **Engine (2)**`TemplatedItems<>` strategy + `Reconciler.BindKeyedItemsSource`,
17+
plus the T-erased `TemplatedItemsErased<>` + `BindErasedKeyedItemsSource`
18+
variant.
19+
- **Port (4)** — RelativePanel descriptor uses the new after-all callback.
20+
- **Port (5) G2**`TemplatedListView<T>` / `TemplatedGridView<T>` via
21+
base-derived registration. Adds a base-walk to
22+
`V1HandlerRegistry.TryGet` (exact-type entries always win; base-derived
23+
entries cached per concrete type for O(1) steady state).
24+
25+
`DescriptorVariantFactory` now registers **52 ported controls** (50
26+
from prior + 2 new via `RegisterHandlerForDerivedTypes` for the typed
27+
templated lists). The bench matrix detects (a) dispatch-table shape
28+
change from the +2 entries and the new strategy pattern-match arms in
29+
`V1HandlerAdapter`, (b) the registry's base-walk fallback cost on
30+
non-derived lookups, and (c) the `DescriptorHandler.Children` switch
31+
gaining the two new templated-items cases.
32+
33+
## Capture environment
34+
35+
`CPC-ander-YTZ3O`, x64 (AMD EPYC 7763 64-Core Processor), Release,
36+
.NET 10.0.8, Windows 11 26200. **Cloud PC — not on AC/dedicated
37+
hardware**. 3 process launches × 5 reps × 13 benches × 4 variants =
38+
780 measurements across `launch-1.jsonl` + `launch-2.jsonl` +
39+
`launch-3.jsonl`.
40+
41+
## Headline — V1 ON (descriptors, post close-out) vs V1 OFF (today)
42+
43+
Median of n=15 (3 launches × 5 reps) per cell. Compared against the
44+
prior `2026-05-27-phase3-final-3x5/` headline (50 controls) to surface
45+
the close-out delta.
46+
47+
| Bench | This capture (V1 ON vs V1 OFF) | Prior `phase3-final-3x5` | Delta vs prior | Notes |
48+
|---|---:|---:|---:|---|
49+
| M1 Mount_Leaf_NoCallback | **+21.2%** | +14.9% | +6.3pp regression | New strategy pattern-match arms in `V1HandlerAdapter.DispatchChildrenMount` add two upfront `is`-checks (`ITemplatedItemsStrategy`, `IErasedTemplatedItemsStrategy`). |
50+
| M2 Mount_Leaf_OneCallback | -0.1% | -1.7% | within noise | |
51+
| M3 Mount_Leaf_ThreeCallbacks | -1.0% | +3.3% | improvement | |
52+
| M4 Dispatch_Switch_Cold | **-20.8%** | -21.2% | held | Dispatch wins persist — the +2 entries don't push past the inflection. |
53+
| M5 Dispatch_Switch_Warm | **-23.9%** | -24.3% | held | Same. |
54+
| M6 Dispatch_ExternalType | -0.5% | +0.2% | within noise | The new base-walk fallback in `V1HandlerRegistry.TryGet` is gated on `_baseEntries.Count == 0` so external-type lookups skip it. |
55+
| M7 Update_NoChange | +6.3% | +7.4% | minor improvement | |
56+
| M8 Update_OneLeafChanged | **+18.9%** | +25.5% | improvement (-6.6pp) | Largest movement. `DescriptorHandler.Children` switch refactor (added `ITemplatedItemsStrategy` / `IErasedTemplatedItemsStrategy` arms returning `null` so dispatch happens inline) shortens the non-ItemsHost Update path. |
57+
| M9 Update_AllChanged | +4.5% | +3.6% | within noise | |
58+
| M10 EventHandlerState_Alloc | -1.7% | +8.7% | improvement (-10.4pp) | Volatile run-to-run; not load-bearing. |
59+
| M11 ModifierEHS_Frequency | +9.7% | +8.5% | within judgment band | |
60+
| M12 Pool_Rent_HotPath | **+18.5%** | +20.9% | held | Descriptor-interpreter pool-rent overhead persists; known regression. |
61+
| M13 Setters_Suppression_Scope | -2.1% | -0.9% | within noise | |
62+
63+
**Net signal**:
64+
65+
- **M1 regressed +6.3pp** from the prior advisory — directly attributable
66+
to the strategy pattern-match arms added in
67+
`V1HandlerAdapter.DispatchChildrenMount`. Two `is`-checks fire before
68+
the pattern switch on every Mount, even for leaves that don't use
69+
templated items. Worth folding into the prior `case` switch in a
70+
Phase 4 perf-tuning pass; not load-bearing for correctness.
71+
- **M8 improved -6.6pp** — the `DescriptorHandler.Children` switch
72+
short-circuit for ItemsHost / templated-items strategies is a
73+
structural win.
74+
- **M4 / M5 dispatch wins held**.
75+
- **M12 Pool_Rent_HotPath +18.5%** carry-over — same descriptor-rent
76+
overhead the prior capture already documented; nothing in this branch
77+
intersects.
78+
79+
## Q1 decision matrix — for completeness
80+
81+
Per §13 Q1's pre-committed decision matrix applied to
82+
ReactorDescriptors vs ReactorV2:
83+
84+
| Bench | vs ReactorV2 ns | Q1 band |
85+
|---|---:|---|
86+
| M1 | +20.2% | exceeds 15% — judgment call vs LOC/readability |
87+
| M2 | -0.5% | ship descriptors |
88+
| M5 | -19.5% | ship descriptors (improvement) |
89+
| M7 | +7.3% | judgment-call band |
90+
| M10 | +5.2% | judgment-call band |
91+
92+
**Verdict:** No reopen condition for Q1 — Q1's reopen is gated on
93+
source-gen (§7) landing, not advisory perf noise. The close-out scope's
94+
M1 +21.2% and M12 +18.5% should be confirmed on stable-AC ARM64 before
95+
any spec-text change.
96+
97+
## Caveats
98+
99+
- **Cloud PC noise.** Per the prior README: "noise-prone, advisory.
100+
Do not cite in §13/§14 spec text."
101+
- **ARM64 stable-AC re-capture on `LAPTOP-4MEP83VI` is deferred** for
102+
the §14 ratification gate.
103+
104+
## Reproduce
105+
106+
```powershell
107+
cd C:\Users\andersonch\Code\reactor2
108+
dotnet build tests/perf_bench/PerfBench.ControlModel -c Release -p:Platform=x64
109+
$exe = "tests\perf_bench\PerfBench.ControlModel\bin\x64\Release\net10.0-windows10.0.22621.0\PerfBench.ControlModel.exe"
110+
$out = "docs\specs\047\phase3-results\CPC-ander-YTZ3O-x64-advisory\2026-05-27-phase3-closeout-3x5"
111+
$results = "tests\perf_bench\PerfBench.ControlModel\bin\x64\Release\net10.0-windows10.0.22621.0\results.jsonl"
112+
for ($i = 1; $i -le 3; $i++) {
113+
Remove-Item $results -ErrorAction SilentlyContinue
114+
Start-Process -FilePath $exe -Wait -NoNewWindow # Start-Process -Wait is required;
115+
# `& $exe` does not block on this WinUI app.
116+
Copy-Item $results "$out\launch-$i.jsonl"
117+
}
118+
python "$out\aggregate.py" > "$out\summary.md"
119+
```
120+
121+
See `summary.md` for the full per-bench table.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Spec 047 §14 Phase 2 (Q1 spike) — aggregate launch-N.jsonl into a means
2+
+ 95% CI table per (bench, variant), and emit the Q1 decision-matrix deltas
3+
(ReactorDescriptors vs ReactorV2, ReactorDescriptors vs ReactorToday).
4+
5+
Usage: python aggregate.py # reads launch-*.jsonl in CWD
6+
"""
7+
import glob
8+
import json
9+
import math
10+
import statistics
11+
from collections import defaultdict
12+
13+
14+
def main():
15+
rows = []
16+
for path in sorted(glob.glob("launch-*.jsonl")):
17+
with open(path, "r", encoding="utf-8") as f:
18+
for line in f:
19+
line = line.strip()
20+
if not line:
21+
continue
22+
row = json.loads(line)
23+
if row.get("status") != "ok":
24+
continue
25+
rows.append(row)
26+
27+
# Group by (benchId, variant).
28+
buckets = defaultdict(list)
29+
for r in rows:
30+
buckets[(r["benchId"], r["variant"])].append(r)
31+
32+
benches = sorted({b for (b, _) in buckets}, key=_bench_key)
33+
variants = ["ReactorToday", "ReactorV2", "ReactorDescriptors"]
34+
35+
def summarize(rs, key):
36+
vals = [r[key] for r in rs]
37+
if not vals:
38+
return (math.nan, math.nan, 0)
39+
mean = statistics.mean(vals)
40+
if len(vals) > 1:
41+
stdev = statistics.stdev(vals)
42+
# 95% CI half-width for a t-distribution. For n=15 dof=14, t ≈ 2.145.
43+
# Approximate with 1.96 for simplicity — close enough at n≥10.
44+
ci_half = 1.96 * stdev / math.sqrt(len(vals))
45+
else:
46+
ci_half = math.nan
47+
return mean, ci_half, len(vals)
48+
49+
# ── Per-(bench, variant) summary table. ──
50+
print("# Per-(bench, variant) means")
51+
print()
52+
print(f"| Bench | Variant | n | Mean ns | 95% CI ±ns | Mean alloc B | 95% CI ±B |")
53+
print(f"|---|---|---:|---:|---:|---:|---:|")
54+
for b in benches:
55+
for v in variants:
56+
rs = buckets.get((b, v), [])
57+
mean_ns, ci_ns, n = summarize(rs, "meanNs")
58+
mean_b, ci_b, _ = summarize(rs, "allocBytes")
59+
if n == 0:
60+
print(f"| {b} | {v} | 0 | — | — | — | — |")
61+
else:
62+
print(
63+
f"| {b} | {v} | {n} | {mean_ns:,.0f} | {ci_ns:,.0f} "
64+
f"| {mean_b:,.0f} | {ci_b:,.0f} |"
65+
)
66+
print(f"| | | | | | | |")
67+
68+
# ── Q1 decision-matrix deltas. ──
69+
print()
70+
print("# Q1 head-to-head — ReactorDescriptors deltas")
71+
print()
72+
print(
73+
"| Bench | vs ReactorV2 ns | vs ReactorV2 alloc | vs ReactorToday ns | vs ReactorToday alloc | Q1 band |"
74+
)
75+
print("|---|---:|---:|---:|---:|---|")
76+
for b in benches:
77+
ds = buckets.get((b, "ReactorDescriptors"), [])
78+
v2 = buckets.get((b, "ReactorV2"), [])
79+
today = buckets.get((b, "ReactorToday"), [])
80+
d_ns, _, _ = summarize(ds, "meanNs")
81+
d_b, _, _ = summarize(ds, "allocBytes")
82+
v_ns, _, _ = summarize(v2, "meanNs")
83+
v_b, _, _ = summarize(v2, "allocBytes")
84+
t_ns, _, _ = summarize(today, "meanNs")
85+
t_b, _, _ = summarize(today, "allocBytes")
86+
87+
def pct(a, base):
88+
if base and not math.isnan(base) and not math.isnan(a):
89+
return (a - base) / base * 100.0
90+
return math.nan
91+
92+
vs_v2_ns = pct(d_ns, v_ns)
93+
vs_v2_b = pct(d_b, v_b)
94+
vs_t_ns = pct(d_ns, t_ns)
95+
vs_t_b = pct(d_b, t_b)
96+
97+
# §13 Q1 matrix bands keyed off the worst of ns vs V2.
98+
worst = vs_v2_ns
99+
if math.isnan(worst):
100+
band = "-"
101+
elif abs(worst) <= 5:
102+
band = "<=5%: ship descriptors"
103+
elif abs(worst) <= 15:
104+
band = "5-15%: judgment call"
105+
else:
106+
band = ">15%: ship hand-coded"
107+
108+
print(
109+
f"| {b} | {vs_v2_ns:+.1f}% | {vs_v2_b:+.1f}% | {vs_t_ns:+.1f}% | {vs_t_b:+.1f}% | {band} |"
110+
)
111+
112+
113+
def _bench_key(s):
114+
# M1, M2, ..., M13 — sort numerically.
115+
try:
116+
return int(s.lstrip("M"))
117+
except ValueError:
118+
return 999
119+
120+
121+
if __name__ == "__main__":
122+
main()

0 commit comments

Comments
 (0)