# 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](KNOWLEDGE_PIPELINE_TODO.md) (plan) Β· [eval/knowledge/](../../eval/knowledge/) (gold set, scorer, frozen baseline) Β· [knowledge_pipeline_context.md](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 `