Acceptance run: real pages in, every level back out (2026-09-27)
This run took a few pages of each hard shape from ~/Fichero Test Corpus/ and put them into
one new library, ~/Fichero Test Library/Acceptance 2026-09-27.fichero. They went in through
the real folder import (#5132). The run then read every page back through the engine’s public
surface, exported each page in its original format, and timed the main operations. The engine
ran from this branch’s source on its own socket (/tmp/fichero-acceptance.sock), with its
own state directory and key file, so it shared nothing with the maintainer’s app. It was stopped
at the end of every step.
The scripts are in scripts/acceptance/. _engine.py starts the engine and always stops it,
and the engine also exits if the script dies (FICHERO_PARENT_PID). The steps are
create_library.py, import_corpus.py, readback.py, mcp_check.py, export_check.py
and speed.py. source_truth.py reads the source files with plain lxml, not with the engine’s
own readers, so the expected side of every comparison does not depend on the code under test.
Results go to acceptance-out/ (or ACCEPTANCE_OUT), which is not committed.
Import
Each folder was staged (a few pages copied), then dropped with POST /api/ingest/folder
(copy mode, recursive), and the run polled GET /api/ingest/status/{id} until it finished,
the way the app does. TEI and YOLO labels do not pair with an image by design. They went in
next through the one-file import (fichero import page, POST /api/documents/{id}/import).
| Shape | Folder | Pages | Pages with a pass | Segments | Time |
|---|---|---|---|---|---|
| Right-to-left, PAGE | Syriac, Vienna Cod. Syr. 1 | 3 | 3 | 62 | 0.53 s |
| Right-to-left, ALTO | Hebrew, BiblIA | 2 | 2 | 98 | 6.98 s |
| Vertical Chinese | Chinese, classical (CHI-KNOW-PO) | 2 | 2 | 56 | 1.67 s |
| TEI facsimile | Tale of Genji | 2 | 0 | 0 | 5.01 s (TEI refused, see below) |
| MUFI private use | Clm 13027 | 2 | 2 | 500 | 1.11 s |
| Interlinear glosses | Eutyches | 2 | 2 | 299 | 1.29 s |
| Table (TableRegion/TableCell) | Reichsanzeiger | 2 | 0 | 0 | 0.56 s (images refused, see below) |
| Table (TableRegion) | USS Albatross logbook | 1 | 1 | 759 | 0.88 s |
| ALTO in mm10 | Padeřov Bible | 2 | 2 | 346 | 2.07 s |
| ALTO in inch1200, densest page | Cherokee Phoenix p. 2 | 1 | 1 | 4,525 | 5.95 s |
| Line language tags | Rule of St Benedict | 2 | 2 | 72 | 1.69 s |
| YOLO labels | YALTAi SegmOnto | 2 | 2 (ALTO + YOLO) | 22 | 1.66 s |
Every task ended completed, including Reichsanzeiger, where both images were refused and
neither page got a pass.
Read-back
Segments were matched to source elements by level and position. The engine keeps no source
element id (see the defects). A text counts as exact when the engine’s reading is == the
source string, which after UTF-8 means byte for byte. Across all pages that went in,
every one of 4,997 segment texts checked was exact. That covers 100 Syriac lines and Hebrew
words with right-to-left characters, 94 words with MUFI private-use characters, 347 texts with
combining marks, and all 3,910 words of the Cherokee page. Every
segment count matched the source at every level, and every polygon survived.
| Page set | Regions | Lines | Words | Glyphs | Baselines | Language / script / direction | Nesting | Order |
|---|---|---|---|---|---|---|---|---|
| Syriac (PAGE) | pass | pass | none in source | none | pass | FAIL (block direction ltr) |
pass | FAIL (1 step back on 2 of 3 pages) |
| Hebrew (ALTO) | pass | pass | pass | none | FAIL (lost) | FAIL (block direction ltr) |
not readable | pass |
| Chinese vertical (PAGE) | pass | pass | none | none | pass | none in source | pass | FAIL (scrambled) |
| Clm 13027 MUFI (ALTO) | pass | pass | pass | none | FAIL (lost) | none in source | not readable | FAIL (columns interleaved) |
| Eutyches (ALTO) | pass | pass | pass | none | FAIL (lost) | none in source | not readable | FAIL |
| Albatross table (PAGE) | pass (2 tables, 436 cells) | pass | none | none | pass | none in source | pass | FAIL (112 steps back) |
| Padeřov mm10 (ALTO) | pass | pass | pass | none | FAIL (lost) | none in source | not readable | FAIL on one page |
| Cherokee inch1200 (ALTO) | pass | pass | pass (3,910) | none | none in source | none in source | not readable | FAIL (1,243 steps back) |
| Benedict (ALTO) | pass | pass | pass | none | FAIL (lost) | FAIL (line types dropped) | not readable | pass |
| YOLO SegmOnto | FAIL (drop capital read as word) |
none | none | none | none | none | none | none |
| Genji TEI, Reichsanzeiger | not imported |
No page in the corpus that went in has glyph-level shapes. None of the files state
primaryLanguage, primaryScript, readingDirection or ALTO LANG on a segment, so
language and script per segment could only be checked as “nothing invented”, and nothing was.
“Not readable” under nesting means the public surface does not expose a segment’s parent (see
the defects). “Order” compares the spans of the derived page text
(GET /api/segments/document/{id}/text) with the file’s own order, and counts each step that
goes backwards.
Through the CLI, every level can be reached. segments list-document gives the shapes,
segments list-readings the exact text of one segment, segments get-document-text the page,
and reading-orders the orders. Through MCP, fichero_segments and fichero_segment give
the shapes, but no tool returns a segment’s text or the page’s text. An agent can list 4,525
segments and read none of them, except by exporting the whole page as XML.
Export
Every page that has a pass exported in its original format, and every export passed
scripts/validate_exports.py’s schema check: 19 of 19 (PAGE 2019 or ALTO 4.4). Across all
levels, the set of texts in each export equals the set that went in. These fail:
| Check | Result |
|---|---|
| Texts in the same order as the input | FAIL on every ALTO page’s words and on every PAGE page’s lines (the export follows the same derived order as the text route) |
| Region membership | FAIL on PAGE: Syriac 2-3 regions became 4-16, Chinese 3 became 16, Albatross 438 became 587. Lines are placed in invented fichero {implicit:true;} regions, and the Chinese table-of-contents lines end up under the wrong region. |
| ALTO baselines | lost on every ALTO page (the reader never reads BASELINE, and the writer does not write it) |
| PAGE baselines | pass |
| Reichsanzeiger, Genji | nothing to export (404, “no pass to export”) |
Speed
The machine was a MacBookPro17,1 (M1) on macOS 27.0, running the engine from source and measured through the CLI’s own client over the Unix socket. Each figure is 5 measured runs after 1 discarded cold run. The 1-minute load is given; other lanes kept the machine at 10-140 throughout. The segment totals come from the library’s 6,739 segments.
| Operation | Median | Max | Load | Target |
|---|---|---|---|---|
| Folder import, densest page (4,525 segments, one run) | 5.95 s | — | ~20 | none |
Pass import of the densest page’s ALTO (format.import) |
9.77 s (2.16 s per 1,000 segments) | 14.10 s | 78.6 | none |
| List a document’s segments, densest page | 381 ms | 797 ms | 14.8 | none |
| List a document’s segments, 20-segment page | 20 ms | 61 ms | 14.8 | none |
GET /api/segments, library scope, first 200 |
59 ms | 64 ms | 14.8 | none |
GET /api/segments, library scope, last 1,000 |
146 ms | 215 ms | 14.8 | none |
GET /api/segments, whole library paged by 1,000 |
583 ms | 605 ms | 14.3 | none |
| Read one line’s text (readings route) | 4.3 ms | 4.6 ms | 14.3 | 100 ms edit budget (reference only) |
| Read one word’s text on the densest page | 4.4 ms | 4.5 ms | 14.3 | same |
| Derive the densest page’s whole text | 576 ms | 745 ms | 13.8 | see below |
| Export the densest page as ALTO | 4.73 s | 5.04 s | 11.1 | none |
| Read one line’s text via the CLI process | 2.74 s | 3.04 s | 9.3 | none |
Slice 12 (source.perf.*) sets two numeric budgets: 16.7 ms per frame, which is for the app
and cannot be measured here, and 100 ms for an engine edit and its undo
(engine-edit-undo). Nothing in this run is an edit. The only engine read the spec measures is
the derived page text, recorded at about 80-100 ms warm for its generated 20,000-shape fixture.
Here the real 4,525-shape Cherokee page took 576 ms at load 14, about six times as long for a
quarter of the shapes. That difference is worth a trial of its own. The fixture’s shapes are
mostly words and characters that carry no reading of their own, while every word on the
Cherokee page carries one. Import, listing, scoped listing and export have no target to compare
with. The CLI’s own process start (about 2.7 s) dominates any single CLI read.
Defects
Each defect below is one line: the call, what came back, and what was expected.
POST /api/ingest/folderon the Reichsanzeiger pages: the task reportscompleted, but each image fails with “Image too large for ingest (9632x6648 pixels)” (the limit is 50 MP), and its PAGE file becomes no pass. Expected: a real 64 MP newspaper scan imported, or at least a task that does not saycompletedwhile its pages are empty.GET /api/ingest/status/{id}: never carries the pairing report (imported_as_passes,unpaired,not_imported). A layout file that did not become a pass is only logged in the engine. Expected: the report in the task status, so the app and the CLI can name the file.fichero import page <R0000022> kouigenji-01-kiritsubo.tei.xmlreturns 422 “segments lie outside the page … l3, l4, l5, l6”, but the TEI lines have no position at all. Their place is the<zone>named by the preceding<pb corresp>. Expected: lines placed in their zone, or a refusal that says they have no geometry.fichero import page <000.jpg> 000.txt --format yolo:classes.txtbeside the labels is not sent. Class 2 (DropCapitalZone) is stored as kindword, and the class name is kept nowhere (kind_rawnull). Expected: the class names used, or the import refused without them.- ALTO import drops
TextLine@BASELINE, both the space form and the comma form: 0 of 647 baselines on Clm, Hebrew, Eutyches, Padeřov and Benedict. The export writes none either. Expected: baselines kept, as on PAGE. - ALTO import drops
TAGREFSblock and line types (SegmOnto MainZone, DropCapitalZone, Benedict’s LatinLine and EnglishLine).kind_rawis null andmetadatais empty. Expected: them kept, at least askind_rawor foreign. GET /api/segments/document/{id}/texton Syriac and Hebrew pages returns every block’sdirectionasltr. Expected:rtlderived from the script when the file states nothing.GET /api/segments/document/{id}/texton the two-column Clm 13027 38r interleaves the columns line by line (source indices 0, 61, 1, 2, 62, …). The same happens on the Cherokee page (2,234 blocks for 600 lines, 1,243 backward steps). Expected: the file’s block, line and word order, which the engine itself namesas-written.- The same route on the vertical Chinese pages returns lines in a scrambled order (0, 1, 21, 7, 11, 17, 5, …). Expected: the file’s right-to-left column order.
GET /api/documents/{id}/export/pagexmlon the Syriac, Chinese and Albatross pages inventsfichero {implicit:true;}regions for lines that had a region in the source (for example, Syriac 3 regions became 15). Lines end up in the wrong region. Expected: each line written inside the region it was imported in.GET /api/segments/document/{id},GET /api/segments,GET /api/segments/{id}and the MCPfichero_segments/fichero_segmenttools all returntext: nullfor every real segment, and no parent (parent_segment_idis not in the response). Expected: the text and parent a caller needs, without one readings request per segment.- MCP has no tool for a segment’s readings, the page text, the reading orders or the scoped
GET /api/segments. Expected: an agent can read the text it can already see the shapes of. - The engine keeps no source element id: the PAGE
@idand ALTO@IDare not inmetadata. Expected: kept, so a segment can be traced back to the file it came from. - Any request carrying
X-Fichero-Library-Path(the first wasGET /api/health) creates the named package and its missing parent folders on disk. Expected: onlyPOST /api/librarycreates a library. FicheroClient(library_path="")is documented as “explicit no library”, butlibrary_path or os.environ[...]turns""back into the environment’s path. Expected: the empty string honoured.
Re-run after the fixes (2026-09-27, evening)
The same selection and scripts, from integration at fbc059708, into a FRESH library,
~/Fichero Test Library/Acceptance 2026-09-27b.fichero. The first run’s library was not
touched: _engine.py now takes ACCEPTANCE_LIBRARY (a package name under the test-library
folder), and create_library.py refuses an existing package rather than an existing folder.
The steps run were create_library, import_corpus, readback, mcp_check, dump, export_check and
validate_exports.py over the exports. speed.py was not run, because the machine’s load stood
at 40-140 and its timings would have been void.
Every page that went in reads back exactly: 4,997 of 4,997 segment texts are exact, every
count matches at every level, and every one of the 1,072 stated baselines on those pages
survives (the other 1,323 in the source files are on the Reichsanzeiger pages, which did not go
in). Checked through each segment’s parent_segment_id against plain lxml’s parents, nesting is
right for all 6,229 child segments, with none wrong. The 19 exports all validate
(validate_exports.py: 19 exported, 0 invalid), and none writes an invented region.
| # | Defect | Now | Evidence from the re-run | Fixed by |
|---|---|---|---|---|
| 1 | Folder import completed while its scans were refused |
FIXED (reporting) | Reichsanzeiger: task failed, error names both scans (9448x6520, 9632x6648) and both PAGE files left out. Importing a 64 MP scan is still refused; downscaling or raising the cap is with the maintainer. |
447fe14b1 |
| 2 | No pairing report in the task status | FIXED | status carries imported_as_passes, not_imported, unpaired |
447fe14b1 |
| 3 | Genji TEI refused (“outside the page”) | FIXED | the Genji folder drop pairs its pages with its scans: R0000022 gets page 1 (14 lines), R0000023 pages 2 and 3 (28 lines), each line anchored in its zone (left or right half). A later one-file import of the same file is a 409, because it is already there. | 8fb3c9ef1, e229e4aad, e3680c831, 1bf7fe4b3 |
| 4 | YOLO class 2 stored as word, no class name |
FIXED | kind_raw DropCapitalZone, MainZone, RunningTitleZone; classes.txt sent with the labels |
d3a08c01f, 7428fc536 |
| 5 | ALTO baselines dropped, on import and export | FIXED | 647 of 647 on the five ALTO page sets, kept and written back (BASELINE on every exported TextLine) |
d3a08c01f |
| 6 | ALTO TAGREFS types dropped | FIXED | kind_raw MainZone, DefaultLine, InterlinearLine, LatinLine, MarginTextZone |
d3a08c01f |
| 7 | Syriac and Hebrew blocks ltr |
FIXED | Hebrew: every block rtl; Syriac: the text blocks rtl, the numbering zone (digits) ltr |
62c62fbbd |
| 8 | Clm columns interleaved; Cherokee out of order | FIXED | 0 backward steps on Clm 38r and 41v; Cherokee 0 backward steps in 3,910 spans, 600 blocks for 600 lines (was 2,234 and 1,243) | 62c62fbbd, fdfdc8fae, 76953b774 |
| 9 | Vertical Chinese lines scrambled | FIXED | 0 backward steps on both pages | 62c62fbbd |
| 10 | PAGE export invents implicit regions |
FIXED | Syriac 3, 2, 2, Chinese 3, 4, Albatross 438 regions written, as in the source; implicit:true appears 0 times |
d3a08c01f |
| 11 | Segment lists text: null, no parent |
FIXED | every text-bearing segment has its text (a line whose text lives in its words, and a region, stay null); parent_segment_id on every segment |
b16db7917 |
| 12 | MCP cannot read text | FIXED | fichero_segment_readings, fichero_document_text, fichero_reading_orders, fichero_reading_order_entries, fichero_segments_in_scope |
7765cbf7c |
| 13 | No source element id | FIXED | metadata.source_id on every segment whose element has an id; ALTO Strings in these files carry none |
d3a08c01f |
| 14 | A library header creates the package | FIXED | GET /api/health naming a missing package answers unhealthy (“Library does not exist …”); GET /api/documents answers 404; nothing on disk, parent folder included |
0d777f05a |
| 15 | FicheroClient(library_path="") falls back to the env |
FIXED | library_path == "" with FICHERO_LIBRARY_PATH set |
0d777f05a |
New, filed: #5145. The Albatross page reads its text region first. The file’s
<ReadingOrder> names only that region, at index 2, and its two tables carry index 0 and 1 in
custom="readingOrder {index:n;}". That leaves one backward step in the page text, and the PAGE
export writes its lines in that order. It is the only order problem left on these pages.
Not defects, noted:
* The vertical Chinese and Genji blocks report ltr. The files state no direction, and the
engine’s ruling (language_policy._MAYBE_VERTICAL_SCRIPTS) resolves a script that may be
vertical to ltr rather than guessing.
* Two of the harness’s own checks are now out of date. readback.py’s nesting check maps words
to regions through the text route’s blocks, and the blocks are now one per line, so it reports
0 for words. Nesting checked through parent_segment_id is 100% right. import_corpus.py’s
one-file TEI import after the folder drop gets a 409, because the folder drop now imports the
file itself. dump.py could not write into a fresh ACCEPTANCE_OUT; save() now makes the
folder.
Directions in the Reader (2026-09-27, night)
The Reader now lays each page out in its own direction (2eba3a6dd, #5147 Reader half). An rtl
page gets dir="rtl" and a ttb page gets writing-mode: vertical-rl. A run in the other
horizontal direction becomes a <span dir> isolate, and the Unicode bidi algorithm orders the
text inside a line. A line move and its ⌘Z patch the one page in place instead of reloading it
(8e891c1ec, #5170).
Each file below is a real page from the Fichero Test Corpus, copied into a temporary library and
run through format.import with the scan size ingest records. The page’s own pageBodyMarkup was
cut from the served /view/document/{id} and run in node. “Map” is the set of directions in the
served line map; “body” is the page element the Reader draws.
| Case | File | Expected | Result |
|---|---|---|---|
| a. RTL page, Maghrebi Arabic | BULAC_MS_ARA_417_0003.xml |
rtl | PASS: map {rtl}, body dir="rtl", 13 lines |
| a. RTL page, Urdu | 7219_1668198.xml |
rtl | PASS: map {rtl}, dir="rtl" |
| a. RTL page, Ottoman Turkish | 2939_598100.xml |
rtl | PASS: map {rtl}, dir="rtl", 20 lines |
| b. Persian nastaliq with English on the page | 136_21220.xml |
rtl page; Herbert LLoyd, Chunar ltr isolates |
PASS for the English lines. The digit-only line 1773 also resolves ltr: FAIL, #5172 (it renders correctly, but it is reported as an English line) |
| b. LTR inside an RTL line (Hebrew line with a Latin letter) | btv1b10539358v.xml |
the line stays rtl; bidi orders the letter | PASS: map {rtl}, one text node, bidi orders the run |
| c. Arabic with interlinear Persian (two hands, both RTL) | 1461_184094.xml |
rtl throughout | PASS: map |
| c. Syriac with a Latin folio number | 0001_00000016.xml (Vienna Syr. 1) |
rtl page; 1v an ltr isolate |
PASS: body dir="rtl", one <span dir="ltr">1v |
| c. Latin with Hebrew | none | ltr page, Hebrew rtl isolate | NOT RUN: the corpus has no Latin page with Hebrew on it (the Eutyches and Notre-Dame pages have none). The isolate path is covered by the Syriac case in reverse |
| d. Aljamiado (Spanish in Arabic script) | BNE_MSS5301_Hadiz-de-los-dos-amigos.pdf_page_1.xml |
rtl, decided by the letters | PASS: its 17 text lines are rtl. Its digit-only lines 2 and 1 are ltr: #5172. The BNE_MSS:5302_Historias_* pages have no text in the file (every String has an empty CONTENT), so they show nothing |
| e. Chinese classical, vertical | BULAC_BIULO_CHI_1087_1_0068.xml |
ttb from the line shapes, vertical-rl | PASS: map {ttb}, body data-direction="ttb" |
| e. Genji, direction stated on the source | kouigenji-01-kiritsubo.tei.xml + source_setting.set direction=ttb |
ttb | FAIL, #5171: document_text blocks are ttb, but the served line map stays ltr, because the setting does not refresh the page-text cache |
| f. Mongolian, vertical-lr | none | vertical-lr | NOT RUN: the corpus has no Mongolian page. Model gap, #5173: ttb cannot say which way columns advance, so the Reader draws every ttb page vertical-rl and does not guess from the script |
| g. Caret and ⌥⌘↑ in an RTL block | BULAC_MS_ARA_417_0003.xml |
the caret’s line is the one lineMove names; after a real move the caret is back on it |
PASS: segmentAtOffset names the caret’s line, lineMoveMessage returns {segmentId, pageId, step: "up"}, and caretAfterMove lands on the moved line in the new offsets |
| g. Caret and ⌥⌘↑ in a vertical block | BULAC_BIULO_CHI_1087_1_0068.xml |
same | PASS, same three checks; map |
The caret and move checks are logical offsets and do not depend on direction. The DOM step (the
selection put back with selection.collapse) was not run in a browser here. Nothing in the
engine or in node exercises it, so it needs one look in the app.
Fixed after the run: #5171 in a156fd9fd (the Reader resolves each line’s direction at render, so the Genji
set to ttb is columns at once), and #5172 in b01e0eb47 and e7b608a01 (a line with no letters takes its block’s direction, else its
page’s, and the Reader’s map says which: the Persian 1773 is rtl from its block, the Aljamiado 2
and 1 and the Vienna Syriac 2 are rtl from the page; 1v and the English notes stay ltr).
In the repo, test_reader_directions.py pins the rtl page, the Syriac folio isolate, the page of
columns and the caret after a move (rtl and vertical) on the vendored fixture pages. All six tests
fail on the code before 2eba3a6dd and 8e891c1ec.