Download docs/knowledge/KNOWLEDGE_PIPELINE_CALIBRATION.md from DataEyond/Agentic-Service-Data-Eyond-Catalog: direct link, hf CLI and curl.
- Browser
- Download file 61.3 kB
-
https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/main/docs/knowledge/KNOWLEDGE_PIPELINE_CALIBRATION.md
- Command line
-
hf download hf://spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/docs/knowledge/KNOWLEDGE_PIPELINE_CALIBRATION.md
-
curl -L -o KNOWLEDGE_PIPELINE_CALIBRATION.md https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/main/docs/knowledge/KNOWLEDGE_PIPELINE_CALIBRATION.md
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. Seeeval/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 |
β 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:
- The ranked list is never mutated, so everything reading
evidence_chunk_idssees the number it saw before. evidence_block(the prompt) andevidence_text(the span check) both take their ids from the sametop_kcall, 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:
- Running headers/footers β every page repeats the title block and "Confidential".
- 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 andpage_endextends. - Colon-continuation β legend rows arrive as
MOHHthen: Machine on Hand Hourson 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
::0016contains 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.txtwas 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:
- 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.
- Any A/B must re-run BOTH arms on the same day. Yesterday's median is not a control.
temperature=0was never determinism (Β§5 already said so within a day); it is not even stability across days.- Borderline judgements move most.
::0016is a tabular chunk whose only prose sits in aCatatan:line β exactly the caserule.txtasks 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:
- 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.
- New correctness requirement 3: evidence whose only prescriptive content is a formula returns
statement: null. - 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
::0015held both the units sentence (goldGAINLOSS_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 rawcontent_list.jsoncontains no\timesand no\mathrmβ just a literalx, 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):
- Braced wrappers are unwrapped before
\frac, consuming their braces. The old_WRAPPERSdeleted only the command name and left{ }behind β those leftover braces were what stopped\fracfrom matching. This is the ordering decision the diagnosis asked for. \fracuses a balanced-brace scanner, not a regex.[^{}]*can never handle a nested argument, and it fails silently rather than raising.- 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
mediumandhighcapture 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.