From: Thomas Walker Lynch Date: Fri, 7 Aug 2026 01:23:35 +0000 (+0000) Subject: fixes split bug X-Git-Url: https://git.reasoningtechnology.com/%27%20%20%20full_path%20%20%20%27?a=commitdiff_plain;h=64fd8c78f9217a60ac2b537043c1fdfe63c4c13d;p=RT-Style fixes split bug --- diff --git a/developer/authored/Manuscript.copy/Document/design.html b/developer/authored/Manuscript.copy/Document/design.html index 46bc54b..2ede5bf 100644 --- a/developer/authored/Manuscript.copy/Document/design.html +++ b/developer/authored/Manuscript.copy/Document/design.html @@ -464,6 +464,44 @@

+ + A Break Between Siblings Splits the Parent +

+ A break does not always fall inside a scope. It may fall in the gap between two sibling scopes, after the first has closed and before the second has opened. In that case neither sibling is cut, and neither should be soft closed. The element that spans the boundary is the parent. +

+

+ This follows from the chain view rather than being a separate rule. A position between two siblings has those siblings nowhere in its enclosing chain; the chain runs from the gap upward through the parent. Soft closing a sibling that merely happens to precede the gap suspends a scope that was never cut. +

+

+ When the parent is itself a step scope, it is the parent that is soft closed and continuation opened, its children distributed between the two fragments. When the parent is not a scope, as with top level sections whose parent is the article, nothing needs suspending at all: the counter walk traverses the whole document tree after pagination and is indifferent to page boundaries, so it enters and exits each intact scope in turn and the numbering follows without any protocol. +

+

+ The practical consequence for an implementation is that a splitter must be engaged only when the break is strictly interior to the element. Asking an element to split itself when the break falls after it is asking the wrong question, and an element obliging by marking itself continued produces a scope that is never resumed and never exits. +

+

+ This was the cause of a real defect. Sibling top level sections numbered one and then one point one: the first section was handed to the splitter although the break fell after it, was marked continued, and produced no remainder. Its exit was therefore suppressed with no continuation fragment to resume it, the scope stayed open, and the next sibling entered from status preamble and indented where it should have incremented. The guard is to test whether the element fits in the space remaining before invoking its splitter, and to treat an interior forced break as the one case that requires splitting an element that would otherwise fit. +

+

+ A second guard belongs inside the splitter itself, as an invariant rather than as the primary fix: the continuation attributes should be written only once a remainder is known to exist. A fragment marked continued with no matching continuation fragment is not a split, it is a leak. +

+
+ + + Nested Cuts of One Counter +

+ Two scopes of the same counter may be cut at a single boundary: a section and a subsection within it, both spanning the break. The far side then carries two make tags bearing continues, one per suspension, and each legitimately replaces the live machine for that counter name as it restores its own saved state. +

+

+ The consequence for implementation is that a scope must record which counter it will exit, not which machine object. Holding a reference taken at enter time applies the exit to a machine that a nested restoration has since displaced; the live machine never receives that exit, and its list is left one level too deep. The next sibling then increments within the stale level rather than at its own, giving one point two where two was meant. +

+

+ Resolving the counter name at exit time applies the exit to whichever machine is current. Where nothing displaces the entry the two are identical, which is why the fault appears only under nested cuts and is invisible to any test that splits a single scope. +

+

+ This is the same hazard as the rule against holding references to snapshots, in a different guise: the live machine is a moving target, and anything that means to act on the current one must look it up when it acts. +

+
+ Breaks Migrate Outward

