From 325dcd553f1d7ae3b99e0a64b852708678c93f34 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 15 Jul 2026 12:00:33 +0100 Subject: [PATCH] =?UTF-8?q?fix(injections):=20PR=20#122=20round=202=20?= =?UTF-8?q?=E2=80=94=20sibling=20precedence,=20observable=20cap,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups + doc cleanup. [P2] Same-depth sibling precedence was reversed. The wire priority was (depth, capture_order), omitting the layer ordinal, so two overlapping spans from different sibling layers tied — and the active-set insert then applied the later one first, making the earlier sibling win, the opposite of the grid's layer-by-layer paint. Priority is now (layer_index, capture_order): layer_index is the depth-ascending position in bundle.layers, so a deeper layer AND a later same-depth sibling both override, matching the grid exactly. New flatten_same_depth_sibling_later_layer_wins pins it. [P2] Cap surfacing had no end-to-end test. Added injection_cap_surfaced_once_and_rearms_via_lua, which drives the real Lua settle path (syntax.lua tick -> _injection_capped -> pmacs.error) and asserts surfaced-once, suppressed-on-unchanged-reparse, and re-armed-after-dropping-below-then-exceeding-the-cap. Docs: - Q#IJ6 now states the accurate bound O(n log n + Sum active) for the event sweep, not O(boundaries). - The full-buffer perf test is renamed/narrowed to guard the FLATTENER regression; the summary's per-line dominant-style tally is a separate pre-existing O(lines x spans) loop, not claimed linear. - Framing #9 now matches the test: it drives spans_from_segments (the extracted replace_style_spans transform) + source_color_at, not a live State render. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan --- docs/multi-language-injections-framing.md | 51 ++++++----- src/semantic_render.rs | 89 +++++++++++++++--- tests/injection_acceptance.rs | 105 ++++++++++++++++++++++ 3 files changed, 212 insertions(+), 33 deletions(-) diff --git a/docs/multi-language-injections-framing.md b/docs/multi-language-injections-framing.md index c11dd8f..2e3b111 100644 --- a/docs/multi-language-injections-framing.md +++ b/docs/multi-language-injections-framing.md @@ -215,17 +215,20 @@ Because the wire re-sorts by start (`main.rs:4117`/`4130`), producer order cannot carry overlap precedence. So overlaps are resolved **in the producer, before the wire**: -- **`scoped_style_spans` flattens** all layers over the viewport into - **disjoint effective spans** — a sweep-line that, at each byte, takes - the deepest layer covering it, and within a layer the existing - wider-first "narrower overrides" rule. Output is disjoint runs of a - single folded style. The viewport already bounds the sweep, so this is - O(boundaries) in the visible range. Positional re-sorting downstream is - then a no-op on precedence, and the disjoint output aligns with the - model's existing style-tile disjointness invariant. (Per-byte effective - style is unchanged from today for single-layer buffers; only the *shape* - goes overlapping→disjoint, so existing `StyleSpans` shape assertions are - updated to match.) +- **`scoped_style_spans` flattens** all layers into **disjoint effective + spans** — an ordered active-set **event sweep**: activate a span at its + start boundary, expire it at its end, and at each interval fold the + active set (deeper layer / later same-depth sibling / narrower capture + wins, keyed by `(layer_index, capture_order)`). Cost is + **O(n·log n + Σ active)** in the span count — linear in practice, since + the active set is bounded by overlap depth, not the total span count. + This bound matters because the file-style summary runs the flattener + over the **whole buffer**, not just the viewport. Positional re-sorting + downstream is then a no-op on precedence, and the disjoint output aligns + with the model's existing style-tile disjointness invariant. (Per-byte + effective style is unchanged from today for single-layer buffers; only + the *shape* goes overlapping→disjoint, so existing `StyleSpans` shape + assertions are updated to match.) - **The GPU `source_color_at` fold fix is kept** (fold all covering spans in order, matching `effective_style_at`) — defense-in-depth and a contract alignment, correct even for any residual same-start overlap. @@ -233,9 +236,12 @@ producer, before the wire**: it paints layer-by-layer in depth order (later overrides via the cell merge), which is already correct. -Acceptance test #9 drives the **full message-application path** -(`replace_style_spans` → render → `source_color_at`) with an overlapping -parent-red / child-green case, not a direct `source_color_at` call. +Acceptance test #9 drives the real full-frame message transform +(`spans_from_segments`, extracted from `replace_style_spans`) then +`source_color_at`, with an overlapping parent-red / child-green case — +exercising the start-sort + fold, not a hand-rolled sort. (A live-`State` +render pass needs a GPU device and is out of unit-test scope; the extracted +transform is the code that matters here.) ### Q#IJ7 — Both producers walk layers; Policy A unchanged at buffer scope @@ -329,15 +335,20 @@ half-styled frame. `grammar_style_parse_not_ready` unchanged. falsify multi-range.) 7. `recursion_bounds_terminate` — rust macro self-injection terminates within the depth bound; `injection_layer_cap_surfaces_and_preserves_root` - drives >4096 fences and asserts the surfaced `injection_capped` flag, - the bounded count, and an intact root; a failing child drops only itself - (Q#IJ3). + drives >4096 fences (bundle-flag level), and + `injection_cap_surfaced_once_and_rearms_via_lua` (round 2) drives the + **observable** Lua settle path: `pmacs.error` once, suppressed on an + unchanged re-parse, and re-armed after dropping below the cap and + exceeding it again. A failing child drops only itself (Q#IJ3). 8. `wire_producer_emits_disjoint_child_spans_in_fence` — `scoped_style_spans` over a ` ```rust ` fence emits disjoint `StyleSpan`s covering a rust keyword **inside** the fence. **Bite-verified** vs the single-layer - producer. Plus `full_buffer_summary_scales_on_large_grammar_file` - (round 1): the whole-buffer summary path stays ~linear under the event - sweep (a quadratic flatten regresses it). + producer. `flatten_same_depth_sibling_later_layer_wins` (round 2): a + later same-depth sibling layer wins the fold, matching grid paint order. + `full_buffer_summary_flatten_scales_on_large_grammar_file` (round 1): + the whole-buffer **flatten** stays ~linear under the event sweep (a + quadratic flatten regresses it; the summary's per-line tally is a + separate pre-existing loop). 9. `source_color_folds_overlapping_child_over_parent` — parent-red / child-green overlap driven through the real `spans_from_segments` (`replace_style_spans` body): the child (green) wins the fold. diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 61c8b25..b3f5981 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -1766,7 +1766,7 @@ fn scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec = Vec::new(); - for layer in &bundle.layers { + for (layer_idx, layer) in bundle.layers.iter().enumerate() { let Some(query) = layer.highlight_query.as_ref() else { continue; }; @@ -1794,7 +1794,7 @@ fn scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec Vec usize { + state.lua_host.lua().load("return #_CAP").eval().unwrap() + }; + let current = + |state: &EditorState| state.syntax_registry.view(buf_id).and_then(|h| h.current()); + let replace_all = |state: &EditorState, bytes: &[u8]| { + let core = state.core.borrow(); + let mut reg = core.registry.borrow_mut(); + let buf = reg.get_mut(buf_id).expect("buffer"); + let len = buf.len(); + buf.apply_edit(EditOp::Replace { + range: Range::new(0, len), + bytes, + }) + .expect("replace"); + }; + + // 1) First settle → surfaced exactly once, message names the cap. + dispatch(&state); + pump_async(&mut state, |s| current(s).is_some()); + assert_eq!(cap_count(&state), 1, "cap surfaced once on first settle"); + let msg: String = state.lua_host.lua().load("return _CAP[1]").eval().unwrap(); + assert!( + msg.contains("injection layer cap"), + "message names the cap: {msg}" + ); + + // 2) Re-dispatch with no change → suppressed (still once). + let b1 = current(&state).unwrap(); + dispatch(&state); + pump_async(&mut state, |s| { + current(s).is_some_and(|b| !Arc::ptr_eq(&b1, &b)) + }); + assert_eq!( + cap_count(&state), + 1, + "once-per-buffer: no re-warn without change" + ); + + // 3) Shrink below the cap → warned flag clears, no new error. + replace_all(&state, b"# small\n\n```rust\nx\n```\n"); + let b2 = current(&state).unwrap(); + dispatch(&state); + pump_async(&mut state, |s| { + current(s).is_some_and(|b| !Arc::ptr_eq(&b2, &b)) + }); + assert_eq!( + cap_count(&state), + 1, + "dropping below the cap surfaces no new error" + ); + + // 4) Grow back above the cap → re-armed, warns once more. + replace_all(&state, capping.as_bytes()); + let b3 = current(&state).unwrap(); + dispatch(&state); + pump_async(&mut state, |s| { + current(s).is_some_and(|b| !Arc::ptr_eq(&b3, &b)) + }); + assert_eq!( + cap_count(&state), + 2, + "re-armed: exceeding the cap again warns once more" + ); +} + /// Framing acceptance #12: a large all-inline markdown buffer settles /// (root + one cold inline layer per paragraph) within a comfortable /// budget, and the FINAL paragraph still receives an inline layer — the