Skip to content

Spec 08 Phases 0–1 and Spec 09 S9-1/S9-2: −41% layout residency, Android build restored and gated in CI - #37

Merged
kevincarlson merged 21 commits into
mainfrom
claude/loki-spec-08-remediation-s021rd
Jul 26, 2026
Merged

Spec 08 Phases 0–1 and Spec 09 S9-1/S9-2: −41% layout residency, Android build restored and gated in CI#37
kevincarlson merged 21 commits into
mainfrom
claude/loki-spec-08-remediation-s021rd

Conversation

@kevincarlson

@kevincarlson kevincarlson commented Jul 26, 2026

Copy link
Copy Markdown
Member

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.

  • E0 is a permanent bench (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×.
  • S9-1ParaCache holds Arc<ParagraphLayout>, so the shaping cache and the page editing index share one allocation instead of deep-copying per placement. Copy-on-write via Arc::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.
  • S9-2ByteIndexMap replaces two Vec<usize> index maps with u32 entries plus an Identity variant. ~96% of real-document bytes turn out to sit in paragraphs needing no map at all.
  • A CI gate banning Arc::get_mut in loki-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

  • Phase 0 — six spike findings documents under docs/spikes/ (S0.1–S0.6). No production code.
  • Phase 0.5 — restored the Android build (I-16: duplicate android_main/ANDROID_MAIN_RUNNING, E0428, invisible to desktop CI), porting the IME bridge into loki_app_shell::android_main! before deleting the duplicate that carried it. Calc and Slides inherit a bridge they never had. Added the android-check CI job (I-17).
  • Phase 1ViewportController and animation driver in appthere-ui; caret follow (I-05); fixed the wheel-scroll regression (I-20) and narrowed I-21 to two branches with a tracing::debug! on loki_text::caret_follow.

The Android gate found a real break on its first run

android-check had never executed. Its first run resolved the NDK, compiled the full dependency graph, and failed inside loki-renderer — a crate this branch never touched, broken on main. document_view.rs gated use dioxus::prelude::* to the GPU path while lib.rs leaves the module ungated, so on --target aarch64-linux-android the component compiled without #[component], Element, rsx!, use_hook or provide_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.rs and 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

  • All three CI jobs green: android-check, build-and-test, lint.
  • 210 test suites pass; cargo fmt --all --check clean.
  • The exact CI clippy command passes — cargo clippy --workspace --all-features -- -D warnings -D clippy::unwrap_used -D clippy::expect_used — and again with --all-targets.
  • All ten script gates pass.

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.

  • Phase 1's three acceptance criteria are behavioural and unverified: trailing space at 50/100/200%, Page Down not fighting the user, no auto-scroll on an unmodified document.
  • I-21's debug numbers are unread. One line, or a coordinate-layer change that amends S0.3 and pre-emptively concerns T4.1 and T7.3 (R26).
  • Probe P1 (nested scroll input routing) not attempted; still gates T7.3.
  • DeviceProfile probes all return Unknown, so L08-011 is asserted but not yet true (R24).
  • Whether further Android-only breaks exist behind the one fixed here — the first run stopped at the first failing crate.

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.

claude added 20 commits July 25, 2026 12:37
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

Copy link
Copy Markdown
Member Author

android-check earned its keep on its first run

The NDK is fine — R21's concern is discharged. The job resolved the toolchain, compiled the whole dependency graph including dioxus-native and loki-file-access's Java shim DEX, and reached our code. It then failed inside loki-renderer, which is the informative outcome.

The break is pre-existing 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 that 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. On --target aarch64-linux-android without android_gpu the component therefore compiled without the names it needs: six name-resolution errors, while every desktop build stayed green.

That is the I-16 failure shape exactly — an Android-only break invisible to desktop CI — which is what I-17 added this job to catch. It found one that was already in main, on the first run.

Fixed in 2e504c5. The three sibling views already do it correctly by gating the module in lib.rs and importing the prelude unconditionally inside (view_types.rs, reflow_view.rs, page_tile.rs); document_view.rs was the only file that gated the import instead, and an audit of the crate found no others.

Risk to desktop: none. The old cfg evaluated true on every non-Android target, so the import was already present there — the change is a literal no-op off Android. Verified with the desktop clippy run.

Two things this does not establish

  • The Android half is unverified locally. There is no NDK in my environment (cc-rs fails on aarch64-linux-android-clang, the same wall the original attempt hit), and I deliberately did not point it at a host or -gnu cross-compiler to get a green local result — that is the degradation L08-014's corollary exists to prevent. CI is the verifier.
  • This may not be the only Android-only break. The build stopped at the first failing crate, so loki-text itself was never reached. If the next run fails further along, that is progress rather than a regression.

Phase 0.5's remaining criterion is unchanged

A green run here would satisfy the first of the two conditions — the Android target compiles. The second still stands: a deliberate Android-only error must be shown to fail CI. A job that can pass without doing its work is worse than no job, and today's result is evidence the job can do its work, not that it fails when it should.


Generated by Claude Code

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
@kevincarlson kevincarlson self-assigned this Jul 26, 2026
@kevincarlson kevincarlson changed the title Spec 08 Phases 0–1 and Spec 09 S9-1/S9-2 (draft — opened to get CI on the branch) Spec 08 Phases 0–1 and Spec 09 S9-1/S9-2: −41% layout residency, Android build restored and gated in CI Jul 26, 2026
@kevincarlson
kevincarlson marked this pull request as ready for review July 26, 2026 05:21
@kevincarlson
kevincarlson merged commit a95e647 into main Jul 26, 2026
3 checks passed
@AppThere AppThere locked as resolved and limited conversation to collaborators Jul 26, 2026
@kevincarlson
kevincarlson deleted the claude/loki-spec-08-remediation-s021rd branch July 26, 2026 05:24
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants