merge: P6, the evidence pipeline (branch P6, checkpoint ce366408)

The last of the paused branches, from its "not for merge" checkpoint:
extraction in ledger-import (text, pages, page images; capped, caught),
journaled as attachment.text_extracted; dedup by hash with
books.find_attachment and books.consolidate_duplicates; annotations and
books.set_entry_settles; the company record categories; the two fuzz
targets; feature attachment_text. Nineteen files conflicted with the
four P4b merges, resolved as plan 8.0.2 says: P6 retakes schema 18 (it
had taken 16, which S4.3 holds; S5 holds 17); the tool table is 129 and
the destructive count 31; both sides' appends kept with their seams
restored; one B-27 paragraph naming every feature; in the old-books
fixture P6's hand-written reimbursement is entry 7 (its ids collided
with S4.3's invoice); the replay fuzzer's list gains S7's
invoice.written_off; S4.3's issued-PDF test expects the extraction
record P6 journals right after a filing.

One integration defect between S7 and P6, found by the arap property
test and fixed here: under P6's dedup, a write-off whose evidence bytes
were filed already (the same collection letter for a second write-off of
the same invoice) was refused only after its entry had been journaled,
at the final link to the invoice ("already linked"). The write-off now
resolves the dedup before anything is journaled and keeps a link the
record already has to the invoice instead of refusing it; an explicit
existing_attachment_id already on an entry is still refused (S7's
one-receipt-once rule). P6's gate never ran before the pause; it runs on
main after this merge, and its ledger-store -> ledger-import dependency
is the open review point (plan 8.0.1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-28 23:08:59 -06:00
commit 2ad192bbcd
70 files changed
+4618 -384

No files matched your search

+4
View File
@@ -100,6 +100,10 @@ impl ledger_package::PackageBooks for View<'_> {
Ok(self.0.attachments.clone())
}
fn extractions(&self) -> Result<Vec<ledger_store::Extraction>, StoreError> {
Ok(self.0.extractions.clone())
}
fn links_of(&self, attachment_id: u64) -> Result<Vec<Subject>, StoreError> {
Ok(self.0.links_of(attachment_id).to_vec())
}
+5
View File
@@ -325,6 +325,9 @@ pub struct Books {
pub statements: Vec<Statement>,
/// Reconciliations, voided ones included, by date then id.
pub reconciliations: Vec<Reconciliation>,
/// What reading each file's text found (plan P6): the same records the
/// server's Needs attention reads.
pub extractions: Vec<ledger_store::Extraction>,
/// The format upgrade the books wait for, if they do (plan 3.9, D-39):
/// read from their journal, as the server reads it.
pub format_upgrade: Option<ledger_store::FormatUpgrade>,
@@ -666,6 +669,7 @@ impl Books {
}
let statements = snap.statements(None).map_err(|e| e.to_string())?;
let reconciliations = snap.reconciliations(None).map_err(|e| e.to_string())?;
let extractions = snap.extractions().map_err(|e| e.to_string())?;
let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64);
let mut fresh = HashSet::new();
let closings = ledger.closing_entry_ids();
@@ -832,6 +836,7 @@ impl Books {
links,
statements,
reconciliations,
extractions,
format_upgrade: snap.journal_format().upgrade(),
};
books.parties = books.party_infos();
+102 -9
View File
@@ -18,6 +18,9 @@ use ledger_store::{Attachment, Subject};
#[derive(Default)]
pub struct State {
pub category: Option<String>,
/// One subject's files only (plan P6 step 6: grouped by category and
/// subject).
pub subject: Option<Subject>,
pub show_withdrawn: bool,
pub query: String,
}
@@ -72,6 +75,38 @@ fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
st.category = Some(c.clone());
}
}
// One subject's files (plan P6 step 6): every subject any
// document is evidence of, and the company's own.
let mut subjects: Vec<Subject> = Vec::new();
for a in &b.attachments {
if let Some(e) = a.entry_id {
subjects.push(Subject::Entry(e));
}
subjects.extend(b.links_of(a.id));
}
subjects.sort();
subjects.dedup();
egui::ComboBox::from_id_salt("documents-subject")
.selected_text(match st.subject {
None => "Any subject".to_string(),
Some(s) => b.subject_label(s),
})
.show_ui(ui, |ui| {
if ui
.selectable_label(st.subject.is_none(), "Any subject")
.clicked()
{
st.subject = None;
}
for s in subjects {
if ui
.selectable_label(st.subject == Some(s), b.subject_label(s))
.clicked()
{
st.subject = Some(s);
}
}
});
ui.checkbox(&mut st.show_withdrawn, "Show withdrawn");
});
ui.add_space(12.0);
@@ -82,6 +117,12 @@ fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
.rev()
.filter(|a| st.show_withdrawn || a.withdrawn_at.is_none())
.filter(|a| st.category.as_ref().is_none_or(|c| &a.category == c))
.filter(|a| match st.subject {
None => true,
Some(s) => {
a.entry_id.map(Subject::Entry) == Some(s) || b.links_of(a.id).contains(&s)
}
})
.filter(|a| {
q.is_empty()
|| a.file_name.to_lowercase().contains(&q)
@@ -101,16 +142,34 @@ fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
let tile_w =
((ui.available_width() - 16.0 * (per_row as f32 - 1.0)) / per_row as f32).min(280.0);
let mut go = None;
for row in docs.chunks(per_row) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 16.0;
for a in row {
if tile(ui, b, a, tile_w, selected == Some(a.id), shell).clicked() {
go = Some(a.id);
// Grouped by category unless one is chosen (plan P6 step 6): a
// heading, then its files; the subject filter narrows each group.
let by_category = st.category.is_none();
let mut groups: std::collections::BTreeMap<String, Vec<&Attachment>> =
std::collections::BTreeMap::new();
for a in docs {
groups.entry(a.category.clone()).or_default().push(a);
}
for (category, files) in groups {
if by_category {
ui.label(
RichText::new(fmt::label(&category))
.font(theme::semibold(12.5))
.color(pal(ui.ctx()).muted),
);
ui.add_space(6.0);
}
for row in files.chunks(per_row) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 16.0;
for a in row {
if tile(ui, b, a, tile_w, selected == Some(a.id), shell).clicked() {
go = Some(a.id);
}
}
}
});
ui.add_space(16.0);
});
ui.add_space(16.0);
}
}
if let Some(id) = go {
shell.go(Route::Document(id));
@@ -625,6 +684,40 @@ mod tests {
use super::*;
use crate::test_support::{Build, d, find, render};
/// The list groups by category and offers one subject's files (plan P6
/// step 6).
#[test]
fn documents_group_by_category_and_subject() {
let mut b = Build::new("documents-groups");
let (cash, capital) = (b.cash, b.capital);
let e = b.post(d(2026, 1, 2), cash, capital, 100_000);
let (accounting, _, _) =
b.s.file_document(
"accounting",
"ledger.pdf",
"application/pdf",
b"%PDF ledger",
None,
)
.unwrap();
b.s.link_attachment(accounting, ledger_store::Subject::Entry(e))
.unwrap();
b.s.file_document(
"notices",
"notice.pdf",
"application/pdf",
b"%PDF notice",
None,
)
.unwrap();
let (_dir, books) = b.books();
let (texts, _) = render(books, Route::Documents, |_| {});
for group in ["Receipts", "Accounting", "Notices"] {
find(&texts, group);
}
find(&texts, "Any subject");
}
/// A document's page lists what it is linked to: an entry with its date
/// and amount.
#[test]
+55 -1
View File
@@ -391,9 +391,63 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
}
"attachment.withdrawn" => ev(
icon::ARCHIVE,
format!("Withdrew {}: {}", attachment_name(b, att), s(p, "reason")),
format!(
"Withdrew {}{}",
attachment_name(b, att),
match p.get("duplicate_of").and_then(|v| v.as_u64()) {
Some(first) => format!(", a duplicate of #{first}: {}", s(p, "reason")),
None => format!(": {}", s(p, "reason")),
}
),
Some(Route::Document(att)),
),
"attachment.text_extracted" => ev(
icon::FILE_TEXT,
format!(
"Read {}: {}",
attachment_name(b, att),
match s(p, "status") {
"ok" => "its text".to_string(),
"none" => "no text to read".to_string(),
_ => "the reader failed".to_string(),
}
),
Some(Route::Document(att)),
),
"attachment.annotated" => ev(
icon::PENCIL_SIMPLE,
match p
.get("fields")
.and_then(|f| f.get("receipt_number"))
.and_then(|v| v.as_u64())
{
Some(n) => format!("Annotated {} as receipt {n}", attachment_name(b, att)),
None => format!("Annotated {}", attachment_name(b, att)),
},
Some(Route::Document(att)),
),
"entry.settles" => {
let settles: Vec<String> = p
.get("settles")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_u64())
.map(|e| format!("#{e}"))
.collect()
})
.unwrap_or_default();
let text = if settles.is_empty() {
format!("Cleared what #{} settles", entry_id.unwrap_or(0))
} else {
format!(
"Entry #{} settles {}",
entry_id.unwrap_or(0),
settles.join(", ")
)
};
ev(icon::CHECK, text, entry_id.map(Route::Entry))
}
"account.added" => ev(
icon::PLUS_CIRCLE,
format!("Added account {} {}", s(p, "code"), s(p, "name")),
+31 -65
View File
@@ -1,7 +1,7 @@
//! Rendering receipts and documents to textures, off the UI thread.
//!
//! PDFs are rasterized with `hayro` (pure Rust), images decoded with
//! `image`; Markdown and plain text are read as UTF-8 and drawn as text
//! PDFs are rasterized by the one extractor in `ledger-import` (pure Rust,
//! plan P6), images decoded with `image`; Markdown and plain text are read as UTF-8 and drawn as text
//! (Markdown by `egui_commonmark`), so they follow the theme and select
//! like any text. Results are cached by blob hash: the same bytes never render
//! twice, and a hash is the content, so the cache never goes stale. Bytes
@@ -61,6 +61,10 @@ pub const MAX_SIDE: f32 = 8192.0;
/// lines.
const THUMB_TEXT_BYTES: usize = 2048;
/// A page shown in full is rendered at most this many pixels on its
/// longest side: a letter page at twice its points (612 x 792).
const PAGE_PX: u32 = 1584;
/// Where the live viewer keeps thumbnails: the user's cache directory
/// (`$XDG_CACHE_HOME`, else `~/.cache`), `wanderbooks/thumbs/v1-560`;
/// the version and width name how they were drawn, so a change in either
@@ -322,23 +326,27 @@ fn render_thumb(job: &Job, dir: Option<&std::path::Path>) -> Result<Rendered, St
return Ok(Rendered::Pages(vec![to_color_image(img.to_rgba8())]));
}
let img = if job.mime == "application/pdf" || bytes.starts_with(b"%PDF") {
let png = pdf_first_page_png(bytes)?;
// A page rendered twice the tile's width, then cut down here: the
// same pixels a picture is, and one extractor for every reader
// (plan P6).
let png = ledger_import::extract::page_png(&bytes, 0, (THUMB_PX * 2.0) as u32)
.map_err(|e| e.to_string())?;
image::load_from_memory(&png).map_err(|e| e.to_string())?
} else if job.mime.starts_with("image/") {
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
if img.width() as f32 > THUMB_PX {
let h = (img.height() as f32 * THUMB_PX / img.width() as f32).max(1.0);
img.resize_exact(
THUMB_PX as u32,
h as u32,
image::imageops::FilterType::Triangle,
)
} else {
img
}
image::load_from_memory(&bytes).map_err(|e| e.to_string())?
} else {
return Err(format!("{} can't be previewed", job.mime));
};
let img = if img.width() as f32 > THUMB_PX {
let h = (img.height() as f32 * THUMB_PX / img.width() as f32).max(1.0);
img.resize_exact(
THUMB_PX as u32,
h as u32,
image::imageops::FilterType::Triangle,
)
} else {
img
};
let img = img.crop_imm(0, 0, img.width(), img.height().min(THUMB_MAX_H));
if let Some(path) = &kept {
let mut png = Vec::new();
@@ -385,43 +393,13 @@ fn keep(path: &std::path::Path, png: &[u8]) {
}
}
/// A PDF's first page as a PNG, [`THUMB_PX`] wide.
fn pdf_first_page_png(bytes: Vec<u8>) -> Result<Vec<u8>, String> {
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{PixmapSettings, RenderCache, RenderSettings, render};
let pdf = Pdf::new(bytes).map_err(|e| format!("not a readable PDF: {e:?}"))?;
let pages = pdf.pages();
let page = pages.first().ok_or("the PDF has no pages")?;
let (w, h) = page.render_dimensions();
// THUMB_PX wide, but never past a texture's side on a long page (the
// picture is cut to THUMB_MAX_H after), and never nothing.
let scale = (THUMB_PX / w.max(1.0))
.min(MAX_SIDE / h.max(1.0))
.max(1.0 / w.max(h).max(1.0));
let px = PixmapSettings {
x_scale: scale,
y_scale: scale,
bg_color: WHITE,
};
let pixmap = render(
page,
&RenderCache::new(),
&InterpreterSettings::default(),
&RenderSettings::default(),
&px,
);
pixmap.into_png().map_err(|e| format!("{e:?}"))
}
fn render(job: &Job) -> Result<Rendered, String> {
let bytes = BlobStore::open_read_only(&job.root)
.get(&job.sha)
.map_err(|e| e.to_string())?;
let is_pdf = job.mime == "application/pdf" || bytes.starts_with(b"%PDF");
if is_pdf {
render_pdf(bytes).map(Rendered::Pages)
render_pdf(&bytes).map(Rendered::Pages)
} else if job.mime.starts_with("image/") {
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
// Past a texture's side, scaled down to fit (aspect kept).
@@ -478,27 +456,15 @@ pub fn text_of(bytes: Vec<u8>) -> Result<String, String> {
Ok(text)
}
fn render_pdf(bytes: Vec<u8>) -> Result<Vec<ColorImage>, String> {
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{PixmapSettings, RenderCache, RenderSettings, render};
let pdf = Pdf::new(bytes).map_err(|e| format!("not a readable PDF: {e:?}"))?;
let cache = RenderCache::new();
let interp = InterpreterSettings::default();
let settings = RenderSettings::default();
/// A PDF's pages, in order, through the one extractor (`ledger-import`,
/// plan P6): each at most [`PAGE_PX`] pixels on its longest side, at most
/// [`ledger_import::extract::MAX_PAGES`] pages.
fn render_pdf(bytes: &[u8]) -> Result<Vec<ColorImage>, String> {
let pages = ledger_import::extract::page_count(bytes).map_err(|e| e.to_string())?;
let mut out = Vec::new();
for page in pdf.pages().iter().take(30) {
// Twice the page's size, but never past a texture's side.
let (w, h) = page.render_dimensions();
let scale = 2.0f32.min(MAX_SIDE / w.max(h).max(1.0));
let px = PixmapSettings {
x_scale: scale,
y_scale: scale,
bg_color: WHITE,
};
let pixmap = render(page, &cache, &interp, &settings, &px);
let png = pixmap.into_png().map_err(|e| format!("{e:?}"))?;
for page in 0..pages.min(ledger_import::extract::MAX_PAGES) {
let png =
ledger_import::extract::page_png(bytes, page, PAGE_PX).map_err(|e| e.to_string())?;
let img = image::load_from_memory(&png).map_err(|e| e.to_string())?;
out.push(to_color_image(img.to_rgba8()));
}