Agentic-Service-Data-Eyond-Catalog / docs /knowledge /KNOWLEDGE_PIPELINE_CALIBRATION.md
ishaq101's picture
/fix parsing and term extract (#21)
f07443e
|
Raw History Blame Contribute Delete
61.3 kB

Knowledge Pipeline β€” Calibration Reference

πŸ”„ LIVE β€” tuned constants and the reason behind each. Read before changing any constant in src/knowledge_extraction/settings.py.

Note (2026-09-02): the primary parser is now Mistral OCR (no GPU). The MinerU-specific parsing sections here (Β§10 quality, Β§12 render_latex, Β§13 backend choice) are historical as of 2026-09-07 β€” MinerU was dropped and is installed nowhere, so those sections record how the choice was made rather than a path you can run; the extraction constants (GLiNER, clustering, ranking, thresholds) are unaffected and current. See eval/parsing/README.md.

Harvested from the kex prototype, 2026-08-19. Every number below cost a run, a bug, or a measurement to find, and none of it is in the spec β€” it lived only as constants and comments in a codebase that is not being ported.

This file exists because clean rewrites drop accidental knowledge and then rediscover it as production defects. When building extraction v2, take the value and the reason. If you change one, change it deliberately and record why.

Companions: KNOWLEDGE_PIPELINE_TODO.md (plan) Β· eval/knowledge/ (gold set, scorer, frozen baseline) Β· knowledge_pipeline_context.md (intent).


1. Term filter β€” GLiNER

Constant Value Why this value
Model urchade/gliner_multi-v2.1 CPU, ~400 MB. Cheap enough to run unconditionally on every chunk, which is what lets us reject a standalone relevance gate
Score threshold 0.25 Not 0.35. The sweep measured recall 0.854 @ 0.25 vs 0.658 @ 0.35 on the broad label set. Precision falls (0.41 vs 0.50) and that is accepted: the filter is meant to be over-inclusive, clustering and evidence ranking absorb the noise, and a term the filter never proposes can never be recovered downstream
Label variant broad Two earlier variants both missed the same class β€” mining activities and materials (coal mining, land clearing, waste removal, joint survey). broad adds mining activity, material or commodity, data source or system, work category, analysis method to cover it
Window size 130 words, overlap 30 (re-confirmed 2026-09-10: 256 truncates 46 of 99 Open Pit windows, 180 truncates 4; 130 is the only size measured safe on both English documents. One BUMA window exceeds the cap at EVERY size β€” pre-existing) Not 220. GLiNER truncates past ~384 of its own tokens and warns rather than failing β€” silently dropping the tail of every long chunk. Indonesian technical prose subword-tokenises at roughly 2.5Γ—, so a 220-word window still tripped the cap. Chunks are fed as overlapping word windows with offsets remapped
Span token cap 12 (GLiNER's own) Compound terms ("UA plan grouping (composite) actual") exceed it. Candidates that hit the cap are flagged, not silently truncated, so a recall shortfall can be attributed to the cap

Label sets (labels.yaml) β€” three variants were tried and A/B'd without touching code. Keep that property: label phrasing is the main recall lever and GLiNER is very sensitive to it. The Indonesian-phrasing variant (istilah produksi tambang, …) did not win; broad did.


2. Clustering

Constant Value Why this value
Match order exact (normalised) β†’ abbreviation↔expansion β†’ fuzzy Cheapest and most certain first
Fuzzy threshold 92 β›” RETIRED 2026-09-10. token_set_ratio returns exactly 100 when one token set is a SUBSET of the other, so this threshold was inert and a head noun always absorbed its compounds. 131 of 140 merges were subset matches; the 6 correct ones were plurals, now handled by normalize(lemma=True). The pass is commented out at its call site; _fuzzy_match is parked. Subset merges went to 0 on all three test documents
Fuzzy disabled below 5 characters min(len(a), len(b)) < 5 β†’ skip The one to not lose. PA vs UA scores high on token_set_ratio. Below 5 chars only exact matching is allowed
Canonical surface shortest variant β‰₯ 2 chars That is how a reader looks a term up β€” PA, not Physical Availability (PA) untuk …

Over-merging is worse than under-merging, and the asymmetry is not close. An under-merge costs one extra LLM call and one extra review row. A wrong merge silently destroys a distinct term and the expert never sees it β€” there is no downstream mechanism that recovers it.

Legend extraction must run before clustering. Without the abbreviation index, PA and Physical Availability cluster as two unrelated terms.

Stop surfaces β€” dropped as whole surface forms only (never as substrings, so no term is lost): unit Β· type Β· class Β· equipment Β· equipment unit Β· parameter Β· activity Β· data Β· nilai Β· proses Β· hasil Β· total.

Normalisation (clustering only, never for span checking): NFKC β†’ casefold β†’ -/_ β†’ space β†’ strip .’' β†’ non-word to space β†’ collapse whitespace β†’ strip ()/.


3. Evidence ranking

Six signals plus one penalty. The full ranked list is retained, not just the top K β€” escalation needs the tail.

Signal Weight
Definitional cue within 100 chars of the mention +5.0
Term appears in the chunk heading +4.0
Mention sits inside a legend block +3.5
Chunk contains a formula +2.0
Mention is bold/italic +1.5
First occurrence of the cluster +1.0
Chunk is tabular βˆ’3.0
Constant Value Why
evidence_k 5 (raised from 3, 2026-09-10) Chunks fed per extraction round
Cue proximity 100 chars Distance from mention to a definitional cue
evidence_link_partners 1 (added 2026-09-11) Linked partner chunks pulled into the window on top of the ranked K

Linked partners (2026-09-11)

Schema 0.4.0 pairs a table chunk with the prose that introduces it, via referenced_by in both directions. top_k now pulls at most one partner into the evidence window.

Why it is not redundant β€” the measurement that decided it. Ranking only, on the three v2 artifacts and their recorded filter payloads (free; no GLiNER, no spend):

clusters top-5 touches a linked pair both sides already there only one side
BUMA 83 31 0 31
Komatsu 93 55 0 55
Open Pit 427 70 0 70

Zero clusters out of 603 saw both sides. The ranker never co-selects a table and the prose that names it β€” and that prose is frequently the table's only title ("Troubleshooting of engine (S-mode)" above a bare S-1 | 6 grid). Cost at the cap: evidence read +14.5% / +24.5% / +9.9%, which is +1.6% / +4.1% / +4.2% of total prompt spend; Open Pit moves 71% β†’ ~74% of the 2M ceiling.

Two placement rules, both load-bearing. The expansion lives in top_k, not rank_evidence:

  1. The ranked list is never mutated, so everything reading evidence_chunk_ids sees the number it saw before.
  2. evidence_block (the prompt) and evidence_text (the span check) both take their ids from the same top_k call, so what the model reads is exactly what the span check searches stays true. Expanding in the prompt renderer instead would null every quote from a partner as unlocatable. Verified across all 603 clusters: 0 parity failures.

Scope, stated honestly. The document this helps most (Komatsu, 55 of 93 clusters) is the one that returns 0 definitions for genre reasons. This is groundwork for the table branch (Step 8) rather than a glossary win, and it has not been measured against a paid run β€” with this corpus's noise a single run could not have shown anything either way. Set EVIDENCE_LINK_PARTNERS = 0 to disable.

Heading matching is word-boundary, never substring. PA is a substring of Parameter, pada and composite β€” substring matching handed the +4.0 heading bonus to almost every Indonesian heading and pushed real definition sections below formula tables.

Why this filter is legitimate when the relevance gate was rejected: no term is dropped (only its evidence is narrowed), every mention stays in provenance so a bad top-3 is visible, and the full ranked list is retained so the tail stays reachable. The rejected gate had none of those three properties. Preserve all three if ranking is ever revisited. (The third property used to be phrased as "the escalation loop self-corrects"; escalation was retired on 2026-09-11 with MAX_ESCALATION_ROUNDS = 0, but retaining the tail is what made the property true and it still holds.)


4. Chunking

Chunking stays section-based. Page-based grouping was tested on 2026-09-02 and did not improve term recall β€” it lowered it, and the terms it recovered were offset by ones it lost to span-overlap competition. Full numbers and the reasoning in Β§7. Do not re-propose it without new evidence; the lever it was reaching for is in filters/span_filter.py, not here.

Constant Value Why
Max chunk tokens 1500 With paragraph-boundary splitting; sentence boundaries as fallback for a single over-budget paragraph
Max heading length 90 chars Longer lines are sentences or formula rows, not headings
Boilerplate frequency β‰₯ 0.6 of pages A line repeating on 60%+ of pages is a running header/footer. Detected by frequency with digits normalised to # so page counters collapse β€” never by a hardcoded document-specific string
Tabular detection short lines > 0.6 and numeric lines > 0.4 Feeds the βˆ’3.0 ranking penalty

Three things the real document forced, all of which corrupt every downstream number if missed:

  1. Running headers/footers β€” every page repeats the title block and "Confidential".
  2. Breadcrumb headings β€” pages re-print 2. PENJELASAN PARAMETER / 2.1. Production Parameter / … at the top. A naive splitter re-opens the section and shatters it. Rule used: a heading that is the current section or an ancestor of it, already seen, is a breadcrumb β€” the section continues and page_end extends.
  3. Colon-continuation β€” legend rows arrive as MOHH then : Machine on Hand Hours on the next line. Rejoined before the legend filter sees them.

Points 1 and 2 apply to MinerU output too: its title blocks repeat the same breadcrumbs on pages 2–8 of the standard.


5. Extraction (the paid stage)

Constant Value Why
Temperature 0.0 And still not deterministic β€” see below
Seed 7
Cache minimum 1024 prompt tokens OpenAI-family prompt caching does not engage at all below this. The glossary prefix is padded past it on purpose. Measured hit rate 54% β€” 125,184 of 155,313 prompt tokens
Structured output json_schema, fallback json_object + validate-retry Which mode was used is recorded in the run manifest
API version 2024-12-01-preview json_schema needs β‰₯ 2024-08-01-preview

Prompts live in files, not code, and the fixed prefix must stay byte-identical across calls β€” any drift and caching stops engaging, silently, at ~10Γ— the input cost.

temperature=0 is not determinism. Consecutive runs of the identical pipeline scored 0.75 and 0.625 on schema fill. Never report a single run as a measurement on a small base.


6. Validation

Constant Value Why
Span normalisation whitespace only (+ NFKC) Not case, not punctuation, not diacritics. Every additional normalisation is a hole a fabrication fits through
LaTeX normalisation notation-aware canonical form (+ NFKC) Whitespace removed entirely, LaTeX spacing commands (~, \,, \;, \quad, \ ) dropped, presentational wrappers (\mathrm{}, \text{}, \mathbf{}, \operatorname{}) unwrapped, \cdotβ†’\times, \%β†’%, \frac{A}{B}β†’frac(A)(B), remaining braces dropped. A different notion of equality, not a relaxation β€” the prompt asks the model to transcribe into LaTeX, which is a translation, so identity is the wrong test. Structure is preserved on purpose: dropping braces outright would make \frac{a}{bc} and \frac{ab}{c} normalise alike. Applies only to formula_latex against Chunk.latex β€” never to prose, where deleting whitespace would make β€œno data” match β€œnodata”
Escalation rounds max 2 Then keep the entry flagged no_definition_found and pass it to review anyway β€” a term we found but could not define is still useful; dropping it hides a known unknown
Conflict overlap threshold 0.4 token overlap Token overlap, not embeddings: cheaper, needs no model, and explainable to the reviewer who has to act on it
Duplicate overlap threshold 0.8 Above it, a differing definition is a duplicate; below, a conflict

Guarded fields (span-checked; failure sets the field to None and logs it):

Branch Fields
glossary definition, full_name, formula_latex, interpretation
rule statement, condition, consequence
formula formula_latex β€” checked against Chunk.latex, not text
summary title, purpose_verbatim β€” changed 2026-09-02, was (none)

If the provenance span itself is not verbatim, every guarded field on the entry is rejected β€” the entry's only evidence link is broken, so nothing on it can be trusted.

The summary branch became guardable by changing the question, not the model (2026-09-02). It was the one exception here: it asked for a summary, and a summary cannot be located in the source by construction, so guarding it would have rejected every correct answer. It now asks the model to locate the document's own statement of purpose β€” purpose became purpose_verbatim β€” and a quote is checkable like any other transcription. Both fields are in _TRANSCRIBED as well as GUARDED_FIELDS; the formula_latex story below is why that distinction matters. Measured on the reference document: 0 rejections on a real run, and a deliberately fabricated purpose is refused. This also weakens the case that the branch needs a larger tier (D3 / risk R5) β€” locating a sentence is a much smaller ask than composing one.

formula_latex is checked against the source markup, not the rendered text (2026-08-26). It was previously guarded in name only: it sat in GUARDED_FIELDS but not in _TRANSCRIBED, so the LaTeX value itself was never searched β€” only its provenance.span was. A structurally wrong transcription with a valid prose span passed, which is exactly the shape X21 produced (a \frac that lost its division reads as a product and states something the document does not).

The fix could not simply add the field to _TRANSCRIBED: text carries rendered prose and can never contain LaTeX, so that would have nulled every formula entry and presented as a model failure. Chunk.latex β€” which the parsing half has always emitted, and which extraction's adapter used to drop on the floor β€” now crosses the seam and is the haystack instead, compared with whitespace removed entirely (see the normalisation row above). Found by Sofhia; verified on the real MinerU shapes.

An artifact carrying no latex fails open, keeping the old provenance-only guard rather than rejecting. β€œCannot verify” must not present as β€œfailed verification” β€” the alternative silently voids every formula entry on every artifact written before this landed. The consequence to know: on such an artifact formula_latex carries the weaker guarantee, and nothing in the output says which one applied.

Identity was still the wrong test β€” corrected 2026-08-26. Sofhia ran the first version against real artifacts and found Production = MOHH \times Qty \times … rejected. Probing further: on a source that does carry \times, the guard also rejected a model that dropped \mathrm{}, used \cdot, or wrote idiomatic LaTeX β€” 4 of 5 plausible correct outputs. It passed only when the model mirrored MinerU's notation character for character. The first version's own passing test passed only because the fixture was hand-written to mirror the source.

That is the same category error as the original defect, one level up: first the haystack was the wrong format, then it was the wrong notion of correctness. The fix is the notation-aware form in the table above. It still catches X21 β€” a lost \frac is a structural difference, not a notational one.

The pipeline backend cannot express multiplication, and that is reported rather than guessed. pipeline writes Production = MOHH x Qty x PA β€” a bare letter x, no \times, no \mathrm, no signal at all (Β§12, Still open). A claim spelling out the operator is therefore unprovable rather than wrong, and takes the same fail-open path as an empty latex. Without that, every product formula on every pipeline artifact would be nulled silently and read as a bad model β€” and every artifact on Sofhia's machine is pipeline, because she has no GPU.

Resolving x to multiplication inside the guard was rejected: it would silently settle a decision Β§12 deliberately leaves open, and would let Ma\times Capacity match a source reading MaxCapacity. The narrow exception covers a missing operator only β€” a claim that also loses its division is still rejected, and there is a test for exactly that.

\mathrm{x} is treated as a multiplication sign, before the wrapper unwrap that would destroy the distinction. That is grounded: \mathrm{x}100\% is the real PA formula's tail in this document.

Measured against real markup, 2026-08-26. Sofhia supplied the same 11 equations as MinerU transcribed them under four backends (44 strings, committed at eval/knowledge/fixtures/latex_backends.json). Agreement with hybrid/high, the production backend:

Backend Whitespace-only Notation-aware After the evidence-led additions
vlm/medium 9/11 11/11 11/11
hybrid/medium β€” 10/11 11/11
pipeline 5/11 8/11 10/11

The additions came from the inventory, not from guesswork: \mathsf (equation 7) and \displaystyle (equation 4) occur in this corpus and were missing; the array alignment spec ({l} from one backend, {r} from another) and a trailing row separator before \end{array} are presentational. A separator BETWEEN rows stays structural. \mathbf, \mathit, \textrm and \operatorname were guesses and never occur β€” kept because they are the same class and cost nothing, but their presence is not evidence of anything.

The single residual, pipeline equation 1, is Production = MOHH x Qty x PA x UA x Pty: the operator is genuinely absent from the source, so it is the marker's job, not the normaliser's. It is reported unverified_operator, which is the designed behaviour.

X20 same-day A/B: it costs a rule (2026-08-27)

The first controlled comparison in this project. Arms alternated within one session so any drift hit both equally; arm B reverts X20's three edits and keeps X23's synthetic examples, so the only variable is X20. Rule branch only, 15 calls per run, 5 rounds each. Served by gpt-5.4-nano-2026-03-17.

Arm Runs Result
A β€” X20 in 5 5/14 every time
B β€” X20 reverted 5 6/14 every time

Zero variance in both arms, and exactly one rule differs: GAINLOSS_QTY_ZERO_PLAN, 0% with X20 and 100% without it. X20 is a net βˆ’1 as written.

The mechanism is identifiable, and it is X20's own wording. That gold rule's fragments are QtyP(M) = 0 and QtyA(M) > 0 β€” a genuine conditional whose CONDITION is written symbolically (β€œJika (Qty_P(M) = 0, Qty_A(M) > 0), maka: …”). X20 closes with β€œa statement that is mostly symbols, = and numbers is an equation”, which instructs the model to null precisely this. The heuristic cannot tell a computation from a symbolic condition.

And X20 is not buying what it was written to buy β€” today. GAINLOSS_UNITS, the rule it targets, fires 0% in BOTH arms. Yesterday it fired 6/7 with X20 in place. So X20's apparent win was the day, not the prompt (see the cross-day section below), and with today's model X20 has no measurable upside and one measurable cost.

REVERTED 2026-08-27, on Rifqi's decision. The carve-out idea (jika / apabila / maka β†’ a rule however symbolic its condition) was considered and not taken: X20 had a measured cost and no measured benefit, and refining it would spend more runs defending a change that has yet to earn any. The intent β€” stop the rule branch duplicating the formula branch β€” remains valid and is available to anyone who wants to attempt it properly.

The shipped prompt is byte-identical to arm B, verified by hash. That matters for provenance: the state now in main is the exact state measured at 6/14 across 5 same-day runs on gpt-5.4-nano-2026-03-17, not a reconstruction of it. No re-run was needed and none was done.

Scope of that number: arm B measured the rule branch only (15 calls). It does not carry E1, E2 or E3, and no full-run result file reflects the reverted prompt β€” every committed full run on 26–27 Aug had X20 in. Do not read one of those as the current configuration's baseline.

Cache floor survived the revert: rule.txt is 1097 estimated tokens, still clear of 1024. X20 was what first pushed it over, so this was worth checking rather than assuming.

Method note worth keeping: rule-branch-only runs cost 15 calls against 82 for a full run and take ~35s. A/Bs on this branch are cheap enough that no prompt change to rule.txt should ever again be argued about instead of measured.

⚠️ Scores are not stable across DAYS (2026-08-27)

The most important measurement fact found so far, and it invalidates casual cross-day comparison.

GAINLOSS_UNITS scored 6/7 on 2026-08-26 and 0/12 on 2026-08-27 β€” 3 eval runs, 1 CLI run and 8 isolated repeats of the single call. Every input was proven identical:

  • only ::0016 contains the gold fragments (BCM/ton/jam), so no other chunk could ever have scored it;
  • that chunk's candidate is byte-identical with and without the day's cue change β€” same cue (Catatan), same offset (255), same snippet, verified by building both candidate sets;
  • rule.txt was untouched between the two days;
  • 0 span rejections, so the field is a genuine model abstention, not a guard kill.

Same input, same prompt, same deployment name, temperature=0 β€” 6/7 one day, 0/12 the next. The cause is outside this repo (a server-side model or deployment change is the obvious candidate) and cannot be determined from here.

What follows from it, and it is not optional:

  1. A committed result file is a measurement of a day, not of the pipeline. Comparing today's run to a file from last week silently mixes in whatever the model did in between.
  2. Any A/B must re-run BOTH arms on the same day. Yesterday's median is not a control.
  3. temperature=0 was never determinism (Β§5 already said so within a day); it is not even stability across days.
  4. Borderline judgements move most. ::0016 is a tabular chunk whose only prose sits in a Catatan: line β€” exactly the case rule.txt asks the model to weigh formula against prose. Stable rules (ECA_OTHER_ACTIVITY, QTY_ACTUAL_ZERO_PLAN_ALT) did not move at all.

This retracts yesterday's β€œX20 confirmed” reading. X20 was called confirmed because GAINLOSS_UNITS β€” the rule it names β€” fired 6/7. That rule now fires 0/12 with X20 still in place, so the 6/7 measured a day, not a fix. X20 may still be correct; it is no longer demonstrated, and showing it needs a same-day A/B against a reverted rule.txt.

Rule-branch noise band, and what it revealed (2026-08-26)

Seven runs of the identical pipeline on the same artifact, temperature=0. Run 1 carried the latex-guard defect; it is included because that defect cannot touch the rule branch.

Rule recall Runs
3/15 1
4/15 5
6/15 1

min 3 Β· median 4 Β· max 6 Β· mean 4.14. The branch is far less noisy than one pair of runs suggested β€” 5 of 7 land on exactly 4/15. Never quote a single run; quote the median and the band.

Per-rule stability is the useful view, not the total. Three rules fire in 7/7 runs (GAINLOSS_QTY_ZERO_PLAN, QTY_ACTUAL_ZERO_PLAN_ALT, ECA_OTHER_ACTIVITY). Two fire in 1/7 (QTY_PER_ACTIVITY, UA_BY_ACTIVITY) and are the whole of the variance. Nine never fire at all.

X20 is demonstrated, not assumed. GAINLOSS_UNITS β€” the single rule X20 was written to recover, the units sentence that shared a chunk with a Gain/Loss equation β€” fires in 6 of 7 runs against 0 at the 3/15 baseline. That is a stable +1, outside the noise, and it is the rule the fix names.

X23's gold question is answered by measurement. WATERFALL_TWO_METHODS fires 0/7 with the few-shot that forbade it removed. The prompt was never the binding constraint: the model judges that sentence descriptive on its own. The gold entry is unreachable with this prompt and model, so the remaining decision is about the gold, not about the prompt.

The real ceiling is upstream, and it is not the prompt. Traced properly on 2026-08-27, which corrected a wrong first reading β€” an earlier version of this section blamed tabular chunks. It is not tables: 5 of the 8 candidates already come from tabular chunks, so the cue regex reaches them fine. Three separate causes, and only one is the prompt's business:

# Cause Costs Fix lives in
1 Two gold rules have no matching text in ANY chunk β€” PTY_PRODUCTION_SOURCE, PTY_WORKING_HOURS_DEFINITION 2 rules, permanently The gold set, or the parse. Not extraction
2 One call returns ONE rule, but a chunk can hold several. ::0006 alone holds 4 gold rules 3 rules Schema/call unit β€” a list instead of a single object
3 Four prose chunks are never proposed as candidates (::0008, ::0011, ::0017, ::0024) 4 rules The discourse-cue regex, on prose

Arithmetic: 13 reachable gold rules live in 9 chunks, and one-rule-per-call caps the branch at 9/15 even with perfect candidates and perfect extraction. Against a median of 4/15, the headroom is 5 rules β€” and none of it is reachable by tuning rule.txt.

E3 was 1.00 in all seven runs, on a scoreable base of 6–7. Zero variance: when nano answers, it answers correctly; the open question is how rarely it answers, not whether to trust it.

What the LaTeX normalisation assumes, and where it stops

Audited 2026-08-26 against cases the corpus does not contain, because every fixture here is BUMA and a suite built only on BUMA cannot catch a rule that is really a fact about one document.

Held, and these are the ones that matter. Different operands, precedence (\frac{a+b}{c} vs a+\frac{b}{c}), operand order, sub- versus superscript, and + versus - all stay distinct. The guard's discrimination follows from preserving structure, not from anything about this corpus.

Two deliberate over-normalisations. Neither is safe to assume elsewhere.

Assumption Why it is accepted here When it breaks
\mathrm{x} is a multiplication sign It is how MinerU transcribes Γ— in this corpus. Applied to both sides, so a genuine roman x converts identically in claim and source and they still match A corpus where x is a real variable name β€” algebra, statistics. Residual risk is a contrived false acceptance, never a false rejection
Whitespace inside \text{} is deleted In text mode a space is a real word boundary, so \text{Total Hours} and \text{TotalHours} compare equal Two labels differing only by internal spacing. Tolerated because a fabrication differing from the truth by one space is not a threat worth a mode-aware scanner β€” but it IS lost discrimination, not a neutral simplification

\cdot is deliberately NOT folded into \times. It was, speculatively β€” it appears nowhere in the 44 real strings β€” and on vectors the two are different operations. The cost is real and pinned by a test: a model writing \cdot for scalar multiplication is now rejected. If that shows up in a live run, report it the way unverified_operator is reported; do not re-fold the two.

Known incompleteness, all in the fail-safe direction (a false rejection, never a false acceptance): plain-TeX {a \over b} is infix and unhandled; \mathbf, \mathit, \textrm, \operatorname, \quad and the thin-space commands are handled but never observed, so their presence in the code is not evidence that this corpus emits them.

One boundary bug worth remembering: \limits\b never matched before a subscript, because _ is a word character. That is the same defect X22 found in the eval scorer, in a different file. Where a LaTeX command can be followed by _, \b is the wrong boundary β€” use (?![A-Za-z]).

Which guarantee applied is recorded on the entry (latex_verification, added 2026-08-26). Three paths leave formula_latex populated β€” located in the markup, no markup to check against, operator not recoverable β€” and they are not equally strong. Until this field existed the output could not tell them apart, so a verified formula and an unverifiable one reached the expert as identical rows, and on a pipeline artifact most product formulas are the unverifiable kind. Set by the span check, never by the model; null when there is no claim to characterise. Values and per-field notes are in the output contract Β§7.

Never repair a failed span. A repaired span is an unfalsifiable claim, which is exactly what the control exists to prevent.

Conflict detection never picks a winner. The expert decides.


7. Negative findings β€” do not re-derive these

Finding Detail
nano is not sufficient for schema fill 0.75 precision against a 0.80 kill line. Decision 2026-08-19: stay on nano anyway for now; the __54n env quad is provisioned. Revisit if quality blocks the demo
Escalation is unreachable on small documents 54 of 66 clusters had exactly 1 evidence chunk against K=3, so rounds_available() correctly returned 0 and the loop never fired. escalated = 0 is not a bug. It stays unverified until a larger corpus runs
Conflict detection had nothing to find 0 conflicts on a single internally-consistent standard. Untested against real disagreement
85% abstention 56 of 66 entries returned no definition. Correct behaviour, but it means the review queue is mostly "term found, no definition in document". Whether that is useful or noise is a review-experience question for Mas Beta
Diff only ever ran the empty-baseline path The prototype diffed against the file it then overwrote, so every entry came back new. v2 needs a real active-version pointer
Literal source wording was normalised away The standard's heading reads "Physical of Availability (PA)"; the entry carried full_name: "Physical Availability". The gold set records the literal form correctly, so gold was right and the pipeline was wrong. Mechanism: the heading is a separate field from the chunk text, and the chunk text does not contain the phrase, so the literal wording never reached the model. Fix: feed the heading verbatim + a span-checked source_wording field
A glob('*.pdf') picked the wrong document once Scored the textbook against the standard's gold set β†’ recall 0.05. Document selection must be explicit and error on ambiguity
Page-based chunking does not improve term recall (2026-09-02) Tested directly: the 31 section chunks of the BUMA fixture regrouped into 9 page chunks, everything else held constant. It fixed the structural symptom β€” chunks under GLiNER's 130-word window went 28/31 β†’ 2/9 β€” and E1 fell 0.8293 β†’ 0.8049 (34/41 β†’ 33/41). The net βˆ’1 hides the real result, which is a swap of 7 terms: page-grouping recovered Fleet management, Mineplan, Uncontrollable (the terms the short-context theory predicts) but lost Coal barging, Model Unit, Standby, Total Hours, and the filter's dropped_overlapping rose 7 β†’ 16. So chunk size is not the binding constraint β€” span-overlap resolution is, and that lives in filters/span_filter.py, not in the parser. Read this as no evidence page-based helps rather than page-based is worse: n=1 document, 41 gold terms, and a 1-term delta is inside the noise band Β§6 documents. Runs: results/v2_filters_2026-09-02_092638.json (control) and _092714.json (experiment)
torch wheels install corrupt torchgen missing, version reports None. Fix is delete site-packages/torch* and reinstall. Needs torch β‰₯ 2.6. Documented so nobody debugs it as a GLiNER problem

8. What was measured, so v2 can be compared

Frozen in eval/knowledge/results/baseline_prototype_2026-08-13_145132.json.

Funnel on the 9-page standard: 9 pages β†’ 13 chunks β†’ 169 mentions (195 before noise filtering) β†’ 66 clusters β†’ 83 LLM calls β†’ 66 entries β†’ 66 review-queue items.

E1 recall 0.854 Β· E2 compression 2.56Γ— Β· E3 schema-fill precision 0.75 (FAIL) Β· E4 fabrication rejection 1.00 with 0.00 false rejections. Cost $0.0069, 242 s wall clock, 3 fields rejected by span check.

When quoting E2 externally: the context doc's 6.2Γ— is a 1,000-page projection; 2.56Γ— is the 9-page measurement. Compression grows as terms repeat across a corpus. Both are true β€” use the right one for the audience.


9. Deliberate deviations from the prototype (v2)

Changes made while rebuilding, with the reason. Each one alters behaviour, so each is listed rather than folded silently into the tables above.

# Change Why Measured?
V1 A heading that names the term counts as a mention at offset 0 for cue-proximity scoring Indonesian standards are written "2.1.3 Physical of Availability (PA)" / "Adalah ketersediaan fisik…" β€” the heading names the term and the body opens with the definitional cue without repeating it. The prototype therefore awarded no cue bonus on the definition section itself. Measured on the BUMA standard: PA's own definition section (Β§2.1.3) scored 7.5 and ranked third, below a computation section (Β§2.2.4, 8.5) that merely mentions PA often. After the fix, all five terms rank their own definition section first β€” PAβ†’2.1.3, UAβ†’2.1.4, Qtyβ†’2.1.2, Ptyβ†’2.1.5, MOHHβ†’2.1.1 Structurally verified on the 9-page standard. Not yet scored β€” needs the paid stage to show up in E3
V2 first_occurrence follows document order, and ranking ties break on document order The prototype awarded the +1.0 to whichever chunk a set iteration yielded first, making the bonus arbitrary and the ranking unreproducible across runs. "First occurrence" should mean the term's first appearance in the document Determinism locked by test; verified stable across PYTHONHASHSEED 0/1/7/42/99
V3 source_wording field added to the glossary entry The control for the literal-source-wording defect (Β§7). Not yet populated β€” needs the seam to carry the heading verbatim and the extraction prompt to fill it No

Both V1 and V2 change which chunks reach the paid stage, so they can move E3 when the extraction branch lands. Re-score against eval/knowledge/results/baseline_prototype_2026-08-13_145132.json at that point and record the delta rather than assuming the improvement carries.

V1 and the R1 literal-wording defect share a root cause: the heading is a separate field from the chunk text, so anything that reasons over "the term and its definition together" has to be told to look at both.

| V4 | source_wording is taken from the section heading, not from the model | Asked to quote the source wording, the model returned "Physical Availability (PA)" β€” a genuine verbatim quote from the page-1 intro, but not the Β§2.1.3 heading "Physical of Availability (PA)". Both occur in the document; only the heading form reveals the disagreement. Taking it deterministically means it cannot be normalised away | Live-verified on the pilot: the entry now carries both forms and the queue flags the mismatch | | V5 | evidence_text includes chunk headings | The heading is part of the source document and is often the only place a term is formally named. Excluding it rejected a correct verbatim quotation of the document's own section title (2 rejections on a 3-entry pilot, both spurious) | Live-verified: 0 rejections after the change | | V6 | full_name and source_wording are span-checked against the source themselves | Both claim to be literal transcriptions. The provenance span passing does not prove the transcription is faithful, and a normalised "full name" is exactly the silent correction this pipeline exists to surface | Unit-tested |

Cache-floor finding (measured on the real prompts): only the glossary prefix clears the 1024-token floor at 1,401 tokens. rule (731), formula (459) and summary (288) are not cacheable and cache nothing today. Glossary is also the branch with by far the most calls (66 of 83 on the reference document), so the padding is on the branch that matters β€” but padding the other three is free money if their call counts ever grow.


10. Parser comparison β€” MinerU vs PyMuPDF (2026-08-19)

First run of the extraction half on MinerU-parsed input, same document, same gold set, same pipeline code. Result: eval/knowledge/results/v2_parser_comparison_2026-08-19_152842.json.

Input E1 recall Terms found
PyMuPDF (what the frozen baseline used) 0.8537 35 / 41
MinerU, raw markup 0.7561 31 / 41
MinerU, formulas + tables rendered to prose 0.8293 34 / 41

The span model reads prose, not markup. MinerU's structure is an asset for the formula branch and a liability for the filter unless it is rendered first:

  • Terms wrapped in LaTeX are invisible. \text {Total Hours} - \text {Breakdown} yields no mentions for either term, though both are gold terms and both are found in the plain-text parse. Unwrapping \text{}/\mathrm{}/\frac{}{} and collapsing MinerU's character-spaced output recovers them.
  • Raw <table> HTML in the chunk text is noise the model has to read past. Eight of fourteen chunks carried it before rendering.

The residual ~2.4 points are chunking, not content. MinerU marks non-section labels β€” Keterangan:, Keterangan grafik: β€” as text_level titles. Opening a new section at each one splits a legend away from the figure it explains, and Other Activity / Uncontrollable are then missed despite being present as prose in a chunk the filter processed. Only numbered headings should open a section, which is consistent with Β§9's finding that MinerU flattens every heading to level: 2 β€” numbering is the only reliable section signal it gives us.

Caveat on attribution: the MinerU artifacts here were built by a scratch converter, because section-aware chunking belongs to the parsing half. These are findings about what that chunker must handle, not a measurement of MinerU itself.

Re-measured through the production module β€” B3 (2026-08-26)

The caveat above is now discharged: src/knowledge_parsing produced the artifact, not a scratch converter. eval/parsing/run_eval.py, result in eval/parsing/results/.

Input E1 recall Terms
PyMuPDF β€” frozen baseline 0.8537 35 / 41
MinerU hybrid/high, production module 0.8293 34 / 41
scratch converter (above) 0.8293 34 / 41

Parity with the scratch converter, from code that ships. The miss lists differ by one term in each direction β€” the module finds Other Activity, which the converter missed, and misses Breakdown, which it found. The other six are identical.

Getting there required fixing a real defect. The first run scored 0.7073, and the cause was not MinerU: normalize.py silently dropped every type: "list" item. MinerU keeps list content in list_items and gives those items no text field, so they fell through the generic text path, came back empty, and were skipped with no warning β€” the same shape of bug the chart/image branch was written to prevent. Two dropped blocks took five gold terms with them (Joint survey, Truck count, EWH, Controllable, Hierarki Level Analysis). Fixing it moved recall +0.1220.

Note what this says about Β§10's own conclusion: the scratch converter did handle list blocks, so Uncontrollable really was "present as prose but missed" in that run, exactly as recorded. Β§10's attribution was right. The production module had regressed against it, and B3 is what surfaced that.

The residual 0.0244 is not parsing's to close. Breakdown appears three times in the text and sits in the same chunks as Total Hours, which the filter does find β€” same chunk, same sentence, ( Total Hours - Breakdown ), one tagged and one not. Chunk arrangement cannot explain it, so it is filter behaviour. Chunk counts confirm there is no over-splitting to blame: 31 chunks = 15 prose (11 headed, 9 distinct sections) + 14 tables + 2 images, against the converter's 14 prose chunks. On prose granularity the two agree.

Trap for anyone re-running this. A harness that re-normalises a cached content_list must pass the page vocabulary, as run.py does. Without it, word-boundary recovery has no arbiter, INPR Hours fuses into INPRHours, and the scorer's word-boundary match fails β€” costing a full term. That mis-measurement (0.8049) was produced and discarded during B3; the module was never at fault.


11. Full-document run, both halves (2026-08-24)

First run with the parsing module building the artifact and extraction running every branch. Result: eval/knowledge/results/v2_full_document_2026-08-24_093051.json.

Metric v2 Prototype Verdict
E1 term-filter recall 0.8049 0.8537 PASS (kill 0.70)
E3 schema fill 0.90 0.75 PASS (kill 0.80) β€” the prototype FAILED this
Rule-branch recall 0.0667 0.2667 REGRESSION

77 calls, 139,283 prompt tokens with 110,848 cached (80%), 2 fields rejected by the span check, 48 of 58 entries abstained.

Read E3 with the same care the prototype's number needed. The scoreable base is 10 entries, so one adjudication moves it ~10 points, and nano is not deterministic at temperature=0. The single failure β€” produksi β€” is the same one the prototype recorded, and looks like a gold-adjudication case rather than a model error: the document carries a broader definition than the gold set names.

E1 is below the baseline because the chunking differs, not because the filter regressed. The parsing module splits to a token budget (31 chunks) where the earlier comparison used 14, so the span model sees different windows. Same code, same gold set.

The rule branch went backwards and is not yet diagnosed (checklist X19). Candidate generation is not the cause: 8 cue passages against the prototype's 9.

Bug this run surfaced β€” blank lines closed the legend block

The legend filter returned zero abbreviation pairs on the parsing half's output. Cause: that module separates blocks with a blank line β€” ordinary prose formatting β€” and find_legend_lines treated the first blank as the end of the block.

The consequence is exactly what the filter exists to prevent: PA and Physical Availability clustered as two unrelated terms and reached the expert's queue as two rows. Fixed by skipping blank lines and closing the block on two consecutive blanks.

Before After
Abbreviation pairs 0 5
Clusters 61 58
LLM calls 81 77
Cache hit rate 57% 80%
PA rows in the queue 2 1

The lesson generalises: the two halves agree on the contract, and this still slipped through β€” a formatting choice that is completely reasonable on one side silently broke a regex on the other. Whitespace is part of the interface whether or not the schema says so.


12. Rule-branch regression β€” diagnosed (2026-08-24)

The full run scored the rule branch at 1/15, against the prototype's 4/15. Diagnosed to three separate causes, only one of which is an actual quality loss.

Cause 1 β€” two of the three losses are a scoring artifact, not a regression

The gold set's statement_contains strings were written against the PyMuPDF-era notation, which ran subscripts together: QtyA, QtyP. The rendered LaTeX writes them with an underscore: Qty_A, Qty_P. The scorer normalises punctuation but _ is a word character, so qty_a never contains qtya and the match fails on notation alone.

Scoring Rules matched
As committed 1/15
Collapsing _ to nothing 3/15

GAINLOSS_QTY_ZERO_PLAN and QTY_ACTUAL_ZERO_PLAN_ALT are both correct extractions scored as misses. The true comparison is 4/15 β†’ 3/15, a loss of one rule. Fixing this means changing either the scorer or the gold set β€” both are the frozen measuring instrument, so it is Rifqi's call, not a silent edit.

Cause 2 β€” the real loss: rendered formulas now compete with prose

4 of 8 v2 rule statements are bare equations. In the prototype, 0 of 7 were.

Run Formula statements Prose statements
v2 4 4
Prototype 0 7

Before rendering, formulas reached the model as mangled character-spaced LaTeX and did not read as statements. Rendered to clean prose they read exactly like prescriptive rules, and the model takes them. rule.txt never excludes them β€” it defines a rule as "a statement that constrains or directs behaviour", which a formula arguably is.

The lost rule shows the mechanism exactly: chunk ::0015 contains both the units sentence ("Gain/Loss menggunakan satuan BCM atau ton…" β€” gold GAINLOSS_UNITS) and a Gain/Loss equation. The chunk was a cue candidate, so the model saw it, and returned the equation.

This is pure loss, because the formula branch already captures those equations β€” Qty = (I NPRHour s) / (T otalHour s) appears in both branches of the same run. The rule branch is spending calls duplicating the formula branch and dropping the operational prose it exists for.

Fix direction: rule.txt should exclude bare equations explicitly ("an equation is the formula branch's job; a rule is prose that constrains behaviour"). That is a prompt change and therefore owes an eval run per CLAUDE.md Β§7B β€” not to be done on vibes.

X20 β€” the fix, applied 2026-08-26 (296cdd7). NOT YET EVALUATED.

Three edits to rule.txt:

  1. A rule is now prose, not "a statement", with a paragraph stating that an equation belongs to the formula branch and that "calculation policy" means prose saying how, when or on what basis a calculation applies β€” never the calculation itself.
  2. New correctness requirement 3: evidence whose only prescriptive content is a formula returns statement: null.
  3. The load-bearing clause: evidence carrying both a formula and constraining prose yields the prose. This is the one that targets the measured loss β€” chunk ::0015 held both the units sentence (gold GAINLOSS_UNITS) and a Gain/Loss equation, and the model returned the equation.

Worked example 3 is deliberately synthetic (MTTR, absent from all 15 gold rules). Using the real GAINLOSS_UNITS case would have leaked a gold answer into the prompt and made the eval score it trivially. Worth knowing: worked example 1 already is gold rule PTY_PRODUCTION_SOURCE verbatim, so that contamination pre-exists β€” the new example was kept clean rather than adding to it.

Committed without the eval Β§7B requires, on Rifqi's explicit call, because the dev box has no source document, no parsed artifact and no MinerU install (data/knowledge_docs/, data/knowledge_cache/, data/knowledge_runs/ are gitignored and absent; there is also no eval/knowledge/run_eval.py β€” the 2026-08-24 run was assembled ad-hoc). Restoring one parsed artifact unblocks the measurement. The measurement is still owed.

Compare against 3/15, not 1/15. X22 landed on 2026-08-24 (abae344) after this regression was diagnosed: the scorer now deletes underscores, so the notation artifact in Cause 1 is gone. The 1/15 in v2_full_document_2026-08-24_093051.json was measured with the old instrument and is no longer the number to beat.

Side effect to verify on the first paid run. rule.txt grew from ~731 to ~1140 estimated tokens and crossed the 1024 prompt-cache floor, so cacheable('rule') now returns True for the first time β€” the "free money" Β§9 predicted. But est_tokens is len/3.6; only the API's cached_tokens proves a hit.

Found while doing this, not actioned β€” the rule branch cannot score better than 14/15 today. Worked example 2 teaches that "Waterfall Analysis dapat di-review melalui dua metode" is not a rule and must return null; the gold set records that exact sentence as WATERFALL_TWO_METHODS. The prompt and the frozen gold contradict each other, so one gold rule is unwinnable by construction. Both are measuring instruments β€” Rifqi's call, tracked as X23.

Cause 3 β€” three distinct render_latex faults (FIXED 2026-08-24)

Two corrections, both from re-measuring against the document's own 11 equations.

An earlier version of this section said render_latex "eats the first character". That was retracted β€” the repro was mangled by shell escaping, not by the code.

The retraction then over-corrected. It claimed Production = MOHH Γ— Qty Γ— PA Γ— UA Γ— Pty "renders perfectly". It does not. The document's raw content_list.json contains no \times and no \mathrm β€” just a literal x, spaced like every other character:

'P r o d u c t i o n = M O H H x Q t y x P A x U A x P t y'
  ->  'Production = MOHHxQtyxPAxUAxPty'

MOHH, Qty, PA, UA and Pty β€” the five terms the glossary is built around β€” fuse into one token. See Still open below.

(A) A spaced run touching a bracket is only half-collapsed. _SPACED_RUN was bounded by (?<!\S) / (?!\S), so a run could not match if it touched ( or ). The \frac{a}{b} β†’ (a) / (b) rewrite inserts exactly those brackets, stranding a character at each end:

'I N P R H o u r s'      ->  'INPRHours'      # correct, no brackets
'(I N P R H o u r s)'    ->  '(I NPRHour s)'  # first and last stranded

(B) The subscript fold ran before the collapse and split the run. Q t y _ {a c t i v i t y} became Qt y_a ctivity β€” the _ glued mid-run, so the collapse saw broken fragments.

(C) The serious one β€” fraction structure was silently dropped.

\mathrm { P A } = { \frac { \mathrm { T o t a l } ~ H o u r s - B r e a k d o w n }
                          { \mathrm { T o t a l } ~ H o u r s } } \mathrm { x } 1 0 0 \%
  ->  'PA = Total Hours - BreakdownTotal Hoursx 100 %'

The \frac rewrite needed \{([^{}]*)\}, which cannot match a numerator containing nested braces. The unmatched \frac then fell through to the generic "drop any leftover command" pass, so the division was deleted rather than rendered. This is not a spacing blemish β€” the formula reads as a product and states something the document does not.

The nesting comes from \mathrm{…} and \sum _{ij}, not \text{…}. The BUMA standard never uses \text{}. A fix written against \text{} would repair a synthetic example and no real equation.

Measured tally, 11 equations:

Before After
Division lost 4 β€” eq 6, 7, 9, 11 (PA, PA_ij, UA_ij, PTY_ij) 0
Stranded characters 4 0
Clean 5 9 + 2 carrying only the document's own literal x

Not two equations and not only PA/UA: every composite (_ij) form was affected too.

The fix (src/knowledge_parsing/render.py):

  1. Braced wrappers are unwrapped before \frac, consuming their braces. The old _WRAPPERS deleted only the command name and left { } behind β€” those leftover braces were what stopped \frac from matching. This is the ordering decision the diagnosis asked for.
  2. \frac uses a balanced-brace scanner, not a regex. [^{}]* can never handle a nested argument, and it fails silently rather than raising.
  3. Run boundaries are word-character classes, and subscript contents are joined at fold time (_ { i j } β†’ _ij).

Six regression cases hold unchanged: C_p, C 5, C_p Γ— T, \frac{a}{b}, Purchasing costs, and a bare spaced run.

Still open β€” a decision, not a defect. After MinerU spaces every character, a multiplication x is indistinguishable from the letter x: no \times, no \mathrm, no signal at all. Treating it as an operator is right for engineering formulas and wrong for a variable named x. The _SYMBOLS comment shows the author already weighed this trade for \times. Left unfixed deliberately, because it changes interpretation rather than mechanics.

Impact of what remains: a term occurring only inside Production = … cannot be separated from its neighbours, which costs the term filter five mentions on the standard's flagship formula.

Owner: Sofhia β€” src/knowledge_parsing/render.py.


13. Backend comparison β€” all four measured (2026-08-24)

Every backend, one Colab GPU, the same two documents (BUMA standard 9 pages + McGraw-Hill handbook 20 pages = 29 pages). Until this run, hybrid had never been measured at all, and every effort figure in circulation came from an older Colab session on different hardware.

Backend s/page Image descriptions Multiplication sign
hybrid / high ⭐ 4.85 3 · 1 \times
hybrid / medium 1.88 0 Β· 0 β€” force-disabled \times
pipeline 2.67 0 Β· 0 β€” never analyses letter x
vlm 6.96 3 Β· 1 \times

Formula transcription overlap, across the standard's 11 equations:

Pair Identical
pipeline vs everything else 0%
vlm vs hybrid/high 100%
medium vs high 70%

Two findings decide the backend. vlm and hybrid/high are the same output β€” same LaTeX, same descriptions β€” so vlm buys nothing for its extra time. And pipeline is the only backend that copies the multiplication sign as a literal x, fusing MOHH x Qty x PA into one token and taking five glossary terms with it; it is also slower than hybrid/medium, so it loses on both counts.

The choice therefore narrows to hybrid/medium vs hybrid/high, i.e. whether image descriptions are wanted. Across the whole 6,800-page BUMA corpus that is $2.67 against $6.89 β€” a $4 difference, too small to trade the feature for. Decided: hybrid + effort=high.

Rifqi's claim that medium and high capture only ~30% of the same formulas did not reproduce β€” measured at 70%.

Image descriptions are gated by sub_type, and coverage is partial

MinerU only describes a picture it has first classified. The pattern held 6 of 6 across both documents:

sub_type content
line Β· bar Β· flowchart filled
absent empty

Coverage was 4 of 6 (67%), and the miss is not explained by image quality: Gambar 2.1 is a plainly legible waterfall chart with seven labelled bars, and got no sub_type at all. Meanwhile Gambar 2.2 was labelled flowchart while the description it produced calls it "This waterfall chart…" β€” so the classifier is shaky in both directions.

Consequence for planning: the parsing stage cannot fully replace a dedicated image-description pipeline. It supplies some descriptions free, but a clear chart can be skipped with no warning. A fallback path is easy to target, though β€” chunks with images populated and generated_description null are exactly the gap.

Determinism β€” settled (2026-08-26)

hybrid/high is deterministic. Two runs over the same two documents, with the parse cache moved aside in between so both genuinely re-parsed (from_cache: false on all four records), produced 0 differences across 50 chunks β€” every one of chunk_id, kind, page_idx, section_no, heading, text, latex, table_html and generated_description identical.

That result is not vacuous: all four image descriptions were present in both runs and character-for-character identical (192, 256, 77 and 146 chars). Those are the only model-written content in the artifact and were the likeliest thing to wobble; they did not.

This closes the strongest objection to calibrating on the VLM family β€” that a frozen baseline would drift between runs and no eval number could be trusted. It does not. Existing baselines stay valid and need no re-measurement on this account, and the backend decision is final rather than provisional.

Parse time does vary β€” 167.4 s against 65.2 s for the same 20-page document, which is model loading on the first run. The output does not.


14. Word-boundary recovery β€” reading the answer from the source (2026-08-24)

MinerU writes formulas one character at a time (T o t a l H o u r s). Once those are joined the boundaries are gone: Total Hours and TotalHours become indistinguishable, and a multiplication written as the letter x fuses in too, because it looks like any other spaced character.

Two obvious fixes were tried and rejected. Rewriting a lone x to a multiplication sign guesses, and guesses wrong β€” silently β€” on any document with a variable named x; it also leaves TotalHours untouched, which is the larger half of the damage. Emitting a warning instead fires on 7 of 11 equations: a warning lighting up on 64% of items is not actionable and recovers nothing.

The source PDF still holds the boundaries β€” its text layer reads MOHH x Qty x PA x UA x Pty, properly spaced. So the answer is read from the document. A token not already a word on that page is split only if EVERY piece is a word on that page, and only if exactly one shortest such split exists. Ambiguity is left alone; so is anything unsegmentable. It fails closed.

NFKC normalisation is required β€” formulas use a mathematical font, so the page literally contains U+1D440 and friends, and without it nothing matches.

Measured across three documents, two of them an English textbook with nothing in common with the BUMA standard:

Document Text-layer quality Equations repaired
BUMA standard, 9 pages 99.6% 6 of 11
Handbook, 20 pages 96.0% 1 of 3
Handbook, 5 pages 98.1% 8 of 20
MOHHxQtyxPAxUAxPty  ->  MOHH x Qty x PA x UA x Pty
TotalHours          ->  Total Hours
newwrenchtime       ->  new wrench time
areaofroom, sqft    ->  area of room , sq ft

Glossary terms readable as whole tokens went 320 -> 397 on the standard, across 9 of 31 chunks. The second test document was unchanged β€” as it should be, having no such damage.

The guard that makes it safe on documents nobody has seen

Trusting the text layer is safe when it is intact and dangerous when it is HALF there: a legitimate word starts looking unknown and gets split. So each page has to prove itself β€” how many of MinerU's own prose words on that page are actually found in its text layer. A page scoring below 90%, or carrying too little prose to judge, loses its vocabulary and skips recovery: damaged but honest, rather than repaired into something the document never said.

That replaces a constant tuned against one document with a measure that assesses each page on its own evidence. A scanned PDF with no text layer is already safe β€” no vocabulary, no recovery. The untested territory is a text layer well below 96%, which none of the three documents provides.

Threshold measured rather than picked: a minimum token length of 6, 4 and 3 give identical results on equations, and 3 additionally recovers MOHHx / xPA_A inside table cells. 2 starts doing damage, splitting PA_ij into PA_i j because i and j are both valid words on that page.