oxedyne/fe2o3/fe2o3_austenite/tests/memo.rs
24.2 KiB, 42 runs
created by r1870400018:58623, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The incremental memo, proved on the native recompile path. |
| 2 | //! |
| 3 | //! The master goal is to swap Austenite in for Daimond's Typst-wasm compiler; the blocker is that a |
| 4 | //! straight swap re-authors and re-emits the whole document on every keystroke (~15x the live-view |
| 5 | //! latency). The [`memo`](oxedyne_fe2o3_austenite::memo) layer caches the two costly content-addressed |
| 6 | //! stages -- block authoring and page emit -- so an edit recomputes only what changed. These tests hold |
| 7 | //! that memo to the one standard that matters: the memo must never change a single output byte. A warm |
| 8 | //! recompile after an edit must be byte-identical to a cold compile of the edited source, while all but |
| 9 | //! the one edited block hit the authoring cache and every page outside the edit's pagination cascade hits |
| 10 | //! the emit cache. |
| 11 | |
| 12 | use oxedyne_fe2o3_austenite::bib::Bibliography; |
| 13 | use oxedyne_fe2o3_austenite::compile::{ |
| 14 | author_and_run_memo, |
| 15 | Assembled, |
| 16 | }; |
| 17 | use oxedyne_fe2o3_austenite::doc::{ |
| 18 | Block, |
| 19 | Segment, |
| 20 | }; |
| 21 | use oxedyne_fe2o3_austenite::emit::svg; |
| 22 | use oxedyne_fe2o3_austenite::fonts::{ |
| 23 | self, |
| 24 | FaceResolver, |
| 25 | }; |
| 26 | use oxedyne_fe2o3_austenite::memo::Memo; |
| 27 | use oxedyne_fe2o3_austenite::page::PageGeometry; |
| 28 | use oxedyne_fe2o3_austenite::theme::Theme; |
| 29 | |
| 30 | use oxedyne_fe2o3_core::prelude::*; |
| 31 | |
| 32 | use std::sync::Arc; |
| 33 | use std::time::Instant; |
| 34 | |
| 35 | /// A deterministic body of prose paragraphs. Each is a few sentences of varying-length words, enough to |
| 36 | /// break into several lines and paginate over many pages, so a mid-document edit has both earlier pages |
| 37 | /// to leave untouched and later pages to cascade. `edit_at` names a paragraph to rewrite, standing in for |
| 38 | /// the one edit a keystroke makes; `None` is the untouched source. |
| 39 | fn build_doc(n: usize, edit_at: Option<usize>) -> Vec<Block> { |
| 40 | let mut blocks = Vec::with_capacity(n); |
| 41 | for k in 0..n { |
| 42 | blocks.push(Block::paragraph(paragraph_text(k, edit_at == Some(k)))); |
| 43 | } |
| 44 | blocks |
| 45 | } |
| 46 | |
| 47 | /// One paragraph's text, keyed by its index so the corpus is stable run to run and, crucially, unique per |
| 48 | /// paragraph: each opens with its own ordinal word. Uniqueness matters to the hit-count assertions -- the |
| 49 | /// authoring memo is content-addressed, so two paragraphs of identical text would legitimately share one |
| 50 | /// cache entry (a real and desirable property: a moved or duplicated paragraph reuses its authored nodes), |
| 51 | /// which would blur "one edit, one miss". An edited paragraph swaps in a different but similarly shaped |
| 52 | /// body, so it genuinely re-breaks and re-paginates from that point -- the honest case for the cascade. |
| 53 | fn paragraph_text(k: usize, edited: bool) -> String { |
| 54 | let words = [ |
| 55 | "typesetting", "streams", "a", "document", "through", "two", "passes", "of", "the", "driver", |
| 56 | "while", "the", "ledger", "resolves", "each", "anchor", "into", "the", "page", "it", "landed", |
| 57 | "on", "and", "the", "breaker", "sets", "every", "justified", "line", "to", "the", "measure", |
| 58 | "without", "a", "single", "floating", "point", "along", "the", "way", "so", "the", "build", |
| 59 | "stays", "byte", "identical", "from", "one", "run", "to", "the", "very", "next", "one", "here", |
| 60 | ]; |
| 61 | let seed = if edited { k * 7 + 3 } else { k * 5 + 1 }; |
| 62 | let count = 34 + (seed % 20); |
| 63 | // The unique ordinal opener, distinct for every paragraph and for an edited one against its original. |
| 64 | let mut s = fmt!("Paragraph{}{}.", k, if edited { "edited" } else { "" }); |
| 65 | for i in 0..count { |
| 66 | s.push(' '); |
| 67 | s.push_str(words[(seed + i * 3) % words.len()]); |
| 68 | } |
| 69 | s.push('.'); |
| 70 | s |
| 71 | } |
| 72 | |
| 73 | /// Authors, runs and emits a document to a vector of per-page SVG strings, threading the memo (when given) |
| 74 | /// through both the authoring stage and the page emit, exactly as the native `--watch` path would. `None` |
| 75 | /// is the plain, un-memoised compile -- the honest cold baseline, and the byte reference every memo path |
| 76 | /// is held against. `bib` threads a marked bibliography through exactly as a real compile does (see |
| 77 | /// `book.rs:662-665`), so a `#cite` segment resolves and the cited set folds into the memo's global |
| 78 | /// fingerprint; plain callers with no citations pass `None`. |
| 79 | fn compile_pages(blocks: Vec<Block>, bib: Option<Bibliography>, memo: Option<&mut Memo>) -> Outcome<Vec<String>> { |
| 80 | let fonts = Arc::new(res!(fonts::libertinus())); |
| 81 | let geom = PageGeometry::a4(); |
| 82 | let assembled = Assembled { |
| 83 | blocks, |
| 84 | fonts, |
| 85 | geom, |
| 86 | style: Theme::default(), |
| 87 | title: String::new(), |
| 88 | faces: FaceResolver::default(), |
| 89 | front: None, |
| 90 | bib, |
| 91 | }; |
| 92 | let mut memo = memo; |
| 93 | let rendered = res!(author_and_run_memo(assembled, memo.as_deref_mut())); |
| 94 | let mut svgs = Vec::with_capacity(rendered.out.pages.len()); |
| 95 | for page in &rendered.out.pages { |
| 96 | let svg = match memo.as_deref_mut() { |
| 97 | Some(m) => res!(svg::render_page_memo(page, m)), |
| 98 | None => res!(svg::render_page(page)), |
| 99 | }; |
| 100 | svgs.push(svg); |
| 101 | } |
| 102 | // The caller closes the generation once the pages are emitted, so the page cache survives to be reused. |
| 103 | if let Some(m) = memo.as_deref_mut() { |
| 104 | m.sweep(); |
| 105 | } |
| 106 | Ok(svgs) |
| 107 | } |
| 108 | |
| 109 | /// The heart of the gate: a warm recompile after a one-paragraph edit is byte-identical to a cold compile |
| 110 | /// of the edited source, every unedited block hits the authoring cache, and every page the edit does not |
| 111 | /// reach hits the emit cache. |
| 112 | #[test] |
| 113 | fn warm_recompile_is_byte_identical_after_a_one_paragraph_edit() -> Outcome<()> { |
| 114 | let n = 40; |
| 115 | let edit_at = 34; // late, so many earlier pages fall outside the pagination cascade |
| 116 | |
| 117 | // The cold references: the original source and the edited source, each compiled with no memo at all. |
| 118 | let cold_orig = res!(compile_pages(build_doc(n, None), None, None)); |
| 119 | let cold_edited = res!(compile_pages(build_doc(n, Some(edit_at)), None, None)); |
| 120 | |
| 121 | // Prime a memo on the original, then recompile the edited source warm against it. |
| 122 | let mut memo = Memo::new(); |
| 123 | let warm_orig = res!(compile_pages(build_doc(n, None), None, Some(&mut memo))); |
| 124 | assert_eq!(warm_orig, cold_orig, |
| 125 | "a first, cold compile through the memo must be byte-identical to a compile with no memo"); |
| 126 | // After the priming compile every block was a miss (the cache was empty) and every page was rendered. |
| 127 | assert_eq!(memo.block_hits, 0, "the priming compile fills the cache, so nothing hits"); |
| 128 | assert_eq!(memo.block_misses as usize, n, "every block is authored on the priming compile"); |
| 129 | |
| 130 | let warm_edited = res!(compile_pages(build_doc(n, Some(edit_at)), None, Some(&mut memo))); |
| 131 | |
| 132 | // (1) Byte identity -- the one property the memo must never break. |
| 133 | assert_eq!(warm_edited, cold_edited, |
| 134 | "a warm recompile after an edit must be byte-identical to a cold compile of the edited source"); |
| 135 | |
| 136 | // (2) The authoring cache: only the edited block misses; every other block splices its cached nodes. |
| 137 | assert_eq!(memo.block_misses, 1, |
| 138 | "only the one edited block should miss the authoring cache, found {} misses", memo.block_misses); |
| 139 | assert_eq!(memo.block_hits as usize, n - 1, |
| 140 | "every block but the edited one should hit the authoring cache, found {} hits", memo.block_hits); |
| 141 | |
| 142 | // (3) The emit cache: every page the edit does not reach must hit. A page is provably outside the |
| 143 | // cascade exactly when its cold SVG is unchanged by the edit; that is a rigorous lower bound on the |
| 144 | // page hits the warm compile must score, since such a page's body frame is byte-identical to the one |
| 145 | // the priming compile cached. |
| 146 | let untouched = cold_orig.iter().zip(cold_edited.iter()).filter(|(a, b)| a == b).count(); |
| 147 | assert!(untouched > 0, "the edit must be late enough to leave earlier pages untouched"); |
| 148 | assert!(memo.page_hits as usize >= untouched, |
| 149 | "every page outside the cascade must hit the emit cache: {} hits < {} untouched pages", |
| 150 | memo.page_hits, untouched); |
| 151 | assert_eq!((memo.page_hits + memo.page_misses) as usize, warm_edited.len(), |
| 152 | "every page is either a hit or a miss"); |
| 153 | assert!(memo.page_misses >= 1, "the edited region must re-render at least one page"); |
| 154 | |
| 155 | eprintln!( |
| 156 | "[memo] {} blocks over {} pages: warm edit -> block {}/{} hit, page {}/{} hit ({} untouched)", |
| 157 | n, warm_edited.len(), memo.block_hits, n - 1, |
| 158 | memo.page_hits, warm_edited.len(), untouched); |
| 159 | Ok(()) |
| 160 | } |
| 161 | |
| 162 | /// A warm recompile over a document of headings and paragraphs -- where a chapter heading keeps the first |
| 163 | /// line of the paragraph it introduces, consuming two source blocks as one authored unit -- must still be |
| 164 | /// byte-identical to a cold compile of the edited source. This exercises the splice of a cached heading |
| 165 | /// unit on a hit, the path plain prose never reaches. |
| 166 | #[test] |
| 167 | fn warm_recompile_is_byte_identical_with_headings() -> Outcome<()> { |
| 168 | let doc = |edit: bool| -> Vec<Block> { |
| 169 | let mut v = Vec::new(); |
| 170 | for c in 0..4 { |
| 171 | v.push(Block::heading(1, fmt!("Chapter{}", c))); |
| 172 | for p in 0..6 { |
| 173 | let k = c * 6 + p; |
| 174 | // Edit one paragraph in the third chapter, well clear of any heading's kept first line. |
| 175 | v.push(Block::paragraph(paragraph_text(k, edit && k == 15))); |
| 176 | } |
| 177 | } |
| 178 | v |
| 179 | }; |
| 180 | let cold_edited = res!(compile_pages(doc(true), None, None)); |
| 181 | let mut memo = Memo::new(); |
| 182 | let _ = res!(compile_pages(doc(false), None, Some(&mut memo))); // prime on the original |
| 183 | let warm = res!(compile_pages(doc(true), None, Some(&mut memo))); |
| 184 | assert_eq!(warm, cold_edited, |
| 185 | "a warm recompile with chapter headings must be byte-identical to a cold compile of the edit"); |
| 186 | assert!(memo.block_misses >= 1, "the edited paragraph must miss"); |
| 187 | assert!(memo.block_hits >= 1, "the unedited blocks, headings included, must hit"); |
| 188 | Ok(()) |
| 189 | } |
| 190 | |
| 191 | /// A cold compile with the memo present-but-empty must match a compile with no memo, page for page, over |
| 192 | /// a document exercising more than plain prose -- headings, a list and a code block -- so the byte-identity |
| 193 | /// guarantee is not resting on paragraphs alone. |
| 194 | #[test] |
| 195 | fn a_cold_memo_compile_matches_a_no_memo_compile_on_mixed_blocks() -> Outcome<()> { |
| 196 | let blocks = || vec![ |
| 197 | Block::heading(1, "The Streaming Driver"), |
| 198 | Block::paragraph(paragraph_text(0, false)), |
| 199 | Block::paragraph(paragraph_text(1, false)), |
| 200 | Block::heading(2, "Line Breaking"), |
| 201 | Block::paragraph(paragraph_text(2, false)), |
| 202 | Block::list(false, vec![], false), |
| 203 | Block::code(vec!["let x = 1;".to_string(), "let y = x + 1;".to_string()]), |
| 204 | Block::paragraph(paragraph_text(3, false)), |
| 205 | ]; |
| 206 | let no_memo = res!(compile_pages(blocks(), None, None)); |
| 207 | let mut memo = Memo::new(); |
| 208 | let with_memo = res!(compile_pages(blocks(), None, Some(&mut memo))); |
| 209 | assert_eq!(no_memo, with_memo, |
| 210 | "a present-but-cold memo must not change one output byte over mixed block kinds"); |
| 211 | Ok(()) |
| 212 | } |
| 213 | |
| 214 | /// The measurement the swap rests on: cold (no memo) versus warm (one-paragraph edit through the memo) |
| 215 | /// wall time on a document of a few hundred pages. Not an assertion -- machines differ -- but it prints |
| 216 | /// the speedup the live-view loop would see. Run with `--nocapture` to read it. |
| 217 | #[test] |
| 218 | fn measure_cold_versus_warm_recompile() -> Outcome<()> { |
| 219 | let n = 260; |
| 220 | let edit_at = 130; |
| 221 | |
| 222 | // Cold: a full compile with no memo, the latency a straight swap would pay on every edit. |
| 223 | let t0 = Instant::now(); |
| 224 | let cold = res!(compile_pages(build_doc(n, None), None, None)); |
| 225 | let cold_ms = t0.elapsed().as_secs_f64() * 1000.0; |
| 226 | |
| 227 | // Prime the memo on the original document (the first open of the document, not the edit loop). |
| 228 | let mut memo = Memo::new(); |
| 229 | let _ = res!(compile_pages(build_doc(n, None), None, Some(&mut memo))); |
| 230 | |
| 231 | // Warm: the edit-and-re-render a keystroke triggers. |
| 232 | let t1 = Instant::now(); |
| 233 | let warm = res!(compile_pages(build_doc(n, Some(edit_at)), None, Some(&mut memo))); |
| 234 | let warm_ms = t1.elapsed().as_secs_f64() * 1000.0; |
| 235 | |
| 236 | let cold_ref = res!(compile_pages(build_doc(n, Some(edit_at)), None, None)); |
| 237 | assert_eq!(warm, cold_ref, "the measured warm recompile must still be byte-identical"); |
| 238 | |
| 239 | eprintln!( |
| 240 | "[memo] {} pages: cold {:.1} ms, warm {:.1} ms, speedup {:.1}x (block {}/{} hit, page {}/{} hit)", |
| 241 | cold.len(), cold_ms, warm_ms, cold_ms / warm_ms.max(0.001), |
| 242 | memo.block_hits, n - 1, memo.page_hits, warm.len()); |
| 243 | Ok(()) |
| 244 | } |
| 245 | |
| 246 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 247 | // │ THE LEDGER GATE: pagination-shifting edits against folios, cites, glossary │ |
| 248 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 249 | // |
| 250 | // The tests above hold the memo to byte-identity over plain prose and headings, where an edit only ever |
| 251 | // lengthens or shortens text. The gate below tests the sharper claim: that byte-identity survives an edit |
| 252 | // that shifts which PAGE later content lands on (so folios printed in the furniture move) and an edit that |
| 253 | // moves WHICH block first uses a glossary term (so the term's bold-italic first-use markup moves). Both are |
| 254 | // ledger facts -- resolved after the driver's pages converge -- that the memo must never freeze stale. |
| 255 | |
| 256 | /// Three entries, two of them Zuboff works dated 2019, so the reference list and the in-text label both |
| 257 | /// exercise the year-suffix disambiguation (`2019a`/`2019b`) the way a real bibliography does. |
| 258 | const LEDGER_BIB: &str = r#" |
| 259 | @book{scott1976, |
| 260 | author = {Scott, James C.}, |
| 261 | title = {The Moral Economy of the Peasant}, |
| 262 | year = {1976}, |
| 263 | publisher = {Yale University Press} |
| 264 | } |
| 265 | @book{zuboff2019a, |
| 266 | author = {Zuboff, Shoshana}, |
| 267 | title = {The Age of Surveillance Capitalism}, |
| 268 | year = {2019}, |
| 269 | publisher = {PublicAffairs} |
| 270 | } |
| 271 | @book{zuboff2019b, |
| 272 | author = {Zuboff, Shoshana}, |
| 273 | title = {Big Other}, |
| 274 | year = {2019}, |
| 275 | publisher = {Profile Books} |
| 276 | } |
| 277 | "#; |
| 278 | |
| 279 | /// Parses [`LEDGER_BIB`] and marks `cited` as cited, mirroring `book.rs:662-665` -- the bibliography must |
| 280 | /// enter the assembled doc with its cited set already settled, not built up as the body walk goes. |
| 281 | fn ledger_bib(cited: &[&str]) -> Outcome<Bibliography> { |
| 282 | let mut bib = res!(Bibliography::parse(LEDGER_BIB)); |
| 283 | for k in cited { |
| 284 | bib.mark_cited(k); |
| 285 | } |
| 286 | Ok(bib) |
| 287 | } |
| 288 | |
| 289 | /// The three ledger-fixture variants. `Orig` and `Shift` differ only in the edit site's length; `Orig` and |
| 290 | /// `DropFirstUse` differ only in whether the first chapter's rich paragraph carries the glossary segment. |
| 291 | #[derive(Clone, Copy, PartialEq, Eq, Debug)] |
| 292 | enum Variant { Orig, Shift, DropFirstUse } |
| 293 | |
| 294 | /// The sentence the edit site repeats under `Shift`. Forty repeats is long enough to add a whole extra |
| 295 | /// page and push every later block -- the rest of chapter one, the two rich paragraphs, chapter two and |
| 296 | /// the back matter -- onto later pages than they land on under `Orig`. |
| 297 | fn edit_sentence() -> &'static str { |
| 298 | "the ledger resolves this very anchor into the folio it lands on once the driver's second pass converges" |
| 299 | } |
| 300 | |
| 301 | /// The one middle paragraph the ledger fixture edits. `shift` repeats its sentence forty times instead of |
| 302 | /// once; nothing else about the fixture changes, so this is the sole block whose content differs. |
| 303 | fn edit_site(shift: bool) -> Block { |
| 304 | let reps = if shift { 40 } else { 1 }; |
| 305 | let mut s = String::from("EditSite."); |
| 306 | for _ in 0..reps { |
| 307 | s.push(' '); |
| 308 | s.push_str(edit_sentence()); |
| 309 | s.push('.'); |
| 310 | } |
| 311 | Block::paragraph(s) |
| 312 | } |
| 313 | |
| 314 | /// Chapter one's rich paragraph: a glossary first use (omitted under `DropFirstUse`), a citation, an index |
| 315 | /// marker, a footnote and a margin note -- the one paragraph exercising every ledger-facing segment kind. |
| 316 | /// Everything but the glossary segment is fixed across variants, so a miss traces to exactly that change. |
| 317 | fn rich_paragraph_one(drop_first_use: bool) -> Block { |
| 318 | let mut segments = vec![Segment::text("The driver's ledger resolves ")]; |
| 319 | if !drop_first_use { |
| 320 | segments.push(Segment::glossary("memoisation", "memoisation")); |
| 321 | segments.push(Segment::text(" as ")); |
| 322 | } |
| 323 | segments.push(Segment::text("the technique that caches ")); |
| 324 | segments.push(Segment::cite(vec!["scott1976".to_string()])); |
| 325 | segments.push(Segment::text("'s peasant economy against a repeated anchor, recording it ")); |
| 326 | segments.push(Segment::index("Ledger anchor", None, false, vec![Segment::text("Ledger anchor")])); |
| 327 | segments.push(Segment::text(" once per pass")); |
| 328 | segments.push(Segment::footnote(vec![Segment::text("A pass converges when no anchor moves twice.")])); |
| 329 | segments.push(Segment::text(", and marks it ")); |
| 330 | segments.push(Segment::margin_note("R1", vec!["R1".to_string()])); |
| 331 | segments.push(Segment::text("in the outside margin.")); |
| 332 | Block::rich(segments) |
| 333 | } |
| 334 | |
| 335 | /// The paragraph carrying the term's second textual mention (the first paragraph's own topic sentence |
| 336 | /// leads into it): always carries the glossary term (a later use under `Orig`/`Shift`, the FIRST use under |
| 337 | /// `DropFirstUse`), a two-key citation exercising the Zuboff year-suffix pair, an index marker, a footnote |
| 338 | /// and a margin note. Fixed across all three variants; kept directly adjacent to `rich_paragraph_one` (see |
| 339 | /// `ledger_doc`) so `DropFirstUse`'s effect on the memo is isolated to exactly these two blocks. |
| 340 | fn rich_paragraph_two() -> Block { |
| 341 | Block::rich(vec![ |
| 342 | Segment::text("The account returns to "), |
| 343 | Segment::glossary("memoisation", "memoisation"), |
| 344 | Segment::text(" once more, this time citing "), |
| 345 | Segment::cite(vec!["zuboff2019a".to_string(), "zuboff2019b".to_string()]), |
| 346 | Segment::text(" on the surveillance ledger, indexing "), |
| 347 | Segment::index("Surveillance ledger", None, false, vec![Segment::text("Surveillance ledger")]), |
| 348 | Segment::text(" and noting"), |
| 349 | Segment::footnote(vec![Segment::text("Both works share the one 2019 imprint year.")]), |
| 350 | Segment::text(" the citation in "), |
| 351 | Segment::margin_note("R2", vec!["R2".to_string()]), |
| 352 | Segment::text("the margin."), |
| 353 | ]) |
| 354 | } |
| 355 | |
| 356 | /// The ledger fixture: a chapter of leading filler enough to paginate over several pages before the edit |
| 357 | /// site, the edit site itself, more filler, the two rich paragraphs, a second chapter heading and its own |
| 358 | /// filler, then a reverse claim index and back-matter index to close it. `paragraph_text`'s indices are |
| 359 | /// unique per paragraph and identical across every variant, so only the edit site and (under |
| 360 | /// `DropFirstUse`) the two rich paragraphs can ever differ. |
| 361 | /// |
| 362 | /// The two rich paragraphs sit directly adjacent, with no heading or filler between them. That is |
| 363 | /// deliberate, not merely tidy: the block-authoring memo folds the glossary first-use `seen` set into |
| 364 | /// *every* block's key (`Authoring::seen_hash`, `doc.rs:1266`), so once the term's first use moves from |
| 365 | /// the first rich paragraph to the second, any block sitting between them would key on a `seen` state that |
| 366 | /// differs from the one it was primed under -- a correct, safe, but non-minimal miss, since that block's |
| 367 | /// own rendered output never reads the term. Keeping the pair adjacent isolates `DropFirstUse`'s effect to |
| 368 | /// exactly the two blocks that do read it, which is what `warm_recompile_reauthors_a_later_glossary_use_*` |
| 369 | /// holds the memo to. |
| 370 | fn ledger_doc(variant: Variant) -> Vec<Block> { |
| 371 | let drop_first_use = variant == Variant::DropFirstUse; |
| 372 | let shift = variant == Variant::Shift; |
| 373 | let mut blocks = Vec::new(); |
| 374 | |
| 375 | blocks.push(Block::heading(1, "Chapter One")); |
| 376 | for k in 0..24 { |
| 377 | blocks.push(Block::paragraph(paragraph_text(k, false))); |
| 378 | } |
| 379 | blocks.push(edit_site(shift)); // the one edited paragraph, well after several leading pages |
| 380 | for k in 24..30 { |
| 381 | blocks.push(Block::paragraph(paragraph_text(k, false))); |
| 382 | } |
| 383 | blocks.push(rich_paragraph_one(drop_first_use)); |
| 384 | blocks.push(rich_paragraph_two()); |
| 385 | |
| 386 | blocks.push(Block::heading(1, "Chapter Two")); |
| 387 | for k in 30..40 { |
| 388 | blocks.push(Block::paragraph(paragraph_text(k, false))); |
| 389 | } |
| 390 | |
| 391 | blocks.push(Block::ClaimIndex); |
| 392 | blocks.push(Block::Index); |
| 393 | blocks |
| 394 | } |
| 395 | |
| 396 | /// THE gate: a warm recompile after an edit that shifts pagination -- moving every later folio, the second |
| 397 | /// chapter's rich paragraph and the back matter onto later pages -- must still be byte-identical to a cold |
| 398 | /// compile of the shifted source, while only the one edited block misses the authoring cache. |
| 399 | #[test] |
| 400 | fn warm_recompile_is_byte_identical_when_pagination_shifts_ledger_facts() -> Outcome<()> { |
| 401 | let full_cites = ["scott1976", "zuboff2019a", "zuboff2019b"]; |
| 402 | |
| 403 | let cold_orig = res!(compile_pages(ledger_doc(Variant::Orig), Some(res!(ledger_bib(&full_cites))), None)); |
| 404 | let cold_shift = res!(compile_pages(ledger_doc(Variant::Shift), Some(res!(ledger_bib(&full_cites))), None)); |
| 405 | |
| 406 | // Precondition: the edit really does shift pagination -- the tail page differs and the document grows. |
| 407 | // A test that does not move the folios proves nothing about the ledger, only about plain byte diffing. |
| 408 | assert_ne!(cold_orig.last(), cold_shift.last(), |
| 409 | "the shifted edit must move the final page's content -- the precondition the whole gate rests on"); |
| 410 | assert!(cold_shift.len() > cold_orig.len(), |
| 411 | "the shifted edit must add at least one page, found {} vs {} pages", |
| 412 | cold_shift.len(), cold_orig.len()); |
| 413 | |
| 414 | // Prime on the original, then warm-compile the shift. |
| 415 | let mut memo = Memo::new(); |
| 416 | let _ = res!(compile_pages(ledger_doc(Variant::Orig), Some(res!(ledger_bib(&full_cites))), Some(&mut memo))); |
| 417 | let warm_shift = res!(compile_pages(ledger_doc(Variant::Shift), Some(res!(ledger_bib(&full_cites))), Some(&mut memo))); |
| 418 | |
| 419 | // (1) Byte identity -- THE gate. |
| 420 | assert_eq!(warm_shift, cold_shift, |
| 421 | "a warm recompile after a pagination-shifting edit must be byte-identical to the cold shifted compile"); |
| 422 | |
| 423 | // (2) Only the edited paragraph misses; the rich paragraphs, headings and back-matter blocks all hit, |
| 424 | // even though every one of them now lands on a different page than it did under `Orig`. |
| 425 | assert_eq!(memo.block_misses, 1, |
| 426 | "only the one edited block should miss the authoring cache, found {} misses", memo.block_misses); |
| 427 | |
| 428 | // (3) The emit cache: the cascade must actually reach several pages, and at least the leading page(s) |
| 429 | // before the edit site must still hit. |
| 430 | assert!(memo.page_misses >= 3, |
| 431 | "the pagination cascade must re-render at least three pages, found {} misses", memo.page_misses); |
| 432 | assert!(memo.page_hits >= 1, |
| 433 | "at least one page before the edit site must hit the emit cache, found {} hits", memo.page_hits); |
| 434 | |
| 435 | eprintln!( |
| 436 | "[ledger] {} -> {} pages: block {} miss, page {}/{} hit", |
| 437 | cold_orig.len(), cold_shift.len(), memo.block_misses, memo.page_hits, memo.page_hits + memo.page_misses); |
| 438 | Ok(()) |
| 439 | } |
| 440 | |
| 441 | /// Removing the first chapter's glossary segment pushes the term's first use into the second chapter's |
| 442 | /// rich paragraph, which must now render its bold-italic markup instead of the plain text it rendered |
| 443 | /// under `Orig` -- a warm recompile must reauthor that later paragraph, not serve its stale plain form. |
| 444 | #[test] |
| 445 | fn warm_recompile_reauthors_a_later_glossary_use_when_the_first_use_is_removed() -> Outcome<()> { |
| 446 | let full_cites = ["scott1976", "zuboff2019a", "zuboff2019b"]; |
| 447 | |
| 448 | let cold_drop = res!(compile_pages(ledger_doc(Variant::DropFirstUse), Some(res!(ledger_bib(&full_cites))), None)); |
| 449 | |
| 450 | let mut memo = Memo::new(); |
| 451 | let _ = res!(compile_pages(ledger_doc(Variant::Orig), Some(res!(ledger_bib(&full_cites))), Some(&mut memo))); |
| 452 | let warm_drop = res!(compile_pages(ledger_doc(Variant::DropFirstUse), Some(res!(ledger_bib(&full_cites))), Some(&mut memo))); |
| 453 | |
| 454 | assert_eq!(warm_drop, cold_drop, |
| 455 | "a warm recompile that drops the first glossary use must be byte-identical to the cold compile"); |
| 456 | // Rich paragraph one misses (its own content changed, the segment is gone) and rich paragraph two |
| 457 | // misses (its content is unchanged, but it now enters with a different glossary `seen` state, so it |
| 458 | // must re-author its bold-italic first use rather than splice back its cached plain-text nodes). |
| 459 | assert_eq!(memo.block_misses, 2, |
| 460 | "exactly the two rich paragraphs should miss, found {} misses", memo.block_misses); |
| 461 | Ok(()) |
| 462 | } |
| 463 | |
| 464 | /// Citing a new work the memo was not primed with must clear the whole cache: the cited set folds into the |
| 465 | /// memo's global fingerprint (`memo_fingerprint`, via the bibliography's `Debug`), so a document that has |
| 466 | /// not changed one block still misses everywhere once what it cites has changed. |
| 467 | #[test] |
| 468 | fn a_new_citation_clears_the_memo() -> Outcome<()> { |
| 469 | let blocks = || ledger_doc(Variant::Orig); |
| 470 | |
| 471 | let cold_two_cites = res!(compile_pages(blocks(), Some(res!(ledger_bib(&["scott1976", "zuboff2019a"]))), None)); |
| 472 | |
| 473 | let mut memo = Memo::new(); |
| 474 | let _ = res!(compile_pages(blocks(), Some(res!(ledger_bib(&["scott1976"]))), Some(&mut memo))); |
| 475 | let warm = res!(compile_pages(blocks(), Some(res!(ledger_bib(&["scott1976", "zuboff2019a"]))), Some(&mut memo))); |
| 476 | |
| 477 | assert_eq!(warm, cold_two_cites, |
| 478 | "a warm recompile after a citation-set change must be byte-identical to a cold compile with that set"); |
| 479 | assert_eq!(memo.block_hits, 0, |
| 480 | "a changed cited set must clear the global fingerprint and miss every block, found {} hits", |
| 481 | memo.block_hits); |
| 482 | Ok(()) |
| 483 | } |