Rifqi Hafizuddin Claude Fable 5 commited on
Commit
60cf4a8
·
1 Parent(s): 7cce5ff

[NOTICKET] feat(eval): knowledge-extraction gold set, scorer + frozen prototype baseline

Browse files

P2 + P3 + P4 from the knowledge-pipeline plan. Lifts the two things out of the
kex prototype that could not be recreated if the machine died — the calibration
knowledge and the measurement baseline — without porting any pipeline code.

eval/knowledge/ (new, follows the eval/{intent,help,readiness,planner} convention):
- knowledge_gold.yaml 41 terms + 15 rules, verbatim from the prototype. Still
provisional (not expert-reviewed); accepted as good enough
so the rebuild is not blocked on expert time.
- score.py ported; only change is dropping the prototype's sys.path
bootstrap. Pipeline-independent: scores plain lists, so it
works against the prototype's artifacts and v2 alike.
- results/baseline_prototype_2026-08-13_145132.json
FROZEN. The numbers v2 must match or beat.
- README.md provenance, what the scorer refuses to conflate, and how
to read E3 on an 8-entry scoreable base.

Migration verified faithful: the ported scorer re-derives the baseline exactly
from the prototype's artifacts — E1 recall 0.8537, E3 precision 0.75, scoreable
base 8, abstentions 56. All MATCH.

KNOWLEDGE_PIPELINE_CALIBRATION.md (new): every tuned constant with its reason —
GLiNER 0.25 threshold and the 130-word window, rapidfuzz 92 with fuzzy off below
5 chars (PA/UA collide), the six-signal weight table, chunking traps, the
1024-token cache floor, span/escalation/conflict thresholds — plus §7, the eight
negative findings v2 must not re-derive.

Decisions recorded: prototype stays as-is unversioned (risk accepted, now much
reduced); all four branches stay on nano via the __54n env quad; provisional gold
is good enough for now.

Verification: ruff clean on eval/knowledge/; PYTHONPATH=. python -c "import main" OK.
No dependency, schema or endpoint change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