diff --git a/developer/authored/Manuscript.copy/Layout/counter.js b/developer/authored/Manuscript.copy/Layout/counter.js index 238b826..36c4cfa 100644 --- a/developer/authored/Manuscript.copy/Layout/counter.js +++ b/developer/authored/Manuscript.copy/Layout/counter.js @@ -351,7 +351,20 @@ if(node.nodeType !== Node.ELEMENT_NODE) return; const tag = (node.tagName || '').toLowerCase(); - let machine_to_exit = null; + /* The counter NAME is captured ,not the machine object. + + A nested continuation legitimately replaces ns.dict_instance[name]: the + inner make tag carrying 'continues' restores its own saved state over + the live entry. Holding an object reference from enter time would then + apply this scope's exit to a machine that is no longer current ,and the + live machine would never receive the exit. Two scopes of one counter cut + at the same boundary is exactly that case. + + Resolving the name at exit time applies the exit to whichever machine is + current. Where nothing replaces the entry ,this is identical to holding + the reference. + */ + let counter_to_exit = null; if(tag === 'rt·counter·make'){ const name = node.getAttribute('counter'); @@ -405,7 +418,7 @@ active_machine.count.set_name(step_name); } } - machine_to_exit = active_machine; + counter_to_exit = name; } }else if(tag === 'rt·counter·snapshot'){ const counter_name = node.getAttribute('counter'); @@ -428,7 +441,10 @@ child = child.nextElementSibling; } - if(machine_to_exit){ + if(counter_to_exit){ + // Resolve now ,not at enter time: a nested continuation may have + // replaced the live machine for this counter. + const machine_to_exit = ns.dict_instance[counter_to_exit]; const is_continued = node.getAttribute('continued') === 'true'; if(!is_continued){ machine_to_exit.exit(); @@ -436,6 +452,15 @@ const split_id = node.getAttribute('split-id'); if(split_id){ ns.dict_serial[split_id] = machine_to_exit.clone(); + }else{ + /* A soft close with no split id cannot be resumed: nothing names the + parked state ,so no continuation fragment can restore it and the + scope's exit never runs. Report it and exit normally rather than + suspending the scope permanently. */ + RT.Debug.error('counter' + ,"soft close with no split-id on counter '" + node.getAttribute('counter') + + "'. Scope cannot be resumed; exiting normally."); + machine_to_exit.exit(); } } } diff --git a/developer/authored/Manuscript.copy/Layout/paginate.js b/developer/authored/Manuscript.copy/Layout/paginate.js index f53b2ac..ac81af9 100644 --- a/developer/authored/Manuscript.copy/Layout/paginate.js +++ b/developer/authored/Manuscript.copy/Layout/paginate.js @@ -269,10 +269,28 @@ return { first: null ,rest: el ,firstHeight: 0 }; } + /* Decide whether a remainder exists BEFORE marking the fragment. + + A fragment marked 'continued' is soft closed: the counter walk suppresses + its exit and parks the machine under the split id, to be resumed by the + matching continuation fragment. If no remainder is produced there is no + continuation fragment ,so the scope would be suspended permanently ,its + exit never running. The next sibling step would then enter from status + 'preamble' rather than 'between' ,indenting instead of incrementing. + + This path is reached whenever a splitable element fits entirely ,which is + the common case: the caller invokes the splitter for every splitable + element without first testing whether it overflows. + */ + const has_rest = (best_count < children.length) || !!split_child_result || forced_break; + const first = el.cloneNode(false); - first.setAttribute('continued' ,'true'); const split_id = 'split_' + Math.random().toString(36).substr(2 ,9); - first.setAttribute('split-id' ,split_id); + + if(has_rest){ + first.setAttribute('continued' ,'true'); + first.setAttribute('split-id' ,split_id); + } for(let i = 0; i < best_count; i++){ first.appendChild(children[i].cloneNode(true)); @@ -280,7 +298,7 @@ if(split_child_result) first.appendChild(split_child_result.first); let rest = null; - if(best_count < children.length || split_child_result || forced_break){ + if(has_rest){ rest = el.cloneNode(false); rest.setAttribute('continuation' ,'true'); @@ -362,6 +380,35 @@ if(splitter){ const remaining = page_height_limit - current_h; + + /* A break belongs to this element only if it falls strictly inside it. + + When the element fits in the space remaining ,any break falls after + it — in the gap between this scope and the next sibling — and that + gap belongs to the parent ,not to this element. Neither sibling is + cut ,so neither should be soft closed; the counter walk simply + enters and exits each intact scope in turn ,across the page + boundary ,and the numbering follows. + + Splitting here regardless is what produced sibling top level + sections numbered 1 then 1.1: the first was marked continued ,its + exit suppressed ,and with no remainder there was no continuation + fragment to resume it. The scope stayed open ,so the next sibling + entered from 'preamble' and indented instead of incrementing. + + An interior forced break still requires splitting even when the + element fits ,since the break is genuinely inside it. + */ + const el_h = get_el_height(el); + const has_interior_break = !!el.querySelector('RT·page-break, RT·page-break-primitive'); + + if(el_h <= remaining && !has_interior_break){ + current_batch_seq.push(el); + current_h += el_h; + i++; + continue; + } + const { first ,rest ,firstHeight } = splitter(remaining); if(first){