Spec 08 Phases 0–1 and Spec 09 S9-1/S9-2: −41% layout residency, Android build restored and gated in CI - #37
Conversation
Phase 0 is investigation only, no production code. Each document is cited to file and line; no device run was possible in this environment (no GPU/display), so every figure is derived from the code rather than a profiler, in the manner of docs/memory-audit-2026-06-12.md. Findings that change the program: - S0.1: five of the six §3.1 scroll capabilities already exist in the vendored patch set and ship today (custom scrollbar, tile virtualization, width sensor, spelling popup). Only animated programmatic scroll is missing, and it belongs app-side. R1 closes; T1.1 reduces to documentation. Nested scroll containers are modelled in blitz-dom but unproven — probe P1 gates T7.3. - S0.2: the Hot/Warm/Cold tiers the spec asks about no longer exist; loki-render-cache is 115 lines with no cache. Texture residency is already independent of document length — the unbounded axis is zoom x DPI (165 MB at 200% on HiDPI). What is O(document) is PaginatedLayout.pages and its per-page editing_data, which Phase 2 as scoped does not touch, so the "500-page RSS within 20% of 10-page" criterion cannot be met by texture windowing. Recommends bounding resident texture bytes instead, and flags the Android CPU path as bypassing virtualization entirely. - S0.3: documents the full document -> fragment -> column -> page -> viewport -> texture chain and the ten sites that reimplement 96/72. Corrects §3.3: squiggles are already emitted per layout line via Parley selection geometry. Leading candidate for I-06 is instead the fragment clip floor in emit_fragment, justified only for glyph ink, discarding the descender band the squiggle is deliberately placed in — which is exactly the last line before a page or column break. Also finds an unrelated editing-origin/paint x mismatch for continuation blocks inside list items. - S0.4: the IME patch was never lost — every layer is intact. The merge cce9772 kept both sides of the A-14 refactor, so loki-text defines android_main twice and the Android build cannot compile (E0428 confirmed with a standalone rustc reproduction; desktop CI is blind to it because both copies are behind cfg(target_os = "android")). The IME wiring only ever existed in the copy to be deleted, so spreadsheet and presentation have never had it. R6 closes. - S0.5: §3.4's format analysis is confirmed against our own importers and exporters, but ADR-0012 Decision 2 already built most of Phase 6 — Length<Emu>, PageStyle, the N-column model, ODT master-page round-trip, DOCX section export, mirror_margins, even/odd headers. The real backlog is style:page-usage, the page-size catalogue, application-scoped defaults, the advisory custom part, and the UI. Recommends scoping the EMU migration to the page family only. - S0.6: R13 does not materialise — 49 target_os sites, 11 behavioural, all enumerated. The responsive foundation DeviceProfile needs already exists (Spec 03 D4 size classes, Spec 01 A-1 single width source), so the profile should extend AtResponsiveContext rather than sit beside it. Adds the --cfg android_gpu build flag to the D-08 violation list. README.md indexes the set, records the exit criteria, confirms all eight §7 decisions still hold (D-06 with a correction: appthere-color does not exist), and lists five recommended spec amendments plus the open items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
I-16, I-17, and the shared half of I-07. Ordered as T0.5.1 requires: the IME bridge is ported into the surviving entry point first, and only then is the duplicate deleted — the copy that had to go was the copy carrying the feature. T0.5.1 / T0.5.4 — one entry point, one IME bridge: - loki-app-shell/src/android.rs: the soft-keyboard visibility bridge (set_ime_visibility_listener + install_ime_listener, registered in that order so the first callback is not dropped) moves into the android_main! macro, immediately after set_android_app. It was previously wired only in loki-text's hand-written entry point, so loki-spreadsheet and loki-presentation never had it (S0.4 §4). One implementation, three consumers. - loki-text/src/lib.rs: delete the hand-written ANDROID_MAIN_RUNNING + android_main left behind by merge cce9772. Verified the macro body is equivalent to the deleted one: same tag, same null_context init, same Android-16 double-fire guard, same panic hook, same launch_cfg with bundled fonts. `crate::recent_documents::set_android_data_dir` and the macro's `$crate::` path resolve to the same static — loki-text's module is `pub use loki_app_shell::recent_documents::*`. - The macro's module docs now state that it is the only android_main a binary may define, why (macro_rules! item names are unhygienic), and how that went unnoticed for three weeks. T0.5.2 — Android target in CI (L08-014): New android-check job. `cargo check`, not `build`: it type-checks the cfg'd code, which is all that was ever wrong, without needing an SDK, d8 or a keystore. It does need the NDK, because ring (via reqwest/rustls) compiles C in its build script even under check; ubuntu-latest ships one, so the step just points cargo and cc-rs at it and fails loudly if the layout changes rather than silently falling back to a host compiler. Scoped to loki-text with a TODO(android-ci) for the other two, which do not currently build (R11). T0.5.3 — audit for the same shape elsewhere (recorded in S0.4 §6a): Two scans over all workspace Rust. 22 duplicate top-level item names, every one a proper cfg/not(cfg) or feature/not(feature) pair, confirmed by reading the attribute above each definition. Five item-emitting macros; only android_main! could collide, and dhat_global_allocator!'s 10 call sites are host-target so a collision there would already fail. I-16 is a single incident, not a pattern — what let it survive was the missing target, which is why T0.5.2 rather than this audit is the durable fix. Also answers the r3 §3.1 open question (S0.1 §2a): the one missing scroll capability is animated programmatic scroll, not nested scroll containers. r1's row 3 covered instant and animated together; the spike table splits them, which is where "5 of 6" comes from. Consequences: T1.1 has no patch to land (the animation belongs app-side, so L08-001 is not engaged), and R2 should read "unverified" rather than "unsupported" — blitz-dom models nested containers, nothing has ever driven one. Probe P1 still gates T7.3. Not verified here: the Android target itself. The sandbox has no NDK, so `cargo check --target aarch64-linux-android` fails in ring's build script before reaching our code. That is precisely the gap the new CI job closes, and it will run on this branch. Host `cargo check --workspace` and the exact CI clippy command both pass, as do the licence-header, file-ceiling, suppression, TODO-format and unsafe-policy gates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
r4 holds Phase 0.5 open on two acceptance criteria. One of them is now measured rather than argued; the other is blocked by this environment, and the blocker is recorded so closing the phase is mechanical. Half one — host jobs are blind to Android-only errors. Injected a bare type error (`let _: u32 = "…";`) into the android_main! macro body, on the path every Android build takes. `cargo check --workspace` exits 0. The full CI clippy command exits 0. That is I-17 demonstrated, and it is why I-16 survived three weeks. Reverted immediately; the fixture is written down in S0.4 §6b so it does not have to be reinvented. Half two — whether android-check catches it — cannot be answered here. The Android target installs, but the build dies in ring's build script for want of an NDK, and the NDK cannot be fetched: the agent proxy denies dl.google.com:443 with a 403 at CONNECT. That is a network policy, not a missing step. S0.4 §6b records the four-step procedure to run on a machine that has the NDK, including the expected signature — android-check red while lint and build-and-test stay green, since a fixture that reddens the host jobs is in the wrong place and proves nothing. Also recorded, because it blocks r4's close condition outright: rust.yml triggers on pushes to main and PRs targeting main, so a push to claude/** runs no CI at all. The android-check job has therefore never executed. Closing Phase 0.5 needs either a PR against main or a branch glob on the push trigger — an unexecuted gate is indistinguishable from an absent one, which is L08-014 turned back on itself. Macro module docs: restore the sentence my earlier insertion split, and replace the assertion that host jobs cannot see a cfg'd break with the measurement above. Full `cargo test --workspace --all-features` was re-run after installing poppler-utils; the three earlier failures were `raster::tests::*` failing loudly on a missing pdftoppm, exactly as that harness is designed to, and not regressions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
T1.2 ViewportController, in appthere-ui per L08-005. Built *on* the existing ScrollMetrics signal rather than beside it: the six numbers a scrollbar already mirrors from onscroll are the same six a reveal needs, and a second source would recreate the divergence Spec 01 audit A-1 removed from viewport width (S0.1 §3). ScrollMetrics therefore moves out of loki-text into appthere-ui and is re-exported under its old path, so the editor's call sites are unchanged. T1.1 animation driver. S0.1's addendum identified animated programmatic scroll as the one missing scroll capability — not nested containers, as r3 presumed — and it belongs app-side, so there is no patch and L08-001 is not engaged. The driver steps the offset itself: ease-out cubic, snapping exactly to the target because a sub-pixel residue leaves reveal_offset asking for the same scroll forever. Cancellable, and every new command supersedes an in-flight animation via a generation counter, including an instant one — otherwise a keystroke's reveal would be undone by the tail of a smooth scroll still running under it. MotionPreference::Reduced degrades to instant: the scroll still happens, it just does not animate. Ticks come from a worker thread through a channel, the same cross-thread yield the layout task and save-status auto-clear use, because dioxus-native has no async timer and Blitz no per-element animation clock. T1.3 caret follow, as a zero-output sensor component mounted in the canvas subtree. It could not be a hook call: render_canvas_area is a plain function, and editor_inner is over the ceiling and pinned by the ratchet. The reveal margin is three body lines below and one above, measured from the caret's own line rect, so it holds at every font size and zoom rather than being 60 px regardless. Behaviour is explicit and instant for typing — a smooth caret-follow would read as lag at typing speed, which is the failure T1.3 warns about; smooth is reserved for discrete jumps. T1.4 falls out of subscribing to cursor_state: wheel and touch scrolls do not move the caret, so they cannot trigger a reveal, and drag-select is gated on is_dragging. No debounce timer, deliberately: reveal_offset is idempotent and moves the minimum distance, so successive keystrokes produce either nothing or a few pixels of follow, never the oscillation a debounce would guard against. A timer here would make the caret lag the text. Reasoning recorded in the module docs so the omission is not read as an oversight. T1.5 needs no code. The reveal measures against the scroll container's own measured client_height, and routes::shell sizes the shell calc(100vh - inset_total) with the Android inset query folding in WindowInsets.Type.ime() (S0.4) — so the container physically shrinks when the keyboard appears and the measurement follows. Where no IME exists the inset is zero and the safe area is the window, which is what T1.5 specifies. A second explicit safe-area term would double-count it. T1.6 DeviceProfile: the type, context, hooks and pointer-precision latch, with Fine+Coarse latching to Both for the Android-desktop case. Injectable by construction — probes are supplied, never run inside — which is R12's mitigation and the only way Phases 2/4/5/7 get tested without the hardware. Partial: every probe behind it is still Unknown, so none of the 11 behavioural cfg sites from S0.6 §2a retire yet. Tracked in docs/spikes/README.md. T1.7 (Probe P1) not attempted — it needs a running app to test input routing, which this sandbox cannot do. R2 stays unverified. Paying for the new mount: editor_canvas.rs is over the ceiling, so the caret-follow mount was paid for by extracting open_spell_panel_at into editor_canvas_spell.rs (460 -> 432, baseline ratcheted down). The suppression baseline gains one #[allow(non_snake_case)] — required for a PascalCase component taking a hand-written props struct, since Arc<Mutex<DocumentState>> is not PartialEq and #[component] would demand it; the same trade PageTile and ReflowDocView already make, justified inline. --update also lowered two unrelated baselines (loki-spreadsheet editor_inner, loki-layout para.rs) that had shrunk without being re-recorded; both move down, never up. 31 new unit tests over the reveal arithmetic, the easing/stepper, the metrics invariant that scroll_height is a distance not a size, the caret page-stack transform, and the pointer latch. cargo test --workspace --all-features, the exact CI clippy command, and all eight script gates pass. Not verified: any of it on screen. The acceptance criteria are behavioural — typing at the page bottom keeping two lines of trailing space at 50/100/200%, Page Down then typing not fighting the user — and need a running app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
I-20 root cause, confirmed by inspection and exactly the §3.1 hypothesis: ViewportController::metrics() read the metrics signal reactively, and scroll_to_reveal called it from inside the caret-follow effect. The effect therefore subscribed to every scroll event. Turning the wheel re-ran it, it recomputed the caret against the new scroll offset, found it outside the margin band — because the user had just scrolled it there — and scrolled back. Wheel travel was capped at the band, asymmetrically (one line up, three down) because the margin is asymmetric. What hid it: the effect body reads only cursor_state. The subscription was three frames away, inside a method whose name says "metrics", not "subscribe". Fixed in two independent layers, because the first already looked sufficient once and was not: - Controller: commands peek, observers read. metrics_now() is private and peeking and is the only accessor a command path may use; metrics() and visible_rect() stay reactive and now say so. The rule and the incident are recorded on the type so the next person does not have to rediscover why. - Trigger (L08-019): CaretRevision + should_reveal. The reveal fires on caret identity change and nothing else. Six tests, including "an unchanged caret does not re-reveal" — I-20 in miniature. A subscription reintroduced by a later edit can no longer restart the loop. Verified the effect now holds exactly one subscription, to cursor_state. Deliberately given up: zoom changes and layout-induced caret movement no longer scroll the caret back into view. The buggy version did, as a side effect rather than by design. That closes R25 as a consequence instead of pre-emptive coding. Documented that the only route back to "keep the caret visible across zoom" is an explicit zoom trigger, never widening this effect's subscriptions — the tempting fix is the regression. I-21 narrowed, not tuned. T1.9's diagnostic needs a screen; inspection ruled out three of the four candidates — the margin arithmetic matches T1.3's wording (three clear lines below the caret's own line), the custom scrollbars are siblings so no chrome sits inside client_height, and the margin scales with zoom by construction since it derives from the caret rect in CSS px. Two remain: a fixed 24 px error if Blitz places the scroll origin after the container padding rather than at it, versus three lines simply being too generous. A tracing::debug! on loki_text::caret_follow emits the five numbers that separate them in one observation. The margin value is unchanged: three of the four causes are bugs that lowering it would mask. editor_caret_follow.rs reached 296 lines, four under the ceiling, so the geometry resolution moved to editor_caret_follow_geom.rs — proactively, per the house rule, rather than leaving the next edit to trip CI. Gates: the exact CI clippy command exits 0; all eight script gates pass; the two edited crates are green (loki-text 243, appthere-ui 93). The full --all-features workspace run was still finishing at 160 result blocks with zero failures when this was committed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
Spec 09 §3 names itself the deliverable of the first session on that spec. S09.0 answers all seven questions against the code, cited to file and line. No production code; §5's caveat that the figures are derived rather than profiled is restated and sharpened. Three findings change the shape of the problem. Q1 — the cost is four contributors that nest, not one: ~40 B/char inside Parley's cluster and glyph arrays, ~16 B/char in our own duplicate glyph entries, ~16 B/char in two Vec<usize> byte-index maps, and ~16 B/char in the page's paint copy. About 88 B/char resident, of which ~72 is editing residency that Spec 09 can window. A dense 500-page document is therefore ~132 MB of layout, ~108 MB of it evictable. The index maps are the finding S0.2 missed. `orig_to_clean` and `clean_to_orig` are one usize per byte of source text each — verified at para.rs:58, `vec![0; text.len() + 1]` — so 16 B/char for ASCII, comparable to Parley's entire per-character footprint and about a fifth of editing residency. They are also the cheapest thing on the list to shrink and need no residency architecture: u32 indices halve the term, and a paragraph with no cleaning divergence needs no vector at all. Q4 — the re-materialisation mechanism already exists, built for incremental relayout. PageStart checkpoints capture exactly the flow state that makes a page non-local (page number, list counters, note counter, indent), so recovering a page costs one page of flow rather than a document relayout. The limitation is precise: checkpoints are pushed only at clean page tops at a block boundary (flow_run.rs:136), so a page beginning mid-paragraph — the common case in prose — has none, and recovery must resume from the last clean checkpoint before it. That turns eviction cost into a function of distance-to-checkpoint rather than a constant, which a policy can either respect or fix by extending checkpoints to mid-paragraph resume. Q7 — the trap §4 predicted is real and specific. `preserve_for_editing` is one document-wide boolean and `editing_data: Option<_>` already means "read-only document". Every consumer treats None as give-up-quietly via `?`. Under windowing those same `?` operators turn an evicted page into a silently wrong answer — caret does not move, find skips a match — with no error and no log. The contract change is the actual work, not the window. Q2 found one consumer that genuinely breaks: `nested_para_page` (navigation_find.rs:57) scans every page to locate a table cell's sibling block across a page break. It needs a block-index → page map held outside the evictable data, which also removes an O(pages) scan from an arrow-key press. Q6 is the encouraging one: editing data is a pure function of the document and nothing mutates it in place, so eviction is always safe in the correctness sense. The question is entirely cost and consumer contracts. Q3 relocates the split — page geometry must stay resident (scroll extent and page slots need every page's height) while page content can go, so the sparseness belongs inside LayoutPage rather than across `pages`, preserving every `layout.pages[i]` access in the tree. Q5 — ~55% of editing residency is Parley's, behind one Option<Arc<_>>, and only hit_test_point and cursor_rect need it. Dropping Parley layouts alone is a partial tier worth roughly half the editing cost at a fraction of the blast radius. §9 proposes four independently shippable steps ordered by value ÷ risk — index maps, block→page index, Parley-only eviction, then full page eviction — so the plan degrades gracefully if the profile disagrees with the model. §10 keeps Spec 09's profiling requirement and names the cheapest experiment that validates the whole model without a code change: open a large document read-only and compare RSS against the same document open for editing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
…cted Spec 09 L9-005 blocks the phase plan on E0, and §4 describes E0 as a manual RSS comparison across two process runs on real hardware. It does not need to be. Layout is CPU-only — Parley shaping plus our pagination, with the GPU involved in painting rather than layout — so the experiment runs headless under dhat. That also disposes of both methodological caveats §4 raises: the full layout pass is forced by construction, and allocator retention cannot mask what dhat counts as live heap. Committed as loki-bench/benches/layout_editing_residency.rs rather than run once, so it is the regression guard for S9-1 … S9-4. Result — the census's headline figure holds: medium (60 paras, 27k chars) editing residency 70.2 B/char large (250 paras, 113k chars) editing residency 70.1 B/char Predicted 72, measured 70.1, and flat across a 4x change in document size, which is what a genuinely per-character model predicts and a model contaminated by fixed overhead would not show. The 10-paragraph tier reads 411 B/char and is a floor artefact of per-document costs at 4.5k characters, not a data point. L9-005 is satisfied. The total did not hold, and the gap is the useful part. Predicted 88 B/char, measured 124. The missing 36 has a specific cause found while reconciling: ParaCache stores ParagraphLayout by value (para_cache.rs:41), get returns a clone, and the flow then does Arc::new(para_layout.clone()) again when populating editing data (flow_para_place.rs:68). A cached paragraph's glyph items therefore exist three times — cache, editing Arc, page paint — and its index maps twice. Modelling that gives ~120 B/char against 124 measured, and the editing half independently reconciles: dropping Parley (~40) plus the editing Arc's items and maps (~32) is ~72 against 70 measured. Two arithmetic paths agreeing with each other and with the instrument. That yields a cheaper win than anything previously on the list: cache Arc<ParagraphLayout> instead of ParagraphLayout so the cache and the editing index share one allocation. ~32 B/char, about 26% of total residency, confined to ParaCache and its callers — no eviction machinery, no contract change, no consumer audit. It displaces index-map shrinking as step 0. The page's content_items copy must stay; those are translated into page space and are genuinely different values. S09.0 is updated throughout rather than only appended to, so §1 and §2 no longer contradict §10 on first read. The derived sections are left as written with §10 as the correction — the divergence between predicted and measured is itself the record worth keeping. The new bench passes the CI clippy command; the pre-existing expect() calls in portable_model and portable_io are untouched and remain unlinted because CI's clippy invocation does not pass --benches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
…ot hold Two open items closed, one of them against my own earlier framing. R9-07 (is ParaCache bounded?) — yes, and the bound matters. CACHE_CAP is 2048 entries per generation over two generations, so at most ~4096 paragraph layouts, cleared per document (para_cache.rs:25-40). But it is bounded by entry *count*, not bytes, and its own doc comment notes 2048 "comfortably covers a ~80-page document". At roughly four paragraphs per page the cap binds only past ~1000 pages, so across the entire realistic document range the cache holds every paragraph and is O(document) in practice. That confirms the concern raised about S9-1: once the cache and the editing index share one allocation, the cache becomes the owner of record and dropping editing_data frees nothing until the cache releases too. It is not a reason to defer S9-1 — one copy beats two unconditionally — but S9-4/S9-5 must release both owners, and bounding the cache by bytes rather than entries belongs in that work. The corpus tier — and a correction to its premise. appthere-conformance holds six fixture documents on disk, not ~130; the ~143 TC-* entries are the planned case catalog in src/corpus/catalog/, and manifest.rs carries only what exists (a test asserts the two agree). Still worth doing, and it changed the conclusion: synthetic medium 27,173 chars 70.2 B/char editing 56% evictable synthetic large 113,394 chars 70.1 57% para-carlito 377 chars 71.6 51% iris-blueprint 24,832 chars 176.4 72% acid2-docx 4,042 chars 349.1 74% acid-docx 5,477 chars 3950.4 49% iris-blueprint is the only corpus document large enough to compare against a synthetic tier of similar size, and it reads 2.5x the modelled rate. acid-docx is another order of magnitude out — it is the fidelity torture document, so its residency is images, tables and per-run structures, and a per-character denominator does not describe it at all. Below ~5k characters the fixed per-document cost dominates and the rate is meaningless, the same artefact as the 10-paragraph synthetic tier. So the per-character model describes body text and is a floor, not a typical value — §2.4's worked table understates real documents by at least 2.5x. What is durable is the ratio: the evictable fraction sits between 49% and 74% across synthetic, plain prose, richly formatted and pathological documents, while the rate moves by two orders of magnitude. Spec 09 should target the fraction; a goal in bytes per character would be wrong for every real document. Also fixed while extending the bench: char_count matched only Para/Plain with three inline variants, so every real document reported zero characters and silently skipped. It now builds on inline_plain_text, which already flattens every inline variant, and walks StyledPara, Heading, lists, quotes and table cells. Worth noting the failure mode — the bench did not error, it printed "skipped" six times, which is exactly the kind of quiet wrong answer S09.0 §8 warns about in a different context. The corpus is read by path rather than by depending on appthere-conformance, so no crate edge is added for the dependency-direction gate to weigh; missing fixtures degrade to a stated "unavailable" rather than failing the run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
…cted Chasing the para-carlito inconsistency found the cause, and it invalidates most of the corpus table recorded in Spec 09 r3. One-time costs land inside whichever measure() runs first, in two layers. Process-wide costs (Parley context, first-use caches) were billed to the first tier measured. Per-document costs — font loading for the faces a document uses — were billed to each document's own first measurement, which a process-wide warm-up does not cover. Both are now paid outside the measured region, and an ordering control re-measures the first document last: 69.5 B/char both times, where it previously read 411.8 first and 74.5 last. para-carlito was never anomalous. It ran late, so its costs were already paid, while the synthetic small tier ran first and absorbed everything. Both readings were correct about an instrument that was order-dependent. There is no floor artefact at 4.5k characters — warm, the 10-paragraph tier reads 69.5 against the 250-paragraph tier's 69.4. What the correction was worth, cold vs warm: styles-tinos 39264.8 -> 155.7 (252x) para-gelasio 841.2 -> 84.8 (9.9x) acid2-docx 349.1 -> 168.1 (2.1x) iris-blueprint 176.4 -> 117.5 (1.5x) synthetic small 411.8 -> 69.5 (5.9x) Only the three rows that were already warm — the two large synthetic tiers and para-carlito — survived unchanged. Three conclusions change. The "2.5x worse for real formatting" figure drops to 1.7x (iris-blueprint 117.5 against synthetic 69.4). The rate is far more consistent than it looked: excluding acid-docx the corpus spans 69-168 B/char rather than 71-3950, and plain prose sits within 22% of synthetic. And the evictable band is *wider*, not narrower: 45-63% for text-bearing documents with acid-docx at 98%, rather than the 49-74% the cold run suggested. L9-008's premise survives — the fraction moves by under 2x while the rate moves 60x — but the band needs restating, and acid-docx's 98% is informative rather than anomalous: object-heavy content is *more* amenable to eviction. The proposed size-at-constant-formatting test does not work, and the bench now says so in its own output. Repeating iris-blueprint's blocks makes them byte-identical, so ParaCache keys collide and nine of every ten paragraphs are cache hits rather than fresh layouts; the cache then holds a tenth as many entries per character while editing_data still holds an Arc per placement, so the rate falls 117.5 -> 47.1 for a reason unrelated to size. The row is kept labelled "not evidence" because the failure is the lesson — scaling by repetition changes the cache-hit profile. Answering the size question needs larger real fixtures. Also lands L9-009 concretely (R9-11): report_doc asserts a non-zero character count and fails with the reason, rather than printing "skipped". char_count and the corpus helpers move to benches/support/ so the bench stays under the ceiling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
The ×10 experiment could not do what it was aimed at, but run as a proper sweep it measures something no other run in this program produces. Repeating a document's blocks makes them byte-identical, so ParaCache keys collide: at ×n only 1/n of paragraphs are distinct content while editing_data still holds an Arc per placement. With x = unique/total, residency per character is rate(x) = P + C·x, where P is the per-placement cost every copy pays and C is the content-keyed cost that deduplicates. Run at ×1/×2/×5/×10 on iris-blueprint: x=1.00 117.5 B/char fitted 117.5 residual +0.00 x=0.50 78.4 fitted 78.4 residual +0.00 x=0.20 54.9 fitted 54.9 residual -0.04 x=0.10 47.1 fitted 47.1 residual -0.02 C = 78.2 B/char content-keyed, P = 39.3 B/char per-placement — matching the two-point estimate exactly, but now over four points with residuals under 0.04 across a range that moves 2.5×. The two-component model is exact to measurement noise rather than a two-point coincidence. Three consequences. It sizes S9-1 from measurement rather than struct arithmetic: sharing one allocation between ParaCache and the editing index removes a copy of the content-keyed 78.2, not the per-placement 39.3. It is a product fact — residency is per unique paragraph content plus per placement, so boilerplate-heavy documents (form rows, repeated headers, template blocks) deduplicate for free and a flat B/char figure overstates them. And it supports L9-008 independently: the evictable fraction holds at 63.2/63.1/62.9/62.8% while the rate moves 2.5×, which is stronger evidence than the cross-document band because everything except duplication is held constant. Also in this change: The ordering control now asserts rather than reports (L9-011). Sentinel checks catch an instrument that fails silently; only a self-consistency check catches one that fails plausibly, and plausible is what gets ratified into specs — 39,264 B/char read exactly like a small-document artefact. Tolerance is 5%; warm, the two readings agree exactly, so any real drift means a one-time cost is still leaking into a measured region. The failure message says what to fix rather than just what differed. The runtime header no longer calls the evictable percentage a target. It is a property of each document; the engineering goal is what fraction of it we reclaim. Quoting the band as a goal would score acid-docx at 98% as a success for reasons unrelated to any implementation. S09.0 gains §10b for the decomposition and drops r3's overcorrection: warm, text-bearing documents span 69–168 B/char, so per-character is serviceable within ~2.4× and fails only for object-heavy content — which is L9-010, and acid-docx's 98% evictable strengthens it. Caveat kept visible: one document, one axis. The decomposition is clean but it is iris-blueprint's formatting profile only, and duplication is the sole variable exercised. Size-at-constant-formatting still needs larger real fixtures; concatenation cannot substitute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
…013) L9-013 requires each residency step to write its predicted effect on C and P before the change, so the re-run sweep tests the ownership model rather than just confirming a saving. This is that record, written with loki-layout untouched. Reading the code to derive it found that S09.0 §10b was wrong. It said S9-1 "removes a copy of the content-keyed portion — the 78.2, not the 39.3". It does not. C and P decompose the *editing delta*, and preserve_for_editing adds exactly two things: parley_layout (an Arc inside the cached ParagraphLayout, refcount-bumped on a cache hit, so paid per unique content → C) and the Arc::new(para_layout.clone()) in place_paragraph_layout (deep copy of items, line_boundaries and both index maps, once per placement → P). Sharing the cache's allocation with the editing index deletes the second. The content-keyed copy is the allocation being shared *into*, not the one removed. So the coefficients name specific allocations: C = 78.2 is the Parley Layout object, and P = 39.3 is one duplicated ParagraphLayout body. Note C is 1.9× the ~40 B/char §6 estimated for Parley from struct definitions, which makes S9-4 a larger prize than it was ranked. Prediction: P falls 39.3 → ≤3, C holds at 78.2, rate at x=1 falls 117.5 → ~79 (−31 to −33%). P does not reach zero because paragraphs the flow mutates after layout — inline images, floats, picture bullets — must clone-on-write and keep paying it in full; iris-blueprint is image-bearing, so the surviving P measures what fraction of its placements are mutated. The old ~32 B/char struct-arithmetic estimate is superseded by 39.3, and §10c records what each of the four possible outcomes would mean, so the result is falsifiable rather than fitted afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
…ting index ParaCache now holds Arc<ParagraphLayout>, so a cache hit is a refcount bump and push_editing_para clones the pointer instead of deep-copying items, line boundaries and both byte-index maps per placement. Paragraphs the flow mutates after shaping — inline images, floats, picture bullets — take a private copy via Arc::make_mut, which fires only when there is something to inject. Measured against the prediction recorded in 67d8c86 (L9-013), same warm instrument, ordering control 0.0% drift both runs: C content-keyed 78.2 → 78.2 predicted unchanged P per-placement 39.3 → 1.1 predicted ≤3 iris ×1 rate 117.5 → 79.3 predicted ~79-81 Body text: editing residency 69.4 → 34.8 B/char, total 123.3 → 89.0 (−27.8%). The total lands on the census's original ~88 B/char prediction — the estimate that measured 124 and whose 36 B/char gap §2.2 diagnosed as one copy too many. Remove the copy, get the predicted number. P = 1.1 also answers what the residual would mean: at ~60 chars per paragraph that is ~66 bytes, the Arc pointer plus PageParagraphData bookkeeping and nothing else, so copy-on-write is rare even in an image-bearing document. The prediction missed one thing and the instrument caught it: the read-only condition ROSE ~11 B/char, exactly content-keyed. put(key, result.clone()) had been doing more than storing a copy — Vec::clone allocates capacity == len and the clone is deep, so the cached layout was silently compacted at every level while the push-grown original stayed transient. Moving the original into the Arc reverses which survives. Fixed by making the compaction explicit (ParagraphLayout::shrink_to_fit + PositionedItem::shrink_to_fit, once per miss); it has to recurse into PositionedGlyphRun::glyphs, since a shallow shrink recovered only 4 of the 11 B/char. Three new tests assert identity rather than equality, because sharing is invisible to every behavioural test: identical paragraphs share one allocation, distinct content does not, and an image-bearing paragraph does not share with its plain twin (that one would leak the image onto both). Four files crossed the 300-line ceiling and were split rather than baselined, since the growth was mine: para_cache tests extracted to a sibling, para.rs's text-cleaning cluster to para_clean.rs, items.rs's glyph types to items_glyph.rs (re-exported, so public paths are unchanged), and flow_para.rs's image placement to flow_para_images.rs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
…L9-013) R9-14 asked whether S9-1's copy-on-write is Arc::make_mut or a manual refcount check, since the two give different kinds of guarantee. It is make_mut at both mutation sites, with no manual check and no Arc::get_mut in the crate, so the guarantee is structural: Arc<T> hands out &T only, and a mutation path that forgets to take a private copy is an E0596 borrow error rather than a silent cross-placement corruption. No interior mutability in ParagraphLayout or anything it owns, and forbid(unsafe_code) at the crate root, so there is no route below the borrow checker. The one shape to watch is Arc::get_mut, which compiles, skips the mutation whenever shared, and says nothing — a defect on sight on a layout. S9-2's prediction is that C and P do not move at all, which is itself a consequence of S9-1. The index maps are built unconditionally, so they are identical under preserve_for_editing on and off, and since S9-1 they exist in exactly one place — the shared cache entry. Identical in both conditions means they cancel in the delta. The whole effect lands in the read-only baseline: synthetic large total 89.0 → ~73, iris-blueprint 150.2 → 134–142. So the duplication sweep is the wrong instrument for S9-2, and saying so first is the point. Before S9-1 the maps were copied per placement and were part of P; the same change measured earlier would have moved a coefficient. A bench reporting only its target delta would now show S9-2 achieving nothing — L9-014 with a worked example one step after it was written. Where iris lands in its band is a free measurement of tab density: the cleaner drops tabs, so paragraphs containing them keep a halved Vec<u32> while the rest go to identity. Nearer −8 means the identity case is rarer in real documents than the synthetic tier suggests, which bears on S9-5's sizing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
…ction
ByteIndexMap replaces the two Vec<usize> maps: u32 entries in a Box<[u32]>, and
an Identity { len } variant for paragraphs where the cleaner dropped nothing.
Compacted once per cache miss at the point the map is stored, so the
construction logic — which needs random-access mutation for the drop-cap rebase
— is untouched. Confined to loki-layout: the maps had no cross-crate consumers,
and the five hand-rolled clamps in para_query collapse to one get_clamped.
Measured against the prediction recorded in 10ea537 (L9-013):
C content-keyed 78.2 → 78.2 predicted unchanged
P per-placement 1.1 → 1.1 predicted unchanged
synthetic large 89.0 → 73.0 predicted ~73
iris-blueprint 150.2 → 134.5 predicted 134–142
The null prediction is the load-bearing one. Every row above the rate floor
holds its editing rate exactly (large 34.8 → 34.8, iris 79.3 → 79.3), because
after S9-1 the maps live in one place and appear identically in both measured
conditions, so they cancel in the delta. A harness reporting only the
duplication sweep would have concluded S9-2 did nothing while total residency
fell 18% — L9-014 demonstrated rather than argued, one step after it was
written.
Where iris landed inside its band was a free measurement, as predicted: 134.5 is
the top of the saving, essentially the full 16 B/char rather than the 8 that u32
alone buys. Solving 8 + 8f = 15.7 puts ~96% of its bytes in paragraphs that need
no map at all, so the identity case is not an artefact of synthetic text — tabs
are far rarer per byte than their per-document presence suggests. acid2-docx
saved 20.4, more than 16, which is the expected signature of multibyte UTF-8 and
an independent check that the maps are sized per source byte as §2.3 modelled.
Two sub-floor rows moved (para-gelasio 172 chars, styles-tinos 45), which is
what the rate floor exists to flag; no conclusion rests on them and none is
drawn.
Body text is now 73.0 B/char total and 34.8 editing, against 124 and 69.4 when
E0 first ran: 41% off total residency across the two steps, with no eviction
machinery, no contract change, and no behavioural difference.
Also corrects the last stale copy of the backwards attribution in the census
headline: S9-1 removes the per-placement copy, not the content-keyed one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
…tric Three follow-ups from the S9-2 review. No S9-3 code — the decision to cross into Spec 09's invasive half is deliberate and not taken here. L9-016 as a gate (scripts/check-arc-get-mut.py, wired into rust.yml). Arc::get_mut compiles, returns None whenever the value is shared — always, for a cached layout — and silently skips the mutation. It is banned in loki-layout by script rather than by review. Verified both ways: a real call fails the gate, and a doc comment naming the pattern passes it. That second half is deliberate — the suppression ratchet text-matches `let _ =` in prose and so trips on documentation of its own subject (Spec 08 §8); this gate strips comments before matching so the hazard can be named in the code it guards. R9-15 measured rather than recorded. E0 now has a CJK tier, and this sandbox has CJK faces (wqy-zenhei, ipafont-gothic), so it shapes for real: latin large 113394 chars editing 34.8 total 73.0 evictable 47.7% cjk (120p) 16092 chars editing 111.7 total 219.5 evictable 50.9% The per-character rate does not transfer — CJK is 3.2× Latin — so every B/char figure in this spec is a Latin figure and under-predicts non-Latin documents threefold. The evictable fraction does transfer, 50.9% against 47.7%, which is L9-008's cross-sectional framing surviving on a second independent axis. The tier counts .notdef before reporting a rate and fails rather than prints: tofu allocates and paginates and yields a perfectly believable number, which is R9-13's failure mode. This run: 16092 glyphs, 0 .notdef. Mechanism for the 3× is not established — glyph count is 1.00/char in both scripts, so it is not more glyphs; recorded as two hypotheses with the test that separates them. S9-3's governing metric derived as page-access-set bounds, per L9-013 r7. For an operation targeting page M of an N-page document, A(op) is the set of pages whose editing_data it touches; S9-3 must make |A| constant in both N and M. Deriving it found the scan inventory is four sites, not the one Q2 recorded — and that the worst is not nested_para_page. recompute_page_index scans from page 0 regardless of caret position and runs on EVERY KEYSTROKE, so typing on page 300 of a windowed document would touch pages 0-300 per character. That is the site that would actually cancel windowing (R9-04). Two of the four already start at the caret's own page and are well-behaved on a hit. Predicted: 1/0 for three sites, 1-2/0 for recompute_page_index (a paragraph straddling a break legitimately has entries on two pages). Any site still scaling with N or M means S9-3 failed whatever residency says. The instrument does not exist yet — it needs a counting accessor over page editing_data — which is the honest reason S9-3 is not in S9-1 and S9-2's cost class. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
…trol's limit Three measurements. Two overturned a claim; one of those was mine. R9-16 (per-byte denominator) is REFUTED by its own discriminator. The bench now computes measured source bytes rather than assuming a nominal ratio — CJK lands at 2.94 b/ch, not 3.0, because of leading digits and full stops — and adds a Cyrillic+Greek tier at 1.85 b/ch with Latin-like shaping: latin 1.00 b/ch 73.0 B/char 73.0 B/src-byte evict 47.7% cyrillic+greek 1.85 b/ch 88.1 B/char 47.7 B/src-byte evict 50.4% cjk 2.94 b/ch 219.5 B/char 74.7 B/src-byte evict 50.9% Per-byte predicted 135.1 B/char for Cyrillic; it measures 88.1. Per source byte the three read 73.0/47.7/74.7 rather than agreeing. The CJK/Latin match was a two-point coincidence, exactly the risk that motivated demanding a third point. Neither characters nor bytes are invariant, so the denominator problem is real script-dependence, not a unit error. The extra observables rule mechanisms in and out rather than leaving them as speculation. Glyphs/char is flat (0.89/0.91/1.00), so not glyph count. Lines/char is 0.017/0.018/0.030, so CJK does pay ~1.76× the per-line overheads — the first of §10h's hypotheses confirmed — but the rate moves 3.01×, and a two-term fit over (lines, bytes) predicts 144.9 for CJK against 219.5 measured. A CJK-specific term remains, and reading Parley's break structures is a separate investigation. What survives is the useful part: the evictable fraction holds at 47.7/50.4/50.9% across three scripts spanning a 3× rate. Third independent axis for L9-008. The per-keystroke scan is measured (new loki-text/benches/page_locate_latency.rs) and it corrected §10i, which was mine and was wrong in the worse direction. Cost is FLAT in caret position — 0.95× and 0.98× from first page to last — and a guaranteed full scan costs the same as any hit. The loop runs to completion every call, so the access set is all N pages, not the prefix 0..=M §10i predicted. Under windowing, typing one character would touch every page. It also leaves a question for S9-3: the `visible` early exit appears never to fire, which is either dead code or a bug, and must be understood before the function is replaced. But it is NOT a present-day latency defect: 3.3 us at 445 pages, 13.6 us at 889, against a ~16 ms frame. So S9-3 stays architecture and the case for crossing the boundary rests on eviction groundwork alone — the measurement argues against crossing, not for it. Scaling is superlinear (2.00x pages -> 4.01x time, entries only doubling), plausibly cache behaviour; unverified and it does not change the verdict. Two instrument controls earned their place. The first probe seeded the correct page index, which makes the result trivially equal to the input whether or not the lookup found anything; fixed by seeding a stale index and asserting resolution. The second is a timer floor — near-identical medians across different workloads is what a coarse clock looks like — reading 22 ns against 3300 ns of signal. Also records that the ordering control has a blind spot: it re-measures the first subject, so it catches warm-up order-dependence, not allocator variance around a few large allocations. That is why acid-docx can move 1.7% while the control reads 0.0%, and the next such move should not be filed as noise by default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
…racted R9-18 said the `visible` early exit never fires, so the answer always comes from `first_holder` and the access set is all N pages. I wrote that, it went into r9 flagged High, and it is wrong. The evidence for it was a timing inference: cost flat in caret position and a guaranteed full scan costing the same as any hit. That is evidence about time, and the claim it was used to support is about control flow. The characterisation test settles it directly. For a paragraph on one page, `visible` and `first_holder` give the same answer, which is why timing was the only handle available — so the test uses a paragraph split across a page break, where they differ, on a REAL laid-out document rather than hand-built geometry. The last byte resolves to a later page than the first, which only the band check can produce. `visible` fires. R9-18 retracted. The flat timing is now unexplained and is left that way. Swapping one inference for another is how the first got written down as fact, so §10l states what is observed (3.3 us at 445 pages, 13.6 at 889, flat in M, miss ~= hit), what is not established (the access set on a hit — §10i's 0..=M follows from the code and is no longer contradicted, but is not confirmed), and what would settle it (the counting accessor S9-3 has to build anyway — a second reason to build it first). Two things made the inference persuasive: it was self-consistent across two document sizes, and it made S9-3 look more valuable, which is the direction I was already leaning. The boundary verdict is unaffected — it rests on the microsecond figures, which are observations, not on what the loop does internally. S9-3 stays architecture. The characterisation tests survive R9-18 and are the durable artefact, because the hazard the user identified is real independent of which branch wins: these pin behaviour on flow-engine geometry so a replacement is checked against observations rather than the code's apparent intent. Three cases — the split paragraph discriminator, the single-page resolve-from-stale-index path every keystroke takes, and the absent-block arm that must leave the position untouched rather than clamp it. Nothing outside this file would catch a replacement that sent a split paragraph's late bytes to the wrong page: the sibling unit tests supply their own geometry, which is the geometry the author expected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
…ss set The retraction went a step past the evidence. Two claims were bundled and retired together; they rest on different evidence and only one fails. Refuted, cleanly and by direct observation: "visible never fires" (R9-18). Still open, on independent evidence that survives: the access set, now R9-19. Cost is NOT proportional to M — page 0 is 3,442 ns and page 444 is 3,284 ns on the same document, where a 0..=M walk would make M=0 nearly free — yet IS superlinear in N, 3.3 to 13.6 us across 445 to 889 pages. Something N-sized is touched per call regardless of caret position. That points at N rather than the prefix the code's shape suggests, which is the worse prior and the one S9-3 must plan against. Deleting both claims would have discarded the more important half. The two observations are compatible, and why matters: they cover different geometries. The characterisation test exercises a paragraph split across pages; the bench exercises single-page paragraphs at byte 0. visible firing for the first and not the second is not a contradiction — and the second is the case every keystroke hits. L9-017 sharpened to the editing_data DEREFERENCE set rather than pages visited. Walking N pages' metadata is harmless for windowing; dereferencing N pages' editing_data is fatal. A counter blurring them reports a frightening number for something benign or a clean one for something ruinous. That also promotes the counting accessor to a prerequisite for S9-3 rather than a component: the falsification condition needs a current baseline, and neither timing nor code shape provides one, so without it S9-3 has no testable prediction at all. L9-018 records the shape both refuted claims shared, which is sharper than the first diagnosis. Each crossed an observable domain boundary without an observation in the target domain: unit invariance inferred from byte counts, control flow inferred from time. Coherence within the measured domain is exactly what you would see either way, so it carries no information about the target. "Internally consistent and pointing where the author leaned" was also true, but describes how the claim felt; this describes what was structurally missing. L9-019 makes the three-way Observed / Not established / What would settle it split the standard form for findings in both specs, written when the claim is recorded rather than after it is corrected. Applied at the time it would have caught both — neither could have been filed under Observed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
…rong axis Folds r12 into the census. Neither of my two treatments of R9-18 was right: the first asserted it unqualified, the second deleted it. It needed a qualifier. "visible never fires" is dead — the characterisation test puts a split paragraph's last byte on a later page, which only the band check produces. But for the bench's geometry, single-page paragraphs at byte 0, the original evidence still stands: a guaranteed miss costs the same as a hit, and cost is flat in M where a 0..=M walk would make page 0 nearly free. Both hold at once. The uncontrolled variable was geometry, and geometry is what selects the branch. The bench sweeps caret position while holding geometry fixed, so it varies the axis that does not matter and holds the one that does. Recorded in the bench's own header as a known limitation, since it is an instrument built here. The four cases worth timing: byte 0 of a single-page paragraph (measured), mid-paragraph single-page, first byte of a paragraph carried from the previous page, last byte of one continuing onto the next. That sweep discriminates R9-19 too — flat across all four means an N-sized preamble runs regardless of path and the pessimistic prior holds; collapsing for the straddling cases means there is no preamble, the N-scaling was specific to byte-0 lookups, and S9-3's target is much narrower than R9-19 assumes. And the keystroke path is neither geometry measured. Typing is mid-paragraph at arbitrary offsets in paragraphs that may or may not straddle, and Q4 already established that pages starting mid-paragraph are the common case in prose. So the representative path is plausibly the split one, where visible fires and the walk is short — which would make R9-19's N prior pessimistic for exactly the case that matters, and S9-3 scoped against a worst case the editor rarely hits. Worth settling before S9-3 is sized, and cheap: it is the same four-case sweep. The three-way record is updated to scope each observation to its geometry, so "flat in caret position" no longer reads as a general fact about the function. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
… GPU path First real run of the android-check job (I-17) found a pre-existing break on main, in a crate this branch never touched. loki-renderer/src/document_view.rs gated `use dioxus::prelude::*` behind `any(not(target_os = "android"), android_gpu)`, but lib.rs declares the module ungated and DocumentView uses `#[component]`, `Element`, `rsx!`, `use_hook` and `provide_context` on BOTH arms — the Android CPU early-return as much as the GPU/desktop body. So on `--target aarch64-linux-android` without `android_gpu` the component compiled without the names it needs: six name-resolution errors, while every desktop build stayed green. That is the I-16 failure shape exactly, and it is what the job was added to catch. The three sibling views already get this right by gating the *module* in lib.rs and importing the prelude unconditionally inside — view_types.rs, reflow_view.rs and page_tile.rs all do. document_view.rs was the only file that gated the import instead, and the audit found no others. Zero risk to desktop: the old cfg evaluated true on every non-Android target, so the import was already present there and this change is a literal no-op for them. Verified with the desktop clippy run; the Android half cannot be verified here, since there is no NDK in this environment (cc-rs fails on aarch64-linux-android-clang, the same wall the original local attempt hit). CI is the verifier, which is what the job is for. Not claimed: that this is the only Android-only break. The build stopped at the first failing crate, so loki-text itself was never reached. More may sit behind this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
|
My own miss, and a process one: the nine-line explanation added in 2e504c5 took document_view.rs from 297 to 306 lines, over the 300 ceiling. After that edit I ran fmt and clippy but not the ten script gates I had been running all session — on the one change that added prose to a file already near the limit. Baselining it was not an option: the growth was mine, and the ratchet exists so the backlog can only shrink. Trimmed to the three load-bearing lines instead — why it must be unconditional, and the actionable rule (gate the module, never this import). The full reasoning lives in 2e504c5's message and the PR thread, which is the right place for it when the file has no budget. Note for the next edit: document_view.rs is now at exactly 300, so it must be split before anything else is added to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TCbjvyXZQkoSz6TCgZVqJ
21 commits across two specs. Spec 09 takes 41% off layout residency with two contained changes; Spec 08 restores the Android build and puts a CI gate under it — a gate that caught a second, pre-existing Android break on its first real run.
Spec 09 — S9-1 and S9-2
Body text goes from 124 → 73.0 B/char total (34.8 of it editing residency), with no eviction machinery, no contract change, and no behavioural difference.
loki-bench/benches/layout_editing_residency.rs), not a one-off: process warm-up, per-document warm-up, and an ordering control that re-measures the first subject last. It runs headless under dhat — layout is CPU-only, so the "needs hardware" constraint this spec inherited did not apply. Correcting the instrument's order-dependence moved corpus figures by up to 252×.ParaCacheholdsArc<ParagraphLayout>, so the shaping cache and the page editing index share one allocation instead of deep-copying per placement. Copy-on-write viaArc::make_mut, which makes the guarantee structural:Arc<T>yields&T, so a forgotten private copy is a compile error, not a silent cross-placement corruption.ByteIndexMapreplaces twoVec<usize>index maps withu32entries plus anIdentityvariant. ~96% of real-document bytes turn out to sit in paragraphs needing no map at all.Arc::get_mutinloki-layout(scripts/check-arc-get-mut.py), verified both ways: it fails on a real call and passes on prose describing one.Every residency change recorded a prediction before implementation and re-measured after (L9-013). Two predictions were wrong and are recorded as such — a per-byte census denominator, refuted by a Cyrillic tier, and a control-flow claim inferred from timing, refuted by a characterisation test.
Spec 08 — Phases 0, 0.5, 1
docs/spikes/(S0.1–S0.6). No production code.android_main/ANDROID_MAIN_RUNNING,E0428, invisible to desktop CI), porting the IME bridge intoloki_app_shell::android_main!before deleting the duplicate that carried it. Calc and Slides inherit a bridge they never had. Added theandroid-checkCI job (I-17).ViewportControllerand animation driver inappthere-ui; caret follow (I-05); fixed the wheel-scroll regression (I-20) and narrowed I-21 to two branches with atracing::debug!onloki_text::caret_follow.The Android gate found a real break on its first run
android-checkhad never executed. Its first run resolved the NDK, compiled the full dependency graph, and failed insideloki-renderer— a crate this branch never touched, broken onmain.document_view.rsgateduse dioxus::prelude::*to the GPU path whilelib.rsleaves the module ungated, so on--target aarch64-linux-androidthe component compiled without#[component],Element,rsx!,use_hookorprovide_context. Six name-resolution errors, desktop green throughout — the I-16 shape exactly, which is what I-17 added the job to catch.Fixed in 2e504c5; the three sibling views already gate the module in
lib.rsand import the prelude unconditionally, and an audit found no other offenders. The change is a no-op off Android, where the old cfg already evaluated true.This also serves as Phase 0.5's negative test, on a real error rather than an injected one: the job went red on a genuine Android-only break and green once fixed. One caveat — the break was in a dependency crate, not in
loki-text's own#[cfg(target_os = "android")]code, so a synthetic error inside that block would close T0.5.2 without an asterisk.Verification
android-check,build-and-test,lint.cargo fmt --all --checkclean.cargo clippy --workspace --all-features -- -D warnings -D clippy::unwrap_used -D clippy::expect_used— and again with--all-targets.What this does not settle
Green CI cannot see any of the following, and R23 is the program's highest risk precisely because of that — I-20 passed every gate while being visibly broken on screen.
DeviceProfileprobes all returnUnknown, so L08-011 is asserted but not yet true (R24).Recommended next step per §3.6: screen-test Phase 1 before Phase 2 opens. Merging this does not force that sequencing decision; building on it would.