KNOWLEDGE_PIPELINE_CALIBRATION.md ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Knowledge Pipeline — Calibration Reference
2
+
3
+ **Harvested from the `kex` prototype, 2026-08-19.** Every number below cost a run, a bug, or a
4
+ measurement to find, and **none of it is in the spec** — it lived only as constants and comments in
5
+ a codebase that is not being ported.
6
+
7
+ This file exists because clean rewrites drop accidental knowledge and then rediscover it as
8
+ production defects. When building extraction v2, take the value **and** the reason. If you change
9
+ one, change it deliberately and record why.
10
+
11
+ Companions: [KNOWLEDGE_PIPELINE_TODO.md](KNOWLEDGE_PIPELINE_TODO.md) (plan) ·
12
+ [eval/knowledge/](eval/knowledge/) (gold set, scorer, frozen baseline) ·
13
+ [knowledge_pipeline_context.md](knowledge_pipeline_context.md) (intent).
14
+
15
+ ---
16
+
17
+ ## 1. Term filter — GLiNER
18
+
19
+ | Constant | Value | Why this value |
20
+ |---|---|---|
21
+ | 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 |
22
+ | 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** |
23
+ | 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 |
24
+ | Window size | **130 words**, overlap **30** | 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 |
25
+ | 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 |
26
+
27
+ **Label sets** (`labels.yaml`) — three variants were tried and A/B'd without touching code. Keep
28
+ that property: label phrasing is the main recall lever and GLiNER is very sensitive to it. The
29
+ Indonesian-phrasing variant (`istilah produksi tambang`, …) did **not** win; `broad` did.
30
+
31
+ ---
32
+
33
+ ## 2. Clustering
34
+
35
+ | Constant | Value | Why this value |
36
+ |---|---|---|
37
+ | Match order | exact (normalised) → abbreviation↔expansion → fuzzy | Cheapest and most certain first |
38
+ | Fuzzy threshold | **92** (`rapidfuzz.token_set_ratio`) | Conservative on purpose |
39
+ | **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 |
40
+ | Canonical surface | shortest variant ≥ 2 chars | That is how a reader looks a term up — `PA`, not `Physical Availability (PA) untuk …` |
41
+
42
+ **Over-merging is worse than under-merging, and the asymmetry is not close.** An under-merge costs
43
+ one extra LLM call and one extra review row. A wrong merge silently destroys a distinct term and
44
+ **the expert never sees it** — there is no downstream mechanism that recovers it.
45
+
46
+ **Legend extraction must run before clustering.** Without the abbreviation index, `PA` and
47
+ `Physical Availability` cluster as two unrelated terms.
48
+
49
+ **Stop surfaces** — dropped as whole surface forms only (never as substrings, so no term is lost):
50
+ `unit · type · class · equipment · equipment unit · parameter · activity · data · nilai · proses ·
51
+ hasil · total`.
52
+
53
+ **Normalisation** (clustering only, never for span checking): NFKC → casefold → `-`/`_` → space →
54
+ strip `.’'` → non-word to space → collapse whitespace → strip ` ()/`.
55
+
56
+ ---
57
+
58
+ ## 3. Evidence ranking
59
+
60
+ Six signals plus one penalty. The full ranked list is retained, not just the top K — escalation
61
+ needs the tail.
62
+
63
+ | Signal | Weight |
64
+ |---|---|
65
+ | Definitional cue within 100 chars of the mention | **+5.0** |
66
+ | Term appears in the chunk heading | **+4.0** |
67
+ | Mention sits inside a legend block | **+3.5** |
68
+ | Chunk contains a formula | **+2.0** |
69
+ | Mention is bold/italic | **+1.5** |
70
+ | First occurrence of the cluster | **+1.0** |
71
+ | Chunk is tabular | **−3.0** |
72
+
73
+ | Constant | Value | Why |
74
+ |---|---|---|
75
+ | `evidence_k` | **3** | Chunks fed per extraction round |
76
+ | Cue proximity | **100 chars** | Distance from mention to a definitional cue |
77
+
78
+ **Heading matching is word-boundary, never substring.** `PA` is a substring of `Parameter`, `pada`
79
+ and `composite` — substring matching handed the +4.0 heading bonus to almost every Indonesian
80
+ heading and pushed real definition sections *below* formula tables.
81
+
82
+ **Why this filter is legitimate when the relevance gate was rejected:** no term is dropped (only its
83
+ *evidence* is narrowed), every mention stays in provenance so a bad top-3 is visible, and the
84
+ escalation loop self-corrects. The rejected gate had none of those three properties. Preserve all
85
+ three if ranking is ever revisited.
86
+
87
+ ---
88
+
89
+ ## 4. Chunking
90
+
91
+ | Constant | Value | Why |
92
+ |---|---|---|
93
+ | Max chunk tokens | **1500** | With paragraph-boundary splitting; sentence boundaries as fallback for a single over-budget paragraph |
94
+ | Max heading length | **90 chars** | Longer lines are sentences or formula rows, not headings |
95
+ | 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 |
96
+ | Tabular detection | short lines > 0.6 **and** numeric lines > 0.4 | Feeds the −3.0 ranking penalty |
97
+
98
+ **Three things the real document forced, all of which corrupt every downstream number if missed:**
99
+
100
+ 1. **Running headers/footers** — every page repeats the title block and "Confidential".
101
+ 2. **Breadcrumb headings** — pages re-print `2. PENJELASAN PARAMETER / 2.1. Production Parameter / …`
102
+ at the top. A naive splitter re-opens the section and shatters it. Rule used: *a heading that is
103
+ the current section or an ancestor of it, already seen, is a breadcrumb* — the section continues
104
+ and `page_end` extends.
105
+ 3. **Colon-continuation** — legend rows arrive as `MOHH` then `: Machine on Hand Hours` on the next
106
+ line. Rejoined before the legend filter sees them.
107
+
108
+ Points 1 and 2 apply to MinerU output too: its `title` blocks repeat the same breadcrumbs on pages
109
+ 2–8 of the standard.
110
+
111
+ ---
112
+
113
+ ## 5. Extraction (the paid stage)
114
+
115
+ | Constant | Value | Why |
116
+ |---|---|---|
117
+ | Temperature | **0.0** | And still not deterministic — see below |
118
+ | Seed | 7 | |
119
+ | **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 |
120
+ | Structured output | `json_schema`, fallback `json_object` + validate-retry | Which mode was used is recorded in the run manifest |
121
+ | API version | `2024-12-01-preview` | `json_schema` needs ≥ `2024-08-01-preview` |
122
+
123
+ **Prompts live in files, not code, and the fixed prefix must stay byte-identical across calls** —
124
+ any drift and caching stops engaging, silently, at ~10× the input cost.
125
+
126
+ **`temperature=0` is not determinism.** Consecutive runs of the identical pipeline scored 0.75 and
127
+ 0.625 on schema fill. Never report a single run as a measurement on a small base.
128
+
129
+ ---
130
+
131
+ ## 6. Validation
132
+
133
+ | Constant | Value | Why |
134
+ |---|---|---|
135
+ | Span normalisation | **whitespace only** (+ NFKC) | Not case, not punctuation, not diacritics. Every additional normalisation is a hole a fabrication fits through |
136
+ | 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 |
137
+ | 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** |
138
+ | Duplicate overlap threshold | **0.8** | Above it, a differing definition is a duplicate; below, a conflict |
139
+
140
+ **Guarded fields** (span-checked; failure sets the field to `None` and logs it):
141
+
142
+ | Branch | Fields |
143
+ |---|---|
144
+ | glossary | `definition`, `full_name`, `formula_latex`, `interpretation` |
145
+ | rule | `statement`, `condition`, `consequence` |
146
+ | formula | `formula_latex` |
147
+ | summary | *(none — generation cannot be span-checked at all)* |
148
+
149
+ **If the provenance span itself is not verbatim, every guarded field on the entry is rejected** —
150
+ the entry's only evidence link is broken, so nothing on it can be trusted.
151
+
152
+ **Never repair a failed span.** A repaired span is an unfalsifiable claim, which is exactly what the
153
+ control exists to prevent.
154
+
155
+ **Conflict detection never picks a winner.** The expert decides.
156
+
157
+ ---
158
+
159
+ ## 7. Negative findings — do not re-derive these
160
+
161
+ | Finding | Detail |
162
+ |---|---|
163
+ | **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 |
164
+ | **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 |
165
+ | **Conflict detection had nothing to find** | 0 conflicts on a single internally-consistent standard. Untested against real disagreement |
166
+ | **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 |
167
+ | **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 |
168
+ | **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 |
169
+ | **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 |
170
+ | **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 |
171
+
172
+ ---
173
+
174
+ ## 8. What was measured, so v2 can be compared
175
+
176
+ Frozen in `eval/knowledge/results/baseline_prototype_2026-08-13_145132.json`.
177
+
178
+ Funnel on the 9-page standard: **9 pages → 13 chunks → 169 mentions** (195 before noise filtering)
179
+ **→ 66 clusters → 83 LLM calls → 66 entries → 66 review-queue items.**
180
+
181
+ E1 recall **0.854** · E2 compression **2.56×** · E3 schema-fill precision **0.75** (FAIL) ·
182
+ E4 fabrication rejection **1.00** with **0.00** false rejections. Cost **$0.0069**, 242 s wall
183
+ clock, 3 fields rejected by span check.
184
+
185
+ **When quoting E2 externally:** the context doc's **6.2×** is a 1,000-page projection; **2.56×** is
186
+ the 9-page measurement. Compression grows as terms repeat across a corpus. Both are true — use the
187
+ right one for the audience.
KNOWLEDGE_PIPELINE_TODO.md CHANGED
@@ -66,10 +66,10 @@ either port scaffolding you didn't want or discard measurements you can't recrea
66
 
67
  | # | Task | Owner | Status | Description |
68
  |---|---|---|---|---|
69
- | **P1** | **Freeze the prototype** | Rifqi | | It has no `.git`. ~2,763 LOC, both gold sets and all four experiment results exist on exactly one machine. Snapshot it read-only zip, orphan branch or private repo, whichever is least ceremony. It is an archive, not a codebase to develop |
70
- | **P2** | **Harvest the tuned constants** | Rifqi | | Every number that took runs to find, written down with its reason, **before** v2 code. Known set: rapidfuzz threshold **92**; fuzzy matching **off below 5 characters** (because `PA` and `UA` score highly against each other); evidence **K=3**; the six-signal evidence weight table; the **1024-token prefix pad** (below that threshold OpenAI-family caching does not engage at all worth the measured 54% hit rate); `labels.yaml` GLiNER label set; `cues.yaml` discourse cues; the 4 prompt files. None of this is in the spec; it lives only as constants and comments, and a clean rewrite will rediscover them as production defects |
71
- | **P3** | **Migrate the evidence into `eval/knowledge/`** | Rifqi | | Gold sets (41 terms + 15 rules) + scorer + the four experiment runners. Follows the existing convention exactly — `eval/{intent,help,readiness,planner}/` each carry `README.md`, `<area>_dataset.json`, `run_eval.py`, and `results/<name>_result_YYYY-MM-DD_HHMMSS.json` files that are never overwritten. **Do this before any v2 pipeline code**: without a standing baseline, "new and improved" is unfalsifiable, and given E3 already failed, quality is exactly what this work will be judged on |
72
- | **P4** | **Record the negative findings** | Rifqi | | Things v2 must not re-derive: nano fails schema fill; escalation is unreachable on small documents (54 of 66 clusters had exactly 1 evidence chunk against K=3, so the loop correctly never fired); 85% of entries returned no definition; conflict detection had nothing to find in a single internally-consistent standard |
73
 
74
  ---
75
 
@@ -166,10 +166,10 @@ name (`src/kex/`, `src/knowledge_extraction/`, TBD in §6).
166
  |---|---|---|---|---|
167
  | **D1** | New dependencies | Rifqi (asks) | ⬜ | GLiNER + torch (heavy) for extraction; MinerU for parsing. `pyproject.toml` changes need sign-off. Prototype needed **torch ≥ 2.6** and hit a corrupt-wheel failure worth not re-debugging |
168
  | **D2** | New tables (DDL handoff) | Rifqi → Harry | ⬜ | Parsed artifacts, candidate entries, glossary versions + approval audit trail. Go owns the dedorch schema — Python never executes DDL. One consolidated Harry-ready handoff beats three |
169
- | **D3** | Model tier per branch | Rifqi | | **Half-answered: nano FAILS at 0.75 vs 0.80.** Blocked on a **mini deployment being provisioned**. Until then all four branches run on nano including rule and summary, whose failure mode is least detectable, since a plausible summary cannot be span-checked |
170
  | **D4** | Admin ingest surface | Rifqi | ⬜ | Whether it is HTTP at all, and if so its auth posture. A write surface triggered by an admin is a different risk class from the current unauthenticated read-only chat surface — raise it, don't inherit the posture by default. Only after the offline path works |
171
  | **D5** | v2 module name | Rifqi | ⬜ | `src/knowledge/` is taken by the existing OCR→pgvector service. Pick a non-colliding name before the first commit |
172
- | **D6** | Expert review of the gold set | Rifqi → Mas Beta | | The 41-term / 15-rule gold set is self-bootstrapped and provisional. E1 and E3 both move when it is reviewed, and E3's scoreable base is 8 entries each adjudication shifts it ~12 points. Now *more* important than before: it is the baseline v2 gets measured against |
173
  | **D7** | GLiNER + LangExtract ensembling | — | ⏸️ | Proposed, not decided; flagged as scope creep. Cheap in API terms but adds a reconciliation step between two candidate sets that may disagree. Not in scope for the test integration |
174
 
175
  ---
 
66
 
67
  | # | Task | Owner | Status | Description |
68
  |---|---|---|---|---|
69
+ | **P1** | Freeze the prototype | Rifqi | | **Resolved 2026-08-19: kept as-is, in place, unversioned Rifqi's call.** The machine-failure risk is accepted knowingly. It is materially reduced anyway now that P2 and P3 have lifted the two things that could not be recreated (the calibration knowledge and the gold set + baseline) into this repo |
70
+ | **P2** | Harvest the tuned constants | Rifqi | | **Done 2026-08-19 [KNOWLEDGE_PIPELINE_CALIBRATION.md](KNOWLEDGE_PIPELINE_CALIBRATION.md).** 8 sections, every value with its reason: GLiNER threshold 0.25 (recall 0.854 vs 0.658 @ 0.35) and the 130-word window (Indonesian subword-tokenises ~2.5×, so 220 still tripped GLiNER's 384 cap); rapidfuzz 92 with fuzzy disabled below 5 chars (`PA`/`UA`); the six-signal weight table + word-boundary heading matching; chunking's 1500-token cap, 0.6 boilerplate frequency and the three real-document traps; the 1024-token cache floor; span/escalation/conflict thresholds and the guarded-field table |
71
+ | **P3** | Migrate the evidence into `eval/knowledge/` | Rifqi | | **Done 2026-08-19.** `eval/knowledge/` now carries `knowledge_gold.yaml` (41 terms + 15 rules, verbatim), `score.py` (ported; only the prototype's `sys.path` bootstrap removed), `README.md`, and the frozen `results/baseline_prototype_2026-08-13_145132.json`. **Migration verified faithful:** the ported scorer re-derives the baseline exactly from the prototype's artifacts E1 recall 0.8537, E3 precision 0.75, scoreable base 8, abstentions 56, all MATCH. `ruff` clean; `import main` OK. No `run_eval.py` yet — it lands with the first v2 stage that produces scoreable output |
72
+ | **P4** | Record the negative findings | Rifqi | | **Done 2026-08-19 calibration doc §7.** Eight entries: nano insufficient for schema fill; escalation unreachable on small documents; conflict detection had nothing to find; 85% abstention; diff only ever ran the empty-baseline path; literal source wording normalised away (gold was right, pipeline wrong); a `glob('*.pdf')` once scored the wrong document (recall 0.05); torch wheels install corrupt and need 2.6 |
73
 
74
  ---
75
 
 
166
  |---|---|---|---|---|
167
  | **D1** | New dependencies | Rifqi (asks) | ⬜ | GLiNER + torch (heavy) for extraction; MinerU for parsing. `pyproject.toml` changes need sign-off. Prototype needed **torch ≥ 2.6** and hit a corrupt-wheel failure worth not re-debugging |
168
  | **D2** | New tables (DDL handoff) | Rifqi → Harry | ⬜ | Parsed artifacts, candidate entries, glossary versions + approval audit trail. Go owns the dedorch schema — Python never executes DDL. One consolidated Harry-ready handoff beats three |
169
+ | **D3** | Model tier per branch | Rifqi | | **Decided 2026-08-19: stay on nano for all four branches.** The `.env` carries the quad `azureai__{api_key,endpoint__url,deployment__name,api__version}__54n`, mirroring the existing `__54m` scheme. Accepted knowingly: nano measured 0.75 against a 0.80 line, and `rule`/`summary` run on the tier whose failure mode is least detectable. Revisit if extraction quality blocks the demo. **`src/config/settings.py` does not expose the `__54n` quad yet** — four `Field(alias=…)` entries, first build step |
170
  | **D4** | Admin ingest surface | Rifqi | ⬜ | Whether it is HTTP at all, and if so its auth posture. A write surface triggered by an admin is a different risk class from the current unauthenticated read-only chat surface — raise it, don't inherit the posture by default. Only after the offline path works |
171
  | **D5** | v2 module name | Rifqi | ⬜ | `src/knowledge/` is taken by the existing OCR→pgvector service. Pick a non-colliding name before the first commit |
172
+ | **D6** | Expert review of the gold set | Rifqi → Mas Beta | ⏸️ | **Deferred 2026-08-19: provisional gold is good enough for now** so the rebuild is not blocked on expert time. Standing caveat: E1 and E3 both move when it is reviewed, and E3's scoreable base is 8 entries, so each adjudication shifts it ~12 points. Every E3 failure is listed verbatim in the frozen baseline for when he does review it |
173
  | **D7** | GLiNER + LangExtract ensembling | — | ⏸️ | Proposed, not decided; flagged as scope creep. Cheap in API terms but adds a reconciliation step between two candidate sets that may disagree. Not in scope for the test integration |
174
 
175
  ---
eval/knowledge/README.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # eval/knowledge — knowledge-extraction eval harness
2
+
3
+ Scores the knowledge-extraction pipeline against a gold set built from
4
+ **BUMA `STD/2026/006/MNO Rev.0.0` — Production Parameter & ECA** (9 pages, majority Bahasa
5
+ Indonesia).
6
+
7
+ Migrated from the `kex` prototype on 2026-08-19. **The prototype itself is not being ported** — it
8
+ answered the design questions and stays where it is. This directory carries the parts that survive
9
+ it: the gold set, the scorer, and a frozen baseline. Plan: [KNOWLEDGE_PIPELINE_TODO.md](../../KNOWLEDGE_PIPELINE_TODO.md),
10
+ tuned constants: [KNOWLEDGE_PIPELINE_CALIBRATION.md](../../KNOWLEDGE_PIPELINE_CALIBRATION.md).
11
+
12
+ ## Why this landed before any v2 pipeline code
13
+
14
+ Without a standing baseline, "new and improved" is unfalsifiable — and one of the four experiments
15
+ already **failed** (E3, schema fill). Quality is what this work gets judged on, so the yardstick
16
+ ships first and every v2 stage is measured from its first commit rather than retroactively.
17
+
18
+ ## Contents
19
+
20
+ | Path | What it is |
21
+ |---|---|
22
+ | `knowledge_gold.yaml` | 41 terms + 15 rules. **Provisional** — bootstrapped by reading the source PDF, *not* expert-reviewed |
23
+ | `score.py` | Precision / recall / F1 per branch. Pipeline-independent: takes plain lists of surfaces and entry dicts, so it scores the prototype's artifacts and v2 alike |
24
+ | `results/baseline_prototype_2026-08-13_145132.json` | **Frozen.** The prototype's measured numbers. Never regenerate — add new timestamped files beside it |
25
+
26
+ No `run_eval.py` yet: there is no v2 pipeline to run. It lands with the first stage that produces
27
+ scoreable output, following the house convention (module mode, timestamped results, never
28
+ overwritten) used by `eval/{intent,help,readiness,planner}/`.
29
+
30
+ ## The baseline to beat
31
+
32
+ Measured by the prototype on the document above, run `20260813-145132`, deployment `gpt-5.4-nano`:
33
+
34
+ | Experiment | Question | Result | Kill line | Verdict |
35
+ |---|---|---|---|---|
36
+ | **E1** | GLiNER recall on Bahasa Indonesia technical prose | **0.854** | 0.70 | **PASS** |
37
+ | **E2** | Does clustering cut expert review burden? | **2.56×** (169 → 66) | 2.0× | **PASS** |
38
+ | **E3** | Is nano sufficient for schema fill? | **0.75** | 0.80 | **FAIL** |
39
+ | **E4** | Does verbatim-span validation catch fabrication? | **1.00** (false-rejection 0.00) | 0.90 | **PASS** |
40
+
41
+ Funnel: 9 pages → 13 chunks → 169 mentions → 66 clusters → 83 LLM calls → 66 entries → 66 queue
42
+ items. Cost $0.0069; 125,184 of 155,313 prompt tokens served from cache.
43
+
44
+ ## Two things the scorer refuses to do
45
+
46
+ **It never conflates term-filter recall with extraction precision.** They are different failure
47
+ modes with different fixes — recall is fixed at the filter stage (GLiNER labels), precision at the
48
+ extraction stage (model tier, prompt). E1 is the recall number specifically.
49
+
50
+ **It never counts abstention as an error.** For a term the document does not define, `null` is the
51
+ correct answer. Scoring is restricted to the *scoreable* subset: entries that produced a definition,
52
+ whose term is in the gold set, and whose gold record carries `definition_contains` to check against.
53
+ Counting the rest as errors would measure gold coverage while claiming to measure model accuracy.
54
+ Coverage is reported separately in `Score.as_dict()`.
55
+
56
+ ## Reading E3 carefully
57
+
58
+ The scoreable base is **8 entries**, so each adjudication moves the number ~12 points. nano is also
59
+ not deterministic at `temperature=0` — consecutive runs of the identical pipeline scored 0.75 and
60
+ 0.625. Treat E3 as a signal, not a measurement, until the gold set is reviewed.
61
+
62
+ Every failure is listed verbatim under `experiments.E3.scoreable_basis.failures` in the baseline
63
+ file, for Mas Beta to adjudicate. Some are genuine extraction errors; others are cases where the
64
+ document carries two valid definitions and the provisional gold names only one. **Gold was not
65
+ edited after seeing output**, so the figure is a lower bound.
66
+
67
+ ## Gold-set conventions (keep these when extending it)
68
+
69
+ - `term` is the string a reader would look up.
70
+ - `full_name` is the **literal source wording**, never normalised. The standard writes *"Physical of
71
+ Availability (PA)"* in the heading and *"Physical Availability"* in the legend — both are recorded
72
+ as variants. This is deliberate: the pipeline is required to surface that discrepancy to the
73
+ expert rather than silently correct it.
74
+ - `definition_contains` are substrings that **must** appear in a correct definition. Substring
75
+ matching, not exact, so a correct-but-differently-worded extraction is not scored as a miss.
76
+ - The gold set is **partial by design** — the expert is the labelling bottleneck, so scoring reports
77
+ coverage rather than blocking on a complete file.
78
+
79
+ ## Status of the gold set
80
+
81
+ `status: provisional`, `labelled_by: claude-bootstrap`, `labelled_on: 2026-08-13`. It has **not**
82
+ been reviewed by Mas Beta. Every score derived from it carries that caveat, and both E1 and E3 will
83
+ move when it is reviewed. Accepted as good enough for now (2026-08-19, Rifqi) so the rebuild is not
84
+ blocked on expert time.
eval/knowledge/knowledge_gold.yaml ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PROVISIONAL gold set — bootstrapped by reading the source PDF, NOT yet expert-reviewed.
2
+ # Reviewer: Mas Beta. Until reviewed, every score derived from this file carries that caveat.
3
+ #
4
+ # Labelling rules used:
5
+ # - `term` is the string a reader would look up.
6
+ # - `full_name` is the LITERAL source wording, not a normalised or corrected form.
7
+ # (e.g. the STD writes "Physical of Availability (PA)" in the heading and
8
+ # "Physical Availability" in the legend — both are recorded as variants.)
9
+ # - `definition_contains` are substrings that MUST appear in a correct definition.
10
+ # Substring matching, not exact match, so a correct-but-differently-worded
11
+ # extraction is not scored as a miss (spec §5).
12
+
13
+ doc_id: STD_2026_006_MNO
14
+ status: provisional
15
+ labelled_by: claude-bootstrap
16
+ labelled_on: 2026-08-13
17
+ source_pages: 9
18
+
19
+ terms:
20
+ - term: Production
21
+ full_name: Production
22
+ variants: ["Production", "produksi"]
23
+ definition_contains: ["nilai tambah", "sumber daya"]
24
+ formula_present: true
25
+ page: 2
26
+ - term: MOHH
27
+ full_name: Machine on Hand Hours
28
+ variants: ["MOHH"]
29
+ definition_contains: ["Machine on Hand Hours"]
30
+ formula_present: false
31
+ page: 2
32
+ - term: Qty
33
+ full_name: Quantity (Qty)
34
+ variants: ["Qty", "Quantity", "quantity"]
35
+ definition_contains: ["jumlah equipment", "periode"]
36
+ formula_present: true
37
+ page: 2
38
+ - term: PA
39
+ full_name: Physical of Availability (PA)
40
+ variants: ["PA", "Physical Availability", "Physical of Availability"]
41
+ definition_contains: ["ketersediaan fisik", "available"]
42
+ formula_present: true
43
+ page: 4
44
+ - term: UA
45
+ full_name: Utilization of Availability (UA)
46
+ variants: ["UA", "Utilization of Availability"]
47
+ definition_contains: ["efektifitas penggunaan", "working hours"]
48
+ formula_present: true
49
+ page: 4
50
+ - term: Pty
51
+ full_name: Productivity (Pty)
52
+ variants: ["Pty", "PTY", "Productivity", "Produktivitas"]
53
+ definition_contains: ["efektifitas penggunaan sumber daya", "output"]
54
+ formula_present: true
55
+ page: 5
56
+ - term: ECA
57
+ full_name: Equipment Capacity Analysis (ECA)
58
+ variants: ["ECA", "Equipment Capacity Analysis"]
59
+ definition_contains: ["ketidaktercapaian", "kapasitas produksi"]
60
+ formula_present: false
61
+ page: 6
62
+ - term: INPR
63
+ full_name: in operation
64
+ variants: ["INPR", "in operation", "INPR Hours"]
65
+ definition_contains: ["in operation"]
66
+ formula_present: false
67
+ page: 2
68
+ - term: Total Hours
69
+ full_name: Total Hours
70
+ variants: ["Total Hours", "total jam"]
71
+ definition_contains: ["total jam"]
72
+ formula_present: true
73
+ page: 2
74
+ - term: Breakdown
75
+ full_name: Breakdown
76
+ variants: ["Breakdown", "breakdown"]
77
+ definition_contains: []
78
+ formula_present: true
79
+ page: 4
80
+ - term: Working Hours
81
+ full_name: Working Hours
82
+ variants: ["Working Hours", "working hours"]
83
+ definition_contains: ["waktu kerja"]
84
+ formula_present: true
85
+ page: 4
86
+ - term: EWH
87
+ full_name: Effective Working Hours (EWH)
88
+ variants: ["EWH", "Effective Working Hours", "productive working hours"]
89
+ definition_contains: ["waktu kerja efektif"]
90
+ formula_present: false
91
+ page: 5
92
+ - term: Land clearing
93
+ full_name: land clearing
94
+ variants: ["land clearing"]
95
+ definition_contains: []
96
+ formula_present: false
97
+ page: 2
98
+ - term: Overburden removal
99
+ full_name: pengupasan lapisan penutup (overburden removal)
100
+ variants: ["overburden removal", "pengupasan lapisan penutup"]
101
+ definition_contains: []
102
+ formula_present: false
103
+ page: 2
104
+ - term: Coal mining
105
+ full_name: penambangan batubara (coal mining)
106
+ variants: ["coal mining", "Coal Mining", "CM", "penambangan batubara"]
107
+ definition_contains: []
108
+ formula_present: false
109
+ page: 2
110
+ - term: Coal hauling
111
+ full_name: pengangkutan batubara (coal hauling/coal transporting)
112
+ variants: ["coal hauling", "coal transporting", "Coal Transport", "CT"]
113
+ definition_contains: []
114
+ formula_present: false
115
+ page: 2
116
+ - term: Coal barging
117
+ full_name: coal barging atau ship loading
118
+ variants: ["coal barging", "ship loading", "pengapalan"]
119
+ definition_contains: []
120
+ formula_present: false
121
+ page: 2
122
+ - term: Waste Removal
123
+ full_name: Waste Removal (WR)
124
+ variants: ["Waste Removal", "WR", "waste removal"]
125
+ definition_contains: []
126
+ formula_present: false
127
+ page: 3
128
+ - term: General Work
129
+ full_name: General Work
130
+ variants: ["General Work"]
131
+ definition_contains: []
132
+ formula_present: false
133
+ page: 3
134
+ - term: Rental
135
+ full_name: Rental
136
+ variants: ["Rental"]
137
+ definition_contains: []
138
+ formula_present: false
139
+ page: 3
140
+ - term: Grouping (Composite)
141
+ full_name: Grouping (Composite)
142
+ variants: ["Grouping (Composite)", "grouping (composite)", "Composite"]
143
+ definition_contains: ["type", "class"]
144
+ formula_present: true
145
+ page: 3
146
+ - term: EX2500
147
+ full_name: EX2500
148
+ variants: ["EX2500"]
149
+ definition_contains: []
150
+ formula_present: false
151
+ page: 3
152
+ - term: Time Performance MCD Application
153
+ full_name: raw data Time Performance MCD Application
154
+ variants: ["Time Performance MCD Application", "MCD Application", "MCD"]
155
+ definition_contains: []
156
+ formula_present: false
157
+ page: 3
158
+ - term: Joint survey
159
+ full_name: joint survey
160
+ variants: ["joint survey"]
161
+ definition_contains: []
162
+ formula_present: false
163
+ page: 5
164
+ - term: Truck count
165
+ full_name: truck count
166
+ variants: ["truck count"]
167
+ definition_contains: []
168
+ formula_present: false
169
+ page: 5
170
+ - term: Weight average
171
+ full_name: rata-rata tertimbang (weight average)
172
+ variants: ["weight average", "rata-rata tertimbang", "pembobotan"]
173
+ definition_contains: []
174
+ formula_present: false
175
+ page: 4
176
+ - term: Gain/Loss
177
+ full_name: Gain/Loss
178
+ variants: ["Gain/Loss", "Gain / Loss"]
179
+ definition_contains: ["plan", "actual"]
180
+ formula_present: true
181
+ page: 6
182
+ - term: Plan
183
+ full_name: rencana (plan)
184
+ variants: ["plan", "Plan", "rencana", "P(M)", "P(G)"]
185
+ definition_contains: []
186
+ formula_present: false
187
+ page: 6
188
+ - term: Actual
189
+ full_name: realisasi (actual)
190
+ variants: ["actual", "Actual", "realisasi", "A(M)", "A(G)"]
191
+ definition_contains: []
192
+ formula_present: false
193
+ page: 6
194
+ - term: Model Unit
195
+ full_name: Model Unit
196
+ variants: ["Model Unit", "model unit", "(M)", "Model"]
197
+ definition_contains: []
198
+ formula_present: true
199
+ page: 6
200
+ - term: Standby
201
+ full_name: Delay/Standby
202
+ variants: ["Standby", "standby", "Delay/Standby", "Gap Standby"]
203
+ definition_contains: []
204
+ formula_present: true
205
+ page: 6
206
+ - term: BCM
207
+ full_name: BCM
208
+ variants: ["BCM"]
209
+ definition_contains: []
210
+ formula_present: false
211
+ page: 6
212
+ - term: Waterfall Analysis
213
+ full_name: Waterfall Analysis
214
+ variants: ["Waterfall Analysis", "grafik Waterfall"]
215
+ definition_contains: ["visualisasi", "Gain/Loss"]
216
+ formula_present: false
217
+ page: 8
218
+ - term: Other Activity
219
+ full_name: Other Activity
220
+ variants: ["Other Activity"]
221
+ definition_contains: ["tidak sesuai", "direncanakan"]
222
+ formula_present: false
223
+ page: 8
224
+ - term: Loader
225
+ full_name: loader
226
+ variants: ["loader", "PA Loader"]
227
+ definition_contains: []
228
+ formula_present: false
229
+ page: 8
230
+ - term: Hauler
231
+ full_name: hauler
232
+ variants: ["hauler"]
233
+ definition_contains: []
234
+ formula_present: false
235
+ page: 8
236
+ - term: Hierarki Level Analysis
237
+ full_name: Hierarki Level Analyisis
238
+ variants: ["Hierarki Level Analyisis", "Hierarki Level Analysis"]
239
+ definition_contains: ["level"]
240
+ formula_present: false
241
+ page: 9
242
+ - term: Uncontrollable
243
+ full_name: Uncontrollable
244
+ variants: ["Uncontrollable", "uncontrollable"]
245
+ definition_contains: ["level 1"]
246
+ formula_present: false
247
+ page: 9
248
+ - term: Controllable
249
+ full_name: controllable
250
+ variants: ["controllable", "UA Controllable"]
251
+ definition_contains: ["level 1"]
252
+ formula_present: false
253
+ page: 9
254
+ - term: Fleet management
255
+ full_name: fleet management
256
+ variants: ["fleet management"]
257
+ definition_contains: []
258
+ formula_present: false
259
+ page: 9
260
+ - term: Mineplan
261
+ full_name: mineplan
262
+ variants: ["mineplan"]
263
+ definition_contains: []
264
+ formula_present: false
265
+ page: 9
266
+
267
+ rules:
268
+ - rule_id: QTY_PER_ACTIVITY
269
+ statement_contains: ["Qty", "kategori aktivitas", "terpisah"]
270
+ page: 3
271
+ - rule_id: QTY_GROUPING_SUM
272
+ statement_contains: ["Qty Grouping", "menjumlahkan"]
273
+ page: 3
274
+ - rule_id: ACTIVITY_CLASSIFICATION_SOURCE
275
+ statement_contains: ["status equipment", "Time Performance MCD"]
276
+ page: 3
277
+ - rule_id: PA_COMPOSITE_WEIGHTED
278
+ statement_contains: ["weight average", "Quantity", "pembobot"]
279
+ page: 4
280
+ - rule_id: UA_BY_ACTIVITY
281
+ statement_contains: ["UA", "kategori aktivitas"]
282
+ page: 4
283
+ - rule_id: PTY_PRODUCTION_SOURCE
284
+ statement_contains: ["joint survey", "truck count"]
285
+ page: 5
286
+ - rule_id: PTY_WORKING_HOURS_DEFINITION
287
+ statement_contains: ["Effective Working Hours", "waktu kerja efektif"]
288
+ page: 5
289
+ - rule_id: PTY_COMPOSITE_WEIGHTED
290
+ statement_contains: ["Pty", "pembobotan", "Quantity"]
291
+ page: 5
292
+ - rule_id: GAINLOSS_QTY_ZERO_PLAN
293
+ statement_contains: ["QtyP(M) = 0", "QtyA(M) > 0"]
294
+ page: 6
295
+ - rule_id: GAINLOSS_COMPOSITE_SUM
296
+ statement_contains: ["grouping", "penjumlahan", "model unit"]
297
+ page: 6
298
+ - rule_id: GAINLOSS_UNITS
299
+ statement_contains: ["BCM", "ton", "jam"]
300
+ page: 6
301
+ - rule_id: QTY_ACTUAL_ZERO_PLAN_ALT
302
+ statement_contains: ["QtyA", "QtyP", "0"]
303
+ page: 7
304
+ - rule_id: QTY_PHYSICAL_REPORTING
305
+ statement_contains: ["fisik unit", "INPR"]
306
+ page: 7
307
+ - rule_id: ECA_OTHER_ACTIVITY
308
+ statement_contains: ["alokasi", "tidak sesuai", "direncanakan"]
309
+ page: 8
310
+ - rule_id: WATERFALL_TWO_METHODS
311
+ statement_contains: ["Waterfall", "dua metode"]
312
+ page: 8
eval/knowledge/results/baseline_prototype_2026-08-13_145132.json ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_note": "FROZEN BASELINE — measured by the `kex` prototype, not by this repo. Extraction v2 must match or beat these numbers on the same document and the same gold set. Copied from the prototype's eval/out/metrics.json on 2026-08-19; never regenerate this file, add a new timestamped one.",
3
+ "_source": {
4
+ "repo": "kex prototype (outside version control)",
5
+ "file": "eval/out/metrics.json",
6
+ "copied_on": "2026-08-19"
7
+ },
8
+ "provenance": {
9
+ "run_id": "20260813-145132",
10
+ "config_hash": "89545a78c702",
11
+ "created_at": "2026-08-13T14:51:32",
12
+ "doc_id": "STD_2026_006_MNO",
13
+ "client_mode": "azure",
14
+ "deployments": {
15
+ "glossary": "gpt-5.4-nano",
16
+ "rule": "gpt-5.4-nano",
17
+ "formula": "gpt-5.4-nano",
18
+ "summary": "gpt-5.4-nano"
19
+ },
20
+ "api_version": "2024-12-01-preview",
21
+ "gold_status": "provisional — not yet expert-reviewed"
22
+ },
23
+ "funnel": {
24
+ "status": "measured",
25
+ "stages": [
26
+ {
27
+ "stage": "pages",
28
+ "count": 9
29
+ },
30
+ {
31
+ "stage": "chunks",
32
+ "count": 13
33
+ },
34
+ {
35
+ "stage": "mentions",
36
+ "count": 169
37
+ },
38
+ {
39
+ "stage": "clusters",
40
+ "count": 66
41
+ },
42
+ {
43
+ "stage": "llm_calls",
44
+ "count": 83
45
+ },
46
+ {
47
+ "stage": "glossary_entries",
48
+ "count": 66
49
+ },
50
+ {
51
+ "stage": "review_queue",
52
+ "count": 66
53
+ }
54
+ ],
55
+ "raw_mentions_before_noise_filter": 195
56
+ },
57
+ "experiments": {
58
+ "E1": {
59
+ "question": "GLiNER recall on Bahasa Indonesia technical prose",
60
+ "status": "measured",
61
+ "value": 0.8537,
62
+ "kill_line": 0.7,
63
+ "verdict": "PASS",
64
+ "detail": "broad @ threshold 0.25",
65
+ "n_gold": 41
66
+ },
67
+ "E2": {
68
+ "question": "Does clustering cut expert review burden?",
69
+ "status": "measured",
70
+ "value": 2.561,
71
+ "kill_line": 2.0,
72
+ "verdict": "PASS",
73
+ "detail": "169 mentions -> 66 clusters",
74
+ "llm_calls_avoided": 103
75
+ },
76
+ "E3": {
77
+ "question": "Is nano sufficient for schema fill?",
78
+ "status": "measured_nano_only",
79
+ "value": 0.75,
80
+ "kill_line": 0.8,
81
+ "verdict": "FAIL",
82
+ "detail": "nano only — no mini deployment provisioned, so the spec's nano-vs-mini A/B could not be run",
83
+ "glossary_precision": 0.75,
84
+ "glossary_recall": 0.3333,
85
+ "glossary_f1": 0.4615,
86
+ "rule_precision": 0.5714,
87
+ "rule_recall": 0.2667,
88
+ "n_gold_terms": 18,
89
+ "scoreable_basis": {
90
+ "entries_total": 66,
91
+ "entries_with_definition": 10,
92
+ "abstained_null_definition": 56,
93
+ "scoreable": 8,
94
+ "unscoreable_term_not_in_gold": 2,
95
+ "unscoreable_gold_has_no_criteria": 0,
96
+ "gold_terms_with_criteria": 18,
97
+ "failures": [
98
+ {
99
+ "term": "produksi",
100
+ "gold_requires": [
101
+ "nilai tambah",
102
+ "sumber daya"
103
+ ],
104
+ "extracted": "Dalam konteks industri pertambangan, produksi merupakan seluruh rangkaian kegiatan operasional penambangan yang dimulai dari land clearing, pengupasan lapisan penutup (overburden removal), penambangan batubara (coal mining), pengangkutan ba"
105
+ },
106
+ {
107
+ "term": "Production",
108
+ "gold_requires": [
109
+ "nilai tambah",
110
+ "sumber daya"
111
+ ],
112
+ "extracted": "Dalam konteks industri pertambangan, produksi merupakan seluruh rangkaian kegiatan operasional penambangan yang dimulai dari land clearing, pengupasan lapisan penutup (overburden removal), penambangan batubara (coal mining), pengangkutan ba"
113
+ }
114
+ ]
115
+ },
116
+ "abstention_note": "entries where nano returned null are NOT counted as errors — for a term the document never defines, null is the correct answer",
117
+ "adjudication_pending": "The scoreable base is small, so each failure moves the number by ~12 points. Every failure is listed verbatim under scoreable_basis.failures for Mas Beta to adjudicate: some are genuine extraction errors, others are cases where the document carries two valid definitions and the provisional gold names only one. Gold was NOT edited after seeing output, so this figure is a lower bound.",
118
+ "stability_warning": "nano is not fully deterministic at temperature=0: consecutive runs of the identical pipeline produced precision 0.75 and 0.625. On a base this small, treat E3 as a signal, not a measurement."
119
+ },
120
+ "E4": {
121
+ "question": "Does verbatim-span validation catch fabrication?",
122
+ "status": "measured",
123
+ "value": 1.0,
124
+ "kill_line": 0.9,
125
+ "verdict": "PASS",
126
+ "false_rejection_rate": 0.0,
127
+ "detail": "span_check is deterministic; measures the control itself"
128
+ }
129
+ },
130
+ "quality": {
131
+ "status": "measured",
132
+ "glossary_entries": 66,
133
+ "rule_entries": 7,
134
+ "formula_entries": 7,
135
+ "fields_rejected_by_span_check": 3,
136
+ "no_definition_found": 56,
137
+ "escalated": 0,
138
+ "definition_conflicts": 0
139
+ },
140
+ "efficiency": {
141
+ "status": "measured + stated assumption",
142
+ "llm_calls_avoided_by_clustering": 103,
143
+ "review_queue_size": 66,
144
+ "assumed_minutes_per_term_review": 3.0,
145
+ "assumed_minutes_per_term_manual": 12.0,
146
+ "est_review_hours": 3.3,
147
+ "est_manual_hours": 13.2,
148
+ "est_hours_saved": 9.9
149
+ },
150
+ "cost_measured": {
151
+ "prompt": 155313,
152
+ "cached": 125184,
153
+ "completion": 11862,
154
+ "usd": 0.006877,
155
+ "latency_s": 242.4
156
+ }
157
+ }
eval/knowledge/score.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Precision / recall / F1 per branch of the knowledge-extraction pipeline.
2
+
3
+ Migrated verbatim (bar the import bootstrap) from the `kex` prototype, 2026-08-19.
4
+ The prototype is not being ported; this scorer is, because it is the only thing
5
+ that can show extraction v2 matches or beats the measured baseline in
6
+ `results/baseline_prototype_2026-08-13_145132.json`.
7
+
8
+ Pipeline-independent by design: it scores plain lists of surfaces / entry dicts,
9
+ so it works against the prototype's artifacts and against v2 alike.
10
+
11
+ The one thing this module refuses to do is conflate **term-filter recall** with
12
+ **extraction precision** (spec §5). They are different failure modes with
13
+ different fixes: recall is fixed at stage 2 (GLiNER labels), precision is fixed
14
+ at stage 3 (model tier / prompt). E1 is the recall number specifically.
15
+
16
+ Gold sets are treated as partial by design — Mas Beta is the labelling
17
+ bottleneck, so scoring reports coverage rather than blocking on a complete file.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ import unicodedata
24
+ from dataclasses import asdict, dataclass
25
+ from pathlib import Path
26
+
27
+ import yaml
28
+
29
+ GOLD_PATH = Path(__file__).resolve().parent / "knowledge_gold.yaml"
30
+
31
+
32
+ def norm(s: str) -> str:
33
+ s = unicodedata.normalize("NFKC", s).casefold()
34
+ s = re.sub(r"[^\w\s]", " ", s)
35
+ return re.sub(r"\s+", " ", s).strip()
36
+
37
+
38
+ @dataclass
39
+ class Score:
40
+ label: str
41
+ n_gold: int
42
+ n_pred: int
43
+ true_positives: int
44
+ precision: float
45
+ recall: float
46
+ f1: float
47
+ misses: list[str]
48
+
49
+ def as_dict(self) -> dict:
50
+ d = asdict(self)
51
+ if hasattr(self, "coverage"):
52
+ d["coverage"] = self.coverage
53
+ return d
54
+
55
+
56
+ def load_gold(path: Path) -> dict:
57
+ with open(path, encoding="utf-8") as fh:
58
+ return yaml.safe_load(fh)
59
+
60
+
61
+ def _prf(tp: int, n_pred: int, n_gold: int) -> tuple[float, float, float]:
62
+ precision = tp / n_pred if n_pred else 0.0
63
+ recall = tp / n_gold if n_gold else 0.0
64
+ f1 = (
65
+ 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
66
+ )
67
+ return round(precision, 4), round(recall, 4), round(f1, 4)
68
+
69
+
70
+ def score_term_filter(gold: dict, surfaces: list[str]) -> Score:
71
+ """E1. A gold term counts as recalled if ANY of its variants appears among
72
+ the filter's mention surfaces (substring match both ways, so 'PA' inside
73
+ 'Physical Availability (PA)' counts).
74
+
75
+ Precision is reported but is NOT E1's criterion: the filter is deliberately
76
+ over-inclusive, and clustering plus evidence ranking absorb the noise.
77
+ """
78
+ normed = {norm(s) for s in surfaces if norm(s)}
79
+ blob = " | ".join(sorted(normed))
80
+ tp, misses = 0, []
81
+ gold_terms = gold.get("terms", [])
82
+ for entry in gold_terms:
83
+ variants = [entry["term"]] + list(entry.get("variants", []))
84
+ if entry.get("full_name"):
85
+ variants.append(entry["full_name"])
86
+ hit = False
87
+ for v in variants:
88
+ nv = norm(v)
89
+ if not nv:
90
+ continue
91
+ if nv in normed or re.search(rf"(?<![\w]){re.escape(nv)}(?![\w])", blob):
92
+ hit = True
93
+ break
94
+ if hit:
95
+ tp += 1
96
+ else:
97
+ misses.append(entry["term"])
98
+ precision, recall, f1 = _prf(tp, len(normed), len(gold_terms))
99
+ return Score(
100
+ label="term_filter_recall(E1)",
101
+ n_gold=len(gold_terms),
102
+ n_pred=len(normed),
103
+ true_positives=tp,
104
+ precision=precision,
105
+ recall=recall,
106
+ f1=f1,
107
+ misses=misses,
108
+ )
109
+
110
+
111
+ def score_glossary(gold: dict, entries: list[dict]) -> Score:
112
+ """E3: when nano fills the schema, is it right?
113
+
114
+ Scoring is restricted to the SCOREABLE subset: entries that produced a
115
+ definition AND whose term is in the gold set AND whose gold record carries
116
+ `definition_contains` to check against.
117
+
118
+ Why not simply tp/len(entries): the term filter is deliberately
119
+ over-inclusive and the gold set is deliberately partial, so most entries are
120
+ for terms gold says nothing about. Counting those as errors would measure
121
+ gold coverage while claiming to measure nano's accuracy — precisely the
122
+ conflation spec §5 forbids. Coverage is reported separately in as_dict().
123
+
124
+ Substring matching, not exact — exact match would under-report
125
+ correct-but-differently-worded extractions (spec §5).
126
+ """
127
+ gold_by_term: dict[str, dict] = {}
128
+ for entry in gold.get("terms", []):
129
+ for v in [entry["term"], *entry.get("variants", [])]:
130
+ gold_by_term.setdefault(norm(v), entry)
131
+
132
+ checkable_gold = [
133
+ g for g in gold.get("terms", []) if g.get("definition_contains")
134
+ ]
135
+ n_checkable_gold = len(checkable_gold)
136
+
137
+ correct, incorrect = 0, 0
138
+ unscoreable_no_gold, unscoreable_no_criteria = 0, 0
139
+ matched_gold, wrong, failures = set(), [], []
140
+
141
+ for pred in entries:
142
+ if not (pred.get("definition") or "").strip():
143
+ continue # abstention is scored separately, not as an error
144
+ g = gold_by_term.get(norm(pred.get("term", "")))
145
+ if not g:
146
+ unscoreable_no_gold += 1
147
+ continue
148
+ required = [norm(x) for x in g.get("definition_contains", [])]
149
+ if not required:
150
+ unscoreable_no_criteria += 1
151
+ continue
152
+ definition = norm(pred.get("definition") or "")
153
+ if all(r in definition for r in required):
154
+ correct += 1
155
+ matched_gold.add(g["term"])
156
+ else:
157
+ incorrect += 1
158
+ wrong.append(f"{pred.get('term')} (definition did not match gold)")
159
+ failures.append(
160
+ {
161
+ "term": pred.get("term"),
162
+ "gold_requires": g.get("definition_contains"),
163
+ "extracted": (pred.get("definition") or "")[:240],
164
+ }
165
+ )
166
+
167
+ misses = [g["term"] for g in checkable_gold if g["term"] not in matched_gold]
168
+ n_scoreable = correct + incorrect
169
+ precision, recall, f1 = _prf(correct, n_scoreable, n_checkable_gold)
170
+
171
+ score = Score(
172
+ label="glossary_schema_fill(E3)",
173
+ n_gold=n_checkable_gold,
174
+ n_pred=n_scoreable,
175
+ true_positives=correct,
176
+ precision=precision,
177
+ recall=recall,
178
+ f1=f1,
179
+ misses=misses + wrong,
180
+ )
181
+ score.coverage = { # type: ignore[attr-defined]
182
+ "entries_total": len(entries),
183
+ "entries_with_definition": sum(
184
+ 1 for e in entries if (e.get("definition") or "").strip()
185
+ ),
186
+ "abstained_null_definition": sum(
187
+ 1 for e in entries if not (e.get("definition") or "").strip()
188
+ ),
189
+ "scoreable": n_scoreable,
190
+ "unscoreable_term_not_in_gold": unscoreable_no_gold,
191
+ "unscoreable_gold_has_no_criteria": unscoreable_no_criteria,
192
+ "gold_terms_with_criteria": n_checkable_gold,
193
+ "failures": failures,
194
+ }
195
+ return score
196
+
197
+
198
+ def score_rules(gold: dict, entries: list[dict]) -> Score:
199
+ gold_rules = gold.get("rules", [])
200
+ pred_blobs = [
201
+ norm(
202
+ " ".join(
203
+ str(v)
204
+ for v in (e.get("statement"), e.get("condition"), e.get("consequence"))
205
+ if v
206
+ )
207
+ )
208
+ for e in entries
209
+ ]
210
+ tp, misses = 0, []
211
+ for rule in gold_rules:
212
+ required = [norm(x) for x in rule.get("statement_contains", [])]
213
+ if any(all(r in blob for r in required) for blob in pred_blobs):
214
+ tp += 1
215
+ else:
216
+ misses.append(rule["rule_id"])
217
+ precision, recall, f1 = _prf(tp, len(entries), len(gold_rules))
218
+ return Score(
219
+ label="rule",
220
+ n_gold=len(gold_rules),
221
+ n_pred=len(entries),
222
+ true_positives=tp,
223
+ precision=precision,
224
+ recall=recall,
225
+ f1=f1,
226
+ misses=misses,
227
+ )