ishaq101 commited on
Commit
f07443e
·
1 Parent(s): b68816f

/fix parsing and term extract (#21)

Browse files

- [NOTICKET] fix: retire the fuzzy clustering pass; add E1c post-cluster recall (2cc21c92688d662c5cfdfeeb6784bb85b98575ca)
- [NOTICKET] feat: parsing schema 0.4.0 — asset pointers for figures and tables (e662ac089b741c90e19b9f51f4933fb97a1225bc)
- [NOTICKET] docs: sync REPO_STATUS / DEV_PLAN / contracts for pipeline v2 (c3c184d83bec4a4d42e9ad3f7265560802817e5b)
- [NOTICKET] docs: close K15 with the paid v1-vs-v2 comparison (62782fed9292f67d7b376783920a07197d6776e9)
- [NOTICKET] fix: test the caption hypothesis; ship the signal at weight 0.0 (36a166e1844d186d6cfb8fff9ea070f3dc69b220)
- [NOTICKET] fix: cap key-parameter pre-emption in the review queue (Step 2) (4e3a6aefef7024db3ea4b7946fd7771a6c269601)
- [NOTICKET] feat: assert on invariants, report a band, classify the genre (Steps 0 + 5) (f1d64b113db0289601806635a9b154201a6127fc)
- [NOTICKET] fix: gate spend in cluster units and surface the estimate (Step 6) (cdb5fe82cc5f2893ccbb5e28282e8dc46a27e061)
- [NOTICKET] fix: retire escalation, budget windows by subword (Finding 6 + the over-cap window) (1faca810ea9c17fd3f86cd388afa942f48dfef78)
- [NOTICKET] docs: sync for the open-queue pass, add the Step 8 and Step 7 proposals (7659cf0333d6d14af923d227ceff139eb359118f)
- [NOTICKET] feat: pull one linked partner chunk into the evidence window (5d5102547e162caa837d7c89839c875b4029f067)
- [NOTICKET] fix: add a fault-code label, measured; re-diagnose the Komatsu tables (366697cb4b2db593a0772bc26b891b5422cc9b31)
- [NOTICKET] feat(knowledge): readable concept keys + key-value lookup (m1, m2, m6, m8) (0ba89b18f02b46549cffbf2e216d51f8e8cc82a8)
- [NOTICKET] feat(knowledge): incremental ingest — versions, reuse, staleness (i1-i7) (72e18a592a8951e8b98d5ce84e4e673986c48b55)
- [NOTICKET] test(knowledge): cross-session dedupe (i6) (0974503b9d280ff6d059898c17c5777a86571014)
- [NOTICKET] feat(knowledge): promoted columns + withdrawal (m3, m4, m5, i7) (e2ae28f347023b01c03208aefe947f1f37b4969d)
- [NOTICKET] feat(knowledge): review diff + the rule/domain boundary (r4, o2) (5cb2e2da72d6002ef8ea96cba8faf3a768faa8dc)
- [NOTICKET] feat(knowledge): two review modes, recorded against a person (r1, r2, r3) (6ef79904de4af18660e493664d47d1c4d1bcf442)
- [NOTICKET] docs(knowledge): bring the DDL handoff current for Harry (e43356130f634eb0078ad709304a143a91b8ad52)
- [NOTICKET] docs: sync REPO_STATUS / DEV_PLAN / contract for the 8 Sep list (K24) (be227a85bb553c5376d85c76cd001f4954ca9829)

Files changed (46) hide show
  1. API_CONTRACT_BE_PYTHON.md +54 -1
  2. DEV_PLAN.md +81 -0
  3. REPO_STATUS.md +114 -7
  4. docs/README.md +2 -0
  5. docs/knowledge/KNOWLEDGE_OUTPUT_CONTRACT.md +109 -14
  6. docs/knowledge/KNOWLEDGE_PARSING_OUTPUT_CONTRACT.md +89 -2
  7. docs/knowledge/KNOWLEDGE_PIPELINE_CALIBRATION.md +43 -6
  8. docs/knowledge/KNOWLEDGE_PIPELINE_TODO.md +10 -3
  9. docs/knowledge/TABLE_BRANCH_PROPOSAL.md +196 -0
  10. docs/knowledge/TERM_HIERARCHY_PROPOSAL.md +122 -0
  11. docs/knowledge/knowledge_tables.sql +152 -14
  12. eval/knowledge/invariants.py +203 -0
  13. eval/knowledge/results/v2_filters_2026-09-10_151557.json +109 -0
  14. eval/knowledge/results/v2_filters_2026-09-11_092207.json +137 -0
  15. eval/knowledge/results/v2_filters_2026-09-11_100546.json +131 -0
  16. eval/knowledge/run_eval.py +291 -14
  17. eval/knowledge/score.py +54 -0
  18. src/api/v1/knowledge.py +14 -1
  19. src/db/postgres/models.py +32 -0
  20. src/knowledge_extraction/adapter.py +24 -2
  21. src/knowledge_extraction/cluster/cluster.py +37 -6
  22. src/knowledge_extraction/cluster/normalize.py +60 -8
  23. src/knowledge_extraction/config/labels.yaml +103 -0
  24. src/knowledge_extraction/extract/base.py +36 -0
  25. src/knowledge_extraction/extract/branches.py +6 -1
  26. src/knowledge_extraction/extract/prompts/glossary.txt +11 -0
  27. src/knowledge_extraction/extract/prompts/rule.txt +11 -0
  28. src/knowledge_extraction/filters/span_filter.py +103 -1
  29. src/knowledge_extraction/genre.py +213 -0
  30. src/knowledge_extraction/keys.py +160 -0
  31. src/knowledge_extraction/models.py +42 -0
  32. src/knowledge_extraction/mutation.py +113 -0
  33. src/knowledge_extraction/queue/review_queue.py +93 -15
  34. src/knowledge_extraction/rank/evidence.py +82 -4
  35. src/knowledge_extraction/service.py +121 -10
  36. src/knowledge_extraction/settings.py +225 -8
  37. src/knowledge_ingest/knowledge_store.py +473 -12
  38. src/knowledge_ingest/models.py +29 -0
  39. src/knowledge_ingest/pipeline.py +136 -7
  40. src/knowledge_ingest/service.py +56 -1
  41. src/knowledge_ingest/store.py +2 -0
  42. src/knowledge_parsing/assets.py +156 -0
  43. src/knowledge_parsing/config.py +5 -0
  44. src/knowledge_parsing/contracts.py +94 -5
  45. src/knowledge_parsing/normalize_mistral.py +195 -16
  46. src/knowledge_parsing/run.py +21 -4
API_CONTRACT_BE_PYTHON.md CHANGED
@@ -802,7 +802,9 @@ Frontend rendering guidance:
802
 
803
  ## Knowledge
804
 
805
- > Added 2026-09-07 (KNOWLEDGE_SURFACE_PLAN.md). Turns a user's uploaded documents into reviewable knowledge artifacts: glossary terms, rules, formulas and one domain context per document. **Admin-shaped, not part of the chat flow** — no chat endpoint calls any of this, and the analytics path does not yet read what it produces.
 
 
806
 
807
  **Asynchronous by necessity.** Parsing a nine-page document takes ~80 s and extracting from it ~170 s, far past any HTTP timeout, so ingestion admits a batch and returns immediately. **Polling, not SSE** (decided 2026-09-07): a job row survives a page refresh and a dropped connection, a stream does not, and a four-minute stream would hold a worker for its duration.
808
 
@@ -821,9 +823,18 @@ Body:
821
  | `user_id` | Yes | The caller. Must be a UUID (422 otherwise). Scope is derived from it. |
822
  | `document_ids` | No | Omit for the one-click case: every parseable document this user owns. Named ids are still filtered by owner and type. |
823
  | `reparse` | No | Re-parse even when the content hash matches a stored artifact. Default `false` — on by default it silently re-spends. |
 
824
 
825
  Only `pdf` and `docx` are parsed; spreadsheets and text files belong to the structured-data path and come back in `skipped` **with a reason**, never silently dropped.
826
 
 
 
 
 
 
 
 
 
827
  `reused: true` means a job for this company was already running and is being returned instead of a second one starting — a double click is not an error.
828
 
829
  ```json
@@ -846,6 +857,48 @@ Job state and per-document outcomes. **404** when the id is unknown — unlike `
846
 
847
  `stage` (`parsing` · `filtering` · `estimating` · `extracting` · `persisting`) is ordered by cost: everything up to and including `estimating` is free, and the ceiling is enforced between `estimating` and `extracting`.
848
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
849
  ### `GET /api/v1/knowledge/jobs`
850
 
851
  Recent jobs for one `user_id`, newest first — lets a caller recover after a refresh without having stashed a `job_id`.
 
802
 
803
  ## Knowledge
804
 
805
+ > Added 2026-09-07 (KNOWLEDGE_SURFACE_PLAN.md). Turns a user's uploaded documents into reviewable knowledge artifacts: glossary terms, rules, formulas and one domain context per document. **Admin-shaped, not part of the chat flow** — no chat endpoint calls any of this.
806
+ >
807
+ > **Corrected 2026-09-14:** the line that stood here ("the analytics path does not yet read what it produces") is out of date — the consumption path was armed 2026-09-08 (DEV_PLAN §0.8 K5/K9/K11). The planner reads the company's approved terminology on every slow-path turn. **Approved entries only**, so it stays inert until an expert works the queue.
808
 
809
  **Asynchronous by necessity.** Parsing a nine-page document takes ~80 s and extracting from it ~170 s, far past any HTTP timeout, so ingestion admits a batch and returns immediately. **Polling, not SSE** (decided 2026-09-07): a job row survives a page refresh and a dropped connection, a stream does not, and a four-minute stream would hold a worker for its duration.
810
 
 
823
  | `user_id` | Yes | The caller. Must be a UUID (422 otherwise). Scope is derived from it. |
824
  | `document_ids` | No | Omit for the one-click case: every parseable document this user owns. Named ids are still filtered by owner and type. |
825
  | `reparse` | No | Re-parse even when the content hash matches a stored artifact. Default `false` — on by default it silently re-spends. |
826
+ | `review_mode` | No | **Added 2026-09-14.** `expert` (default) or `llm`. Who rules on what this batch produces. |
827
 
828
  Only `pdf` and `docx` are parsed; spreadsheets and text files belong to the structured-data path and come back in `skipped` **with a reason**, never silently dropped.
829
 
830
+ **`review_mode` (2026-09-14).** `expert` leaves every entry waiting for a human. `llm` approves
831
+ them on arrival with **no reviewer step and no second opinion** — the entries were written by a
832
+ model and are accepted as-is; there is no second model call. What the FE must surface when
833
+ offering it: choosing `llm` records `authorised_by` against the caller, so an entry approved this
834
+ way can always answer *who allowed this*. Default is `expert`, so nobody gets LLM approval by
835
+ accident. Entries are written through the ordinary append-only review path, so an expert can
836
+ still disagree with any of them afterwards.
837
+
838
  `reused: true` means a job for this company was already running and is being returned instead of a second one starting — a double click is not an error.
839
 
840
  ```json
 
857
 
858
  `stage` (`parsing` · `filtering` · `estimating` · `extracting` · `persisting`) is ordered by cost: everything up to and including `estimating` is free, and the ceiling is enforced between `estimating` and `extracting`.
859
 
860
+ > **Updated 2026-09-11 — the estimate is now returned, and `blocked` is now reachable.**
861
+ >
862
+ > `estimated_cost` was on the job model from the start and was **never populated**, so the field a
863
+ > caller would poll to answer "what will this cost, and why was that document refused" always came
864
+ > back `null`. It now carries the pre-flight shape of the batch, per document and in total. A
865
+ > wholly-refused batch also settles **`blocked`** rather than `failed` — previously every spend
866
+ > refusal was reported as a generic failure, which hid the one outcome with an action attached.
867
+ >
868
+ > **Spend is driven by CLUSTER COUNT** — one LLM call per term cluster — not by page count, so the
869
+ > per-document breakdown is what tells an operator which document to split. There are now **two
870
+ > ceilings**, and `blocked_by` says which one bound:
871
+ >
872
+ > | `blocked_by` | Meaning | The operator's move |
873
+ > |---|---|---|
874
+ > | `clusters` | Too many term clusters (exact, known free before spending) | Split the document |
875
+ > | `prompt_tokens` | Too many estimated prompt tokens | The document is simply long |
876
+ >
877
+ > ```json
878
+ > {
879
+ > "status": "blocked",
880
+ > "estimated_cost": {
881
+ > "clusters": 2083, "total_calls": 2150, "estimated_prompt_tokens": 5100000,
882
+ > "per_document": {
883
+ > "d1": {"clusters": 83, "total_calls": 106, "estimated_prompt_tokens": 201751},
884
+ > "d2": {"clusters": 2000, "total_calls": 2044, "estimated_prompt_tokens": 4898249}
885
+ > },
886
+ > "blocked": {"d2": "clusters"}
887
+ > },
888
+ > "results": [
889
+ > {"document_id": "d2", "status": "failed", "blocked_by": "clusters",
890
+ > "error": "estimated 2,000 term clusters exceeds the ceiling of 750 (267% of it) — nothing was spent; 2,044 LLM calls were declined"}
891
+ > ]
892
+ > }
893
+ > ```
894
+ >
895
+ > Each `results[]` entry also carries `estimate` — **on success as well as refusal**, because a
896
+ > number a caller only ever sees when it is already too late cannot be planned against.
897
+ >
898
+ > `estimate.genre` is advisory document classification (`standard` · `diagnostic` · `conceptual` ·
899
+ > `unknown`) with a `well_served` flag. **Nothing is routed on it yet**; it exists so a caller can be
900
+ > told *before* spending that a troubleshooting manual will yield almost no definitions.
901
+
902
  ### `GET /api/v1/knowledge/jobs`
903
 
904
  Recent jobs for one `user_id`, newest first — lets a caller recover after a refresh without having stashed a `job_id`.
DEV_PLAN.md CHANGED
@@ -278,6 +278,74 @@ not to set for now).
278
 
279
  ## 0.8. Knowledge pipeline — test integration (2026-08-19 → **building, 2026-08-21**)
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  > **Updated 2026-08-21.** The banner here said *"Nothing has landed in this repo"* — that is no
282
  > longer true. **Both halves are now in-tree** (`src/knowledge_parsing/` + `src/knowledge_extraction/`
283
  > + `eval/knowledge/`, ~5,360 lines across commits `024c30a…a7bdeae`), and three dependencies were
@@ -382,6 +450,19 @@ scores **1.000** on a committed fixture; see the K9 row for how to read that num
382
  | K10 | Fixture artifact committed | Rifqi | ✅ | **Done 2026-08-27** — `eval/knowledge/fixtures/parsed_STD_2026_006_MNO.json` (BUMA standard, MinerU 3.4.4 `hybrid`, `schema_version 0.3.0`, 31 chunks). The eval and the extraction CLI both run with MinerU absent. The Open Pit textbook fixture is still open (S7 in the checklist) |
383
  | K11 | Output schema v2 (Harry's contract review) | Rifqi | 🔎 | **Landed 2026-08-26, unevaluated.** Deterministic entity ids, glossary references formulas instead of restating them, rules link to formulas and terms, 16 fields cut (50 → 42). Contract: [docs/knowledge/KNOWLEDGE_OUTPUT_CONTRACT.md](docs/knowledge/KNOWLEDGE_OUTPUT_CONTRACT.md); detail in the checklist as X24. Fixed two live defects — neither `cluster_id` (a frequency ordinal) nor `rule_id` (model-written) survived a re-run, which breaks an expert's approvals, not just links. Suite 497 passed / 7 skipped; **no eval run** — the prompt edits join X20's in the unevaluated queue |
384
  | K12 | Output schema v3 — `DomainContext` | Rifqi | ✅ | **Done 2026-09-02** (`7f47c61` · `6f5d619` · `e2865ac` · `172d8dc`; checklist **X26**). `BriefContext` → **`DomainContext`**, renamed while nothing is persisted — afterwards an id-prefix change is a migration that loses an expert's approvals. **The branch is no longer the pipeline's one unverifiable output:** it was made guardable by changing the question rather than the model — the prompt asks it to *locate* the document's own statement of purpose (`purpose` → `purpose_verbatim`) instead of composing a summary, so `GUARDED_FIELDS["summary"]` goes `()` → `("title", "purpose_verbatim")`. Adds derived `outline`, `subdomains` and entry counts; `key_parameters` gains a corroborating `term_id`, and an unresolved one earns its own review-queue row. **Contract change for Harry:** queue rows now carry `row_kind`, and a `key_parameter` row has no `term_id`, definition or mention count. Measured on the BUMA fixture (nano, 12 calls): 0 fields rejected, fabricated purpose refused, key-parameter linking 0/5 → 4/5 after a bug the smoke caught. Suite 541 passed / 7 skipped |
 
 
 
 
 
 
 
 
 
 
 
 
 
385
 
386
  **Not decided, do not assume:** whether the admin ingest surface is HTTP at all (the offline CLI is
387
  the honest milestone and both halves are there now); and GLiNER + LangExtract ensembling stays ⏸️
 
278
 
279
  ## 0.8. Knowledge pipeline — test integration (2026-08-19 → **building, 2026-08-21**)
280
 
281
+ > ## Updated 2026-09-11 — the open-queue pass (K17–K22)
282
+ >
283
+ > Six items off `knowledge_pipeline_runs/SESSION_HANDOFF.md` §4, **zero API spend**: every number
284
+ > below comes from replaying the six persisted paid runs or re-running the free stages on the three
285
+ > v2 artifacts. Detail in REPO_STATUS §14; per-item rows K17–K22 in the table below.
286
+ >
287
+ > **The headline is Step 2**, the item everyone agreed on and nobody had done: the review queue put
288
+ > 24 unresolved key parameters in front of Komatsu's `Engine` (108 mentions, rank 25). It is rank 1
289
+ > now, with nothing dropped.
290
+ >
291
+ > **Two findings worth reading as findings, not as changes:**
292
+ > 1. **The spend ceiling was gating on a partial number.** `estimate_cost` summed only the glossary
293
+ > branch under a field named `estimated_prompt_tokens`. Real undercount 8.8%–18.4%; Open Pit is at
294
+ > **78%** of the ceiling, not 71%.
295
+ > 2. **Escalation has never worked.** 48 calls across three documents and two configurations,
296
+ > **0 definitions** recovered. Retired by constant, loop left in place.
297
+ >
298
+ > **What is deliberately NOT switched on:** genre routing. The classifier is right on all three
299
+ > documents, but the label sets are unscoreable without a second gold set and branch-skipping rests
300
+ > on a single document, so `ROUTE_LABELS` / `ROUTE_BRANCHES` are both off and the verdict is recorded
301
+ > rather than acted on. **Flipping either is Rifqi's call and one line.**
302
+ >
303
+ > **Still open and unchanged:** the paid Step 0 band (needs authorised repeated runs), Phase 4's
304
+ > vision call, Step 8, Step 7, R4, and E5 — Sofhia still has not seen the `src/knowledge_parsing/`
305
+ > diff.
306
+
307
+ > ## Updated 2026-09-11 — v2: clustering fix + parsing 0.4.0 (asset pointers)
308
+ >
309
+ > Two changes, measured on three documents (BUMA standard · Komatsu shop manual · Open Pit textbook
310
+ > Ch.1) at **zero API spend** for the free stages — the parse cache fingerprint is unchanged, so
311
+ > re-parses reuse the cached Mistral responses. Full write-up:
312
+ > `knowledge_pipeline_runs/CHANGELOG_v2.md`.
313
+ >
314
+ > **The fuzzy clustering pass is RETIRED (K13).** `token_set_ratio` returns exactly 100 when one token
315
+ > set is a subset of the other, so a head noun always absorbed its own compounds and the threshold of
316
+ > 92 was **inert**. 131 of its 140 merges were subset matches (all wrong); 6 were plurals, now folded
317
+ > deterministically; 3 were distinct metrics wrongly merged. Token-subset merges **15/18/97 → 0**, and
318
+ > post-cluster gold recall **0.8293 → 0.8537** — the gold term `EWH`, which `Working Hours` had
319
+ > absorbed, is recovered. `_fuzzy_match` is parked, not deleted.
320
+ >
321
+ > **Parsing `schema_version` 0.4.0 (K14).** A figure is no longer its own chunk. It was, with an EMPTY
322
+ > `text`, which made it unreachable by construction — **13 figures across three documents, each
323
+ > described by a vision model we pay for, and ZERO reaching any prompt.** Now the `![](asset://<id>)`
324
+ > reference is folded into the prose chunk at its reading-order position and the figure travels as an
325
+ > `Asset` (content-addressed, persisted bytes, verbatim caption, unwrapped description). Image chunks
326
+ > **2/3/8 → 0/0/0**; recall unmoved (E1 and E1c both identical v1→v2). Tables deliberately stay their
327
+ > own chunk. `adapter.py` now carries `assets`, `referenced_by` and **`table_html`**, which parsing had
328
+ > emitted for months while the adapter dropped it.
329
+ >
330
+ > **New metric E1c (post-cluster recall)**, and **E2's compression kill line REMOVED** — over-merging
331
+ > *raises* compression, so the metric was rewarding the defect it should have exposed.
332
+ >
333
+ > **Two settings changes measured and REJECTED:** `WINDOW_WORDS` 256 (truncates 46 of 99 Open Pit
334
+ > windows — the "flat recall" justifying it was measured on BUMA, the one document whose truncation
335
+ > count does not vary with window size) and `SPAN_SCORE_THRESHOLD` 0.15 (+50% clusters against a +20%
336
+ > bar). `EVIDENCE_K` raised 3 → 5 on Rifqi's call; measured cost +1.8% to +15% evidence tokens, and on
337
+ > the one gold-set document top-5 recovers the same 16/18 as top-3.
338
+ >
339
+ > **Export for the team:** `knowledge_pipeline_runs/06_export_knowledge_parsing_v0.4.0/`, verified to
340
+ > import and validate a fixture with no repo on `sys.path`. ⚠️ `src/knowledge_parsing/` is Sofhia's
341
+ > half — she needs the diff before it goes out.
342
+ >
343
+ > **Paid comparison landed 2026-09-11 (K15).** Definitions 7→10 / 0→0 / 25→49; queue junk rows down
344
+ > 37–65%; Komatsu still 0, as predicted. Caveats: the `source_wording` gain is largely tautological,
345
+ > and **no definition cited a figure caption on any document** — assets are reachable but unused, which
346
+ > points at the caption-to-filter path (built, flag off) as the next free experiment. Open Pit now sits
347
+ > at **71% of the 2M spend ceiling** for one chapter.
348
+
349
  > **Updated 2026-08-21.** The banner here said *"Nothing has landed in this repo"* — that is no
350
  > longer true. **Both halves are now in-tree** (`src/knowledge_parsing/` + `src/knowledge_extraction/`
351
  > + `eval/knowledge/`, ~5,360 lines across commits `024c30a…a7bdeae`), and three dependencies were
 
450
  | K10 | Fixture artifact committed | Rifqi | ✅ | **Done 2026-08-27** — `eval/knowledge/fixtures/parsed_STD_2026_006_MNO.json` (BUMA standard, MinerU 3.4.4 `hybrid`, `schema_version 0.3.0`, 31 chunks). The eval and the extraction CLI both run with MinerU absent. The Open Pit textbook fixture is still open (S7 in the checklist) |
451
  | K11 | Output schema v2 (Harry's contract review) | Rifqi | 🔎 | **Landed 2026-08-26, unevaluated.** Deterministic entity ids, glossary references formulas instead of restating them, rules link to formulas and terms, 16 fields cut (50 → 42). Contract: [docs/knowledge/KNOWLEDGE_OUTPUT_CONTRACT.md](docs/knowledge/KNOWLEDGE_OUTPUT_CONTRACT.md); detail in the checklist as X24. Fixed two live defects — neither `cluster_id` (a frequency ordinal) nor `rule_id` (model-written) survived a re-run, which breaks an expert's approvals, not just links. Suite 497 passed / 7 skipped; **no eval run** — the prompt edits join X20's in the unevaluated queue |
452
  | K12 | Output schema v3 — `DomainContext` | Rifqi | ✅ | **Done 2026-09-02** (`7f47c61` · `6f5d619` · `e2865ac` · `172d8dc`; checklist **X26**). `BriefContext` → **`DomainContext`**, renamed while nothing is persisted — afterwards an id-prefix change is a migration that loses an expert's approvals. **The branch is no longer the pipeline's one unverifiable output:** it was made guardable by changing the question rather than the model — the prompt asks it to *locate* the document's own statement of purpose (`purpose` → `purpose_verbatim`) instead of composing a summary, so `GUARDED_FIELDS["summary"]` goes `()` → `("title", "purpose_verbatim")`. Adds derived `outline`, `subdomains` and entry counts; `key_parameters` gains a corroborating `term_id`, and an unresolved one earns its own review-queue row. **Contract change for Harry:** queue rows now carry `row_kind`, and a `key_parameter` row has no `term_id`, definition or mention count. Measured on the BUMA fixture (nano, 12 calls): 0 fields rejected, fabricated purpose refused, key-parameter linking 0/5 → 4/5 after a bug the smoke caught. Suite 541 passed / 7 skipped |
453
+ | K13 | **Clustering: retire the fuzzy pass** | Rifqi | ✅ | **Done 2026-09-10.** `token_set_ratio` is blind to the subset case (returns 100), so the threshold never applied and head nouns ate their compounds. Measured across three documents: 131 of 140 merges were subset matches. Now `exact (plural-folded) → abbreviation ↔ expansion` only; `depluralise()` handles the 6 legitimate plural merges. Subset merges **15/18/97 → 0**; E1c_strict **0.8293 → 0.8537** (`EWH` recovered). New metric **E1c** added to `score.py`/`run_eval.py`; **E2's kill line removed** (it rises when the defect fires). Suite 676 passed / 7 skipped |
454
+ | K14 | **Parsing 0.4.0 — asset pointers** | Rifqi (Sofhia's half) | ✅ | **Done 2026-09-10.** Figures fold into the prose chunk as `![](asset://<id>)` at reading-order position; `Asset` carries a content-addressed id, persisted bytes, a **verbatim** caption and a **model-written** description that never enters `text`. Image chunks **2/3/8 → 0/0/0**, 0 dangling refs, 0 description leaks, captions 17/17 preserved, **recall unchanged**. Tables keep their own chunk + two-way pointers. Seam: `adapter.py` carries `assets`/`referenced_by`/**`table_html`**; `SUPPORTED_SCHEMA` → (0,4), so parsing and extraction ship together. Found and fixed a Mistral bug: `image_id` mislabelled on one block, collapsing two figures onto one hash. ⚠️ **Sofhia needs the diff before the team export goes out** |
455
+ | K15 | Paid v1-vs-v2 comparison | Rifqi | ✅ | **Done 2026-09-11**, 3 runs, ~9¢. Definitions **7→10 / 0→0 / 25→49** — Open Pit nearly doubled; junk review-queue rows −37% (Komatsu) / −65% (Open Pit); cache rate +7% on all three; 0 retries. **Komatsu stays at 0 definitions**, as predicted: a troubleshooting manual states symptoms, not definitions, and no plumbing change reaches that. Two caveats recorded rather than buried: `source_wording`'s headline +1608% is mostly **tautological** (informative 12→101, not 13→222), and **`definitions_citing_a_caption` = 0 on every document** — the assets are reachable but nothing consumed them, most likely because captions do not feed the term filter (flag off by decision). ⚠️ Cost: Open Pit is now **71% of the 2M pre-flight ceiling** (was 56%) for one chapter. Three variables moved at once, so no single number is attributable. Write-up: `knowledge_pipeline_runs/V1_VS_V2_PAID_COMPARISON.md` |
456
+ | K16 | Caption hypothesis for the 0 caption-citations | Rifqi | ✅ | **Tested 2026-09-11, HYPOTHESIS FALSIFIED.** Both candidate explanations for `definitions_citing_a_caption = 0` are false: captions ALREADY reach the term filter (they are part of `Chunk.text`; 2/2 BUMA and 3/8 Open Pit captions already produce mentions), and figure-bearing chunks are ALREADY selected (a captioned chunk is in the top-5 for 22/83 BUMA and 64/427 Open Pit clusters). Built the missing ranker signal `asset_caption_names_term` to test it properly: at weight 2.5 it changed the top-5 of **1 cluster out of 603** and moved none from no-figure to has-figure. **The real reason: not one caption in the corpus is a definition** — they are figure titles ("Figure 1.3. Relative ability to influence costs"). A model declining to quote one is CORRECT. Signal ships at **weight 0.0** with the measurement beside it. ⚠️ Also corrects a false claim in the earlier docs: the caption-to-filter flag was described as "built, off by decision" — it was never built. Redirects the remaining figure value to Phase 4 (vision call on the bytes), which is now the only untested path |
457
+
458
+ | K17 | **Step 2 — review queue ordering + key-parameter heads** | Rifqi | ✅ | **Done 2026-09-11.** Unresolved key parameters held their own sort tier, so every one outranked every routine term; the branch emits them in bulk on the documents it understands least, so the promotion fired hardest where least deserved. Now spliced at most **1 per 5** term rows. Replayed on the three persisted v2 runs: Komatsu `Engine` (108 mentions) **rank 25 → 1**, Open Pit `Resource` **8 → 1**, first junk row **1 → 6**, **row counts identical** (nothing dropped), BUMA unchanged (0 unresolved). `term` now carries a head (first `.` `:` or newline, only past 60 chars — "No. of units" survives) with the full text in new **`surface_full`**. Contract: OUTPUT_CONTRACT §9 |
459
+ | K18 | **Step 0 — measurement hardening** | Rifqi | 🔄 | **Harness done 2026-09-11, the paid band is NOT run.** `eval/knowledge/invariants.py`: asserts on **fields and invariants, never exact text** — span locatable, entity ids present and unique, a rejected field still null (rejected-never-repaired, checkable only from outside), page in range, queue ranks contiguous, no leaked sort keys. `--runs N` repeats **only the paid stage** (free stages are deterministic) and reports min/median/max with the **median** as the value, plus whether all runs minted identical entity ids — the K11 defect class, unwatched since K11. `EXPECTED_MODEL_SNAPSHOT` turns X25's recording into an **assertion** (exit 6). New exit codes: 5 invariant, 6 snapshot. **Needs Rifqi's go-ahead to produce a real band** |
460
+ | K19 | **Step 6 — spend ceiling in cluster units** | Rifqi | ✅ | **Done 2026-09-11.** Found first: `estimate_cost` summed **only the glossary branch** under a field named `estimated_prompt_tokens`, so the ceiling read a partial number as a total — undercount **BUMA 18.4% · Komatsu 8.8% · Open Pit 15.2%**, moving Open Pit 67.7% → **78.0%** of the 2M ceiling. Now all four branches (glossary exact, the rest approximated from prefix + chunk text, both labelled). Second ceiling **`MAX_CLUSTERS = 750`** — exact, free, and what drives spend; at ~3,200 tokens/cluster the token ceiling still binds first for these documents, so this guards the *other* shape (many cheap clusters, 494 calls ≈ 15 min). Refusals name the ceiling and the declined call count; `IngestJob.estimated_cost` (declared since the job model, **never populated**) now carries the per-document breakdown; a wholly-refused batch settles **`blocked`**, which the contract documented and the code could not produce |
461
+ | K20 | **Step 5 — genre classification** | Rifqi | 🔄 | **Classifier done and correct on all three documents; routing deliberately OFF.** `src/knowledge_extraction/genre.py` → `standard` · `diagnostic` · `conceptual` · `unknown` from four free signals (heading coverage · tabular ratio · abbrev pairs · rule density), which separate in *different directions* — measured BUMA 46%/29%/5/0.57, Komatsu 1%/74%/0/0.01, Open Pit 86%/7%/0/1.48. **`ROUTE_LABELS` and `ROUTE_BRANCHES` are both `False`**: the genre label sets are unscoreable without a second gold set (more mentions ≠ better recall) and branch-skipping rests on one document. Today it is observability — `well_served=False` warns *before* spend that a shop manual yields almost nothing, and heading coverage raises a per-document warning (**P5**, partly: the warning exists, a parsing-side fallback does not). **Flipping either lever is Rifqi's call** |
462
+ | K21 | **Finding 6 — escalation retired** | Rifqi | ✅ | **Done 2026-09-11, with the number Finding 6 asked for.** Across all six persisted paid runs (v1 K=3 and v2 K=5 × three documents): **48 escalation calls, 0 definitions recovered** — `extraction_status == "escalated"` appears zero times in every file. Why it cannot work as built: escalation hands the model the *next* k evidence chunks, ranked **below** ones it already declined, and 16 of 18 gold definitions sit at rank ≤3. `MAX_ESCALATION_ROUNDS = 0`; loop and call site untouched. Reviving it means changing the **question** (different prompt or tier), not the evidence |
463
+ | K22 | **The over-cap window (pre-existing)** | Rifqi | ✅ | **Done 2026-09-11.** `WINDOW_WORDS` caps words; GLiNER truncates on **subwords** and warns rather than failing. Measured with GLiNER's own tokeniser: **1 window of 368** exceeds the 384-token cap — BUMA `STD_2026_006_MNO::0024`, 130 words / 738 chars → **409 tokens** — losing its tail at *every* window size including 130, exactly as the 2026-09-10 sweep suspected and left. Windows are now checked against the real tokeniser and split only when they do not fit (a character budget would have to assume the tightest ratio seen, 1.57, and chop up the 367 that fit). Halves overlap by `SPAN_TOKEN_CAP` words so no term straddles a cut. Fixture: 3 splits, mentions **186 → 208**, clusters 74 → 80 (**+8% spend**), **E1 0.8293 and E1c unchanged** |
464
+ | K23 | Step 8 + Step 7 design proposals | Rifqi | 💡 | **Written 2026-09-11, nothing built, both need sign-off.** [TABLE_BRANCH_PROPOSAL.md](docs/knowledge/TABLE_BRANCH_PROPOSAL.md) — deterministic (row header, column header, cell mark) → assertion, one call per **table** (59 on Komatsu, fewer than the 93 the glossary branch spends to abstain 93 times), provenance at the cell; five decisions listed, **T4 (what `table_html` shapes the parser emits) is free and comes first**. [TERM_HIERARCHY_PROPOSAL.md](docs/knowledge/TERM_HIERARCHY_PROPOSAL.md) — parent/child **with** cross-document identity because SciCo's point is they are one problem; the dominating constraint is that **hierarchy must not enter `entity_id`**, or every correction invalidates expert approvals. Sequenced behind **R4** (still not asked) and Step 0 |
465
+ | K24 | **The 2026-09-08 discussion list — structure, incremental ingest, review** | Rifqi | ✅ | **Done 2026-09-14 (pr/21, 7 commits).** *Struktur data 7/8:* `label` carries a readable `TYPE_SUBJECT` key (`keys.py`) — prefixes from the `kind` vocabulary, rules borrow the term they govern plus a digest since a rule has no canonical surface, and the key is a **label, never an identity** (`entity_id` stays the hash approvals hang on). `content`/`provenance`/`doc_ids` promoted out of `payload`; `get_by_key()` + `glossary_index()` make lookup O(1) with no model call. **m7 parked** — the `kind` CHECK would reject `'summary'`. *Ingest bertahap 7/7:* same `doc_id` + new `content_hash` = a new VERSION (reuse check moved to any-version, or idempotency would become a paid no-op); an approved term skips the paid call; staleness compares **only the ruled-on fields** (`mutation.py`) because comparing the payload re-opens everything on every ingest and comparing document `content_hash` flips untouched entries; `impacted_by_document()`; withdrawal marks and keeps rather than deletes. *Sistem review 4/4:* `review_mode` at ingest, `decided_by` + `authorised_by` so an LLM-mode approval can say whose decision it was, and `review_diff()` naming which fields moved. **7 columns added to dedorch** (§6/§7 of `knowledge_tables.sql`, run by hand **after** the handoff went to Harry, verified read-only against live — Harry still needs both in the migration set). Suite **816 passed / 7 skipped** (was 742), ruff clean on touched paths, `import main` 0. **Inert until the review queue has rows:** reuse and staleness both key on approved entries, and `knowledge_reviews` is at 0 |
466
 
467
  **Not decided, do not assume:** whether the admin ingest surface is HTTP at all (the offline CLI is
468
  the honest milestone and both halves are there now); and GLiNER + LangExtract ensembling stays ⏸️
REPO_STATUS.md CHANGED
@@ -5,7 +5,7 @@
5
 
6
  **Audience:** teammates onboarding onto the Python repo (`Agentic-Service-Data-Eyond-Catalog`).
7
  **Scope:** what the code does **right now** (branch `pr/4`, ticket KM-652). Describes current state only — no roadmap or to-dos.
8
- **Snapshot date:** 2026-06-25. **Data-layer reconcile 2026-07-01:** §8/§12 updated — dedorch cutover done, `data_catalog` model reconciled. **Query-path fix 2026-07-02:** §8/§13 — dedorch catalogs ship no FKs → Python infers them (`fk_inference.py`); shared-Fernet-key gotcha documented. **Agent-quality fixes 2026-07-08 (pr/13):** from the scoped live-test review — the planner gains an explicit **infeasible** outcome (`TaskList.infeasible_reason` → deterministic EN/ID data-gap reply via `refusals.data_gap_message`; no more force-mapping absent measures like `pa` AS "revenue"), the IR validator rejects bare selects under `group_by` (self-corrects via the planner retry), `analyze_trend` handles integer year/month columns (was collapsing every row into one 1970-01 bucket), planner few-shots add top-N (Example G) + infeasible (Example H), numeric catalog `sample_values` are base64-decoded at read (`catalog/sample_decode.py` — stopgap for Go's byte-marshaling; primary fix is Go-side), traceability no longer emits null source rows for failed retrievals, and `check_data` hides `-1` row counts. **Report v2 + analyze_merge planner support 2026-07-09 (pr/13):** Sofia's `analyze_merge` tool (8abf635, KM-703) is now planner-supported (`_validate_data_source` guards `data_right`, two-retrieve→merge few-shot Example I, planner.md "Two measures per entity" rule); the report gains per-business-question answers (`bq_answers` — drafted by the SAME single LLM call, index-based record refs, deterministic fallback unchanged), "Attempted, Unresolved" + "Excluded Analyses" sections (failed runs are no longer silently dropped), evidence tables copied from `results_snapshot` (table-kind outputs, ≤3/record ≤10 rows ≤8 cols, `check_*` skipped), normalized caveat dedupe with caps (12/10), and single-language output via `detect_reply_language`; the report surface adds `GET /tools/report/{analysis_id}/records` (curation list), `GET …/readiness` (FE delta guard), and `exclude_record_ids` on POST — see API_CONTRACT_BE_PYTHON.md. **Report compaction 2026-07-09 (pr/13):** the rendered markdown drops the "Notes & Limitations", "Attempted, Unresolved", and "How This Was Analyzed" sections (team decision — compact report; render blocks commented out in `report/generator.py`, not deleted). The JSON body keeps `caveats`/`open_questions`/`unresolved`/`method_steps` and the curation/records endpoints are unchanged. **Traceability `data_used` layer 2026-07-13 (pr/15):** `GET /api/v1/traceability` gains a resolved, user-facing `data_used[]` block (one per `retrieve_data` call) — real source/table/column names, joins, plain-language filters, and result columns split into read-from-data vs `computed` (with `formula`, e.g. `total_revenue` = `SUM(line_total)`, so an alias is never shown as a real column). Ids are kept but **machine-only (FE must not render)**. Also adds `tool_calls[].summary`, and `sources[]` now carry `source_name` + every table touched. Deterministic catalog resolution (new `src/traceability/resolve.py`), no LLM, never-throw; catalog threaded to the scratchpad at the slow-path composition root. Additive/non-breaking; contract + `TRACEABILITY_FE_HANDOFF.md` updated. **Spine v2 W2+W1 2026-07-13 (pr/16):** §6/§7/§8/§9/§12 — `render_chart` tool lands (first of the `render_*` family: deterministic Plotly-JSON `dataeyond.chart.v1` envelope, hand-built, **no plotly dependency**; planner-selected only on an explicit chart ask, EN/ID) + Python-owned `message_charts` store + `GET /api/v1/charts` (FE fetches on `done`, same pattern as traceability; empty list = valid 200); planner gains a named **recipe table** + viz few-shots (Example J tail, Example K viz-infeasible) + validator Check 10 (`render_chart.data` must reference a table-producing task); and the slow path gains the **S1a quality checkpoint** (`slow_path/checkpoint.py`, 0 LLM, never-throw, between runner and assembler: CK1 all-failed → deterministic honest-failure answer with **no** assembler call, CK2 empty retrieve + downstream, CK3 10k-cap truncation, CK4 single trend bucket, CK5 all-null column, CK6 chart-spec sanity; flags render as an "Execution assessment" block in the assembler input and every flag logs `repair_candidate` — the S1b evidence base). Design + handoff doc: `SPINE_V2_PLAN.md`. **LLM model + env rename 2026-07-14 (pr/17):** §2/§3/§9/§13 — the generation LLM is now **Azure GPT-5.4-mini** (deployment `gpt-5.4-mini`), not GPT-4o, and the settings quad is renamed `azureai__*__4o` → **`azureai__*__54m`** across all 9 LLM call sites. **Hard rename, no `__4o` fallback** — an environment that still sets only `__4o` resolves to empty strings and fails on the first LLM call, by design: the silent-wrong-model drift is exactly how HF stayed on GPT-4o while local ran 5.4-mini (identical question, identical catalog, divergent planner output — HF hallucinated catalog ids, local planned correctly). Deploying requires all four `__54m` vars set in the HF Space secrets. **`reports.user_id` fix 2026-07-22 (pr/18):** §8/§9/§12/§13 — `ReportStore.save` never wrote `reports.user_id`, and on the new **Neon** dedorch instance that column is `text NOT NULL`, so **every `POST /api/v1/tools/report` 500'd** (`NotNullViolationError`; report persistence deliberately re-raises rather than degrading). The ORM gains `user_id` (declared **nullable** so Python tolerates every deployment shape) and the store writes `report.user_id`, which the endpoint already required as a query param and threaded through the generator. Live-verified against `information_schema` + a green 201. **Root cause (verified against the Go source after pulling `dd37b38`):** the column is *not* new — `0001_create_core_schema.sql` has declared it `NOT NULL` all along, but `0002`/`0004` retrofit it onto pre-existing DBs as a **nullable** `ADD COLUMN IF NOT EXISTS`. The old DB therefore accepted the column-less INSERT and the bug stayed latent; a fresh DB enforces it. Generalized as a §13 gotcha — **Go's migration set is not convergent, so fresh and migrated dedorch DBs differ in NOT NULL constraints** and the migrations alone can't tell you which shape you have. A full ORM-vs-live drift scan the same day found all five Python-written tables clean; the remaining drift is on read-only or unwired paths. **Knowledge pipeline 2026-08-21:** new **§14** — both halves of the knowledge pipeline are now in-tree (`src/knowledge_parsing/` · `src/knowledge_extraction/` · `eval/knowledge/`), as **offline CLIs with no HTTP surface, no table and nothing mounted in `main.py`**. They meet at one versioned `ParsedDocument` artifact; extraction never takes a file path. Term-filter recall **0.8537**, matching the prototype baseline. The existing `src/knowledge/` OCR→pgvector RAG path is untouched. Deps: `rapidfuzz`/`gliner`/`torch` main, `mineru` an optional extra. **Superseded 2026-09-07:** `mineru` dropped (commented out) and `torch` pinned to the Linux CPU wheel — 36 `nvidia-*` packages and `triton` gone from `uv.lock`; §14. **Cross-repo update 2026-06-29:** §2/§8/§11/§12 re-verified against
9
  the **Go source** (`Orchestrator-Agent-Service`), not its docs. The Go service has moved well past its
10
  own (uncommitted, stale) design docs: it now hosts the **dedorch SQL migrations** in-repo and a full
11
  **`/api/v1/analyses` + `/api/v1/skills`** REST surface. Go does **not** call Python yet — those skills
@@ -195,6 +195,7 @@ unless `SKIP_INIT_DB=true`.
195
  | `analyses_messages` *(dedorch)* | the analysis chat room (`role ∈ user\|ai`); replaces deprecated `rooms`/`chat_messages` | Go `/analyses/{id}/messages` | Python chat path **not yet migrated here** (§12) |
196
  | `message_traceability` *(Python-owned)* | one jsonb `TraceabilityPayload` per assistant turn (PK `message_id`); flushed before `done` | chat pipeline (KM-691) | `GET /api/v1/traceability` |
197
  | `message_charts` *(Python-owned, added 2026-07-13)* | one row per `render_chart` chart — `spec` jsonb holds the full `dataeyond.chart.v1` envelope; keyed (`analysis_id`, `message_id`), multiple rows per turn allowed; written before `done`, never-throw | slow-path chart persist (`chat_handler._run_slow_path`) | `GET /api/v1/charts` |
 
198
 
199
  > ✅ **Python ORM ↔ dedorch drift — reconciled 2026-07-01.** `AnalysisStateRow` (`analyses`) dropped
200
  > `problem_statement`/`problem_validated` and added `objective`/`business_questions` (Harry's #3);
@@ -522,6 +523,12 @@ records-based report; floor: ≥1 `analyze_*` success). Wiring Go → Python is
522
 
523
  ## 14. Knowledge pipeline — offline subsystems (added 2026-08-21)
524
 
 
 
 
 
 
 
525
  **Updated 2026-09-07 — this is now on the live surface.** The line that stood here
526
  ("no endpoint, no database table, nothing mounted") is out of date. Eight routes under
527
  `/api/v1/knowledge` are mounted in `main.py` (`src/api/v1/knowledge.py`), and five tables exist in
@@ -532,8 +539,8 @@ RAG.**
532
 
533
  | | |
534
  |---|---|
535
- | Surface | `POST /knowledge/ingest` · `GET /knowledge/jobs/{id}` · `GET /knowledge/jobs` · `GET /knowledge/documents` · `POST /knowledge/documents/{id}/extract` · `GET /knowledge/entries` · `GET /knowledge/queue` · `POST /knowledge/entries/{entity_id}/reviews` |
536
- | Tables | `knowledge_documents` · `knowledge_extraction_runs` · `knowledge_entries` · `knowledge_reviews` · `knowledge_jobs` |
537
  | Orchestration | `src/knowledge_ingest/` — job lifecycle, tenant scope, the pipeline port. Owns no parsing and no extraction logic |
538
  | Contract | `API_CONTRACT_BE_PYTHON.md` §Knowledge |
539
 
@@ -569,8 +576,102 @@ behind them in [KNOWLEDGE_PIPELINE_CALIBRATION.md](docs/knowledge/KNOWLEDGE_PIPE
569
  | Extraction | `src/knowledge_extraction/` | Rifqi | `python -m src.knowledge_extraction.cli <artifact.json> --extract` |
570
  | Eval harness | `eval/knowledge/` | Rifqi | gold set + scorer + frozen baseline |
571
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  > **Current (2026-09-07):** the parser is **Mistral OCR** (`--backend mistral`, no GPU; normalizer
573
- > `normalize_mistral.py`, section-level). `schema_version` is **`0.3.3`** (adds `source_title`,
 
574
  > `normaliser_version`, and a computed 1-based `page_no`).
575
  >
576
  > **MinerU was dropped 2026-09-07** — commented out of the `knowledge-parsing` extra and installed
@@ -586,7 +687,7 @@ behind them in [KNOWLEDGE_PIPELINE_CALIBRATION.md](docs/knowledge/KNOWLEDGE_PIPE
586
  ### The seam
587
 
588
  The two halves meet at **one persisted, versioned artifact** — `ParsedDocument` in
589
- `src/knowledge_parsing/contracts.py` (`schema_version` **`0.3.3`** as of 2026-09-02) — and nowhere else. **Extraction
590
  never takes a file path and never touches the parser**, which is what keeps MinerU swappable: a
591
  Tesseract or Azure Document Intelligence path emits the same artifact and extraction never learns
592
  which one ran. `knowledge_extraction/adapter.py` is the only module that knows the shape.
@@ -666,9 +767,15 @@ silent content drop worth **+0.1220** on the way: `normalize.py` was discarding
666
  Indonesian definitions, 0 span rejections, 7,168 of 8,029 prompt tokens cached. Full-document cost
667
  on the prototype was **$0.0069**.
668
 
669
- **Not yet done:** persistence (stage output is JSON on disk; the DDL handoff to Harry is open), any
670
  HTTP surface, a full-document run on v2, and **schema-fill precision has never been scored on v2** —
671
- the one prototype experiment that failed.
 
 
 
 
 
 
672
 
673
  ### Dependencies
674
 
 
5
 
6
  **Audience:** teammates onboarding onto the Python repo (`Agentic-Service-Data-Eyond-Catalog`).
7
  **Scope:** what the code does **right now** (branch `pr/4`, ticket KM-652). Describes current state only — no roadmap or to-dos.
8
+ **Snapshot date:** 2026-06-25. **Data-layer reconcile 2026-07-01:** §8/§12 updated — dedorch cutover done, `data_catalog` model reconciled. **Query-path fix 2026-07-02:** §8/§13 — dedorch catalogs ship no FKs → Python infers them (`fk_inference.py`); shared-Fernet-key gotcha documented. **Agent-quality fixes 2026-07-08 (pr/13):** from the scoped live-test review — the planner gains an explicit **infeasible** outcome (`TaskList.infeasible_reason` → deterministic EN/ID data-gap reply via `refusals.data_gap_message`; no more force-mapping absent measures like `pa` AS "revenue"), the IR validator rejects bare selects under `group_by` (self-corrects via the planner retry), `analyze_trend` handles integer year/month columns (was collapsing every row into one 1970-01 bucket), planner few-shots add top-N (Example G) + infeasible (Example H), numeric catalog `sample_values` are base64-decoded at read (`catalog/sample_decode.py` — stopgap for Go's byte-marshaling; primary fix is Go-side), traceability no longer emits null source rows for failed retrievals, and `check_data` hides `-1` row counts. **Report v2 + analyze_merge planner support 2026-07-09 (pr/13):** Sofia's `analyze_merge` tool (8abf635, KM-703) is now planner-supported (`_validate_data_source` guards `data_right`, two-retrieve→merge few-shot Example I, planner.md "Two measures per entity" rule); the report gains per-business-question answers (`bq_answers` — drafted by the SAME single LLM call, index-based record refs, deterministic fallback unchanged), "Attempted, Unresolved" + "Excluded Analyses" sections (failed runs are no longer silently dropped), evidence tables copied from `results_snapshot` (table-kind outputs, ≤3/record ≤10 rows ≤8 cols, `check_*` skipped), normalized caveat dedupe with caps (12/10), and single-language output via `detect_reply_language`; the report surface adds `GET /tools/report/{analysis_id}/records` (curation list), `GET …/readiness` (FE delta guard), and `exclude_record_ids` on POST — see API_CONTRACT_BE_PYTHON.md. **Report compaction 2026-07-09 (pr/13):** the rendered markdown drops the "Notes & Limitations", "Attempted, Unresolved", and "How This Was Analyzed" sections (team decision — compact report; render blocks commented out in `report/generator.py`, not deleted). The JSON body keeps `caveats`/`open_questions`/`unresolved`/`method_steps` and the curation/records endpoints are unchanged. **Traceability `data_used` layer 2026-07-13 (pr/15):** `GET /api/v1/traceability` gains a resolved, user-facing `data_used[]` block (one per `retrieve_data` call) — real source/table/column names, joins, plain-language filters, and result columns split into read-from-data vs `computed` (with `formula`, e.g. `total_revenue` = `SUM(line_total)`, so an alias is never shown as a real column). Ids are kept but **machine-only (FE must not render)**. Also adds `tool_calls[].summary`, and `sources[]` now carry `source_name` + every table touched. Deterministic catalog resolution (new `src/traceability/resolve.py`), no LLM, never-throw; catalog threaded to the scratchpad at the slow-path composition root. Additive/non-breaking; contract + `TRACEABILITY_FE_HANDOFF.md` updated. **Spine v2 W2+W1 2026-07-13 (pr/16):** §6/§7/§8/§9/§12 — `render_chart` tool lands (first of the `render_*` family: deterministic Plotly-JSON `dataeyond.chart.v1` envelope, hand-built, **no plotly dependency**; planner-selected only on an explicit chart ask, EN/ID) + Python-owned `message_charts` store + `GET /api/v1/charts` (FE fetches on `done`, same pattern as traceability; empty list = valid 200); planner gains a named **recipe table** + viz few-shots (Example J tail, Example K viz-infeasible) + validator Check 10 (`render_chart.data` must reference a table-producing task); and the slow path gains the **S1a quality checkpoint** (`slow_path/checkpoint.py`, 0 LLM, never-throw, between runner and assembler: CK1 all-failed → deterministic honest-failure answer with **no** assembler call, CK2 empty retrieve + downstream, CK3 10k-cap truncation, CK4 single trend bucket, CK5 all-null column, CK6 chart-spec sanity; flags render as an "Execution assessment" block in the assembler input and every flag logs `repair_candidate` — the S1b evidence base). Design + handoff doc: `SPINE_V2_PLAN.md`. **LLM model + env rename 2026-07-14 (pr/17):** §2/§3/§9/§13 — the generation LLM is now **Azure GPT-5.4-mini** (deployment `gpt-5.4-mini`), not GPT-4o, and the settings quad is renamed `azureai__*__4o` → **`azureai__*__54m`** across all 9 LLM call sites. **Hard rename, no `__4o` fallback** — an environment that still sets only `__4o` resolves to empty strings and fails on the first LLM call, by design: the silent-wrong-model drift is exactly how HF stayed on GPT-4o while local ran 5.4-mini (identical question, identical catalog, divergent planner output — HF hallucinated catalog ids, local planned correctly). Deploying requires all four `__54m` vars set in the HF Space secrets. **`reports.user_id` fix 2026-07-22 (pr/18):** §8/§9/§12/§13 — `ReportStore.save` never wrote `reports.user_id`, and on the new **Neon** dedorch instance that column is `text NOT NULL`, so **every `POST /api/v1/tools/report` 500'd** (`NotNullViolationError`; report persistence deliberately re-raises rather than degrading). The ORM gains `user_id` (declared **nullable** so Python tolerates every deployment shape) and the store writes `report.user_id`, which the endpoint already required as a query param and threaded through the generator. Live-verified against `information_schema` + a green 201. **Root cause (verified against the Go source after pulling `dd37b38`):** the column is *not* new — `0001_create_core_schema.sql` has declared it `NOT NULL` all along, but `0002`/`0004` retrofit it onto pre-existing DBs as a **nullable** `ADD COLUMN IF NOT EXISTS`. The old DB therefore accepted the column-less INSERT and the bug stayed latent; a fresh DB enforces it. Generalized as a §13 gotcha — **Go's migration set is not convergent, so fresh and migrated dedorch DBs differ in NOT NULL constraints** and the migrations alone can't tell you which shape you have. A full ORM-vs-live drift scan the same day found all five Python-written tables clean; the remaining drift is on read-only or unwired paths. **Knowledge pipeline 2026-08-21:** new **§14** — both halves of the knowledge pipeline are now in-tree (`src/knowledge_parsing/` · `src/knowledge_extraction/` · `eval/knowledge/`), as **offline CLIs with no HTTP surface, no table and nothing mounted in `main.py`**. They meet at one versioned `ParsedDocument` artifact; extraction never takes a file path. Term-filter recall **0.8537**, matching the prototype baseline. The existing `src/knowledge/` OCR→pgvector RAG path is untouched. Deps: `rapidfuzz`/`gliner`/`torch` main, `mineru` an optional extra. **Superseded 2026-09-07:** `mineru` dropped (commented out) and `torch` pinned to the Linux CPU wheel — 36 `nvidia-*` packages and `triton` gone from `uv.lock`; §14. **Knowledge 8-Sep task list 2026-09-14 (pr/21):** §8/§14 — the discussion's structure-data, incremental-ingest and review items are built. `knowledge_entries.label` now carries a readable `TYPE_SUBJECT` key (`GLOSSARY_PA`) so a lookup is a dictionary hit rather than a scan, with `entity_id` untouched as the thing approvals hang on; a re-uploaded document becomes a **new version** rather than a new document, so its approvals survive; an already-approved term is no longer re-extracted (one LLM call per cluster is the whole cost of that branch); an approval goes stale only when the **content the expert ruled on** changed, never when a count moved; a withdrawn document stops feeding the baseline without anything being deleted; and ingest takes a `review_mode` — `llm` approves on arrival with no reviewer step, recorded against the person who chose it. Seven columns added to dedorch (run by hand 2026-09-14 **after** the handoff went to Harry, and verified read-only against live). **Cross-repo update 2026-06-29:** §2/§8/§11/§12 re-verified against
9
  the **Go source** (`Orchestrator-Agent-Service`), not its docs. The Go service has moved well past its
10
  own (uncommitted, stale) design docs: it now hosts the **dedorch SQL migrations** in-repo and a full
11
  **`/api/v1/analyses` + `/api/v1/skills`** REST surface. Go does **not** call Python yet — those skills
 
195
  | `analyses_messages` *(dedorch)* | the analysis chat room (`role ∈ user\|ai`); replaces deprecated `rooms`/`chat_messages` | Go `/analyses/{id}/messages` | Python chat path **not yet migrated here** (§12) |
196
  | `message_traceability` *(Python-owned)* | one jsonb `TraceabilityPayload` per assistant turn (PK `message_id`); flushed before `done` | chat pipeline (KM-691) | `GET /api/v1/traceability` |
197
  | `message_charts` *(Python-owned, added 2026-07-13)* | one row per `render_chart` chart — `spec` jsonb holds the full `dataeyond.chart.v1` envelope; keyed (`analysis_id`, `message_id`), multiple rows per turn allowed; written before `done`, never-throw | slow-path chart persist (`chat_handler._run_slow_path`) | `GET /api/v1/charts` |
198
+ | `knowledge_documents` · `knowledge_extraction_runs` · `knowledge_entries` · `knowledge_reviews` · `knowledge_jobs` *(Python-written, Go-owned schema)* | The knowledge pipeline's five tables (§14). **2026-09-14: 7 columns added** — `knowledge_entries.doc_ids`/`.content`/`.provenance` (promoted out of `payload`), `knowledge_documents.withdrawn_at`, `knowledge_jobs.review_mode`, `knowledge_reviews.decided_by`/`.authorised_by`. `label` now carries a readable `TYPE_SUBJECT` key (`GLOSSARY_PA`), not the term. DDL + adoption status: `docs/knowledge/knowledge_tables.sql` | knowledge ingest pipeline | `/api/v1/knowledge/*`, `KnowledgeService` |
199
 
200
  > ✅ **Python ORM ↔ dedorch drift — reconciled 2026-07-01.** `AnalysisStateRow` (`analyses`) dropped
201
  > `problem_statement`/`problem_validated` and added `objective`/`business_questions` (Harry's #3);
 
523
 
524
  ## 14. Knowledge pipeline — offline subsystems (added 2026-08-21)
525
 
526
+ **Updated 2026-09-14 — the 8 Sep discussion landed.** Two claims below were stale and are
527
+ corrected here: the surface is **ten** routes, not eight (`/knowledge/domain/propose` and
528
+ `/knowledge/domain` were added 2026-09-08), and **the analytics path does read what this
529
+ produces** — the consumption path was armed 2026-09-08 (§0.8 K5/K9/K11), approved entries only.
530
+ The 2026-09-08 task list (m*, i*, r*) is built: see **§0.8 K24**.
531
+
532
  **Updated 2026-09-07 — this is now on the live surface.** The line that stood here
533
  ("no endpoint, no database table, nothing mounted") is out of date. Eight routes under
534
  `/api/v1/knowledge` are mounted in `main.py` (`src/api/v1/knowledge.py`), and five tables exist in
 
539
 
540
  | | |
541
  |---|---|
542
+ | Surface | **10 routes** (2026-09-14 count): `POST /knowledge/ingest` · `GET /knowledge/jobs/{id}` · `GET /knowledge/jobs` · `GET /knowledge/documents` · `POST /knowledge/documents/{id}/extract` · `GET /knowledge/entries` · `GET /knowledge/queue` · `POST /knowledge/entries/{entity_id}/reviews` · `POST /knowledge/domain/propose` · `GET /knowledge/domain` |
543
+ | Tables | `knowledge_documents` · `knowledge_extraction_runs` · `knowledge_entries` · `knowledge_reviews` · `knowledge_jobs`. **7 columns added 2026-09-14** — see §8 |
544
  | Orchestration | `src/knowledge_ingest/` — job lifecycle, tenant scope, the pipeline port. Owns no parsing and no extraction logic |
545
  | Contract | `API_CONTRACT_BE_PYTHON.md` §Knowledge |
546
 
 
576
  | Extraction | `src/knowledge_extraction/` | Rifqi | `python -m src.knowledge_extraction.cli <artifact.json> --extract` |
577
  | Eval harness | `eval/knowledge/` | Rifqi | gold set + scorer + frozen baseline |
578
 
579
+ > ## Updated 2026-09-11 — the open-queue pass (queue ordering · measurement · ceilings · genre)
580
+ >
581
+ > Six items from `knowledge_pipeline_runs/SESSION_HANDOFF.md` §4, **all measured at zero API spend**
582
+ > (replays of the persisted paid runs, plus free stages on the three v2 artifacts).
583
+ >
584
+ > **The review queue no longer buries its own product (Step 2).** Unresolved key parameters held a
585
+ > sort tier beneath conflicts, so *every* one of them outranked *every* routine term. Replaying the
586
+ > three v2 runs: Komatsu's `Engine` (108 mentions) moves **rank 25 → 1**, Open Pit's `Resource`
587
+ > **8 → 1**, the first junk row moves **1 → 6** on both, and row counts are identical — nothing is
588
+ > dropped. They are now spliced in at most 1 per 5 term rows. A `key_parameter`'s `term` also carries
589
+ > a **head** (split at the first `.` `:` or newline, only past 60 chars) with the untruncated text in
590
+ > the new **`surface_full`** — the branch picks the right concepts and returns them at paragraph
591
+ > length (185 chars in one case).
592
+ >
593
+ > **Measurement hardening (Step 0).** `eval/knowledge/invariants.py` asserts on **fields and
594
+ > invariants, not exact text** — a span that cannot be located, a duplicate entity id, a rejected
595
+ > field that still carries a value, a queue whose ranks are not contiguous. `--runs N` repeats only
596
+ > the paid stage and reports **min/median/max** (the value becomes the *median*) plus whether every
597
+ > run minted identical entity ids, which is the K11 defect class and has been unwatched since. The
598
+ > pinned model snapshot is now an **assertion**, not a log line: a run served by a different snapshot
599
+ > exits 6 and must not be compared to the committed results.
600
+ >
601
+ > **Two spend ceilings, and a corrected estimate (Step 6).** `estimate_cost` summed **only the
602
+ > glossary branch** while reporting a field named `estimated_prompt_tokens` — so the ceiling gating
603
+ > on it read a partial number as a whole one. Measured undercount: **8.8%–18.4%**; Open Pit moves
604
+ > from 67.7% to **78.0%** of the token ceiling. A second ceiling now gates on **cluster count**
605
+ > (exact, free, and what actually drives spend — one call per cluster), `MAX_CLUSTERS = 750`. A
606
+ > refusal names which ceiling bound and how many calls were declined; `IngestJob.estimated_cost`,
607
+ > declared since the job model was written and **never populated**, now carries the per-document
608
+ > breakdown; and a wholly-refused batch settles **`blocked`** rather than `failed`, which the
609
+ > contract had documented and the code could not produce.
610
+ >
611
+ > **Genre classification (Step 5).** `src/knowledge_extraction/genre.py` names a document
612
+ > `standard` · `diagnostic` · `conceptual` · `unknown` from four free signals, and gets all three
613
+ > reference documents right. **Routing is OFF** (`ROUTE_LABELS` / `ROUTE_BRANCHES`) — the label sets
614
+ > are unscoreable without a second gold set, and branch-skipping is confirmed on one document — so
615
+ > today this is observability: `well_served=False` says *before* spending that a shop manual will
616
+ > yield almost nothing. Heading coverage now raises a per-document warning (Komatsu: 1 of 80 chunks).
617
+ >
618
+ > **Escalation is retired (Finding 6), with the number that was asked for.** Counted off all six
619
+ > persisted paid runs: **48 escalation calls, 0 definitions recovered**. `extraction_status` is set
620
+ > to `escalated` only when a later round succeeds where round 0 failed, and it appears zero times in
621
+ > every file. `MAX_ESCALATION_ROUNDS = 0`; the loop and its call site are untouched.
622
+ >
623
+ > **The over-cap window is fixed.** `WINDOW_WORDS` caps *words*; GLiNER truncates on *subwords* and
624
+ > warns rather than failing. Measured with GLiNER's own tokeniser: **1 window in 368** is over the
625
+ > 384-token cap — BUMA `::0024`, 130 words / 738 chars → **409 tokens** — and it has been losing its
626
+ > tail at every window size, including 130. Windows are now checked against the model's real
627
+ > tokeniser and split only if they do not fit. On the committed fixture: 3 windows split, mentions
628
+ > **186 → 208**, clusters 74 → 80 (**+8% spend**), **E1 and E1c unchanged**.
629
+ >
630
+ > Proposals written, nothing built: [TABLE_BRANCH_PROPOSAL.md](docs/knowledge/TABLE_BRANCH_PROPOSAL.md)
631
+ > (Step 8) and [TERM_HIERARCHY_PROPOSAL.md](docs/knowledge/TERM_HIERARCHY_PROPOSAL.md) (Step 7 + R7).
632
+
633
+ > **Updated 2026-09-10 — parsing `schema_version` 0.4.0 (asset pointers) + clustering fix.** Two
634
+ > changes, both measured on three documents (BUMA standard · Komatsu shop manual · Open Pit textbook
635
+ > Ch.1), at **zero API spend** — the parse cache fingerprint is unchanged so re-parses reuse the
636
+ > cached Mistral responses.
637
+ >
638
+ > **Clustering: the fuzzy pass is RETIRED.** `token_set_ratio` returns exactly 100 when one token set
639
+ > is a subset of the other, so a head noun always absorbed its own compounds and the threshold of 92
640
+ > was inert. 131 of its 140 merges were subset matches (all wrong); 6 were plurals, now folded
641
+ > deterministically by `normalize(lemma=True)`; 3 were distinct metrics wrongly merged. Matching is now
642
+ > `exact (plural-folded) → abbreviation ↔ expansion`, nothing else. Token-subset merges **15/18/97 → 0
643
+ > on all three documents**, and post-cluster gold recall 0.8293 → **0.8537** (the gold term `EWH`, which
644
+ > `Working Hours` had absorbed, is recovered). `_fuzzy_match` is parked, not deleted.
645
+ >
646
+ > **Parsing 0.4.0: a figure is no longer its own chunk.** It used to be, with an EMPTY `text`, which
647
+ > made it unreachable by construction — no text → no mentions → no cluster → never evidence. Measured:
648
+ > **13 figures, each described by a vision model we pay for, and ZERO reaching any prompt.** Now the
649
+ > `![](asset://<id>)` reference is folded into the prose chunk at its reading-order position and the
650
+ > figure travels as an `Asset` (content-addressed `sha256(bytes)[:16]`, persisted bytes, verbatim
651
+ > caption, unwrapped model description). **Image chunks 2/3/8 → 0/0/0**; 0 dangling references; 0
652
+ > model-written descriptions leaked into `text` (the span-check haystack). **Tables deliberately stay
653
+ > their own chunk** — their linearised text earns its evidence slot (74.6% of Komatsu's top-3 evidence
654
+ > is tabular) — and gain an `Asset` plus two-way pointers. `adapter.py` now carries `assets`,
655
+ > `referenced_by` and, finally, **`table_html`**, which parsing had emitted for months while the
656
+ > adapter dropped it. `SUPPORTED_SCHEMA` → (0,4); parsing and extraction ship together by design.
657
+ >
658
+ > **New metric `E1c` (post-cluster recall)** in `eval/knowledge/score.py`: the gold set scored AFTER
659
+ > clustering, which is the window E1 (measured before) and E3 (only over entries that produced a
660
+ > definition) both miss. **E2's compression kill line was REMOVED** — over-merging *raises* compression,
661
+ > so the metric was rewarding the defect.
662
+ >
663
+ > **Two settings changes were measured and REJECTED:** `WINDOW_WORDS` 256 (truncates 46 of 99 Open Pit
664
+ > windows — the "flat recall" that justified it was BUMA-only, the one document whose truncation count
665
+ > does not vary with window size) and `SPAN_SCORE_THRESHOLD` 0.15 (+50% clusters against a +20% bar,
666
+ > despite E1 0.878 → 0.976). `EVIDENCE_K` was raised 3 → 5. Detail:
667
+ > `knowledge_pipeline_runs/CHANGELOG_v2.md`.
668
+ >
669
+ > ⚠️ **Nothing about definition QUALITY is claimed yet** — the paid comparison has not run. Everything
670
+ > above is recall and structural integrity.
671
+
672
  > **Current (2026-09-07):** the parser is **Mistral OCR** (`--backend mistral`, no GPU; normalizer
673
+ > `normalize_mistral.py`, section-level). `schema_version` is **`0.4.0`** as of 2026-09-10 (asset
674
+ > pointers, K14); 0.3.3 added `source_title`,
675
  > `normaliser_version`, and a computed 1-based `page_no`).
676
  >
677
  > **MinerU was dropped 2026-09-07** — commented out of the `knowledge-parsing` extra and installed
 
687
  ### The seam
688
 
689
  The two halves meet at **one persisted, versioned artifact** — `ParsedDocument` in
690
+ `src/knowledge_parsing/contracts.py` (`schema_version` **`0.4.0`** as of 2026-09-10; `SUPPORTED_SCHEMA` is `(0, 4)`, so parsing and extraction ship together) — and nowhere else. **Extraction
691
  never takes a file path and never touches the parser**, which is what keeps MinerU swappable: a
692
  Tesseract or Azure Document Intelligence path emits the same artifact and extraction never learns
693
  which one ran. `knowledge_extraction/adapter.py` is the only module that knows the shape.
 
767
  Indonesian definitions, 0 span rejections, 7,168 of 8,029 prompt tokens cached. Full-document cost
768
  on the prototype was **$0.0069**.
769
 
770
+ ~~**Not yet done:** persistence (stage output is JSON on disk; the DDL handoff to Harry is open), any
771
  HTTP surface, a full-document run on v2, and **schema-fill precision has never been scored on v2** —
772
+ the one prototype experiment that failed.~~
773
+
774
+ **RESOLVED — all four, and the paragraph above is kept only for the record.** Persistence landed
775
+ 2026-09-07 (five tables, `knowledge_store.py`) and the HTTP surface with it (8 routes); the
776
+ full-document run is routine (K9, 2026-08-27) and E3 scores **1.000** on the committed fixture —
777
+ read as *precision on a scoreable base of 6*, since 57 of 63 entries abstain. **What is genuinely
778
+ open is in DEV_PLAN §0.8** and, ahead of it, `knowledge_pipeline_runs/SESSION_HANDOFF.md` §4.
779
 
780
  ### Dependencies
781
 
docs/README.md CHANGED
@@ -39,6 +39,8 @@ Client documents → candidate knowledge entries an expert reviews rather than a
39
  | [knowledge/KNOWLEDGE_PARSING_OUTPUT_CONTRACT.md](knowledge/KNOWLEDGE_PARSING_OUTPUT_CONTRACT.md) | 🔄 | You're working either side of the seam |
40
  | [knowledge/KNOWLEDGE_SURFACE_PLAN.md](knowledge/KNOWLEDGE_SURFACE_PLAN.md) | 🔄 | Persistence + endpoints: who owns what, phased tasks, blockers. Approved 2026-09-01 |
41
  | [knowledge/KNOWLEDGE_CONSUMPTION_PLAN.md](knowledge/KNOWLEDGE_CONSUMPTION_PLAN.md) | 💡 | How agents READ the entries (N14). Which agent gets what, and why. **Proposal, nothing built** |
 
 
42
  | [knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V4.md](knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V4.md) | 💡 | What a *real* domain context is. v3's object is a document brief wearing the name. **Proposal, nothing built** |
43
  | [knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md](knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md) | 💡 | The four-table DDL sent to Harry 2026-09-01. **Pending — no ORM written against it yet** |
44
  | [knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V3.md](knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V3.md) | ✅ | `DomainContext` design reasoning. **Shipped in full 2026-09-02** — the live shape is the output contract §8 |
 
39
  | [knowledge/KNOWLEDGE_PARSING_OUTPUT_CONTRACT.md](knowledge/KNOWLEDGE_PARSING_OUTPUT_CONTRACT.md) | 🔄 | You're working either side of the seam |
40
  | [knowledge/KNOWLEDGE_SURFACE_PLAN.md](knowledge/KNOWLEDGE_SURFACE_PLAN.md) | 🔄 | Persistence + endpoints: who owns what, phased tasks, blockers. Approved 2026-09-01 |
41
  | [knowledge/KNOWLEDGE_CONSUMPTION_PLAN.md](knowledge/KNOWLEDGE_CONSUMPTION_PLAN.md) | 💡 | How agents READ the entries (N14). Which agent gets what, and why. **Proposal, nothing built** |
42
+ | [knowledge/TABLE_BRANCH_PROPOSAL.md](knowledge/TABLE_BRANCH_PROPOSAL.md) | 💡 | Step 8 — the symptom x cause matrix a shop manual actually holds. Deterministic triples, LLM only to phrase. **Proposal, nothing built** |
43
+ | [knowledge/TERM_HIERARCHY_PROPOSAL.md](knowledge/TERM_HIERARCHY_PROPOSAL.md) | 💡 | Step 7 + R7 — parent/child terms and cross-document identity, designed together. **Touches `entity_id`; read before building** |
44
  | [knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V4.md](knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V4.md) | 💡 | What a *real* domain context is. v3's object is a document brief wearing the name. **Proposal, nothing built** |
45
  | [knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md](knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md) | 💡 | The four-table DDL sent to Harry 2026-09-01. **Pending — no ORM written against it yet** |
46
  | [knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V3.md](knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V3.md) | ✅ | `DomainContext` design reasoning. **Shipped in full 2026-09-02** — the live shape is the output contract §8 |
docs/knowledge/KNOWLEDGE_OUTPUT_CONTRACT.md CHANGED
@@ -24,7 +24,7 @@ nothing consumes.
24
 
25
  | Change | Detail |
26
  |---|---|
27
- | **Ids on every entity** | `term_id`, `formula_id`, `domain_id`; `rule_id` re-specified. All deterministic and content-derived |
28
  | **Glossary no longer restates formulas** | `formula_latex` removed → `defining_formula_id` reference |
29
  | **Rules link to formulas and terms** | `formula_ids[]`, `term_ids[]` |
30
  | **`rule_type` added** | `interpretation` \| `calculation` — see §2 for why |
@@ -52,7 +52,8 @@ None is model-generated; none is positional.
52
  term_id = "t_" + sha256(doc_id + "|" + canonical)[:10]
53
  formula_id = "f_" + sha256(doc_id + "|" + name_or_latex_or_chunk_id)[:10]
54
  rule_id = "r_" + sha256(doc_id + "|" + chunk_id + "|" + char_start)[:10]
55
- domain_id = "d_" + sha256(doc_id)[:10]
 
56
  ```
57
 
58
  Implemented in `src/knowledge_extraction/ids.py`. `term_id` is keyed on the **cluster canonical**,
@@ -80,10 +81,42 @@ registry, which belongs with persistence (DEV_PLAN §0.8 D2).
80
  | `glossary.json` | `GlossaryEntry[]` | Terms and their definitions |
81
  | `rules.json` | `RuleEntry[]` | Rules of thumb — how to interpret data or behaviour. **Renamed from `interpretation_pack.json` 2026-09-08** — the file, the entity and the DB `kind` now share one name. *The Interpretation Pack* remains the deliverable's name in prose |
82
  | `formulas.json` | `FormulaEntry[]` | Formulas, transcribed from the document |
83
- | `domain_context.json` | `DomainContext` | One domain-context entry per document (renamed from `brief_context.json` / `BriefContext`, 2026-09-01) |
84
 
85
  Plus three derived/audit payloads: `review_queue.json`, `rejected.json`, `usage.json`.
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  ### Why `RuleEntry` carries a `rule_type`
88
 
89
  The Interpretation Pack exists to **improve analytics insight from expert interpretation rules** —
@@ -104,6 +137,36 @@ A consumer wanting only interpretation logic filters on `rule_type`.
104
 
105
  ---
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  ## 3 · Transport reality
108
 
109
  Every payload is written by the CLI as a file into `--out-dir` (default `out/knowledge/`), as a
@@ -343,7 +406,7 @@ consequence can.
343
 
344
  ---
345
 
346
- ## 8 · `domain_context.json` → `DomainContext` — 6 fields *(single object)*
347
 
348
  > ✅ **The v3 reshape is complete, corrected 2026-09-08.** This banner previously said the
349
  > LLM-facing parts — `purpose` as a span-guarded verbatim quote, and the `subdomains` aggregate —
@@ -358,7 +421,7 @@ consequence can.
358
 
359
  ```json
360
  {
361
- "domain_id": "d_2ef60a8c19",
362
  "title": "Standard Parameter Produksi & ECA",
363
  "purpose_verbatim": "Standard Parameter ini merupakan pedoman dalam perhitungan parameter produksi yang meliputi Quantity (Qty) Physical Availability (PA), Utilization of Availability (UA), Productivity (PTY), serta Equipment Capacity Analysis (ECA).",
364
  "outline": [
@@ -387,7 +450,7 @@ consequence can.
387
 
388
  | Field | Type | Req |
389
  |---|---|---|
390
- | `domain_id` | string | ✅ **New in v2** |
391
  | `title` | string \| null | — |
392
  | `purpose_verbatim` | string \| null | — **Renamed + guarded 2026-09-02** (was `purpose`) |
393
  | `outline` | string[] | ✅ Default `[]`. **Added 2026-09-02** |
@@ -436,7 +499,7 @@ document with no headings.
436
 
437
  ---
438
 
439
- ## 9 · `review_queue.json` → `ReviewQueueRow[]` — 11 fields
440
 
441
  **This is the consumption surface** — the payload a review UI binds to. Deliberately denormalised so
442
  a row renders with no joins.
@@ -465,7 +528,7 @@ a row renders with no joins.
465
  "rank": 2,
466
  "row_kind": "key_parameter",
467
  "term_id": null,
468
- "domain_id": "d_2ef60a8c19",
469
  "term": "Grouping (Composite)",
470
  "definition": null,
471
  "source_wording": null,
@@ -483,7 +546,8 @@ a row renders with no joins.
483
  | `rank` | int | ✅ | 1-based display order — see sorting below |
484
  | `row_kind` | `term` \| `key_parameter` | ✅ | **New 2026-09-02.** Switch on this before reading anything else |
485
  | `term_id` | string \| null | ✅ | **New in v2.** What an approve / edit / reject decision attaches to, so it survives a re-run. **Null on a `key_parameter` row** — that it resolves to no term is the reason the row exists |
486
- | `domain_id` | string | — | `key_parameter` rows only — what the decision attaches to instead |
 
487
  | `term` · `definition` · `source_wording` · `mention_count` | | | Copied from the entry |
488
  | `page` · `page_no` · `section_no` · `span` | | | **Lifted out of `provenance`** so a row is self-contained. Render **`page_no`** (1-based), not `page` |
489
  | `review_reason` | string | ✅ | Human-readable, one of five |
@@ -492,11 +556,42 @@ a row renders with no joins.
492
  `conflict_variants` — all four are already expressed by `review_reason`, and remain available on the
493
  entry itself.
494
 
495
- **Sort order** — `(conflicts, then unresolved key parameters, then mention_count descending)`.
496
- Conflicts outrank everything, because a contradiction is a decision only the expert can make.
497
- Unresolved key parameters sit second: the branch that produced them has **no span check at all**, so
498
- an unresolvable surface is the one hallucination signal it can raise, and it must not sink beneath
499
- sixty routine rows where nobody reaches it.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
500
 
501
  **`review_reason` values:**
502
 
 
24
 
25
  | Change | Detail |
26
  |---|---|
27
+ | **Ids on every entity** | `term_id`, `formula_id`, `brief_id`; `rule_id` re-specified. All deterministic and content-derived |
28
  | **Glossary no longer restates formulas** | `formula_latex` removed → `defining_formula_id` reference |
29
  | **Rules link to formulas and terms** | `formula_ids[]`, `term_ids[]` |
30
  | **`rule_type` added** | `interpretation` \| `calculation` — see §2 for why |
 
52
  term_id = "t_" + sha256(doc_id + "|" + canonical)[:10]
53
  formula_id = "f_" + sha256(doc_id + "|" + name_or_latex_or_chunk_id)[:10]
54
  rule_id = "r_" + sha256(doc_id + "|" + chunk_id + "|" + char_start)[:10]
55
+ brief_id = "b_" + sha256(doc_id)[:10] # per-document card; `d_` belongs to the
56
+ # scope-level DomainContext in src/knowledge_domain/
57
  ```
58
 
59
  Implemented in `src/knowledge_extraction/ids.py`. `term_id` is keyed on the **cluster canonical**,
 
81
  | `glossary.json` | `GlossaryEntry[]` | Terms and their definitions |
82
  | `rules.json` | `RuleEntry[]` | Rules of thumb — how to interpret data or behaviour. **Renamed from `interpretation_pack.json` 2026-09-08** — the file, the entity and the DB `kind` now share one name. *The Interpretation Pack* remains the deliverable's name in prose |
83
  | `formulas.json` | `FormulaEntry[]` | Formulas, transcribed from the document |
84
+ | `document_brief.json` | `DocumentBrief` | One per-document card. Prefix history, not churn: `b_` (BriefContext) -> `d_` (DomainContext, 2026-09-01) -> **`b_` again** (DocumentBrief, 2026-09-08), once v4 gave `d_` to the real scope-level `DomainContext` in `src/knowledge_domain/` |
85
 
86
  Plus three derived/audit payloads: `review_queue.json`, `rejected.json`, `usage.json`.
87
 
88
+ ### Rule vs domain knowledge — where the line is
89
+
90
+ *Added 2026-09-14 (o2). The two blurred on screen during the 2026-09-08 review,
91
+ and a boundary nobody has written down is one the prompts cannot hold.*
92
+
93
+ | | **`rule`** | **`domain`** |
94
+ |---|---|---|
95
+ | Grain | One statement, from one document | One per company, composed across all of them |
96
+ | Shape | A condition and a consequence | Identity, boundary, conventions, measures |
97
+ | Evidence | Span-checked against its source | Derived from entries, or expert-declared |
98
+ | Answers | *"…and how do I compute this correctly?"* | *"…what does this company measure, and what does it call it?"* |
99
+
100
+ **The test to apply.** If removing it would change **how a number is computed
101
+ or sourced**, it is a rule — *"if joint-survey data is unavailable, use truck
102
+ count"* changes the figure. If removing it would only leave an agent **less
103
+ oriented** — not knowing that this company works in overburden and BCM — it is
104
+ domain.
105
+
106
+ Two consequences worth stating, because they are what the distinction is *for*:
107
+
108
+ - **A rule is actionable alone; domain knowledge is not.** A planner can apply
109
+ a rule to a specific calculation. It cannot apply "this is a mining company"
110
+ to anything — that shapes which questions make sense, not which column to sum.
111
+ - **Domain is high-level and rules are specific, but "high-level" is not the
112
+ test.** A long, general rule is still a rule. The grain question — *does this
113
+ come from one document's sentence, or from the corpus as a whole?* — settles
114
+ it faster and does not drift.
115
+
116
+ Where it is genuinely both, prefer the **rule**: it carries provenance and a
117
+ span, so an expert can check it. Domain fields are the ones the pipeline can
118
+ least easily prove.
119
+
120
  ### Why `RuleEntry` carries a `rule_type`
121
 
122
  The Interpretation Pack exists to **improve analytics insight from expert interpretation rules** —
 
137
 
138
  ---
139
 
140
+ ## 2c · Evidence now carries figures and tables *(parsing 0.4.0, 2026-09-10)*
141
+
142
+ The artifact this half consumes changed shape: a figure is no longer its own chunk. It is folded into
143
+ the prose chunk at its reading-order position as `![](asset://<id>)`, and travels as an `Asset` on that
144
+ chunk. `adapter.py` carries `assets`, `referenced_by` and `table_html` across; `evidence_block` renders
145
+ them into the prompt under `ASSETS REFERENCED BY THIS CHUNK`.
146
+
147
+ **What that means for an extraction branch**, and the distinction is enforced in the prompts:
148
+
149
+ | | may be quoted? | may be a provenance span? |
150
+ |---|---|---|
151
+ | `Asset.caption` — printed in the document | **yes** | **yes** |
152
+ | `Asset.description` — written by a vision model | no | **no** — it is not in the document, so the span check rejects it and the field is discarded |
153
+
154
+ Why it mattered: before 0.4.0 a figure chunk had an EMPTY `text`, so it produced no mentions, joined no
155
+ cluster, and was never selected as evidence. 13 figures across three documents, each already described
156
+ by a paid vision call, reached **zero** prompts. Full detail:
157
+ `knowledge_pipeline_runs/CHANGELOG_v2.md` §2.
158
+
159
+ > ✅ **RESOLVED 2026-09-11 — §1, §2 and §8 now match the code.** The note is kept for the record.
160
+ >
161
+ > ⚠️ **Correction to §8 of this document (noted 2026-09-10).** The code writes
162
+ > `document_brief.json` with **`brief_id`** and a `b_` prefix; §8 below documents
163
+ > `domain_context.json` with `domain_id` and `d_`. The v3 rename was partly reverted for the
164
+ > per-document object once the scope-level `DomainContext` became a separate thing in
165
+ > `src/knowledge_domain/`. **§8 does not describe what the code emits.** Worth one edit, and it matters
166
+ > because `entity_id` prefixes are the key an expert's approvals hang on.
167
+
168
+ ---
169
+
170
  ## 3 · Transport reality
171
 
172
  Every payload is written by the CLI as a file into `--out-dir` (default `out/knowledge/`), as a
 
406
 
407
  ---
408
 
409
+ ## 8 · `document_brief.json` → `DocumentBrief` — 6 fields *(single object)*
410
 
411
  > ✅ **The v3 reshape is complete, corrected 2026-09-08.** This banner previously said the
412
  > LLM-facing parts — `purpose` as a span-guarded verbatim quote, and the `subdomains` aggregate —
 
421
 
422
  ```json
423
  {
424
+ "brief_id": "d_2ef60a8c19",
425
  "title": "Standard Parameter Produksi & ECA",
426
  "purpose_verbatim": "Standard Parameter ini merupakan pedoman dalam perhitungan parameter produksi yang meliputi Quantity (Qty) Physical Availability (PA), Utilization of Availability (UA), Productivity (PTY), serta Equipment Capacity Analysis (ECA).",
427
  "outline": [
 
450
 
451
  | Field | Type | Req |
452
  |---|---|---|
453
+ | `brief_id` | string | ✅ **New in v2.** `b_` prefix — reconciled with the code 2026-09-11 |
454
  | `title` | string \| null | — |
455
  | `purpose_verbatim` | string \| null | — **Renamed + guarded 2026-09-02** (was `purpose`) |
456
  | `outline` | string[] | ✅ Default `[]`. **Added 2026-09-02** |
 
499
 
500
  ---
501
 
502
+ ## 9 · `review_queue.json` → `ReviewQueueRow[]` — 12 fields
503
 
504
  **This is the consumption surface** — the payload a review UI binds to. Deliberately denormalised so
505
  a row renders with no joins.
 
528
  "rank": 2,
529
  "row_kind": "key_parameter",
530
  "term_id": null,
531
+ "brief_id": "d_2ef60a8c19",
532
  "term": "Grouping (Composite)",
533
  "definition": null,
534
  "source_wording": null,
 
546
  | `rank` | int | ✅ | 1-based display order — see sorting below |
547
  | `row_kind` | `term` \| `key_parameter` | ✅ | **New 2026-09-02.** Switch on this before reading anything else |
548
  | `term_id` | string \| null | ✅ | **New in v2.** What an approve / edit / reject decision attaches to, so it survives a re-run. **Null on a `key_parameter` row** — that it resolves to no term is the reason the row exists |
549
+ | `brief_id` | string | — | `key_parameter` rows only — what the decision attaches to instead. **Named `brief_id` on the wire**, not `domain_id`; see the correction under Sort order |
550
+ | `surface_full` | string \| null | — | `key_parameter` rows only. **New 2026-09-11.** The untruncated surface when `term` had to be shortened to a head; `null` when it did not |
551
  | `term` · `definition` · `source_wording` · `mention_count` | | | Copied from the entry |
552
  | `page` · `page_no` · `section_no` · `span` | | | **Lifted out of `provenance`** so a row is self-contained. Render **`page_no`** (1-based), not `page` |
553
  | `review_reason` | string | ✅ | Human-readable, one of five |
 
556
  `conflict_variants` — all four are already expressed by `review_reason`, and remain available on the
557
  entry itself.
558
 
559
+ **Sort order** — **changed 2026-09-11.** `(conflicts, then mention_count descending, with unresolved
560
+ key parameters spliced in at most one per five term rows)`.
561
+
562
+ Conflicts still outrank everything, because a contradiction is a decision only the expert can make.
563
+ What changed is the tier beneath them. Unresolved key parameters used to occupy one, which meant
564
+ **every** unresolved parameter outranked **every** routine term regardless of how many there were —
565
+ and the branch produces them in bulk on exactly the documents it understands least. On the Komatsu
566
+ shop manual that was **24 junk rows standing in front of `Engine`, a term with 108 mentions**, which
567
+ sat at rank 25.
568
+
569
+ The original reason for promoting them is preserved and is still sound: that branch has **no span
570
+ check at all**, so an unresolvable surface is the one hallucination signal it can raise, and it must
571
+ not sink beneath sixty routine rows where nobody reaches it. A **cap** expresses that without letting
572
+ the tail take the queue over. Measured by replaying the three persisted v2 runs:
573
+
574
+ | Document | unresolved key parameters | first one at rank | top term, rank |
575
+ |---|---|---|---|
576
+ | BUMA | 0 | — | `Qty` 1 → 1 (unchanged) |
577
+ | Komatsu | 24 | 1 → **6** | `Engine` **25 → 1** |
578
+ | Open Pit | 7 | 1 → **6** | `Resource` **8 → 1** |
579
+
580
+ Row counts are identical before and after on all three — **nothing is dropped**; parameters the
581
+ splice cannot place are appended rather than discarded.
582
+
583
+ **Also new on `key_parameter` rows: `surface_full`.** The branch is supposed to return a parameter
584
+ *name* and on a textbook it returns the right concept wrapped in its whole defining sentence (185
585
+ characters, in one measured case). `term` now carries a head — split at the first `.`, `:` or newline
586
+ — and `surface_full` carries the untruncated text, or `null` when no shortening was needed. The head
587
+ is only taken past 60 characters, which is what keeps a legitimate short name containing a period
588
+ ("No. of units") intact.
589
+
590
+ > ⚠️ **Field-name correction, 2026-09-11.** This document described the `key_parameter` row's owning
591
+ > id as **`domain_id`**. The code emits **`brief_id`** and always has: the K12 rename changed the
592
+ > entity from `BriefContext` to `DomainContext` but left the id field's name alone, deliberately —
593
+ > renaming it is a migration that invalidates every expert approval recorded against the old prefix.
594
+ > **The wire field is `brief_id`.** Corrected here rather than in code, per the trust order.
595
 
596
  **`review_reason` values:**
597
 
docs/knowledge/KNOWLEDGE_PARSING_OUTPUT_CONTRACT.md CHANGED
@@ -11,6 +11,40 @@
11
  Companion to `KNOWLEDGE_OUTPUT_CONTRACT.md` (extraction). This is the half **upstream** of it:
12
  parsing emits `ParsedDocument`, extraction consumes it and never touches the parser.
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  > **Current state (2026-09-02) — additive changes since the reference run below.** The default
15
  > parser is now **Mistral OCR** (`parser_name: "mistral-ocr"`, `parser_backend: "mistral-ocr"`),
16
  > API-based and needing **no GPU**. The artifact shape is **identical whichever parser produced it** —
@@ -90,7 +124,7 @@ Three conventions hold:
90
  | `source_title` | string \| null | — | **(0.3.2)** The document's human-readable name. Equals `doc_id` today; a separate field so the two can diverge once `doc_id` becomes a content hash |
91
  | `normaliser_version` | string \| null | — | **(0.3.1)** Fingerprint of the normalisation code that produced these chunks. `parser_config` covers the parser's settings; this covers our half |
92
  | `version` | int | ✅ | Version of **this artifact for this document**; bumped on re-parse |
93
- | `schema_version` | string | ✅ | Versions **the contract**. `0.3.0` on this reference run; **current `0.3.3`** (see the Current-state note above) |
94
  | `created_at` | string \| null | — | ISO 8601, seconds precision |
95
  | `raw_output_dir` | string \| null | — | Untouched MinerU output, kept content-addressed and never copied |
96
 
@@ -141,11 +175,64 @@ Three conventions hold:
141
  | `is_tabular` | bool | ✅ | |
142
  | `source_items` | int[] | ✅ | Indices into MinerU's `content_list.json` — the trace back |
143
  | `bbox` | int[] \| null | — | `[x0, y0, x1, y1]` of the first source item. Carried for a curation UI; **nothing in the pipeline reasons about it** — ordering uses item sequence, never coordinates |
144
- | `images` | string[] | ✅ | Paths relative to `raw_output_dir`. Formula images, table/chart crops |
 
 
145
  | `latex` | string[] | ✅ | Source markup for formulas, kept beside the rendered prose. ⚠️ **LOAD-BEARING** — extraction's formula guard validates against this field. Reflowing it or leaving it empty weakens the check with no error. See below |
146
  | `table_html` | string \| null | — | Source markup for tables |
147
  | `generated_description` | string \| null | — | ⚠️ **MODEL-WRITTEN, NOT FROM THE DOCUMENT.** MinerU's description of an image or chart. **Never span-checkable** — treat as a model claim about a picture, never as evidence. See below |
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  ### Why `text` is rendered but markup is kept
150
 
151
  The term filter downstream is an NER model reading prose. MinerU writes formulas character-spaced
 
11
  Companion to `KNOWLEDGE_OUTPUT_CONTRACT.md` (extraction). This is the half **upstream** of it:
12
  parsing emits `ParsedDocument`, extraction consumes it and never touches the parser.
13
 
14
+ > ## ⚠️ schema_version **0.4.0** — asset pointers (2026-09-10)
15
+ >
16
+ > **A figure is no longer its own chunk.** Its markdown reference is folded into the prose chunk at
17
+ > its reading-order position, and the figure travels as an `Asset` on that chunk. This is a **MINOR**
18
+ > bump, not a patch: chunk boundaries change, so a 0.3.x consumer would silently read a different
19
+ > document shape. `adapter.check_schema_version` rejects a newer minor by design — parsing and
20
+ > extraction ship together.
21
+ >
22
+ > **Why.** Before 0.4.0 an image became its own chunk with an **empty `text`**, which made it
23
+ > unreachable by construction: no text → no term mentions → joins no cluster → never selected as
24
+ > evidence. Measured across three documents: **13 figures, every one described by a vision model we
25
+ > pay for, and ZERO appearing in any cluster's evidence.** Meanwhile the prose that said
26
+ > "Figure 1.1" had its `![](…)` reference stripped, so nothing connected the sentence to the picture.
27
+ >
28
+ > **What changed**
29
+ >
30
+ > | | 0.3.3 | 0.4.0 |
31
+ > |---|---|---|
32
+ > | A figure | its own chunk, `text=""`, unreachable | folded into the prose chunk as `![](asset://<id>)`, carried in `Chunk.assets` |
33
+ > | A table | its own chunk | **unchanged** — its linearised text earns its evidence slot (74.6% of the Komatsu manual's top-3 evidence is tabular). Gains an `Asset` + two-way pointers |
34
+ > | Asset bytes | returned by the API, **written nowhere**; `images[]` pointed at files that did not exist | content-addressed and persisted; `Asset.storage_key` |
35
+ > | Asset identity | the parser's page-local filename | **`sha256(bytes)[:16]`** |
36
+ > | A caption | landed in a *neighbouring* prose chunk, unlinked | attached to the `Asset` **and** left in the prose text (it is verbatim document content) |
37
+ > | A description | stored raw, JSON braces included | unwrapped; `description_source` records what produced it |
38
+ >
39
+ > **Measured effect** (same three documents, no re-parse — the cache fingerprint is unchanged):
40
+ > chunks 32→28, 85→80, 59→44 · **image chunks 2/3/8 → 0/0/0** · empty-text chunks → 0 ·
41
+ > 13 assets persisted · 0 dangling references · **0 descriptions leaked into `text`** ·
42
+ > captions still 17/17 in text · recall unmoved.
43
+ >
44
+ > **The invariant that must not break:** a model-written `description` never enters `Chunk.text`.
45
+ > `text` is the haystack the span check searches, so generated prose in there would let a
46
+ > fabrication pass the control built to catch it.
47
+
48
  > **Current state (2026-09-02) — additive changes since the reference run below.** The default
49
  > parser is now **Mistral OCR** (`parser_name: "mistral-ocr"`, `parser_backend: "mistral-ocr"`),
50
  > API-based and needing **no GPU**. The artifact shape is **identical whichever parser produced it** —
 
124
  | `source_title` | string \| null | — | **(0.3.2)** The document's human-readable name. Equals `doc_id` today; a separate field so the two can diverge once `doc_id` becomes a content hash |
125
  | `normaliser_version` | string \| null | — | **(0.3.1)** Fingerprint of the normalisation code that produced these chunks. `parser_config` covers the parser's settings; this covers our half |
126
  | `version` | int | ✅ | Version of **this artifact for this document**; bumped on re-parse |
127
+ | `schema_version` | string | ✅ | Versions **the contract**. `0.3.0` on this reference run; **current `0.4.0`** (see the banner above) |
128
  | `created_at` | string \| null | — | ISO 8601, seconds precision |
129
  | `raw_output_dir` | string \| null | — | Untouched MinerU output, kept content-addressed and never copied |
130
 
 
175
  | `is_tabular` | bool | ✅ | |
176
  | `source_items` | int[] | ✅ | Indices into MinerU's `content_list.json` — the trace back |
177
  | `bbox` | int[] \| null | — | `[x0, y0, x1, y1]` of the first source item. Carried for a curation UI; **nothing in the pipeline reasons about it** — ordering uses item sequence, never coordinates |
178
+ | `images` | string[] | ✅ | ⚠️ **DEPRECATED 0.4.0** — superseded by `assets`. Still populated for one version so a 0.3.x reader degrades rather than breaking. Holds the parser's own filenames, which are **not reliable**: Mistral reported `image_id: img-2.jpeg` on a block whose markdown said `img-1.jpeg`, so two figures collided and one image's bytes were lost. Read `assets` |
179
+ | `assets` | Asset[] | ✅ | **New 0.4.0.** Figures and tables this chunk contains or points at. Default `[]` |
180
+ | `referenced_by` | string[] | ✅ | **New 0.4.0.** `chunk_id`s pointing AT this chunk. Populated on table chunks. Default `[]` |
181
  | `latex` | string[] | ✅ | Source markup for formulas, kept beside the rendered prose. ⚠️ **LOAD-BEARING** — extraction's formula guard validates against this field. Reflowing it or leaving it empty weakens the check with no error. See below |
182
  | `table_html` | string \| null | — | Source markup for tables |
183
  | `generated_description` | string \| null | — | ⚠️ **MODEL-WRITTEN, NOT FROM THE DOCUMENT.** MinerU's description of an image or chart. **Never span-checkable** — treat as a model claim about a picture, never as evidence. See below |
184
 
185
+ ## 2b · `Asset` *(new at 0.4.0)*
186
+
187
+ ```json
188
+ {
189
+ "asset_id": "d2e7b4ffe31006ec",
190
+ "kind": "figure",
191
+ "storage_key": "assets/d2e7b4ffe31006ec.jpeg",
192
+ "page_idx": 2,
193
+ "page_no": 3,
194
+ "bbox": [93, 562, 434, 907],
195
+ "caption": "Figure 1.1. The relationship between exploration information, resources and reserves (SME, 1991).",
196
+ "description": "The diagram illustrates the classification of mineral resources and reserves based on increasing levels of geological knowledge…",
197
+ "description_source": "mistral-ocr/bbox_annotation"
198
+ }
199
+ ```
200
+
201
+ | Field | Type | Req | Notes |
202
+ |---|---|---|---|
203
+ | `asset_id` | string | ✅ | **`sha256(bytes)[:16]`.** Not the parser's id — see the `images` row. Content addressing makes a re-parse idempotent and de-duplicates a figure that appears twice (Open Pit has one such pair) |
204
+ | `kind` | enum | ✅ | `figure` · `chart` · `diagram` · `table` — one vocabulary for both cases |
205
+ | `storage_key` | string \| null | — | Where the bytes live. `null` for a table (no bytes of its own) and whenever persistence was skipped or failed — writing assets is a **never-throw** seam |
206
+ | `page_idx` | int | ✅ | 0-based, as the parser reports |
207
+ | `page_no` | int | ✅ | **Computed** `page_idx + 1`. The number a human reads; derived so it cannot drift |
208
+ | `bbox` | int[] \| null | — | `[x0,y0,x1,y1]` of the **asset**, so a UI can highlight the figure. Distinct from the host chunk's `bbox`, which stays where the text is |
209
+ | `caption` | string \| null | — | **VERBATIM** from the document's own caption block. Quotable as evidence, span-checkable. Also left in the prose `text`, where it already landed — the asset gets a copy, not a move |
210
+ | `description` | string \| null | — | ⚠️ **MODEL-WRITTEN.** Never span-checkable, never evidence, and **never in `text`** |
211
+ | `description_source` | string \| null | — | e.g. `mistral-ocr/bbox_annotation`, so a later model swap is traceable rather than silently changing what the field means |
212
+
213
+ **A figure's reference appears inline in its host chunk's `text`**, at the reading-order position the
214
+ document put it:
215
+
216
+ ```text
217
+ …so closely and the geological character is so well defined that size, shape, depth
218
+ and mineral content of the resource are well established.
219
+
220
+ ![](asset://d2e7b4ffe31006ec)
221
+
222
+ Figure 1.1. The relationship between exploration information, resources and reserves (SME, 1991).
223
+
224
+ – Indicated. Quantity and grade…
225
+ ```
226
+
227
+ Two consumer obligations:
228
+
229
+ - **Resolve `asset://<id>` against that chunk's own `assets`.** Every reference is guaranteed to
230
+ resolve locally (0 dangling across the three test documents).
231
+ - **Switch on the caption/description distinction.** A caption may be quoted and used as a provenance
232
+ span; a description may not, and a span taken from one is rejected by the validator.
233
+
234
+ ---
235
+
236
  ### Why `text` is rendered but markup is kept
237
 
238
  The term filter downstream is an NER model reading prose. MinerU writes formulas character-spaced
docs/knowledge/KNOWLEDGE_PIPELINE_CALIBRATION.md CHANGED
@@ -26,7 +26,7 @@ Companions: [KNOWLEDGE_PIPELINE_TODO.md](KNOWLEDGE_PIPELINE_TODO.md) (plan) ·
26
  | 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 |
27
  | 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** |
28
  | 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 |
29
- | 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 |
30
  | 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 |
31
 
32
  **Label sets** (`labels.yaml`) — three variants were tried and A/B'd without touching code. Keep
@@ -40,7 +40,7 @@ Indonesian-phrasing variant (`istilah produksi tambang`, …) did **not** win; `
40
  | Constant | Value | Why this value |
41
  |---|---|---|
42
  | Match order | exact (normalised) → abbreviation↔expansion → fuzzy | Cheapest and most certain first |
43
- | Fuzzy threshold | **92** (`rapidfuzz.token_set_ratio`) | Conservative on purpose |
44
  | **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 |
45
  | Canonical surface | shortest variant ≥ 2 chars | That is how a reader looks a term up — `PA`, not `Physical Availability (PA) untuk …` |
46
 
@@ -77,17 +77,54 @@ needs the tail.
77
 
78
  | Constant | Value | Why |
79
  |---|---|---|
80
- | `evidence_k` | **3** | Chunks fed per extraction round |
81
  | Cue proximity | **100 chars** | Distance from mention to a definitional cue |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
  **Heading matching is word-boundary, never substring.** `PA` is a substring of `Parameter`, `pada`
84
  and `composite` — substring matching handed the +4.0 heading bonus to almost every Indonesian
85
  heading and pushed real definition sections *below* formula tables.
86
 
87
  **Why this filter is legitimate when the relevance gate was rejected:** no term is dropped (only its
88
- *evidence* is narrowed), every mention stays in provenance so a bad top-3 is visible, and the
89
- escalation loop self-corrects. The rejected gate had none of those three properties. Preserve all
90
- three if ranking is ever revisited.
 
 
 
91
 
92
  ---
93
 
 
26
  | 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 |
27
  | 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** |
28
  | 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 |
29
+ | 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 |
30
  | 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 |
31
 
32
  **Label sets** (`labels.yaml`) — three variants were tried and A/B'd without touching code. Keep
 
40
  | Constant | Value | Why this value |
41
  |---|---|---|
42
  | Match order | exact (normalised) → abbreviation↔expansion → fuzzy | Cheapest and most certain first |
43
+ | ~~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 |
44
  | **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 |
45
  | Canonical surface | shortest variant ≥ 2 chars | That is how a reader looks a term up — `PA`, not `Physical Availability (PA) untuk …` |
46
 
 
77
 
78
  | Constant | Value | Why |
79
  |---|---|---|
80
+ | `evidence_k` | **5** (raised from 3, 2026-09-10) | Chunks fed per extraction round |
81
  | Cue proximity | **100 chars** | Distance from mention to a definitional cue |
82
+ | `evidence_link_partners` | **1** (added 2026-09-11) | Linked partner chunks pulled into the window on top of the ranked K |
83
+
84
+ ### Linked partners (2026-09-11)
85
+
86
+ Schema 0.4.0 pairs a table chunk with the prose that introduces it, via `referenced_by` in both
87
+ directions. `top_k` now pulls **at most one** partner into the evidence window.
88
+
89
+ **Why it is not redundant — the measurement that decided it.** Ranking only, on the three v2
90
+ artifacts and their recorded filter payloads (free; no GLiNER, no spend):
91
+
92
+ | | clusters | top-5 touches a linked pair | **both sides already there** | only one side |
93
+ |---|---|---|---|---|
94
+ | BUMA | 83 | 31 | **0** | 31 |
95
+ | Komatsu | 93 | 55 | **0** | 55 |
96
+ | Open Pit | 427 | 70 | **0** | 70 |
97
+
98
+ **Zero clusters out of 603 saw both sides.** The ranker never co-selects a table and the prose that
99
+ names it — and that prose is frequently the table's only title (*"Troubleshooting of engine
100
+ (S-mode)"* above a bare `S-1 | 6` grid). Cost at the cap: evidence read **+14.5% / +24.5% / +9.9%**,
101
+ which is **+1.6% / +4.1% / +4.2%** of total prompt spend; Open Pit moves 71% → ~74% of the 2M ceiling.
102
+
103
+ **Two placement rules, both load-bearing.** The expansion lives in `top_k`, *not* `rank_evidence`:
104
+
105
+ 1. The **ranked list is never mutated**, so everything reading `evidence_chunk_ids` sees the number
106
+ it saw before.
107
+ 2. `evidence_block` (the prompt) and `evidence_text` (the span check) both take their ids from the
108
+ same `top_k` call, so *what the model reads is exactly what the span check searches* stays true.
109
+ Expanding in the prompt renderer instead would null every quote from a partner as unlocatable.
110
+ Verified across all 603 clusters: **0 parity failures**.
111
+
112
+ **Scope, stated honestly.** The document this helps most (Komatsu, 55 of 93 clusters) is the one that
113
+ returns 0 definitions for *genre* reasons. This is groundwork for the table branch (Step 8) rather
114
+ than a glossary win, and it has **not** been measured against a paid run — with this corpus's noise a
115
+ single run could not have shown anything either way. Set `EVIDENCE_LINK_PARTNERS = 0` to disable.
116
 
117
  **Heading matching is word-boundary, never substring.** `PA` is a substring of `Parameter`, `pada`
118
  and `composite` — substring matching handed the +4.0 heading bonus to almost every Indonesian
119
  heading and pushed real definition sections *below* formula tables.
120
 
121
  **Why this filter is legitimate when the relevance gate was rejected:** no term is dropped (only its
122
+ *evidence* is narrowed), every mention stays in provenance so a bad top-3 is visible, and the full
123
+ ranked list is retained so the tail stays reachable. The rejected gate had none of those three
124
+ properties. Preserve all three if ranking is ever revisited. (The third property used to be phrased
125
+ as "the escalation loop self-corrects"; escalation was retired on 2026-09-11 with
126
+ `MAX_ESCALATION_ROUNDS = 0`, but retaining the tail is what made the property true and it still
127
+ holds.)
128
 
129
  ---
130
 
docs/knowledge/KNOWLEDGE_PIPELINE_TODO.md CHANGED
@@ -10,6 +10,13 @@
10
 
11
  **Status legend:** ⬜ not started · 🔄 in progress · ✅ done · ⛔ blocked · 🔎 built but unverified · ⏸️ deferred
12
 
 
 
 
 
 
 
 
13
  > **Updated 2026-08-26.** Both halves are in-tree and run end to end as offline CLIs; the seam is
14
  > settled and consumed from both sides. Tracked in DEV_PLAN §0.8, described in REPO_STATUS §14.
15
  > The full-document run landed (X18) and scored E3 = 0.90. **Schema v2 landed 2026-08-26 (X24)** —
@@ -203,7 +210,7 @@ envelope shape proposed in §3 — so S1 can settle either way without touching
203
  | **X14** | Diff vs. active glossary version | 🔎 | ✅ | `diff/glossary_diff.py`. The baseline is passed in explicitly (`--active-glossary`) rather than read from wherever the last run wrote, so the duplicate and conflicting paths are reachable — all three verified by test |
204
  | **X15** | Frequency-sorted review queue | ✅ | ✅ | `queue/review_queue.py`. Each row carries page, section and the verbatim span so review is quote-vs-page. Gained a review reason for **wording discrepancies**, which is how the literal-wording decision reaches the expert |
205
  | **X16** | Bulk vs. incremental ingest | ⬜ | ⬜ | Bulk setup ingest (corpus-frequency statistics as a candidate booster) and incremental single-file add (no corpus context; diff against the active version). Neither exists |
206
- | **X17** | Persistence | ⬜ | 🔄 | Stage artifacts are written as JSON by the CLI, which is enough for the offline path. Tables still needed → D2 |
207
  | **X24** | Output schema v2 (Harry's contract review) | ✅ | 🔎 | **Landed 2026-08-26, UNEVALUATED.** Four asks, all adopted: deterministic ids (`ids.py` — `term_id`/`formula_id`/`rule_id`/`brief_id`, sha256 over content, never positional and never model-written); the glossary references a formula (`defining_formula_id`) instead of restating it; rules link to formulas and terms (`formula_ids[]`, `term_ids[]`); `brief_id` added. New deterministic `link.py` resolves every reference after the branches run — no LLM, no spend, dangle counts reported not swallowed. **16 fields cut, 8 added (50 → 42).** Two defects fixed on the way: `cluster_id` was a frequency-rank ordinal and `rule_id` was written by the model, so **neither survived a re-run** — which silently breaks approval continuity, not just linking. `rule_type` (`interpretation`\|`calculation`) keeps the Interpretation Pack's name while retaining the calculation conventions the corpus actually yields — **0 of 15 gold rules are interpretation rules**, because a *Standard Parameter* document defines how to compute a parameter, not how to read one. Verified: ruff clean on touched paths, `import main` OK, suite **497 passed / 7 skipped**, and a synthetic end-to-end link check (PA resolves to its own defining formula, not the `Production` formula it merely appears in; `Total Hours` dangles to null as designed). **Not verified: any eval** — see the banner; the prompt edits are in the same unevaluated position as X20 |
208
  | **X26** | Output schema v3 — `DomainContext` | Rifqi | ✅ | ✅ | **Done 2026-09-02** (`7f47c61` · `6f5d619` · `e2865ac` · `172d8dc`). `BriefContext` → **`DomainContext`** (`brief_id` → `domain_id`, `b_` → `d_`) — renamed before persistence exists, because after it a prefix change is an id migration that loses approvals. **The branch stopped being the pipeline's one unverifiable output:** `GUARDED_FIELDS["summary"]` went `()` → `("title", "purpose_verbatim")`, achieved by changing the QUESTION rather than the model — the prompt now asks the model to *locate* the document's own statement of purpose instead of composing a summary, and a quote is checkable where a summary never was. `_run_summary` also never called `validate_entry`, so the guard table alone would have been inert. New derived fields: **`outline`** (ordered unique `heading_path`; needed `heading_path` to cross the adapter, which was dropping it exactly as it once dropped `latex`), **`subdomains`** (roll-up of per-term `subdomain_tags`, frequency-ordered with an alphabetical tiebreak — load-bearing, since `production`/`equipment` tie on the reference doc), and **`n_terms`/`n_formulas`/`n_rules`**. `key_parameters` became `KeyParameter{surface, term_id}`, corroborated in `link.py`. **Smoke found a real bug:** exact lookup resolved 0/5 because the document names parameters `Full Name (ABBR)` while the glossary is keyed on the abbreviation — five FALSE hallucination signals sorted above every real term. `_match_parameter` now tries whole → expansion → abbreviation: **0/5 → 4/5**, ECA correctly still dangling. Measured (BUMA, nano, 12 calls): **0 fields rejected**, fabricated purpose refused, 11-entry outline. Contract §8/§9 quote that run literally. Suite 541 passed / 7 skipped |
209
 
@@ -214,9 +221,9 @@ envelope shape proposed in §3 — so S1 can settle either way without touching
214
  | # | Decision | Owner | Status | Description |
215
  |---|---|---|---|---|
216
  | **D1** | New dependencies | Rifqi | ✅ | **All approved and added 2026-08-19:** `rapidfuzz>=3.14.5`, `gliner>=0.2.13` (resolved 0.2.28), `torch>=2.6` (resolved 2.11.0+cpu). Install verified clean — `import torchgen` OK, so not the corrupt-wheel failure the calibration doc warns about. `PyYAML` was already present transitively |
217
- | **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 |
218
  | **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 |
219
- | **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 |
220
  | **D5** | v2 module name | Rifqi | ✅ | **`src/knowledge_extraction/`** (2026-08-19). Offline runner in `scripts/`, per the repo's existing precedent |
221
  | **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 |
222
  | **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 |
 
10
 
11
  **Status legend:** ⬜ not started · 🔄 in progress · ✅ done · ⛔ blocked · 🔎 built but unverified · ⏸️ deferred
12
 
13
+ > **Updated 2026-09-11 — this file had drifted, and the drift is worth naming.** Rows **X17**, **D2**
14
+ > and **D4** read ⬜ *"not started"* for four days after persistence and the HTTP surface shipped
15
+ > (2026-09-07), while DEV_PLAN §0.8 recorded both ✅. Anyone reading this file first would have
16
+ > concluded the pipeline was still offline. Corrected below. This file also records **nothing of v2**
17
+ > (K13–K16, 2026-09-10/11) or of the 2026-09-11 open-queue pass (K17–K23) — for those, **DEV_PLAN
18
+ > §0.8 is canonical** and this checklist covers the original build-out only.
19
+
20
  > **Updated 2026-08-26.** Both halves are in-tree and run end to end as offline CLIs; the seam is
21
  > settled and consumed from both sides. Tracked in DEV_PLAN §0.8, described in REPO_STATUS §14.
22
  > The full-document run landed (X18) and scored E3 = 0.90. **Schema v2 landed 2026-08-26 (X24)** —
 
210
  | **X14** | Diff vs. active glossary version | 🔎 | ✅ | `diff/glossary_diff.py`. The baseline is passed in explicitly (`--active-glossary`) rather than read from wherever the last run wrote, so the duplicate and conflicting paths are reachable — all three verified by test |
211
  | **X15** | Frequency-sorted review queue | ✅ | ✅ | `queue/review_queue.py`. Each row carries page, section and the verbatim span so review is quote-vs-page. Gained a review reason for **wording discrepancies**, which is how the literal-wording decision reaches the expert |
212
  | **X16** | Bulk vs. incremental ingest | ⬜ | ⬜ | Bulk setup ingest (corpus-frequency statistics as a candidate booster) and incremental single-file add (no corpus context; diff against the active version). Neither exists |
213
+ | **X17** | Persistence | ✅ | ✅ | **Landed 2026-09-07** — five tables + `knowledge_store.py`; 88 entry rows persisted from the BUMA standard. This row said ⬜ until 2026-09-11 while DEV_PLAN §0.8 recorded it done. See D2 for the part that is still open (Harry's migration) |
214
  | **X24** | Output schema v2 (Harry's contract review) | ✅ | 🔎 | **Landed 2026-08-26, UNEVALUATED.** Four asks, all adopted: deterministic ids (`ids.py` — `term_id`/`formula_id`/`rule_id`/`brief_id`, sha256 over content, never positional and never model-written); the glossary references a formula (`defining_formula_id`) instead of restating it; rules link to formulas and terms (`formula_ids[]`, `term_ids[]`); `brief_id` added. New deterministic `link.py` resolves every reference after the branches run — no LLM, no spend, dangle counts reported not swallowed. **16 fields cut, 8 added (50 → 42).** Two defects fixed on the way: `cluster_id` was a frequency-rank ordinal and `rule_id` was written by the model, so **neither survived a re-run** — which silently breaks approval continuity, not just linking. `rule_type` (`interpretation`\|`calculation`) keeps the Interpretation Pack's name while retaining the calculation conventions the corpus actually yields — **0 of 15 gold rules are interpretation rules**, because a *Standard Parameter* document defines how to compute a parameter, not how to read one. Verified: ruff clean on touched paths, `import main` OK, suite **497 passed / 7 skipped**, and a synthetic end-to-end link check (PA resolves to its own defining formula, not the `Production` formula it merely appears in; `Total Hours` dangles to null as designed). **Not verified: any eval** — see the banner; the prompt edits are in the same unevaluated position as X20 |
215
  | **X26** | Output schema v3 — `DomainContext` | Rifqi | ✅ | ✅ | **Done 2026-09-02** (`7f47c61` · `6f5d619` · `e2865ac` · `172d8dc`). `BriefContext` → **`DomainContext`** (`brief_id` → `domain_id`, `b_` → `d_`) — renamed before persistence exists, because after it a prefix change is an id migration that loses approvals. **The branch stopped being the pipeline's one unverifiable output:** `GUARDED_FIELDS["summary"]` went `()` → `("title", "purpose_verbatim")`, achieved by changing the QUESTION rather than the model — the prompt now asks the model to *locate* the document's own statement of purpose instead of composing a summary, and a quote is checkable where a summary never was. `_run_summary` also never called `validate_entry`, so the guard table alone would have been inert. New derived fields: **`outline`** (ordered unique `heading_path`; needed `heading_path` to cross the adapter, which was dropping it exactly as it once dropped `latex`), **`subdomains`** (roll-up of per-term `subdomain_tags`, frequency-ordered with an alphabetical tiebreak — load-bearing, since `production`/`equipment` tie on the reference doc), and **`n_terms`/`n_formulas`/`n_rules`**. `key_parameters` became `KeyParameter{surface, term_id}`, corroborated in `link.py`. **Smoke found a real bug:** exact lookup resolved 0/5 because the document names parameters `Full Name (ABBR)` while the glossary is keyed on the abbreviation — five FALSE hallucination signals sorted above every real term. `_match_parameter` now tries whole → expansion → abbreviation: **0/5 → 4/5**, ECA correctly still dangling. Measured (BUMA, nano, 12 calls): **0 fields rejected**, fabricated purpose refused, 11-entry outline. Contract §8/§9 quote that run literally. Suite 541 passed / 7 skipped |
216
 
 
221
  | # | Decision | Owner | Status | Description |
222
  |---|---|---|---|---|
223
  | **D1** | New dependencies | Rifqi | ✅ | **All approved and added 2026-08-19:** `rapidfuzz>=3.14.5`, `gliner>=0.2.13` (resolved 0.2.28), `torch>=2.6` (resolved 2.11.0+cpu). Install verified clean — `import torchgen` OK, so not the corrupt-wheel failure the calibration doc warns about. `PyYAML` was already present transitively |
224
+ | **D2** | New tables (DDL handoff) | Rifqi → Harry | ✅ (Python) / ⬜ (Go) | **Python side done 2026-09-07**: five tables exist in dedorch exactly as `docs/knowledge/knowledge_tables.sql`, with ORM models and `PostgresJobStore`. ⚠️ **They were created by a ONE-TIME MANUAL RUN.** Go owns the migration and Harry must still fold the SQL in, or a fresh environment comes up without them — the same gap that left `message_traceability` and `message_charts` out of Go's set (Q5) |
225
  | **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 |
226
+ | **D4** | Admin ingest surface | Rifqi | ✅ built / ⛔ auth | **Built 2026-09-07** — 8 routes under `/api/v1/knowledge`, async batch (202 + poll), company-scoped, rate-limited, spend-gated, single-flight. **The auth question it was raised to answer is still open and was re-opened 2026-09-07**: the posture was inherited by default, `POST …/reviews` accepts content that reaches a later prompt, and the caller is the browser SPA (#37/#43). Recorded, not solved |
227
  | **D5** | v2 module name | Rifqi | ✅ | **`src/knowledge_extraction/`** (2026-08-19). Offline runner in `scripts/`, per the repo's existing precedent |
228
  | **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 |
229
  | **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 |
docs/knowledge/TABLE_BRANCH_PROPOSAL.md ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Table branch — design proposal (Step 8)
2
+
3
+ <!-- doc-status -->
4
+ > 💡 **PROPOSAL — nothing is built from this.** Written 2026-09-11 against the open queue in
5
+ > `knowledge_pipeline_runs/SESSION_HANDOFF.md` §4.6. Needs Rifqi's sign-off before any code.
6
+ > Roll-up: [DEV_PLAN.md](../../DEV_PLAN.md) §0.8 · built state: [REPO_STATUS.md](../../REPO_STATUS.md) §14.
7
+
8
+ **Owner:** Rifqi · **Size:** weeks · **Depends on:** parsing 0.4.0 (shipped), genre classification
9
+ (shipped 2026-09-11, advisory) · **Blocks:** any useful result on diagnostic documents.
10
+
11
+ ---
12
+
13
+ ## 1 · The problem, stated as a number
14
+
15
+ The Komatsu HD785-7 troubleshooting manual is **74% tabular** — 59 tables across 80 chunks — and the
16
+ pipeline extracts **zero** knowledge entries from it. Two paid runs, v1 and v2, both returned **0
17
+ definitions** from 75 and 93 clusters respectively. That is not a tuning failure and no prompt change
18
+ reaches it: a shop manual does not define its terms, it **asserts relationships between them**, and
19
+ it does so in a matrix.
20
+
21
+ The genre classifier (`src/knowledge_extraction/genre.py`) now names this document `diagnostic` and
22
+ reports `well_served = False` before anything is spent. This proposal is what would make that answer
23
+ change.
24
+
25
+ ## 2 · What the document actually contains
26
+
27
+ A troubleshooting table is a **symptom × cause matrix**. Each marked cell is one assertion:
28
+
29
+ | Symptom \ Cause | Fuel filter clogged | Injector worn | Air cleaner blocked |
30
+ |---|---|---|---|
31
+ | Engine does not start | ● | ● | |
32
+ | Black exhaust smoke | | ● | ● |
33
+ | Output drops | ● | | ● |
34
+
35
+ Three assertions come out of the first row alone, and **none of them is a definition**:
36
+
37
+ > *"Engine does not start" may be caused by "Fuel filter clogged".*
38
+
39
+ The unit of knowledge here is the **(row header, column header, cell mark) triple**, and it is
40
+ recoverable **deterministically** — the mark is in the parsed HTML, the headers are in the parsed
41
+ HTML, nothing needs to be inferred.
42
+
43
+ ## 3 · The shape of the branch
44
+
45
+ **Deterministic extraction, LLM only to phrase.** This is the inversion of every existing branch, and
46
+ it is the whole point: the assertion's *existence* is read off the table, so the model cannot
47
+ fabricate a relationship that is not in the document — it can only phrase one that is.
48
+
49
+ ```
50
+ table chunk ──► parse table_html ──► (row header, col header, cell mark) triples
51
+ │
52
+ ├── deterministic: the assertion EXISTS
53
+ │
54
+ ▼
55
+ one LLM call per TABLE (not per cell)
56
+ │
57
+ ▼
58
+ natural-language phrasing + typing
59
+ │
60
+ ▼
61
+ span check against the cell's own headers
62
+ ```
63
+
64
+ **Provenance points at the cell**, which is stronger than anything the pipeline produces today: the
65
+ span is the row header plus the column header, both verbatim document text, both locatable by the
66
+ existing `span_present`.
67
+
68
+ ### 3.1 · New entity
69
+
70
+ ```python
71
+ class RelationEntry(BaseModel):
72
+ relation_id: str # sha256 over (doc_id, table asset_id, row, col) — positional
73
+ # within a table, but the table's asset_id is content-addressed,
74
+ # so a re-parse of identical bytes re-mints identical ids.
75
+ kind: Literal["symptom_cause", "component_action", "condition_effect"]
76
+ subject: str # verbatim row header
77
+ object: str # verbatim column header
78
+ statement: str | None # model-phrased; span-checked against subject + object + caption
79
+ table_asset_id: str
80
+ provenance: Provenance # span = the two headers; page from the chunk
81
+ term_ids: list[str] # links into the glossary where headers match known terms
82
+ ```
83
+
84
+ ### 3.2 · Why one call per table, not per cell
85
+
86
+ Komatsu's 59 tables carry on the order of 10³ marked cells. One call per cell is a different cost
87
+ class entirely and would blow the pre-flight ceiling by itself. One call per table hands the model
88
+ the linearised table plus the extracted triples and asks it to phrase each one — 59 calls, which is
89
+ **less than the 93 the glossary branch currently spends to abstain 93 times.**
90
+
91
+ ## 4 · What has to be decided before coding
92
+
93
+ | # | Question | Why it blocks |
94
+ |---|---|---|
95
+ | T1 | Is a relation a first-class **entry kind** (a fifth `kind` in `knowledge_entries`) or a new table? | Persistence contract + Harry's migration. A fifth kind is additive; a table is a DDL handoff |
96
+ | T2 | What does a reviewer **do** with 1,000 relations? | The review queue is frequency-sorted and finishable by construction. 1,000 relations is not finishable. Likely answer: review per TABLE, not per relation — one approve covers the matrix |
97
+ | T3 | Does a mark type carry meaning (`●` vs `○` vs `▲`)? | Komatsu uses more than one glyph. If they mean "primary/secondary cause" the triple is really a quad, and getting this wrong silently inverts assertions |
98
+ | T4 | Which `table_html` shapes does the parser actually emit — merged cells, multi-row headers? | Determines whether header resolution is 20 lines or a small parser. **Measure before designing** |
99
+ | T5 | Does this ship behind the genre router (`diagnostic` only) or for every document? | BUMA is 25% tabular and its tables are *data*, not relation matrices. Running this on them produces noise |
100
+
101
+ **T4 is the one to do first** and it is free: inspect the 59 Komatsu tables and count the shapes.
102
+
103
+ ## 5 · Kill lines, agreed before the first paid run
104
+
105
+ Copying the discipline that made the clustering defect findable — a metric that moves the *wrong* way
106
+ is worse than no metric (E2), so these are stated as recall against a **human-read sample**:
107
+
108
+ | Metric | What it measures | Kill line |
109
+ |---|---|---|
110
+ | **T-E1** | Triples extracted ÷ triples a human reads off the same 5 tables | ≥ 0.90 — it is deterministic; anything less is a parser bug |
111
+ | **T-E2** | Phrasings whose span check passes | ≥ 0.95 |
112
+ | **T-E3** | Phrasings a human judges to *invert or distort* the relation | 0 — this is the one that matters, and it is the reason the mark glyph question (T3) is not cosmetic |
113
+ | **T-cost** | Calls per document | ≤ 1 per table |
114
+
115
+ **T-E3 has no automated proxy.** It needs Mas Beta or an equivalent reader on a sample, which makes
116
+ the R4 conversation (does "found but undefined" help an expert?) worth having in the same sitting.
117
+
118
+ ## 6 · What this does NOT solve
119
+
120
+ A relation matrix is not a glossary. After this branch ships, the Komatsu manual still yields **zero
121
+ definitions** — correctly, because it contains none. If the product's promise is "a glossary from
122
+ your documents", a diagnostic document will always disappoint it, and the honest place to say so is
123
+ the ingest response, which the genre classifier now makes possible.
124
+
125
+ ---
126
+
127
+ ## 7 · Groundwork already landed (2026-09-11)
128
+
129
+ Two pieces of this branch's plumbing exist before the branch does, and both are measured:
130
+
131
+ **The pointer is populated and durable.** 0.4.0 gives every table chunk an `Asset` whose id comes
132
+ from `synthetic_asset_id(doc_id, chunk_id, item_index)` — stable document coordinates, so it survives
133
+ a re-parse — plus `referenced_by` naming the prose chunk that introduces it, and the reverse pointer
134
+ on that prose chunk's `assets`.
135
+
136
+ **One linked partner now joins the evidence window** (`EVIDENCE_LINK_PARTNERS = 1`, in `rank/evidence.py`).
137
+ The measurement that motivated it: across 603 clusters on the three test documents, **zero** had both
138
+ sides of a linked pair in their top-5, so a table reached the model without the prose naming it, and
139
+ vice versa. 31/83, 55/93 and 70/427 clusters now gain a partner, at +1.6%/+4.1%/+4.2% of prompt spend.
140
+
141
+ **What this does *not* do, and the branch must:** `table_html` crosses the seam into
142
+ `Chunk.table_html` and **nothing reads it** — the prompt shows the linearised `render_table` text
143
+ from `Chunk.text` instead. That is deliberate: the span check validates against `heading + text`
144
+ only, so putting HTML in front of the model would let it quote markup that is then nulled as
145
+ unlocatable. A branch that wants the real cell structure has to extend the span-checked text too, or
146
+ carry provenance that points at a cell rather than a character span (§3.1's `cell_ref` does the
147
+ latter, which is the cleaner of the two).
148
+
149
+
150
+ ---
151
+
152
+ ## 8 · Correction and re-diagnosis of the Komatsu tables (2026-09-11)
153
+
154
+ > **Correction.** An earlier reading of this corpus (same day, recorded in the session
155
+ > notes) said the troubleshooting charts had **lost their column headers**, on the
156
+ > evidence that only 4 of 59 tabular chunks contain a `<th>`. That count is accurate
157
+ > and the conclusion drawn from it was **wrong**. The headers are present; they are
158
+ > emitted as ordinary `<td>` cells in a **separate chunk**.
159
+
160
+ ### What Mistral actually produces
161
+
162
+ Each chart is split into three or four sibling tables, and the first cell of each names
163
+ the fragment. Across the 59 tabular chunks:
164
+
165
+ | first cell | count | what it holds |
166
+ |---|---|---|
167
+ | `Causes` | 17 | **the column headers** — the cause list |
168
+ | `Troubleshooting` | 16 | check rows |
169
+ | `Questions` | 15 | interview/symptom rows |
170
+ | `Remedy` | 10 | the action row |
171
+
172
+ So the (row header, column header, cell mark) triple is recoverable, but its three parts
173
+ live in **different chunks**. The branch's first job is re-assembly, not OCR recovery —
174
+ the fragments are adjacent, same-page, and regularly labelled.
175
+
176
+ ### The defect that is real
177
+
178
+ **Marks survive in only 20 of 59 tabular chunks.** Doc-wide there are 278 marks
179
+ (18 ◎, 137 ○, 25 △, 98 ●), and they cluster on the pages where the matrix fragment
180
+ kept them; on pages 9-10 the `Questions` and `Troubleshooting` fragments come through
181
+ with row labels and **zero marks**. Column structure and `rowspan`/`colspan` are
182
+ preserved where the marks are.
183
+
184
+ A chart whose marks were dropped carries no symptom-to-cause link at all, so roughly
185
+ two thirds of the document's assertions are currently unrecoverable from the artifact.
186
+ **That, not the headers, is what gates this branch**, and it has to be fixed at the
187
+ parsing layer.
188
+
189
+ ### Free evidence that the content is reachable
190
+
191
+ Adding a `fault code` label to the span filter (see `config/labels.yaml`) surfaced
192
+ **66 cause phrases** as clusters on this document — *"Clogged fuel filter and/or
193
+ strainer"*, *"Improper injection timing"*, *"Defective alternator (regulator section)"* —
194
+ plus 17 service codes. Those are the column headers, read out of the `Causes`
195
+ fragments. The raw material for the branch is already in the artifact and already
196
+ findable; what is missing is the mark matrix that connects it to the symptoms.
docs/knowledge/TERM_HIERARCHY_PROPOSAL.md ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Parent/child terms and cross-document identity — design proposal (Step 7 + R7)
2
+
3
+ <!-- doc-status -->
4
+ > 💡 **PROPOSAL — nothing is built from this.** Written 2026-09-11 against `SESSION_HANDOFF.md` §4.7.
5
+ > Needs Rifqi's sign-off, and **must not be built before the reading in §6 is done.**
6
+ > Roll-up: [DEV_PLAN.md](../../DEV_PLAN.md) §0.8 · built state: [REPO_STATUS.md](../../REPO_STATUS.md) §14.
7
+
8
+ **Owner:** Rifqi · **Size:** a quarter or more · **Touches:** `entity_id` derivation, which is what
9
+ every expert approval hangs on. **This is the one change on the queue that cannot be done twice.**
10
+
11
+ ---
12
+
13
+ ## 1 · Why v2 sharpened this rather than solving it
14
+
15
+ Retiring the fuzzy clustering pass (K13) was correct and is not in question: `token_set_ratio`
16
+ returned 100 on token-subset pairs, so a head noun always absorbed its own compounds — 131 of 140
17
+ merges were wrong, and post-cluster recall rose 0.8293 → 0.8537 when the pass was removed.
18
+
19
+ But look at what the corpus now says about `engine` and `engine oil`:
20
+
21
+ | | before K13 | after K13 | the truth |
22
+ |---|---|---|---|
23
+ | `engine` / `engine oil` | **the same cluster** (wrong) | **unrelated** (also wrong) | `engine oil` is a *child concept* of `engine` |
24
+
25
+ The pipeline has exactly two ways to express a relationship between two terms — same cluster, or no
26
+ relationship — and neither is right. **The defect was never the threshold; it was the vocabulary.**
27
+ Fixing the merge removed the loud wrong answer and left the quiet one.
28
+
29
+ ## 2 · The two problems are one problem
30
+
31
+ Step 7 (parent/child within a document) and R7 (the same concept across documents) look separable and
32
+ are not. Both ask *"are these two surfaces the same entity, and if not, how are they related?"* — one
33
+ within a document, one across. SciCo's contribution is precisely that **coreference and hierarchy are
34
+ solved jointly**; solving one and bolting on the other produces a model where `engine oil` in
35
+ document A relates to `engine` in document A but has no defined relationship to `engine oil` in
36
+ document B.
37
+
38
+ They also share the thing that makes this irreversible: **both change `entity_id`.**
39
+
40
+ ## 3 · The constraint that dominates the design
41
+
42
+ `entity_id` is `sha256` over content and is what an expert's approval is recorded against
43
+ (`knowledge_reviews.entity_id`). Three consequences, all of them already learned here:
44
+
45
+ 1. **K11** — `cluster_id` was a frequency ordinal and `rule_id` was model-written; neither survived a
46
+ re-run, which broke approvals rather than links. Both are now deterministic.
47
+ 2. **K12** — `BriefContext` → `DomainContext` was renamed *while nothing was persisted*, explicitly
48
+ because afterwards an id-prefix change is a migration that loses approvals.
49
+ 3. **`scope_id` is deliberately excluded from the derivation** (2026-09-07), which is what keeps
50
+ re-pointing a tenant a value migration rather than a re-key.
51
+
52
+ **So: a hierarchy must not be encoded in the id.** `t_<hash>` must stay a hash of the term's own
53
+ content. Parenthood is an edge between two entities, stored beside them — never a path, never a
54
+ compound key. Getting this wrong means every subsequent hierarchy correction invalidates approvals.
55
+
56
+ ## 4 · Proposed model
57
+
58
+ ```python
59
+ class TermRelation(BaseModel):
60
+ parent_id: str
61
+ child_id: str
62
+ kind: Literal["narrower", "variant", "abbreviation", "related"]
63
+ evidence: Literal["lexical_subset", "abbrev_pair", "shared_definition", "expert"]
64
+ confidence: float # never a hard merge; see §5
65
+ ```
66
+
67
+ Four relationship kinds, because collapsing them is what the fuzzy pass did:
68
+
69
+ - **`narrower`** — `engine oil` under `engine`. Lexical subset **plus** type agreement.
70
+ - **`variant`** — `Working Hours` / `working hour`. Already handled deterministically by
71
+ `depluralise()`; this records it rather than changing it.
72
+ - **`abbreviation`** — `PA` / `Physical Availability`. Already a first-class cluster rule.
73
+ - **`related`** — co-defined, no hierarchy. The escape hatch that stops the other three being abused.
74
+
75
+ Clustering is **unchanged**. Relations are computed *after* clustering, between clusters. That is the
76
+ property that makes this safe to ship incrementally: with the relation table empty, behaviour is
77
+ exactly today's.
78
+
79
+ ## 5 · GLiNER types are a signal, not a veto
80
+
81
+ Measured 2026-09-10: **48% of token-subset over-merges involved disagreeing GLiNER types.** That is a
82
+ strong enough signal to *type* a proposed relation and far too noisy to *gate* one — the term filter
83
+ runs at threshold 0.25 precisely because it is meant to be over-inclusive, and its labels are
84
+ correspondingly loose. Using a 0.25-threshold label as a hard merge veto would reintroduce the
85
+ clustering defect with the sign flipped: instead of silently merging distinct terms, it would
86
+ silently refuse real relationships.
87
+
88
+ **Rule: type agreement raises confidence; type disagreement lowers it; neither decides.**
89
+
90
+ ## 6 · Read before building
91
+
92
+ Non-negotiable, and the reason this is a quarter rather than a sprint:
93
+
94
+ | Source | What it settles |
95
+ |---|---|
96
+ | **SciCo** (hierarchical cross-document coreference) | Why the two problems are one, and what a jointly-solved model looks like |
97
+ | **ILLOD** (abbreviation–expansion detection) | Whether the abbreviation path should change at all — it is the one relation already working |
98
+ | **Nested-NER survey** | Whether nested decoding helps here after all. Note the local measurement said no (2026-09-10: +7–12% mentions, **zero** recall gain, +40–80% over-merges) — the survey is for understanding *why*, not for re-opening the setting |
99
+
100
+ ## 7 · Sequencing, and what must come first
101
+
102
+ **This must land before an expert approves anything at volume.** Every approval recorded under the
103
+ current flat model is an approval of a term that may later acquire a parent, and re-keying after the
104
+ fact is the one migration this design exists to avoid.
105
+
106
+ Concretely, in order:
107
+
108
+ 1. **R4 — ask Mas Beta** whether "term found, no definition in document" is useful or noise. It is one
109
+ conversation, it is still not had, and it **gates the queue design** this feeds: 48 of 58 BUMA
110
+ entries and 376 of 425 Open Pit entries are exactly that shape, and whether a hierarchy should
111
+ surface them differently depends entirely on the answer.
112
+ 2. **Step 0** — measurement hardening. A hierarchy change is a *quality* change, and a single run
113
+ stopped being evidence the moment quality became the subject.
114
+ 3. This proposal, reviewed against the reading in §6.
115
+
116
+ ## 8 · What it would buy, honestly
117
+
118
+ No recall. Recall is a term-filter and clustering property and this changes neither. What it buys is
119
+ **a reviewable structure**: an expert approving `engine` once, with `engine oil`, `engine coolant`
120
+ and `engine speed` beneath it, rather than meeting four unrelated rows at four different ranks. On
121
+ the Open Pit textbook — 425 entries, 49 of them defined — that is the difference between a queue
122
+ someone finishes and a queue someone abandons.
docs/knowledge/knowledge_tables.sql CHANGED
@@ -1,26 +1,70 @@
1
  -- Knowledge pipeline tables — dedorch
2
  --
3
- -- Source of record: docs/knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md §2.
4
- -- This file is the exact SQL that was executed, kept so that what ran is
5
- -- reproducible and reviewable rather than retyped from prose.
6
  --
7
  -- ⚠️ OWNERSHIP. Go owns every dedorch migration (CLAUDE.md §2.2). Python
8
  -- executes NO DDL, ever: `SKIP_INIT_DB` stays true, there is no `create_all`
9
- -- and no Alembic against this database. These statements were run ONCE by an
10
- -- operator to unblock the build, following the 2026-07-13 `message_charts`
11
- -- precedent — and that precedent's mistake must not be repeated: the schema was
12
- -- run by hand and never handed over, so a fresh environment built from Go's
13
- -- migrations comes up WITHOUT those tables and both write paths fail silently.
 
14
  --
15
  -- Run by hand, then hand over. Never run by hand only.
16
  --
17
- -- Harry: these five tables now exist in dedorch. Nothing here needs designing —
18
- -- the ask is that the migration set match what is already running, so a fresh
19
- -- environment reproduces it. See §5 Q5 for the two older tables in the same
20
- -- position (message_traceability, message_charts).
21
  --
22
- -- Executed: 2026-09-07 · Idempotent (IF NOT EXISTS throughout) · Additive only:
23
- -- no DROP, no ALTER, nothing touching an existing table.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  -- 1. Parsed document artifact. Chunks live INSIDE `artifact` (jsonb), deliberately
26
  -- not a child table: documents are small (9 pages = 31 chunks) and the artifact
@@ -168,3 +212,97 @@ CREATE TABLE IF NOT EXISTS knowledge_jobs (
168
  completed_at timestamptz
169
  );
170
  CREATE INDEX IF NOT EXISTS idx_knowledge_jobs_user ON knowledge_jobs (user_id, created_at DESC);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  -- Knowledge pipeline tables — dedorch
2
  --
3
+ -- HANDOFF TO HARRY. Updated 2026-09-14. Source of record:
4
+ -- docs/knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md §2.
 
5
  --
6
  -- ⚠️ OWNERSHIP. Go owns every dedorch migration (CLAUDE.md §2.2). Python
7
  -- executes NO DDL, ever: `SKIP_INIT_DB` stays true, there is no `create_all`
8
+ -- and no Alembic against this database. Everything in this file was run ONCE by
9
+ -- an operator to unblock the build, following the 2026-07-13 `message_charts`
10
+ -- precedent — and that precedent's mistake is the one this file exists to avoid:
11
+ -- the schema was run by hand and never handed over, so a fresh environment built
12
+ -- from Go's migrations comes up WITHOUT those tables, and because both write
13
+ -- paths are never-throw seams, nothing errors. They just stop recording.
14
  --
15
  -- Run by hand, then hand over. Never run by hand only.
16
  --
17
+ -- ─────────────────────────────────────────────────────────────────────────────
18
+ -- WHAT IS LIVE IN DEDORCH RIGHT NOW, AND WHAT YOU ALREADY HAVE
19
+ -- ─────────────────────────────────────────────────────────────────────────────
 
20
  --
21
+ -- §1-§5 five tables run 2026-09-07 sent ✅
22
+ -- §6 4 columns + 2 indexes run 2026-09-14 sent ✅
23
+ -- §7 3 columns (review modes) run 2026-09-14 NOT SENT ⬅ new
24
+ --
25
+ -- **If you adopted the copy sent before 2026-09-14, it is now incomplete in two
26
+ -- ways, and one of them is silent:**
27
+ --
28
+ -- 1. §7 is missing entirely. Without it, `POST /knowledge/ingest` with
29
+ -- `review_mode=llm` fails on insert and no entry is ever auto-approved.
30
+ -- 2. §1-§5 DRIFTED AFTER the 2026-09-07 run, and `CREATE TABLE IF NOT EXISTS`
31
+ -- will not repair an existing table. Two edits landed:
32
+ -- * `knowledge_entries.kind` CHECK gained 'document'
33
+ -- * `knowledge_entries.run_id` / `.knowledge_document_id` became NULLABLE
34
+ -- for kind='domain' only, guarded by knowledge_entries_parent_check
35
+ -- Both are reflected in §3 below. **Take the current file, not the old one.**
36
+ --
37
+ -- Nothing here needs designing. The ask is only that the migration set
38
+ -- reproduce what is already running, so a fresh environment matches.
39
+ --
40
+ -- ─────────────────────────────────────────────────────────────────────────────
41
+ -- STILL OUTSTANDING FROM AN EARLIER ROUND — not in this file
42
+ -- ─────────────────────────────────────────────────────────────────────────────
43
+ --
44
+ -- `message_traceability` (80 rows) and `message_charts` (6 rows) have been live
45
+ -- since July, created the same way, and are still absent from the migration set.
46
+ -- Their DDL is in KNOWLEDGE_PERSISTENCE_CONTRACT.md §appendix rather than here.
47
+ -- Say the word and it gets folded into this file so one artifact covers every
48
+ -- Python-written table you are missing.
49
+ --
50
+ -- ─────────────────────────────────────────────────────────────────────────────
51
+ -- VERIFIED AGAINST LIVE — 2026-09-14, read-only
52
+ -- ─────────────────────────────────────────────────────────────────────────────
53
+ --
54
+ -- Not 'what we believe ran'. Read back out of dedorch after the fact:
55
+ --
56
+ -- 5 tables knowledge_documents 19 cols · knowledge_entries 18 ·
57
+ -- knowledge_extraction_runs 20 · knowledge_jobs 16 ·
58
+ -- knowledge_reviews 13
59
+ -- §6+§7 columns 7 of 7 present (doc_ids, content, provenance,
60
+ -- withdrawn_at, review_mode, decided_by, authorised_by)
61
+ -- indexes 19 on knowledge_* (12 named here + 7 PK/unique)
62
+ --
63
+ -- The ORM also reads every one of them against live, so the Python models
64
+ -- and this file agree with the database rather than only with each other.
65
+ --
66
+ -- Idempotent throughout (IF NOT EXISTS) · Additive only: no DROP, no type
67
+ -- change, nothing that rewrites an existing column.
68
 
69
  -- 1. Parsed document artifact. Chunks live INSIDE `artifact` (jsonb), deliberately
70
  -- not a child table: documents are small (9 pages = 31 chunks) and the artifact
 
212
  completed_at timestamptz
213
  );
214
  CREATE INDEX IF NOT EXISTS idx_knowledge_jobs_user ON knowledge_jobs (user_id, created_at DESC);
215
+
216
+
217
+ -- ============================================================================
218
+ -- §6. EXECUTED 2026-09-14 — from the 2026-09-08 discussion (m3, m4, m5, m8, i7).
219
+ --
220
+ -- Verified after the run: 4 new columns and 2 new indexes present, and the
221
+ -- ORM reads all of them against live. Harry still needs this in the
222
+ -- migration set so a fresh environment reproduces it.
223
+ --
224
+ -- Additive only: three columns and one index on `knowledge_entries`. Nothing
225
+ -- is dropped and no column changes type, so a pre-migration reader keeps
226
+ -- working and Python's reads stay tolerant of both shapes while this lands.
227
+ -- ============================================================================
228
+
229
+ -- m3. "doc_id jadi list". One entry fuses meaning from several documents, but
230
+ -- every source stays recorded. `doc_id` REMAINS the primary source — every
231
+ -- existing filter and index uses it, and a single extracted assertion
232
+ -- always has exactly one — while `doc_ids` carries the full list. Additive
233
+ -- rather than a type change: rewriting `doc_id text` into jsonb in place
234
+ -- would break every read at once, for a value that is still needed.
235
+ ALTER TABLE knowledge_entries ADD COLUMN IF NOT EXISTS doc_ids jsonb NOT NULL DEFAULT '[]';
236
+
237
+ -- m4. "kolom content". The definition or rule body, lifted out of `payload` so
238
+ -- a consumer does not have to open jsonb to use it — the objection raised
239
+ -- on 2026-09-08 was that too much JSON makes the data contract hard and
240
+ -- easy to break. `payload` stays the source of truth; this is a promoted
241
+ -- copy written from the same object in the same insert, exactly the
242
+ -- convention `label`, `mention_count` and `diff_status` already follow.
243
+ ALTER TABLE knowledge_entries ADD COLUMN IF NOT EXISTS content text;
244
+
245
+ -- m5. "kolom provenance". Which document, which page — a list of objects:
246
+ -- [{"doc_id": "STD_2026_006_MNO", "page": 3, "page_no": 4,
247
+ -- "section_no": "2.1.3", "chunk_id": "STD_2026_006_MNO::0007"}]
248
+ -- Both page forms travel together on purpose: `page` is 0-based as the
249
+ -- parser reports it, `page_no` is the 1-based number a human reads, and
250
+ -- they are derived from one value so they cannot disagree.
251
+ -- `section_no` is included because the review queue already renders it —
252
+ -- whether it earns a place was left open in the meeting, so drop it from
253
+ -- this shape if the answer is no.
254
+ ALTER TABLE knowledge_entries ADD COLUMN IF NOT EXISTS provenance jsonb NOT NULL DEFAULT '[]';
255
+
256
+ -- m8. Makes the readable-key lookup a hit instead of a scan within the tenant.
257
+ -- `label` now carries the TYPE_SUBJECT key (`GLOSSARY_PA`), and
258
+ -- `KnowledgeStore.get_by_key` filters on exactly this pair. Without the
259
+ -- index the lookup is still correct, just not the O(1) the design intends.
260
+ CREATE INDEX IF NOT EXISTS idx_knowledge_entries_key ON knowledge_entries (scope_id, label);
261
+
262
+ -- i7. "kasus dokumen dihapus" — ruling 2026-09-14: mark withdrawn, keep the
263
+ -- rows, flag what is left. A withdrawn document stops counting towards the
264
+ -- knowledge without anything being destroyed: an approval is an act someone
265
+ -- took, and the record of what it referred to must survive the source going
266
+ -- away. Null = live. Set = withdrawn.
267
+ -- Entries whose last remaining source is withdrawn are surfaced to the
268
+ -- expert to rule on rather than deleted — whether the knowledge leaves with
269
+ -- the document is their call, not the pipeline's.
270
+ ALTER TABLE knowledge_documents ADD COLUMN IF NOT EXISTS withdrawn_at timestamptz;
271
+ CREATE INDEX IF NOT EXISTS idx_knowledge_documents_live
272
+ ON knowledge_documents (scope_id, doc_id) WHERE withdrawn_at IS NULL;
273
+
274
+
275
+ -- ============================================================================
276
+ -- §7. EXECUTED 2026-09-14 — the review system (r1, r2, r3) from the
277
+ -- 2026-09-08 discussion. Verified after the run: the ORM reads
278
+ -- review_mode, decided_by and authorised_by against live.
279
+ --
280
+ -- Additive only: three columns, no drops, no type changes.
281
+ --
282
+ -- The point of these, in Mas's words: an entry approved without a human
283
+ -- looking at it must still say WHO allowed that to happen. *"Kalau ada apa-apa
284
+ -- jangan sampai kita yang disalahin… ya udah itu by mereka aja."* It is a
285
+ -- risk-mitigation requirement, not bookkeeping — which is why it belongs in
286
+ -- columns rather than in a free-text note.
287
+ -- ============================================================================
288
+
289
+ -- r1. Which review mode a batch was ingested under. Chosen by the caller at
290
+ -- ingest time: 'expert' leaves entries waiting for a human; 'llm' takes
291
+ -- them straight to approved with no reviewer step at all.
292
+ -- Nullable so every job written before this column existed stays legal.
293
+ ALTER TABLE knowledge_jobs ADD COLUMN IF NOT EXISTS review_mode text
294
+ CHECK (review_mode IS NULL OR review_mode IN ('expert','llm'));
295
+
296
+ -- r2. WHO decided. 'human' is the existing behaviour and the default, so every
297
+ -- review already recorded keeps its meaning without a backfill.
298
+ ALTER TABLE knowledge_reviews ADD COLUMN IF NOT EXISTS decided_by text
299
+ NOT NULL DEFAULT 'human'
300
+ CHECK (decided_by IN ('human','llm'));
301
+
302
+ -- r3. On WHOSE AUTHORITY. The admin who clicked ingest and the expert who
303
+ -- reviews can be different people, and in 'llm' mode there is no reviewer
304
+ -- at all — so the clicker's id has to reach this row or the decision has
305
+ -- no owner. `reviewer_id` stays "who performed the review";
306
+ -- `authorised_by` is "who permitted it to happen this way".
307
+ -- Together they render: approved by LLM with permission of <person>.
308
+ ALTER TABLE knowledge_reviews ADD COLUMN IF NOT EXISTS authorised_by text;
eval/knowledge/invariants.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 0 — assert on FIELDS AND INVARIANTS, never on exact text.
2
+
3
+ Why this file exists. `temperature=0` is argmax over logits that a shared
4
+ inference server does not hand back identically twice, and this corpus has a
5
+ recorded case of identical input scoring 6/7 one day and 0/12 the next. So an
6
+ assertion of the form "the definition equals this sentence" is not a test — it
7
+ is a coin flip that fails on a day the wording moves by a comma, and passes on a
8
+ day the pipeline is quietly broken in a way the wording does not show.
9
+
10
+ What survives that noise is structure. "A definition, if present, is locatable
11
+ verbatim in the document" is true of every correct run and false of every broken
12
+ one, no matter which sentence the model picked. These are those claims.
13
+
14
+ Every check here is free: deterministic, local, no API call. They run after the
15
+ paid stage on whatever it produced, so they cost nothing to keep on.
16
+
17
+ **A violation is a defect in the pipeline, not a bad score.** A metric moving is
18
+ information; an invariant breaking means a control leaked, so the runner exits
19
+ non-zero on any violation regardless of how the scores looked.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+ from src.knowledge_extraction.validate import span_present
28
+
29
+ # Entry kinds and the field each one calls its identity. Checked by name rather
30
+ # than by isinstance: entries reach here as `model_dump()` dicts, which is the
31
+ # same shape that gets persisted, so this tests what is stored.
32
+ ID_FIELDS = {
33
+ "glossary": "term_id",
34
+ "rule": "rule_id",
35
+ "formula": "formula_id",
36
+ # The domain entry kept `brief_id` through the v3 rename. Recorded here
37
+ # rather than corrected: renaming it is a migration that invalidates every
38
+ # expert approval recorded against the old prefix (K12's own reasoning).
39
+ "document": "brief_id",
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class Violation:
45
+ check: str
46
+ kind: str
47
+ detail: str
48
+
49
+ def as_dict(self) -> dict[str, str]:
50
+ return {"check": self.check, "kind": self.kind, "detail": self.detail}
51
+
52
+
53
+ @dataclass
54
+ class InvariantReport:
55
+ violations: list[Violation] = field(default_factory=list)
56
+ checked: dict[str, int] = field(default_factory=dict)
57
+
58
+ @property
59
+ def ok(self) -> bool:
60
+ return not self.violations
61
+
62
+ def as_dict(self) -> dict[str, Any]:
63
+ return {
64
+ "ok": self.ok,
65
+ "n_violations": len(self.violations),
66
+ "checked": self.checked,
67
+ "violations": [v.as_dict() for v in self.violations[:50]],
68
+ }
69
+
70
+
71
+ def check_invariants(result, doc) -> InvariantReport:
72
+ """Structural claims that must hold of ANY correct run of ANY document."""
73
+ report = InvariantReport()
74
+ add = report.violations.append
75
+
76
+ source = "\n".join(chunk.text or "" for chunk in doc.chunks)
77
+ n_pages = doc.n_pages or 0
78
+
79
+ by_kind = {
80
+ "glossary": result.glossary,
81
+ "rule": result.rules,
82
+ "formula": result.formulas,
83
+ "document": [result.brief] if result.brief else [],
84
+ }
85
+ report.checked = {kind: len(entries) for kind, entries in by_kind.items()}
86
+
87
+ for kind, entries in by_kind.items():
88
+ id_field = ID_FIELDS[kind]
89
+ seen: set[str] = set()
90
+ for entry in entries:
91
+ label = entry.get("term") or entry.get("name") or entry.get(id_field) or "?"
92
+
93
+ # 1 — identity. Without it a re-run cannot tell an expert which rows
94
+ # they already cleared, which is worse than a wrong definition.
95
+ entity_id = entry.get(id_field)
96
+ if not entity_id:
97
+ add(Violation("entity_id_present", kind, f"{label}: {id_field} missing"))
98
+ elif entity_id in seen:
99
+ add(Violation("entity_id_unique", kind, f"{entity_id} appears twice"))
100
+ else:
101
+ seen.add(entity_id)
102
+
103
+ provenance = entry.get("provenance") or {}
104
+ span = (provenance.get("span") or "").strip()
105
+
106
+ # 2 — a claim that survived carries a span, and that span is real.
107
+ # This is the span check restated from the outside: if it ever
108
+ # fires, the guard leaked rather than the model misbehaved.
109
+ if _has_content(entry, kind):
110
+ if not span:
111
+ add(Violation("span_present", kind, f"{label}: content with no span"))
112
+ elif not span_present(span, source):
113
+ add(
114
+ Violation(
115
+ "span_locatable",
116
+ kind,
117
+ f"{label}: span not found verbatim in the document "
118
+ f"({span[:60]!r})",
119
+ )
120
+ )
121
+
122
+ # 3 — provenance points somewhere that exists.
123
+ page = provenance.get("page")
124
+ if page is not None and n_pages and not 0 <= page < n_pages:
125
+ add(
126
+ Violation(
127
+ "page_in_range",
128
+ kind,
129
+ f"{label}: page {page} outside 0..{n_pages - 1}",
130
+ )
131
+ )
132
+
133
+ # 4 — a rejected field is NULL on the entry it came from. The contract says
134
+ # rejected, never repaired; this is the only check that can tell the two
135
+ # apart after the fact.
136
+ for rejection in result.rejected:
137
+ entry = _find_entry(by_kind, rejection.entry_term)
138
+ if entry is not None and entry.get(rejection.field) not in (None, "", []):
139
+ add(
140
+ Violation(
141
+ "rejection_is_null",
142
+ rejection.branch,
143
+ f"{rejection.entry_term}.{rejection.field} was rejected but still "
144
+ "carries a value — a repaired span is an unfalsifiable claim",
145
+ )
146
+ )
147
+
148
+ _check_queue(result.review_queue, report)
149
+ return report
150
+
151
+
152
+ def _check_queue(queue: list[dict], report: InvariantReport) -> None:
153
+ """The consumption surface. A UI binds these, so their shape is a contract."""
154
+ add = report.violations.append
155
+ report.checked["review_queue"] = len(queue)
156
+
157
+ ranks = [row.get("rank") for row in queue]
158
+ if ranks != list(range(1, len(queue) + 1)):
159
+ add(Violation("queue_ranks_contiguous", "review_queue", "ranks are not 1..N"))
160
+
161
+ for row in queue:
162
+ kind = row.get("row_kind")
163
+ if kind not in ("term", "key_parameter"):
164
+ add(Violation("row_kind_known", "review_queue", f"row_kind={kind!r}"))
165
+ if kind == "key_parameter" and row.get("term_id") is not None:
166
+ add(
167
+ Violation(
168
+ "key_parameter_has_no_term",
169
+ "review_queue",
170
+ f"{row.get('term')!r} carries a term_id, which is the one thing "
171
+ "a key_parameter row is defined by NOT having",
172
+ )
173
+ )
174
+ if "_conflict" in row or "_sort" in row:
175
+ add(
176
+ Violation(
177
+ "no_private_keys",
178
+ "review_queue",
179
+ f"{row.get('term')!r} leaks a sorting key into the payload",
180
+ )
181
+ )
182
+
183
+
184
+ def _has_content(entry: dict, kind: str) -> bool:
185
+ """Did this entry actually assert something? An abstention is correct
186
+ behaviour and carries no span obligation — 48 of 58 entries abstain on the
187
+ reference document, and treating that as a violation would make the whole
188
+ report noise."""
189
+ if kind == "glossary":
190
+ return bool(entry.get("definition"))
191
+ if kind == "rule":
192
+ return bool(entry.get("statement"))
193
+ if kind == "formula":
194
+ return bool(entry.get("formula_latex"))
195
+ return bool(entry.get("purpose_verbatim"))
196
+
197
+
198
+ def _find_entry(by_kind: dict[str, list[dict]], label: str) -> dict | None:
199
+ for entries in by_kind.values():
200
+ for entry in entries:
201
+ if label in (entry.get("term"), entry.get("name")):
202
+ return entry
203
+ return None
eval/knowledge/results/v2_filters_2026-09-10_151557.json ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run": {
3
+ "created_at": "2026-09-10_151557",
4
+ "doc_id": "STD_2026_006_MNO",
5
+ "artifact": "eval\\knowledge\\fixtures\\parsed_STD_2026_006_MNO.json",
6
+ "content_hash": "f10807178b20531e86237a2640ff4125d259aa68e5e9d9f910c0c2365cd92833",
7
+ "parser": "mineru/3.4.4/hybrid",
8
+ "parser_backend": "hybrid",
9
+ "extracted": false,
10
+ "mock": false,
11
+ "limit": null,
12
+ "seconds": 68.7,
13
+ "note": "Free stages only. E3 and rule recall require --extract, which spends."
14
+ },
15
+ "funnel": {
16
+ "pages": 9,
17
+ "chunks": 31,
18
+ "mentions_raw": 186,
19
+ "clusters": 74,
20
+ "compression_ratio": 2.176,
21
+ "abbrev_pairs": 5,
22
+ "rule_candidates": 16
23
+ },
24
+ "scores": {
25
+ "E1": {
26
+ "label": "term-filter recall",
27
+ "value": 0.8293,
28
+ "detail": {
29
+ "label": "term_filter_recall(E1)",
30
+ "n_gold": 41,
31
+ "n_pred": 84,
32
+ "true_positives": 34,
33
+ "precision": 0.4048,
34
+ "recall": 0.8293,
35
+ "f1": 0.544,
36
+ "misses": [
37
+ "Breakdown",
38
+ "Overburden removal",
39
+ "Grouping (Composite)",
40
+ "Weight average",
41
+ "Uncontrollable",
42
+ "Fleet management",
43
+ "Mineplan"
44
+ ]
45
+ }
46
+ },
47
+ "E1c_loose": {
48
+ "label": "post-cluster recall (loose)",
49
+ "value": 0.8293,
50
+ "detail": {
51
+ "label": "post_cluster_recall(E1c_loose)",
52
+ "n_gold": 41,
53
+ "n_pred": 74,
54
+ "true_positives": 34,
55
+ "precision": 0.4595,
56
+ "recall": 0.8293,
57
+ "f1": 0.5913,
58
+ "misses": [
59
+ "Breakdown",
60
+ "Overburden removal",
61
+ "Grouping (Composite)",
62
+ "Weight average",
63
+ "Uncontrollable",
64
+ "Fleet management",
65
+ "Mineplan"
66
+ ]
67
+ }
68
+ },
69
+ "E1c_strict": {
70
+ "label": "post-cluster recall (owns its own cluster)",
71
+ "value": 0.7805,
72
+ "detail": {
73
+ "label": "post_cluster_recall(E1c_strict)",
74
+ "n_gold": 41,
75
+ "n_pred": 74,
76
+ "true_positives": 32,
77
+ "precision": 0.4324,
78
+ "recall": 0.7805,
79
+ "f1": 0.5565,
80
+ "misses": [
81
+ "Breakdown",
82
+ "Overburden removal",
83
+ "Grouping (Composite)",
84
+ "Joint survey",
85
+ "Weight average",
86
+ "Plan",
87
+ "Uncontrollable",
88
+ "Fleet management",
89
+ "Mineplan"
90
+ ]
91
+ }
92
+ },
93
+ "E2": {
94
+ "label": "review-burden compression (descriptive, NOT gated)",
95
+ "value": 2.176,
96
+ "detail": {
97
+ "mentions": 161,
98
+ "clusters": 74
99
+ }
100
+ }
101
+ },
102
+ "baseline": {
103
+ "file": "v2_full_2026-09-02_160326.json",
104
+ "E1": 0.878,
105
+ "E2": 2.697,
106
+ "E3": 1.0,
107
+ "rule_recall": 0.5
108
+ }
109
+ }
eval/knowledge/results/v2_filters_2026-09-11_092207.json ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run": {
3
+ "created_at": "2026-09-11_092207",
4
+ "doc_id": "STD_2026_006_MNO",
5
+ "artifact": "eval\\knowledge\\fixtures\\parsed_STD_2026_006_MNO.json",
6
+ "content_hash": "f10807178b20531e86237a2640ff4125d259aa68e5e9d9f910c0c2365cd92833",
7
+ "parser": "mineru/3.4.4/hybrid",
8
+ "parser_backend": "hybrid",
9
+ "extracted": false,
10
+ "mock": false,
11
+ "limit": null,
12
+ "runs": 1,
13
+ "expected_model": "gpt-5.4-nano-2026-03-17",
14
+ "seconds": 87.1,
15
+ "note": "Free stages only. E3 and rule recall require --extract, which spends."
16
+ },
17
+ "funnel": {
18
+ "pages": 9,
19
+ "chunks": 31,
20
+ "mentions_raw": 208,
21
+ "clusters": 80,
22
+ "compression_ratio": 2.125,
23
+ "abbrev_pairs": 5,
24
+ "rule_candidates": 16
25
+ },
26
+ "genre": {
27
+ "genre": "standard",
28
+ "labels_variant": "broad",
29
+ "branches": [
30
+ "glossary",
31
+ "rule",
32
+ "formula",
33
+ "summary"
34
+ ],
35
+ "well_served": true,
36
+ "routing_applied": {
37
+ "labels": false,
38
+ "branches": false
39
+ },
40
+ "signals": {
41
+ "n_chunks": 31,
42
+ "heading_coverage": 0.355,
43
+ "tabular_ratio": 0.452,
44
+ "abbrev_pairs": 5,
45
+ "rule_density": 0.516
46
+ },
47
+ "reasons": [
48
+ "5 abbreviation definitions in headed prose — a document that names its own terms"
49
+ ],
50
+ "warnings": []
51
+ },
52
+ "scores": {
53
+ "E1": {
54
+ "label": "term-filter recall",
55
+ "value": 0.8293,
56
+ "detail": {
57
+ "label": "term_filter_recall(E1)",
58
+ "n_gold": 41,
59
+ "n_pred": 92,
60
+ "true_positives": 34,
61
+ "precision": 0.3696,
62
+ "recall": 0.8293,
63
+ "f1": 0.5113,
64
+ "misses": [
65
+ "Breakdown",
66
+ "Overburden removal",
67
+ "Grouping (Composite)",
68
+ "Weight average",
69
+ "Uncontrollable",
70
+ "Fleet management",
71
+ "Mineplan"
72
+ ]
73
+ }
74
+ },
75
+ "E1c_loose": {
76
+ "label": "post-cluster recall (loose)",
77
+ "value": 0.8293,
78
+ "detail": {
79
+ "label": "post_cluster_recall(E1c_loose)",
80
+ "n_gold": 41,
81
+ "n_pred": 80,
82
+ "true_positives": 34,
83
+ "precision": 0.425,
84
+ "recall": 0.8293,
85
+ "f1": 0.562,
86
+ "misses": [
87
+ "Breakdown",
88
+ "Overburden removal",
89
+ "Grouping (Composite)",
90
+ "Weight average",
91
+ "Uncontrollable",
92
+ "Fleet management",
93
+ "Mineplan"
94
+ ]
95
+ }
96
+ },
97
+ "E1c_strict": {
98
+ "label": "post-cluster recall (owns its own cluster)",
99
+ "value": 0.7805,
100
+ "detail": {
101
+ "label": "post_cluster_recall(E1c_strict)",
102
+ "n_gold": 41,
103
+ "n_pred": 80,
104
+ "true_positives": 32,
105
+ "precision": 0.4,
106
+ "recall": 0.7805,
107
+ "f1": 0.5289,
108
+ "misses": [
109
+ "Breakdown",
110
+ "Overburden removal",
111
+ "Grouping (Composite)",
112
+ "Joint survey",
113
+ "Weight average",
114
+ "Plan",
115
+ "Uncontrollable",
116
+ "Fleet management",
117
+ "Mineplan"
118
+ ]
119
+ }
120
+ },
121
+ "E2": {
122
+ "label": "review-burden compression (descriptive, NOT gated)",
123
+ "value": 2.125,
124
+ "detail": {
125
+ "mentions": 170,
126
+ "clusters": 80
127
+ }
128
+ }
129
+ },
130
+ "baseline": {
131
+ "file": "v2_full_2026-09-02_160326.json",
132
+ "E1": 0.878,
133
+ "E2": 2.697,
134
+ "E3": 1.0,
135
+ "rule_recall": 0.5
136
+ }
137
+ }
eval/knowledge/results/v2_filters_2026-09-11_100546.json ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run": {
3
+ "created_at": "2026-09-11_100546",
4
+ "doc_id": "STD_2026_006_MNO",
5
+ "artifact": "eval\\knowledge\\fixtures\\parsed_mistral_STD_2026_006_MNO.json",
6
+ "content_hash": "0a95466ea17e67bb0a1a83ca4b7022974cf1171b3450cf2ca92cb8903277920d",
7
+ "parser": "mistral/mistral-ocr-latest/mistral",
8
+ "parser_backend": "mistral",
9
+ "extracted": false,
10
+ "mock": false,
11
+ "limit": null,
12
+ "runs": 1,
13
+ "expected_model": "gpt-5.4-nano-2026-03-17",
14
+ "seconds": 69.7,
15
+ "label": "linked_partner",
16
+ "note": "Free stages only. E3 and rule recall require --extract, which spends."
17
+ },
18
+ "funnel": {
19
+ "pages": 9,
20
+ "chunks": 32,
21
+ "mentions_raw": 249,
22
+ "clusters": 84,
23
+ "compression_ratio": 2.298,
24
+ "abbrev_pairs": 5,
25
+ "rule_candidates": 16
26
+ },
27
+ "genre": {
28
+ "genre": "standard",
29
+ "labels_variant": "broad",
30
+ "branches": [
31
+ "glossary",
32
+ "rule",
33
+ "formula",
34
+ "summary"
35
+ ],
36
+ "well_served": true,
37
+ "routing_applied": {
38
+ "labels": false,
39
+ "branches": false
40
+ },
41
+ "signals": {
42
+ "n_chunks": 32,
43
+ "heading_coverage": 0.406,
44
+ "tabular_ratio": 0.25,
45
+ "abbrev_pairs": 5,
46
+ "rule_density": 0.5
47
+ },
48
+ "reasons": [
49
+ "5 abbreviation definitions in headed prose — a document that names its own terms"
50
+ ],
51
+ "warnings": []
52
+ },
53
+ "scores": {
54
+ "E1": {
55
+ "label": "term-filter recall",
56
+ "value": 0.878,
57
+ "detail": {
58
+ "label": "term_filter_recall(E1)",
59
+ "n_gold": 41,
60
+ "n_pred": 98,
61
+ "true_positives": 36,
62
+ "precision": 0.3673,
63
+ "recall": 0.878,
64
+ "f1": 0.518,
65
+ "misses": [
66
+ "Breakdown",
67
+ "Overburden removal",
68
+ "Weight average",
69
+ "Fleet management",
70
+ "Mineplan"
71
+ ]
72
+ }
73
+ },
74
+ "E1c_loose": {
75
+ "label": "post-cluster recall (loose)",
76
+ "value": 0.878,
77
+ "detail": {
78
+ "label": "post_cluster_recall(E1c_loose)",
79
+ "n_gold": 41,
80
+ "n_pred": 84,
81
+ "true_positives": 36,
82
+ "precision": 0.4286,
83
+ "recall": 0.878,
84
+ "f1": 0.576,
85
+ "misses": [
86
+ "Breakdown",
87
+ "Overburden removal",
88
+ "Weight average",
89
+ "Fleet management",
90
+ "Mineplan"
91
+ ]
92
+ }
93
+ },
94
+ "E1c_strict": {
95
+ "label": "post-cluster recall (owns its own cluster)",
96
+ "value": 0.8537,
97
+ "detail": {
98
+ "label": "post_cluster_recall(E1c_strict)",
99
+ "n_gold": 41,
100
+ "n_pred": 84,
101
+ "true_positives": 35,
102
+ "precision": 0.4167,
103
+ "recall": 0.8537,
104
+ "f1": 0.56,
105
+ "misses": [
106
+ "Breakdown",
107
+ "Overburden removal",
108
+ "Joint survey",
109
+ "Weight average",
110
+ "Fleet management",
111
+ "Mineplan"
112
+ ]
113
+ }
114
+ },
115
+ "E2": {
116
+ "label": "review-burden compression (descriptive, NOT gated)",
117
+ "value": 2.298,
118
+ "detail": {
119
+ "mentions": 193,
120
+ "clusters": 84
121
+ }
122
+ }
123
+ },
124
+ "baseline": {
125
+ "file": "v2_full_2026-09-02_160326.json",
126
+ "E1": 0.878,
127
+ "E2": 2.697,
128
+ "E3": 1.0,
129
+ "rule_recall": 0.5
130
+ }
131
+ }
eval/knowledge/run_eval.py CHANGED
@@ -20,7 +20,14 @@ written by `python -m src.knowledge_parsing.run`.
20
  Module mode only (`-m`). A file-path invocation breaks `sys.path` and the
21
  `src.` imports below fail.
22
 
23
- Two things this refuses to do, both for the same reason — a number that cannot
 
 
 
 
 
 
 
24
  be compared is worse than no number:
25
 
26
  * **It will not run on an artifact that does not declare `parser_backend`.**
@@ -30,12 +37,20 @@ be compared is worse than no number:
30
  overrides, and stamps the result file so the caveat travels with it.
31
  * **It never overwrites a result file.** Every run writes a new timestamped
32
  file beside the others; the frozen baseline stays frozen.
 
 
 
 
 
 
 
33
  """
34
 
35
  from __future__ import annotations
36
 
37
  import argparse
38
  import json
 
39
  import sys
40
  from collections import Counter
41
  from datetime import datetime
@@ -44,6 +59,7 @@ from typing import Any
44
 
45
  from src.knowledge_extraction.adapter import parsed_doc_from_artifact
46
  from src.knowledge_extraction.extract import MockExtractor, cacheable, prefix_tokens
 
47
  from src.knowledge_extraction.service import (
48
  build_clusters,
49
  estimate_cost,
@@ -52,14 +68,34 @@ from src.knowledge_extraction.service import (
52
  )
53
  from src.knowledge_extraction.settings import EVIDENCE_K
54
 
55
- from .score import GOLD_PATH, load_gold, score_glossary, score_rules, score_term_filter
 
 
 
 
 
 
 
 
56
 
57
  _HERE = Path(__file__).resolve().parent
58
  RESULTS_DIR = _HERE / "results"
59
 
60
  # The kill lines. Each is a decision already taken, recorded in
61
  # KNOWLEDGE_PIPELINE_CALIBRATION.md — not a target invented per run.
62
- KILL_LINES = {"E1": 0.70, "E2": 2.0, "E3": 0.80}
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  # What the last committed run measured, for the delta column. Rule recall has
65
  # no kill line: it is tracked, not gated, and 3/15 is the post-X22 scoring of
@@ -80,6 +116,29 @@ BASELINE = {
80
  "rule_recall": 0.5,
81
  }
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  def _azure_extractor():
85
  """Built lazily: importing it requires credentials to be configured."""
@@ -117,6 +176,60 @@ def _delta(value: float, previous: float | None) -> str:
117
  return f"{diff:+.4f}"
118
 
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  def build_report(
121
  doc,
122
  filtered,
@@ -126,6 +239,8 @@ def build_report(
126
  args,
127
  started: datetime,
128
  seconds: float,
 
 
129
  ) -> dict:
130
  funnel = {
131
  "pages": doc.n_pages,
@@ -147,12 +262,19 @@ def build_report(
147
  "extracted": bool(args.extract),
148
  "mock": bool(args.mock),
149
  "limit": args.limit,
 
 
150
  "seconds": round(seconds, 1),
151
  },
152
  "funnel": funnel,
 
153
  "scores": scores,
154
  "baseline": BASELINE,
155
  }
 
 
 
 
156
 
157
  if args.allow_unlabelled_artifact and not doc.parser_backend:
158
  report["run"]["comparability"] = (
@@ -170,12 +292,25 @@ def build_report(
170
  # measurement of an unknown: on 2026-08-27 identical input scored 6/7
171
  # one day and 0/12 the next under an unchanged deployment name, and
172
  # nothing recorded said whether the model had moved. See X25.
 
173
  report["run"]["served_by"] = {
174
- "model_version": sorted({u.model_version for u in result.usages if u.model_version}),
175
  "system_fingerprint": sorted(
176
  {u.system_fingerprint for u in result.usages if u.system_fingerprint}
177
  ),
178
  }
 
 
 
 
 
 
 
 
 
 
 
 
179
  prompt, cached, completion = result.total_tokens
180
  report["cost"] = {
181
  "prompt_tokens": prompt,
@@ -204,10 +339,17 @@ def format_summary(report: dict) -> str:
204
  for key, score in report["scores"].items():
205
  value = score["value"]
206
  kill = KILL_LINES.get(key)
 
 
 
 
 
 
 
207
  rows.append(
208
  f" {key:<12} {value:>8.4f} kill {kill if kill else ' — '!s:>5} "
209
  f"{_verdict(value, kill):<5} vs baseline {_delta(value, BASELINE.get(key))}"
210
- f" {score['label']}"
211
  )
212
  funnel = report["funnel"]
213
  out = [
@@ -229,6 +371,39 @@ def format_summary(report: dict) -> str:
229
  " by the span check",
230
  f" latex_verification: {report['quality']['latex_verification']}",
231
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  if "comparability" in report["run"]:
233
  out += ["", f" !! {report['run']['comparability']}"]
234
  return "\n".join(out)
@@ -258,8 +433,39 @@ def main(argv: list[str] | None = None) -> int:
258
  action="store_true",
259
  help="run on an artifact with no parser_backend (results NOT comparable)",
260
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  args = parser.parse_args(argv)
262
 
 
 
 
 
 
 
 
 
 
 
 
263
  if not args.artifact.exists():
264
  print(f"artifact not found: {args.artifact}", file=sys.stderr)
265
  return 2
@@ -297,14 +503,30 @@ def main(argv: list[str] | None = None) -> int:
297
  return 0
298
 
299
  e1 = score_term_filter(gold, [m.surface for m in filtered.mentions])
 
 
 
300
  scores: dict[str, Any] = {
301
  "E1": {
302
  "label": "term-filter recall",
303
  "value": e1.recall,
304
  "detail": e1.as_dict(),
305
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  "E2": {
307
- "label": "review-burden compression",
308
  "value": clustered.compression_ratio,
309
  "detail": {
310
  "mentions": clustered.n_mentions,
@@ -314,6 +536,8 @@ def main(argv: list[str] | None = None) -> int:
314
  }
315
 
316
  result = None
 
 
317
  if args.extract:
318
  extractor = MockExtractor() if args.mock else _azure_extractor()
319
  if extractor is None:
@@ -323,24 +547,58 @@ def main(argv: list[str] | None = None) -> int:
323
  if args.active_glossary
324
  else None
325
  )
326
- result = extract_all(
327
- doc, clustered, filtered, extractor, args.limit, active
328
- )
329
- e3 = score_glossary(gold, result.glossary)
330
- rules = score_rules(gold, result.rules)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  scores["E3"] = {
332
  "label": "glossary schema fill (precision)",
333
- "value": e3.precision,
334
  "detail": e3.as_dict(),
335
  }
336
  scores["rule_recall"] = {
337
  "label": "rule-branch recall",
338
- "value": rules.recall,
339
  "detail": rules.as_dict(),
340
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
341
 
342
  seconds = (datetime.now() - started).total_seconds()
343
- report = build_report(doc, filtered, clustered, scores, result, args, started, seconds)
 
 
 
344
  if args.label:
345
  report["run"]["label"] = args.label
346
  if not args.extract:
@@ -361,6 +619,25 @@ def main(argv: list[str] | None = None) -> int:
361
  )
362
  print(f"\n-> saved: {out_path.relative_to(_HERE.parent.parent)}")
363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  if EVIDENCE_K and args.extract and not args.mock:
365
  failed = [
366
  k for k, s in scores.items() if k in KILL_LINES and s["value"] < KILL_LINES[k]
 
20
  Module mode only (`-m`). A file-path invocation breaks `sys.path` and the
21
  `src.` imports below fail.
22
 
23
+ # a BAND instead of a number — repeats the paid stage only. SPENDS N TIMES.
24
+ ./.venv/Scripts/python.exe -m eval.knowledge.run_eval <artifact.json> --extract --runs 5
25
+
26
+ Exit codes: `0` clean · `1` below a kill line · `2` bad arguments or artifact ·
27
+ `3` no extractor · `4` would overwrite a result · `5` an invariant broke ·
28
+ `6` a different model snapshot served the run.
29
+
30
+ Four things this refuses to do, all for the same reason — a number that cannot
31
  be compared is worse than no number:
32
 
33
  * **It will not run on an artifact that does not declare `parser_backend`.**
 
37
  overrides, and stamps the result file so the caveat travels with it.
38
  * **It never overwrites a result file.** Every run writes a new timestamped
39
  file beside the others; the frozen baseline stays frozen.
40
+ * **It refuses a run served by a different model snapshot** than the committed
41
+ results were measured on (`EXPECTED_MODEL_SNAPSHOT`). X25 recorded what served
42
+ a run; this asserts it, so a silent tier move stops the comparison instead of
43
+ quietly poisoning the delta column.
44
+ * **It refuses a run that broke a structural invariant** (`invariants.py`) —
45
+ a span that is not locatable, a duplicate entity id, a repaired rejection.
46
+ Those are defects rather than scores, so they fail the run outright.
47
  """
48
 
49
  from __future__ import annotations
50
 
51
  import argparse
52
  import json
53
+ import statistics
54
  import sys
55
  from collections import Counter
56
  from datetime import datetime
 
59
 
60
  from src.knowledge_extraction.adapter import parsed_doc_from_artifact
61
  from src.knowledge_extraction.extract import MockExtractor, cacheable, prefix_tokens
62
+ from src.knowledge_extraction.genre import classify as classify_genre
63
  from src.knowledge_extraction.service import (
64
  build_clusters,
65
  estimate_cost,
 
68
  )
69
  from src.knowledge_extraction.settings import EVIDENCE_K
70
 
71
+ from .invariants import check_invariants
72
+ from .score import (
73
+ GOLD_PATH,
74
+ load_gold,
75
+ score_glossary,
76
+ score_post_cluster,
77
+ score_rules,
78
+ score_term_filter,
79
+ )
80
 
81
  _HERE = Path(__file__).resolve().parent
82
  RESULTS_DIR = _HERE / "results"
83
 
84
  # The kill lines. Each is a decision already taken, recorded in
85
  # KNOWLEDGE_PIPELINE_CALIBRATION.md — not a target invented per run.
86
+ # E2's kill line was REMOVED 2026-09-10. Compression is now reported as a
87
+ # descriptive statistic only, never as pass/fail.
88
+ #
89
+ # The reason is not that 2.0 was the wrong number — it is that the metric moves
90
+ # the WRONG WAY. Over-merging distinct terms into one cluster RAISES compression,
91
+ # so the defect that cost the gold term EWH its own entry was pushing this score
92
+ # up and through its own pass line. A metric whose pass condition is partly met
93
+ # by the defect it should expose is worse than no metric. Retiring the fuzzy pass
94
+ # correctly LOWERED compression (BUMA 2.712 -> 2.183), which under a kill line
95
+ # would have read as a regression.
96
+ #
97
+ # E1c (post-cluster recall) is the metric that actually watches this stage.
98
+ KILL_LINES = {"E1": 0.70, "E3": 0.80}
99
 
100
  # What the last committed run measured, for the delta column. Rule recall has
101
  # no kill line: it is tracked, not gated, and 3/15 is the post-X22 scoring of
 
116
  "rule_recall": 0.5,
117
  }
118
 
119
+ # ── Step 0: measurement hardening ───────────────────────────────────────
120
+ # The snapshot every committed result on this branch was measured against. X25
121
+ # started RECORDING what served a run; this makes it an ASSERTION, which is the
122
+ # difference between a fact discoverable by diffing two files afterwards and one
123
+ # that stops a run at the moment it stops being true.
124
+ #
125
+ # A deployment name is OUR label and never changes; the snapshot behind it can
126
+ # move without notice. When it does, every number in `results/` becomes a
127
+ # measurement of a different system and the delta column starts lying. Comparing
128
+ # against a moved model is the failure this catches — not the move itself, which
129
+ # is Azure's business.
130
+ #
131
+ # `--expect-model` overrides for a deliberate tier change; `--allow-model-drift`
132
+ # downgrades the refusal to a stamped warning when you need the number anyway.
133
+ EXPECTED_MODEL_SNAPSHOT = "gpt-5.4-nano-2026-03-17"
134
+
135
+ # Below this spread, two runs of one document are telling the same story. Above
136
+ # it, a single run is not evidence and the band must be quoted instead of the
137
+ # point. 0.10 is the handoff's own read of this corpus (identical input scored
138
+ # 6/7 one day and 0/12 the next), not a measured tolerance — raise or lower it
139
+ # once `--runs` has produced a real band on a real document.
140
+ NOISE_BAND = 0.10
141
+
142
 
143
  def _azure_extractor():
144
  """Built lazily: importing it requires credentials to be configured."""
 
176
  return f"{diff:+.4f}"
177
 
178
 
179
+ def _stability(values: list[float]) -> dict[str, Any]:
180
+ """A band, not a number.
181
+
182
+ The reported value becomes the MEDIAN rather than the mean: one run that
183
+ fails schema validation drags a mean into a region no run actually
184
+ occupied, and a metric nobody measured is worse than a noisy one.
185
+ """
186
+ ordered = sorted(values)
187
+ return {
188
+ "n": len(ordered),
189
+ "min": round(ordered[0], 4),
190
+ "median": round(statistics.median(ordered), 4),
191
+ "max": round(ordered[-1], 4),
192
+ "spread": round(ordered[-1] - ordered[0], 4),
193
+ "runs": [round(v, 4) for v in values],
194
+ "noisy": bool(ordered[-1] - ordered[0] > NOISE_BAND),
195
+ }
196
+
197
+
198
+ def _entity_ids(result) -> dict[str, list[str]]:
199
+ """Every id a run minted, by kind — the thing an expert's approval hangs on.
200
+
201
+ K11 fixed two ids that did not survive a re-run. Nothing has watched them
202
+ since, and a re-run that re-keys its entries silently orphans every approval
203
+ recorded against the old ones. Cheap to check, catastrophic to miss.
204
+ """
205
+ return {
206
+ "glossary": sorted(e.get("term_id") or "" for e in result.glossary),
207
+ "rule": sorted(e.get("rule_id") or "" for e in result.rules),
208
+ "formula": sorted(e.get("formula_id") or "" for e in result.formulas),
209
+ }
210
+
211
+
212
+ def _id_stability(per_run: list[dict[str, list[str]]]) -> dict[str, Any]:
213
+ """Did every run mint the same ids? Anything but `True` is a defect."""
214
+ if len(per_run) < 2:
215
+ return {"comparable": False, "note": "needs --runs 2 or more"}
216
+ first = per_run[0]
217
+ drift = {
218
+ kind: {
219
+ "only_in_first": sorted(set(first[kind]) - set(other[kind]))[:10],
220
+ "only_in_later": sorted(set(other[kind]) - set(first[kind]))[:10],
221
+ }
222
+ for other in per_run[1:]
223
+ for kind in first
224
+ if set(first[kind]) != set(other[kind])
225
+ }
226
+ return {"comparable": True, "identical_across_runs": not drift, "drift": drift}
227
+
228
+
229
+ def _served_snapshots(result) -> list[str]:
230
+ return sorted({u.model_version for u in result.usages if u.model_version})
231
+
232
+
233
  def build_report(
234
  doc,
235
  filtered,
 
239
  args,
240
  started: datetime,
241
  seconds: float,
242
+ invariants=None,
243
+ stability: dict[str, Any] | None = None,
244
  ) -> dict:
245
  funnel = {
246
  "pages": doc.n_pages,
 
262
  "extracted": bool(args.extract),
263
  "mock": bool(args.mock),
264
  "limit": args.limit,
265
+ "runs": args.runs,
266
+ "expected_model": args.expect_model,
267
  "seconds": round(seconds, 1),
268
  },
269
  "funnel": funnel,
270
+ "genre": classify_genre(doc, filtered).as_dict(),
271
  "scores": scores,
272
  "baseline": BASELINE,
273
  }
274
+ if invariants is not None:
275
+ report["invariants"] = invariants.as_dict()
276
+ if stability:
277
+ report["stability"] = stability
278
 
279
  if args.allow_unlabelled_artifact and not doc.parser_backend:
280
  report["run"]["comparability"] = (
 
292
  # measurement of an unknown: on 2026-08-27 identical input scored 6/7
293
  # one day and 0/12 the next under an unchanged deployment name, and
294
  # nothing recorded said whether the model had moved. See X25.
295
+ served = _served_snapshots(result)
296
  report["run"]["served_by"] = {
297
+ "model_version": served,
298
  "system_fingerprint": sorted(
299
  {u.system_fingerprint for u in result.usages if u.system_fingerprint}
300
  ),
301
  }
302
+ # Step 0(a): the recorded snapshot becomes an assertion. A mock run
303
+ # reports no snapshot and is exempt — it measures plumbing, not a model.
304
+ if not args.mock and served and served != [args.expect_model]:
305
+ report["run"]["model_drift"] = {
306
+ "expected": args.expect_model,
307
+ "served": served,
308
+ "forced": bool(args.allow_model_drift),
309
+ "note": (
310
+ "every committed result was measured on the expected snapshot; "
311
+ "a delta against this run compares two different systems"
312
+ ),
313
+ }
314
  prompt, cached, completion = result.total_tokens
315
  report["cost"] = {
316
  "prompt_tokens": prompt,
 
339
  for key, score in report["scores"].items():
340
  value = score["value"]
341
  kill = KILL_LINES.get(key)
342
+ band = score.get("stability")
343
+ suffix = ""
344
+ if band:
345
+ suffix = (
346
+ f" [{band['min']:.4f}..{band['max']:.4f}] n={band['n']}"
347
+ + (" NOISY" if band["noisy"] else "")
348
+ )
349
  rows.append(
350
  f" {key:<12} {value:>8.4f} kill {kill if kill else ' — '!s:>5} "
351
  f"{_verdict(value, kill):<5} vs baseline {_delta(value, BASELINE.get(key))}"
352
+ f" {score['label']}{suffix}"
353
  )
354
  funnel = report["funnel"]
355
  out = [
 
371
  " by the span check",
372
  f" latex_verification: {report['quality']['latex_verification']}",
373
  ]
374
+ genre = report.get("genre")
375
+ if genre:
376
+ out += [
377
+ "",
378
+ f" genre: {genre['genre']} (labels {genre['labels_variant']}, "
379
+ f"well-served {genre['well_served']}) {genre['signals']}",
380
+ ]
381
+ for reason in genre["reasons"]:
382
+ out += [f" · {reason}"]
383
+ for warning in genre["warnings"]:
384
+ out += [f" !! {warning}"]
385
+ if "invariants" in report:
386
+ inv = report["invariants"]
387
+ out += [
388
+ "",
389
+ f" invariants: {'OK' if inv['ok'] else str(inv['n_violations']) + ' VIOLATED'}"
390
+ f" checked {inv['checked']}",
391
+ ]
392
+ for violation in inv["violations"][:10]:
393
+ out += [f" !! {violation['check']} [{violation['kind']}] {violation['detail']}"]
394
+ if "stability" in report:
395
+ ids = report["stability"]["entity_ids"]
396
+ if ids.get("comparable"):
397
+ out += [
398
+ f" entity ids identical across runs: {ids['identical_across_runs']}"
399
+ ]
400
+ if "model_drift" in report["run"]:
401
+ drift = report["run"]["model_drift"]
402
+ out += [
403
+ "",
404
+ f" !! MODEL DRIFT — expected {drift['expected']}, served "
405
+ f"{', '.join(drift['served'])}",
406
+ ]
407
  if "comparability" in report["run"]:
408
  out += ["", f" !! {report['run']['comparability']}"]
409
  return "\n".join(out)
 
433
  action="store_true",
434
  help="run on an artifact with no parser_backend (results NOT comparable)",
435
  )
436
+ parser.add_argument(
437
+ "--runs",
438
+ type=int,
439
+ default=1,
440
+ metavar="N",
441
+ help=(
442
+ "repeat the PAID stage N times and report min/median/max instead of a "
443
+ "single number. MULTIPLIES SPEND BY N — the free stages run once"
444
+ ),
445
+ )
446
+ parser.add_argument(
447
+ "--expect-model",
448
+ default=EXPECTED_MODEL_SNAPSHOT,
449
+ help=f"model snapshot this run must be served by (default {EXPECTED_MODEL_SNAPSHOT})",
450
+ )
451
+ parser.add_argument(
452
+ "--allow-model-drift",
453
+ action="store_true",
454
+ help="record a snapshot mismatch as a warning instead of refusing the run",
455
+ )
456
  args = parser.parse_args(argv)
457
 
458
+ if args.runs < 1:
459
+ print("--runs must be 1 or more", file=sys.stderr)
460
+ return 2
461
+ if args.runs > 1 and not args.extract:
462
+ print(
463
+ "--runs only varies the PAID stage; the free stages are deterministic.\n"
464
+ " Add --extract, or drop --runs.",
465
+ file=sys.stderr,
466
+ )
467
+ return 2
468
+
469
  if not args.artifact.exists():
470
  print(f"artifact not found: {args.artifact}", file=sys.stderr)
471
  return 2
 
503
  return 0
504
 
505
  e1 = score_term_filter(gold, [m.surface for m in filtered.mentions])
506
+ canonicals = [c.canonical for c in clustered.clusters]
507
+ e1c_loose = score_post_cluster(gold, canonicals)
508
+ e1c_strict = score_post_cluster(gold, canonicals, cluster_variants=True)
509
  scores: dict[str, Any] = {
510
  "E1": {
511
  "label": "term-filter recall",
512
  "value": e1.recall,
513
  "detail": e1.as_dict(),
514
  },
515
+ # E1c watches the merge stage, which E1 (measured before clustering) and
516
+ # E3 (measured only over entries that produced a definition) both miss.
517
+ # `E1 - E1c_loose` is the recall clustering destroyed; 0 is the target.
518
+ "E1c_loose": {
519
+ "label": "post-cluster recall (loose)",
520
+ "value": e1c_loose.recall,
521
+ "detail": e1c_loose.as_dict(),
522
+ },
523
+ "E1c_strict": {
524
+ "label": "post-cluster recall (owns its own cluster)",
525
+ "value": e1c_strict.recall,
526
+ "detail": e1c_strict.as_dict(),
527
+ },
528
  "E2": {
529
+ "label": "review-burden compression (descriptive, NOT gated)",
530
  "value": clustered.compression_ratio,
531
  "detail": {
532
  "mentions": clustered.n_mentions,
 
536
  }
537
 
538
  result = None
539
+ invariants = None
540
+ stability: dict[str, Any] = {}
541
  if args.extract:
542
  extractor = MockExtractor() if args.mock else _azure_extractor()
543
  if extractor is None:
 
547
  if args.active_glossary
548
  else None
549
  )
550
+
551
+ # The free stages are deterministic and already done — only the paid
552
+ # stage is repeated. Re-running GLiNER N times would cost minutes to
553
+ # re-derive an identical clustering.
554
+ e3_values: list[float] = []
555
+ rule_values: list[float] = []
556
+ ids_per_run: list[dict[str, list[str]]] = []
557
+ violations_per_run: list[int] = []
558
+
559
+ for run_index in range(args.runs):
560
+ if args.runs > 1:
561
+ print(f" run {run_index + 1}/{args.runs} ...", file=sys.stderr)
562
+ result = extract_all(doc, clustered, filtered, extractor, args.limit, active)
563
+ e3 = score_glossary(gold, result.glossary)
564
+ rules = score_rules(gold, result.rules)
565
+ e3_values.append(e3.precision)
566
+ rule_values.append(rules.recall)
567
+ ids_per_run.append(_entity_ids(result))
568
+ invariants = check_invariants(result, doc)
569
+ violations_per_run.append(len(invariants.violations))
570
+
571
+ # The LAST run's detail is reported beside the band. Detail from one run
572
+ # of several is an example, not a summary — labelled as such below.
573
  scores["E3"] = {
574
  "label": "glossary schema fill (precision)",
575
+ "value": round(statistics.median(e3_values), 4),
576
  "detail": e3.as_dict(),
577
  }
578
  scores["rule_recall"] = {
579
  "label": "rule-branch recall",
580
+ "value": round(statistics.median(rule_values), 4),
581
  "detail": rules.as_dict(),
582
  }
583
+ if args.runs > 1:
584
+ scores["E3"]["stability"] = _stability(e3_values)
585
+ scores["rule_recall"]["stability"] = _stability(rule_values)
586
+ for key in ("E3", "rule_recall"):
587
+ scores[key]["detail"]["note"] = (
588
+ f"detail is from run {args.runs} of {args.runs}; the value is "
589
+ "the median across runs"
590
+ )
591
+ stability = {
592
+ "runs": args.runs,
593
+ "entity_ids": _id_stability(ids_per_run),
594
+ "violations_per_run": violations_per_run,
595
+ }
596
 
597
  seconds = (datetime.now() - started).total_seconds()
598
+ report = build_report(
599
+ doc, filtered, clustered, scores, result, args, started, seconds,
600
+ invariants=invariants, stability=stability,
601
+ )
602
  if args.label:
603
  report["run"]["label"] = args.label
604
  if not args.extract:
 
619
  )
620
  print(f"\n-> saved: {out_path.relative_to(_HERE.parent.parent)}")
621
 
622
+ # Invariants first, and regardless of the tier: a broken structural claim
623
+ # means a control leaked, which invalidates the scores rather than joining
624
+ # them. Mock runs included — the plumbing is exactly what they test.
625
+ if invariants is not None and not invariants.ok:
626
+ print(
627
+ f"\nINVARIANTS VIOLATED: {len(invariants.violations)} — see the result file",
628
+ file=sys.stderr,
629
+ )
630
+ return 5
631
+
632
+ if "model_drift" in report["run"] and not args.allow_model_drift:
633
+ print(
634
+ "\nMODEL SNAPSHOT MISMATCH — the run is recorded but must not be compared\n"
635
+ " to the committed results. Re-pin with --expect-model, or pass\n"
636
+ " --allow-model-drift to accept the caveat.",
637
+ file=sys.stderr,
638
+ )
639
+ return 6
640
+
641
  if EVIDENCE_K and args.extract and not args.mock:
642
  failed = [
643
  k for k, s in scores.items() if k in KILL_LINES and s["value"] < KILL_LINES[k]
eval/knowledge/score.py CHANGED
@@ -125,6 +125,60 @@ def score_term_filter(gold: dict, surfaces: list[str]) -> Score:
125
  )
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  def score_glossary(gold: dict, entries: list[dict]) -> Score:
129
  """E3: when nano fills the schema, is it right?
130
 
 
125
  )
126
 
127
 
128
+ def score_post_cluster(gold: dict, canonicals: list[str], cluster_variants=None) -> Score:
129
+ """**E1c — added 2026-09-10.** The same gold set as E1, scored AFTER clustering.
130
+
131
+ E1 is measured on mention surfaces, which is *before* clustering. That left a
132
+ window nothing observed: a term the filter found correctly, which clustering
133
+ then absorbed into another term's cluster, and which therefore never gets an
134
+ entry an expert could approve. On the reference document that window cost the
135
+ gold term **EWH** — absorbed into `Working Hours`, which then abstained, so
136
+ two gold terms became one null entry.
137
+
138
+ Nothing in the harness could see it. E1 scores it as found; **E2 compression
139
+ is INFLATED by the merge** (which is why compression is no longer gated); and
140
+ E3 only scores entries that produced a definition, so a merged-then-null
141
+ entry is excluded from the scoreable base by construction.
142
+
143
+ Two variants, because the difference between them is informative:
144
+
145
+ - **loose** (`cluster_variants=None`) — identical matching to E1, just fed
146
+ cluster canonicals instead of mention surfaces. Directly comparable to E1,
147
+ so `E1 - E1c_loose` is the recall the merge stage destroyed.
148
+ - **strict** (`cluster_variants` supplied) — a gold term counts only if it is
149
+ some cluster's OWN canonical. Surviving as another term's variant does not
150
+ count, because that is exactly the loss. Always ≤ loose.
151
+ """
152
+ score = score_term_filter(gold, canonicals)
153
+ score.label = "post_cluster_recall(E1c_loose)"
154
+ if cluster_variants is None:
155
+ return score
156
+
157
+ canon = {norm(c) for c in canonicals if norm(c)}
158
+ tp, misses = 0, []
159
+ for entry in gold.get("terms", []):
160
+ variants = [entry["term"]] + list(entry.get("variants", []))
161
+ if entry.get("full_name"):
162
+ variants.append(entry["full_name"])
163
+ normed = {norm(v) for v in variants if norm(v)}
164
+ if normed & canon:
165
+ tp += 1
166
+ else:
167
+ misses.append(entry["term"])
168
+ n_gold = len(gold.get("terms", []))
169
+ precision, recall, f1 = _prf(tp, len(canon), n_gold)
170
+ return Score(
171
+ label="post_cluster_recall(E1c_strict)",
172
+ n_gold=n_gold,
173
+ n_pred=len(canon),
174
+ true_positives=tp,
175
+ precision=precision,
176
+ recall=recall,
177
+ f1=f1,
178
+ misses=misses,
179
+ )
180
+
181
+
182
  def score_glossary(gold: dict, entries: list[dict]) -> Score:
183
  """E3: when nano fills the schema, is it right?
184
 
src/api/v1/knowledge.py CHANGED
@@ -102,6 +102,17 @@ class IngestRequest(BaseModel):
102
  "artifact. Off by default: on, it silently re-spends on every call."
103
  ),
104
  )
 
 
 
 
 
 
 
 
 
 
 
105
 
106
  @field_validator("user_id")
107
  @classmethod
@@ -145,7 +156,9 @@ class IngestAccepted(BaseModel):
145
  async def ingest(request: Request, payload: IngestRequest) -> IngestAccepted:
146
  """Admit a batch and return immediately. Poll `GET /jobs/{job_id}`."""
147
  job, reused = await _service.start(
148
- user_id=payload.user_id, document_ids=payload.document_ids
 
 
149
  )
150
  if reused:
151
  message = "A job for this scope is already running; returning it."
 
102
  "artifact. Off by default: on, it silently re-spends on every call."
103
  ),
104
  )
105
+ review_mode: Literal["expert", "llm"] = Field(
106
+ default="expert",
107
+ description=(
108
+ "Who rules on what this batch produces. 'expert' leaves every "
109
+ "entry waiting for a human. 'llm' approves them on arrival with "
110
+ "NO reviewer step and no second opinion — the entries were "
111
+ "written by a model and are accepted as-is. Choosing it records "
112
+ "`authorised_by` against the caller, so an entry approved this "
113
+ "way can always say who allowed it."
114
+ ),
115
+ )
116
 
117
  @field_validator("user_id")
118
  @classmethod
 
156
  async def ingest(request: Request, payload: IngestRequest) -> IngestAccepted:
157
  """Admit a batch and return immediately. Poll `GET /jobs/{job_id}`."""
158
  job, reused = await _service.start(
159
+ user_id=payload.user_id,
160
+ document_ids=payload.document_ids,
161
+ review_mode=payload.review_mode,
162
  )
163
  if reused:
164
  message = "A job for this scope is already running; returning it."
src/db/postgres/models.py CHANGED
@@ -379,6 +379,11 @@ class KnowledgeDocumentRow(Base):
379
  raw_output_dir = Column(Text)
380
  artifact = Column(JSONB, nullable=False)
381
  created_at = Column(DateTime(timezone=True), server_default=func.now())
 
 
 
 
 
382
 
383
 
384
  class KnowledgeExtractionRunRow(Base):
@@ -467,6 +472,20 @@ class KnowledgeEntryRow(Base):
467
  diff_status = Column(Text)
468
  definition_conflict = Column(Boolean, nullable=False, default=False)
469
  payload = Column(JSONB, nullable=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  created_at = Column(DateTime(timezone=True), server_default=func.now())
471
 
472
 
@@ -501,6 +520,14 @@ class KnowledgeReviewRow(Base):
501
  edited_payload = Column(JSONB)
502
  note = Column(Text)
503
  reviewer_id = Column(Text, nullable=False)
 
 
 
 
 
 
 
 
504
  created_at = Column(DateTime(timezone=True), server_default=func.now())
505
 
506
 
@@ -520,6 +547,11 @@ class KnowledgeJobRow(Base):
520
  user_id = Column(Text, nullable=False)
521
  status = Column(Text, nullable=False, default="queued")
522
  stage = Column(Text)
 
 
 
 
 
523
  document_ids = Column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
524
  n_requested = Column(Integer, nullable=False, default=0)
525
  n_succeeded = Column(Integer, nullable=False, default=0)
 
379
  raw_output_dir = Column(Text)
380
  artifact = Column(JSONB, nullable=False)
381
  created_at = Column(DateTime(timezone=True), server_default=func.now())
382
+ # i7 (2026-09-14): the source document has been removed. Null = live.
383
+ # A withdrawn document stops counting towards the knowledge WITHOUT
384
+ # anything being destroyed -- an approval is an act someone took, and the
385
+ # record of what it referred to has to survive the source going away.
386
+ withdrawn_at = Column(DateTime(timezone=True), nullable=True)
387
 
388
 
389
  class KnowledgeExtractionRunRow(Base):
 
472
  diff_status = Column(Text)
473
  definition_conflict = Column(Boolean, nullable=False, default=False)
474
  payload = Column(JSONB, nullable=False)
475
+ # m3/m4/m5 (2026-09-08 discussion, migrated 2026-09-14). Promoted out of
476
+ # `payload` so a consumer does not have to open jsonb to use them -- the
477
+ # objection raised in that discussion was that too much JSON makes the data
478
+ # contract hard and easy to break. `payload` REMAINS the source of truth;
479
+ # these are written from the same object in the same insert, exactly as
480
+ # `label`, `mention_count` and `diff_status` already are.
481
+ #
482
+ # `doc_ids` is the list of documents behind the entry. `doc_id` above stays
483
+ # the primary source -- every existing filter and index uses it -- and the
484
+ # two agree for an ordinary entry. They differ only when a definition is
485
+ # carried forward onto a later document that re-mentions the term.
486
+ doc_ids = Column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
487
+ content = Column(Text)
488
+ provenance = Column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
489
  created_at = Column(DateTime(timezone=True), server_default=func.now())
490
 
491
 
 
520
  edited_payload = Column(JSONB)
521
  note = Column(Text)
522
  reviewer_id = Column(Text, nullable=False)
523
+ # r2/r3. `decided_by` says WHETHER A HUMAN LOOKED; `authorised_by` says
524
+ # on whose authority it happened. They are separate because the admin who
525
+ # clicks ingest and the expert who reviews can be different people, and in
526
+ # "llm" mode there is no reviewer at all -- so without the clicker's id
527
+ # reaching this row the decision would have no owner. Together they read:
528
+ # approved by LLM with permission of <person>.
529
+ decided_by = Column(Text, nullable=False, server_default=text("'human'"))
530
+ authorised_by = Column(Text)
531
  created_at = Column(DateTime(timezone=True), server_default=func.now())
532
 
533
 
 
547
  user_id = Column(Text, nullable=False)
548
  status = Column(Text, nullable=False, default="queued")
549
  stage = Column(Text)
550
+ # r1 (2026-09-08 discussion): which review mode this batch was ingested
551
+ # under. "expert" leaves entries waiting for a human; "llm" takes them
552
+ # straight to approved with no reviewer step at all. Nullable so jobs
553
+ # written before the column existed stay legal.
554
+ review_mode = Column(Text)
555
  document_ids = Column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
556
  n_requested = Column(Integer, nullable=False, default=0)
557
  n_succeeded = Column(Integer, nullable=False, default=0)
src/knowledge_extraction/adapter.py CHANGED
@@ -26,7 +26,7 @@ from typing import Any
26
 
27
  from src.middlewares.logging import get_logger
28
 
29
- from .models import Chunk, ParsedDoc
30
 
31
  logger = get_logger("knowledge_adapter")
32
 
@@ -54,7 +54,7 @@ _PAGE_END_KEYS = ("page_idx_end", "page_end")
54
  # seam. Drift is caught instead by a local seam test that imports
55
  # `knowledge_parsing.contracts` directly and asserts the two agree, so a bump on
56
  # either side fails loudly in tests rather than silently at read time.
57
- SUPPORTED_SCHEMA = (0, 3)
58
 
59
 
60
  class IncompatibleArtifactError(ValueError):
@@ -156,6 +156,28 @@ def chunk_from_dict(raw: dict[str, Any], doc_id: str, ordinal: int = 0) -> Chunk
156
  # the floor here, which left `formula_latex` unprovable — `text` carries
157
  # rendered prose, so a LaTeX claim had nothing to be checked against.
158
  latex=list(raw.get("latex") or []),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  )
160
 
161
 
 
26
 
27
  from src.middlewares.logging import get_logger
28
 
29
+ from .models import Chunk, ChunkAsset, ParsedDoc
30
 
31
  logger = get_logger("knowledge_adapter")
32
 
 
54
  # seam. Drift is caught instead by a local seam test that imports
55
  # `knowledge_parsing.contracts` directly and asserts the two agree, so a bump on
56
  # either side fails loudly in tests rather than silently at read time.
57
+ SUPPORTED_SCHEMA = (0, 4)
58
 
59
 
60
  class IncompatibleArtifactError(ValueError):
 
156
  # the floor here, which left `formula_latex` unprovable — `text` carries
157
  # rendered prose, so a LaTeX claim had nothing to be checked against.
158
  latex=list(raw.get("latex") or []),
159
+ # Same story a third time: the parsing half emits `table_html` and
160
+ # extraction dropped it here, so a table reached the model ONLY as
161
+ # pipe-linearised text with its row/column structure destroyed. It is the
162
+ # haystack the formula guard needs for tables and the input a future
163
+ # table branch needs; carrying it costs nothing.
164
+ table_html=raw.get("table_html") or None,
165
+ # New at 0.4.0 — see ChunkAsset. A pointer the adapter drops is a pointer
166
+ # that does not exist, which is exactly what happened to `table_html`.
167
+ assets=[_asset(a) for a in (raw.get("assets") or [])],
168
+ referenced_by=list(raw.get("referenced_by") or []),
169
+ )
170
+
171
+
172
+ def _asset(raw: dict[str, Any]) -> ChunkAsset:
173
+ """Map one artifact asset onto the internal shape, tolerating absences."""
174
+ return ChunkAsset(
175
+ asset_id=str(raw.get("asset_id") or ""),
176
+ kind=str(raw.get("kind") or "figure"),
177
+ caption=raw.get("caption") or None,
178
+ description=raw.get("description") or None,
179
+ page_no=raw.get("page_no"),
180
+ storage_key=raw.get("storage_key") or None,
181
  )
182
 
183
 
src/knowledge_extraction/cluster/cluster.py CHANGED
@@ -1,9 +1,13 @@
1
  """Mention[] → TermCluster[].
2
 
3
  Matching order, cheapest and most certain first:
4
- 1. exact match after normalisation
5
  2. abbreviation ↔ expansion (from legend blocks)
6
- 3. conservative fuzzy (token_set_ratio >= 92, and only above 5 characters)
 
 
 
 
7
 
8
  **Over-merging is much worse than under-merging.** An under-merge costs one
9
  extra LLM call and one extra review-queue row. A wrong merge silently destroys a
@@ -58,10 +62,37 @@ def cluster_mentions(
58
  _add(buckets[linked], mention, "abbrev")
59
  continue
60
 
61
- match = _fuzzy_match(key, buckets.keys(), fuzzy_threshold)
62
- if match:
63
- _add(buckets[match], mention, "fuzzy")
64
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  buckets[key] = {
67
  "surfaces": {mention.surface},
 
1
  """Mention[] → TermCluster[].
2
 
3
  Matching order, cheapest and most certain first:
4
+ 1. exact match after normalisation (plural-folded — `normalize(lemma=True)`)
5
  2. abbreviation ↔ expansion (from legend blocks)
6
+
7
+ **There is deliberately no fuzzy pass** (retired 2026-09-10; `_fuzzy_match` is
8
+ parked below with the measurement that retired it). Every merge it made that was
9
+ correct is now done deterministically by plural folding; 94% of what it made was
10
+ a token-subset match, which is always wrong.
11
 
12
  **Over-merging is much worse than under-merging.** An under-merge costs one
13
  extra LLM call and one extra review-queue row. A wrong merge silently destroys a
 
62
  _add(buckets[linked], mention, "abbrev")
63
  continue
64
 
65
+ # --- FUZZY PASS RETIRED 2026-09-10 (measured, not a style call) ---------
66
+ # `_fuzzy_match` is kept below for the record; this call site is not.
67
+ #
68
+ # `token_set_ratio` returns EXACTLY 100 whenever one string's token set is
69
+ # a subset of the other's, so a head noun always absorbed its own
70
+ # compounds and the threshold of 92 was inert. Measured across the BUMA
71
+ # standard, a Komatsu shop manual and an Open Pit textbook chapter:
72
+ #
73
+ # 131 of the 140 merges this pass made were token-subset matches — ALL
74
+ # wrong (`Radiator` ate `radiator cap`; `level` ate `oil level sensor`;
75
+ # `Working Hours` ate `effective working hours`, which cost the gold
76
+ # term EWH its own entry entirely).
77
+ #
78
+ # Of the 9 genuine near-misses, 6 were plain singular/plural and 3 were
79
+ # `gain/loss pty|qty|ua grouping` — three DISTINCT metrics, also wrong.
80
+ #
81
+ # So the pass produced no correct merge that plural folding does not now
82
+ # do deterministically (`normalize(lemma=True)`). Retiring it drove
83
+ # subset-merges to 0 on all three documents and lifted post-cluster gold
84
+ # recall 0.8293 -> 0.8537, while also giving the FEWEST clusters of any
85
+ # correct configuration (Open Pit 431, against 434 for the best guarded
86
+ # fuzzy variant and 445 for no-fuzzy-without-lemma).
87
+ #
88
+ # Matching is now: exact (normalised, plural-folded) -> abbreviation
89
+ # expansion. Nothing else. If you are tempted to restore this, the thing
90
+ # to beat is subset_pairs = 0; see FREE_STAGE_RESULTS.md.
91
+ #
92
+ # match = _fuzzy_match(key, buckets.keys(), fuzzy_threshold)
93
+ # if match:
94
+ # _add(buckets[match], mention, "fuzzy")
95
+ # continue
96
 
97
  buckets[key] = {
98
  "surfaces": {mention.surface},
src/knowledge_extraction/cluster/normalize.py CHANGED
@@ -31,7 +31,54 @@ STOP_SURFACES = {
31
  }
32
 
33
 
34
- def normalize(surface: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  s = unicodedata.normalize("NFKC", surface).casefold()
36
  s = s.replace("-", " ").replace("_", " ")
37
  s = re.sub(r"[.’']", "", s)
@@ -45,11 +92,15 @@ def normalize(surface: str) -> str:
45
  # and gave one concept two clusters. Checked after the strip, not before.
46
  if s.count("(") != s.count(")"):
47
  s = re.sub(r"\s+", " ", s.replace("(", " ").replace(")", " ")).strip()
 
 
 
 
48
  return s
49
 
50
 
51
  def is_noise(surface: str) -> bool:
52
- n = normalize(surface)
53
  if len(n) < 2:
54
  return True
55
  if n in STOP_SURFACES:
@@ -122,7 +173,8 @@ class AbbrevIndex:
122
  self.to_expansion: dict[str, str] = {}
123
  self.to_abbrev: dict[str, str] = {}
124
  for pair in pairs:
125
- abbrev, expansion = normalize(pair.abbrev), normalize(pair.expansion)
 
126
  if not abbrev or not expansion:
127
  continue
128
  self.to_expansion[abbrev] = expansion
@@ -152,9 +204,9 @@ class AbbrevIndex:
152
  continue
153
  outer, inner = parts
154
  if looks_like_abbreviation(inner) and is_initialism(inner, outer):
155
- abbrev, expansion = normalize(inner), normalize(outer)
156
  elif looks_like_abbreviation(outer) and is_initialism(outer, inner):
157
- abbrev, expansion = normalize(outer), normalize(inner)
158
  else:
159
  continue
160
  if abbrev and expansion and abbrev not in self.to_expansion:
@@ -166,7 +218,7 @@ class AbbrevIndex:
166
  def canonical_key(self, surface: str) -> str:
167
  """Map a surface to a shared key so an abbreviation and its expansion
168
  collide into the same bucket."""
169
- n = normalize(surface)
170
  if n in self.to_abbrev:
171
  return self.to_abbrev[n]
172
  if n in self.to_expansion:
@@ -179,7 +231,7 @@ class AbbrevIndex:
179
  parts = split_parenthetical(surface)
180
  if parts:
181
  outer, inner = parts
182
- outer_n, inner_n = normalize(outer), normalize(inner)
183
  if inner_n in self.to_expansion: # the legend knows the bracket
184
  return inner_n
185
  if outer_n in self.to_abbrev: # the legend knows the outer
@@ -193,7 +245,7 @@ class AbbrevIndex:
193
  return n
194
 
195
  def linked(self, a: str, b: str) -> bool:
196
- na, nb = normalize(a), normalize(b)
197
  if self.to_expansion.get(na) == nb or self.to_expansion.get(nb) == na:
198
  return True
199
  # Same reasoning as `canonical_key`: two surfaces are linked when one is
 
31
  }
32
 
33
 
34
+ # Plural folding for the CLUSTERING KEY ONLY (see `normalize(lemma=...)` below).
35
+ # Measured 2026-09-10: of the 140 merges the retired fuzzy pass made across three
36
+ # documents, 131 were token-subset matches (all wrong) and of the 9 genuine
37
+ # near-misses SIX were plain singular/plural pairs — `resource`/`resources`,
38
+ # `ore type`/`ore types`, `deposit`/`deposits`, `measurement`/`measurements`,
39
+ # `project manager`/`project managers`, `reserve`/`reserves`. Folding those
40
+ # deterministically is what let the fuzzy pass be retired instead of guarded: on
41
+ # the Open Pit textbook it gives 431 clusters against 434 for the best guarded
42
+ # fuzzy config and 445 for no-fuzzy-without-lemma.
43
+ #
44
+ # Deliberately hand-rolled rather than a real lemmatiser: a proper one means a
45
+ # new dependency (nltk/spacy) for a measured six-pair gain, and this runs on a
46
+ # normalised key where the only job is collapsing an English plural suffix.
47
+ _PLURAL_EXCEPTIONS = ("ss", "us", "is", "as", "os")
48
+
49
+
50
+ def depluralise(token: str) -> str:
51
+ """Collapse an English plural suffix on a single normalised token.
52
+
53
+ Conservative by construction — it only ever SHORTENS a token, and only on
54
+ suffixes that are plural in this corpus. `status`, `analysis`, `gas` and
55
+ `bias` are protected by `_PLURAL_EXCEPTIONS`; anything under 4 characters is
56
+ left alone so an abbreviation (`UAS`, `BCM`) is never touched.
57
+ """
58
+ for suffix, replacement in (("ies", "y"), ("ses", "s"), ("xes", "x"), ("hes", "h")):
59
+ if token.endswith(suffix) and len(token) > len(suffix) + 1:
60
+ return token[: -len(suffix)] + replacement
61
+ if (
62
+ token.endswith("s")
63
+ and not token.endswith(_PLURAL_EXCEPTIONS)
64
+ and len(token) > 3
65
+ ):
66
+ return token[:-1]
67
+ return token
68
+
69
+
70
+ def normalize(surface: str, lemma: bool = False) -> str:
71
+ """Normalise a surface for comparison.
72
+
73
+ `lemma=True` additionally folds English plurals, and is passed ONLY by the
74
+ clustering key path (`is_noise` and `AbbrevIndex`). It is deliberately NOT
75
+ the default: `rank/evidence.py` imports this function directly to match a
76
+ term against a chunk heading, and the 2026-09-10 measurement that justified
77
+ plural folding patched this module's global — which that direct import
78
+ bypasses. Making it the default would therefore change evidence ranking in a
79
+ way nothing has measured. Keep the ranker on the unfolded form until there
80
+ is a number for it.
81
+ """
82
  s = unicodedata.normalize("NFKC", surface).casefold()
83
  s = s.replace("-", " ").replace("_", " ")
84
  s = re.sub(r"[.’']", "", s)
 
92
  # and gave one concept two clusters. Checked after the strip, not before.
93
  if s.count("(") != s.count(")"):
94
  s = re.sub(r"\s+", " ", s.replace("(", " ").replace(")", " ")).strip()
95
+ # Applied LAST, per token, so it never interferes with the bracket and
96
+ # punctuation handling above.
97
+ if lemma:
98
+ s = " ".join(depluralise(t) for t in s.split())
99
  return s
100
 
101
 
102
  def is_noise(surface: str) -> bool:
103
+ n = normalize(surface, lemma=True)
104
  if len(n) < 2:
105
  return True
106
  if n in STOP_SURFACES:
 
173
  self.to_expansion: dict[str, str] = {}
174
  self.to_abbrev: dict[str, str] = {}
175
  for pair in pairs:
176
+ abbrev = normalize(pair.abbrev, lemma=True)
177
+ expansion = normalize(pair.expansion, lemma=True)
178
  if not abbrev or not expansion:
179
  continue
180
  self.to_expansion[abbrev] = expansion
 
204
  continue
205
  outer, inner = parts
206
  if looks_like_abbreviation(inner) and is_initialism(inner, outer):
207
+ abbrev, expansion = normalize(inner, lemma=True), normalize(outer, lemma=True)
208
  elif looks_like_abbreviation(outer) and is_initialism(outer, inner):
209
+ abbrev, expansion = normalize(outer, lemma=True), normalize(inner, lemma=True)
210
  else:
211
  continue
212
  if abbrev and expansion and abbrev not in self.to_expansion:
 
218
  def canonical_key(self, surface: str) -> str:
219
  """Map a surface to a shared key so an abbreviation and its expansion
220
  collide into the same bucket."""
221
+ n = normalize(surface, lemma=True)
222
  if n in self.to_abbrev:
223
  return self.to_abbrev[n]
224
  if n in self.to_expansion:
 
231
  parts = split_parenthetical(surface)
232
  if parts:
233
  outer, inner = parts
234
+ outer_n, inner_n = normalize(outer, lemma=True), normalize(inner, lemma=True)
235
  if inner_n in self.to_expansion: # the legend knows the bracket
236
  return inner_n
237
  if outer_n in self.to_abbrev: # the legend knows the outer
 
245
  return n
246
 
247
  def linked(self, a: str, b: str) -> bool:
248
+ na, nb = normalize(a, lemma=True), normalize(b, lemma=True)
249
  if self.to_expansion.get(na) == nb or self.to_expansion.get(nb) == na:
250
  return True
251
  # Same reasoning as `canonical_key`: two surfaces are linked when one is
src/knowledge_extraction/config/labels.yaml CHANGED
@@ -37,8 +37,111 @@ broad:
37
  - work category
38
  - analysis method
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  # 0.25, not 0.35: the E1 sweep measured recall 0.854 @ 0.25 vs 0.658 @ 0.35 on the
41
  # `broad` variant. Precision falls (0.41 vs 0.50) but the term filter is meant to be
42
  # over-inclusive — clustering and evidence ranking absorb the noise, and a term the
43
  # filter never proposes can never be recovered downstream.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  threshold: 0.25
 
37
  - work category
38
  - analysis method
39
 
40
+ # ── Genre variants, added 2026-09-10. NOT WIRED: `broad` stays the default. ──────
41
+ #
42
+ # Measured on three documents. `broad` wins where we can score it (BUMA E1 0.8780
43
+ # vs 0.8537 for `conceptual` and 0.4146 for `diagnostic`), which confirms the
44
+ # original calibration. What the other two showed is that label fit is
45
+ # GENRE-dependent, not that either is better:
46
+ #
47
+ # diagnostic -> 1108 mentions on the Komatsu shop manual against `broad`'s 449
48
+ # (2.5x), and 133 on the textbook. Collapses on the standard.
49
+ # conceptual -> 940 mentions on the Open Pit textbook against `broad`'s 792.
50
+ #
51
+ # Those gains are UNSCOREABLE — neither document has a gold set, so "more
52
+ # mentions" is not "better recall", and `diagnostic` on Komatsu also brought 128
53
+ # token-subset merges. They are parked here for the genre router to select once
54
+ # there is a gold set to justify the switch. Selecting one today would be shipping
55
+ # on a mention count.
56
+
57
+ # Shop manuals / troubleshooting: components, symptoms, remedies. The other sets
58
+ # have no notion of a fault or a corrective action, which is most of a manual.
59
+ #
60
+ # ⚠️ `fault code` added 2026-09-11 after a measured miss. The Komatsu manual prints
61
+ # 27 service codes (`[CA559]`, `[CB2249]`) in plain prose, and BOTH `broad` and the
62
+ # original `diagnostic` set returned **zero** mentions for them — a code is not a
63
+ # component, a symptom, a check or an action, so no label was asking. GLiNER finds
64
+ # them readily once asked: 6 of 6 on the probe chunk at 0.54-0.69, well over the
65
+ # 0.25 floor. Worth recording because it corrects an assumption in the Step 5 plan:
66
+ # routing this document to `diagnostic` would NOT have recovered the codes.
67
+ diagnostic:
68
+ - machine component
69
+ - fault or symptom
70
+ - fault code
71
+ - inspection or check
72
+ - corrective action
73
+ - consumable or fluid
74
+ - measurement unit
75
+ - operating condition
76
+ - warning or indicator
77
+
78
+ # `broad` plus the one label it was measurably missing. Exists ONLY as the A/B arm;
79
+ # **do not make it the default.** Measured 2026-09-11, `broad` vs `broad_codes`,
80
+ # both arms on current code (so both include the subword window split):
81
+ #
82
+ # mentions clusters code clusters E1c_strict
83
+ # BUMA 251 -> 200 86 -> 67 0 -> 0 0.8537 -> 0.7805
84
+ # Komatsu 449 -> 548 93 -> 161 0 -> 17 —
85
+ # Open Pit 787 -> 711 427 -> 377 0 -> 0 —
86
+ #
87
+ # **A label is not additive — it perturbs the whole decode.** GLiNER runs flat NER,
88
+ # so a new label competes for spans: BUMA lost 51 mentions, 19 clusters and THREE
89
+ # gold terms (`Coal barging`, `Grouping (Composite)`, `Other Activity`), and
90
+ # `dropped_overlapping` moved 10 -> 6. Open Pit lost 76 mentions. Adding this to
91
+ # `broad` would buy 17 code clusters on one document by damaging the other two.
92
+ #
93
+ # On Komatsu it also costs +73% clusters (93 -> 161) = one LLM call each, against
94
+ # the +20% bar that rejected threshold 0.15. 17 of the 68 new clusters are codes;
95
+ # **66 are cause phrases** ("Clogged fuel filter and/or strainer", "Improper
96
+ # injection timing") — the diagnostic content the other label sets never reached.
97
+ # That is the case for routing, not for widening the default.
98
+ broad_codes:
99
+ - mining production metric
100
+ - equipment availability term
101
+ - operational abbreviation
102
+ - machine or equipment type
103
+ - time category
104
+ - mining activity
105
+ - material or commodity
106
+ - measurement unit
107
+ - data source or system
108
+ - work category
109
+ - analysis method
110
+ - fault code
111
+
112
+ # Textbooks / standards-of-practice: concepts and categories rather than equipment.
113
+ conceptual:
114
+ - technical concept
115
+ - classification category
116
+ - economic measure
117
+ - mining activity
118
+ - material or commodity
119
+ - analysis method
120
+ - regulatory or reporting term
121
+ - measurement unit
122
+
123
  # 0.25, not 0.35: the E1 sweep measured recall 0.854 @ 0.25 vs 0.658 @ 0.35 on the
124
  # `broad` variant. Precision falls (0.41 vs 0.50) but the term filter is meant to be
125
  # over-inclusive — clustering and evidence ranking absorb the noise, and a term the
126
  # filter never proposes can never be recovered downstream.
127
+ #
128
+ # ⚠️ RE-SWEPT 2026-09-10. 0.15 had never been tested and measures E1 0.9756
129
+ # (40/41) against 0.8780 at 0.25 — four more gold terms, and post-cluster recall
130
+ # 0.8537 -> 0.9512. It is NOT shipped, and the reason is cost:
131
+ #
132
+ # clusters vs the 0.25 baseline: BUMA +50% Komatsu +29% Open Pit +10%
133
+ #
134
+ # Clusters drive LLM spend one call each, and a single 46-page chapter already
135
+ # consumes 56% of the 2M pre-flight ceiling. The agreed rule was "ship 0.15 if it
136
+ # lands within +20% of today's cluster count"; the worst case is +50%, so the rule
137
+ # says keep 0.25.
138
+ #
139
+ # The hoped-for escape was pairing it with a larger WINDOW_WORDS to cancel the
140
+ # cost. That is now closed too: 256 truncates 46 of 99 windows on the Open Pit
141
+ # textbook (see settings.WINDOW_WORDS), so the cluster reduction it offered was
142
+ # partly content loss, not efficiency.
143
+ #
144
+ # What would reopen this: a cheaper way to spend a cluster (batching several terms
145
+ # per call), or a gold set for a second document so the recall gain can be
146
+ # confirmed as general rather than BUMA-specific.
147
  threshold: 0.25
src/knowledge_extraction/extract/base.py CHANGED
@@ -63,5 +63,41 @@ def evidence_block(chunks: list[Chunk], scores: list[float] | None = None) -> st
63
  parts.append(
64
  f"[chunk_id={chunk.chunk_id} section={chunk.section_no or '-'} "
65
  f"page={chunk.page_start}{score}]\n{head}{chunk.text}"
 
66
  )
67
  return "EVIDENCE\n" + "\n\n---\n\n".join(parts)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  parts.append(
64
  f"[chunk_id={chunk.chunk_id} section={chunk.section_no or '-'} "
65
  f"page={chunk.page_start}{score}]\n{head}{chunk.text}"
66
+ + _asset_block(chunk)
67
  )
68
  return "EVIDENCE\n" + "\n\n---\n\n".join(parts)
69
+
70
+
71
+ def _asset_block(chunk: Chunk) -> str:
72
+ """Figures and tables this chunk references, appended to its evidence. 0.4.0.
73
+
74
+ The chunk's own text already carries each figure's `![](asset://…)` reference
75
+ inline, at the position the document put it. This resolves those references so
76
+ the model can actually use them — before 0.4.0 a figure was an unreachable
77
+ chunk with empty text, and its vision-model description never reached a prompt
78
+ at all.
79
+
80
+ **The verbatim/generated split is labelled, not merely recorded.** A caption is
81
+ printed in the document, so it is quotable as evidence and the span check can
82
+ locate it. A description is a model's claim about a picture: it cannot be
83
+ span-checked against anything, so quoting it would produce a field that the
84
+ validator then nulls — wasting the call and looking like a model failure. The
85
+ label is what stops that.
86
+ """
87
+ if not chunk.assets:
88
+ return ""
89
+ lines = ["", "ASSETS REFERENCED BY THIS CHUNK"]
90
+ for asset in chunk.assets:
91
+ where = f" · page {asset.page_no}" if asset.page_no else ""
92
+ lines.append(f" [asset://{asset.asset_id} · {asset.kind}{where}]")
93
+ if asset.caption:
94
+ lines.append(f" caption (VERBATIM, quotable as evidence): {asset.caption}")
95
+ if asset.description:
96
+ lines.append(
97
+ " description (MODEL-GENERATED, not from the document — "
98
+ "do NOT quote as evidence):"
99
+ )
100
+ lines.append(f" {asset.description}")
101
+ if not asset.caption and not asset.description:
102
+ lines.append(" (no caption or description available)")
103
+ return "\n".join(lines)
src/knowledge_extraction/extract/branches.py CHANGED
@@ -49,7 +49,12 @@ def _evidence_for(
49
  cluster: TermCluster, chunks: list[Chunk], k: int, round_index: int
50
  ) -> tuple[list[Chunk], list[float]]:
51
  by_id = {c.chunk_id: c for c in chunks}
52
- ids = top_k(cluster, k=k, round_index=round_index)
 
 
 
 
 
53
  scores = cluster.evidence_scores[round_index * k : round_index * k + k]
54
  return [by_id[i] for i in ids if i in by_id], scores
55
 
 
49
  cluster: TermCluster, chunks: list[Chunk], k: int, round_index: int
50
  ) -> tuple[list[Chunk], list[float]]:
51
  by_id = {c.chunk_id: c for c in chunks}
52
+ # `chunks` is passed so a linked partner (0.4.0 table pointers) joins the
53
+ # window. `service` passes it to `top_k` too, so the prompt and the span-check
54
+ # source stay identical — that equality is load-bearing, not incidental.
55
+ ids = top_k(cluster, k=k, round_index=round_index, chunks=chunks)
56
+ # Ranked scores only. A partner was LINKED in, not ranked in, so it has no
57
+ # score — `evidence_block` shows none for it, which is the honest rendering.
58
  scores = cluster.evidence_scores[round_index * k : round_index * k + k]
59
  return [by_id[i] for i in ids if i in by_id], scores
60
 
src/knowledge_extraction/extract/prompts/glossary.txt CHANGED
@@ -70,4 +70,15 @@ COMMON MISTAKES TO AVOID:
70
  - Do NOT put units, table captions, or figure labels in the definition field.
71
  - If the evidence contains only a formula and no prose, set definition to null.
72
 
 
 
 
 
 
 
 
 
 
 
 
73
  Return a single JSON object. No prose, no markdown fence.
 
70
  - Do NOT put units, table captions, or figure labels in the definition field.
71
  - If the evidence contains only a formula and no prose, set definition to null.
72
 
73
+ FIGURES AND TABLES (asset references):
74
+ - Evidence may contain `![](asset://<id>)` markers and an "ASSETS REFERENCED BY THIS
75
+ CHUNK" section. The marker is where the document placed a figure.
76
+ - A caption marked VERBATIM is printed in the document. You MAY quote it, and you may
77
+ use it as a provenance span.
78
+ - A description marked MODEL-GENERATED is another model's claim about a picture. You
79
+ may read it for orientation, but you must NOT quote it and must NOT use it as a
80
+ provenance span — it does not appear in the document, so any span taken from it is
81
+ rejected and the field is discarded.
82
+ - Never treat the `asset://` marker itself as part of a definition or a span.
83
+
84
  Return a single JSON object. No prose, no markdown fence.
src/knowledge_extraction/extract/prompts/rule.txt CHANGED
@@ -54,4 +54,15 @@ corpus, so neither an answer nor a NON-answer for a real document leaks into thi
54
  prompt. Keep it that way — an example lifted from the corpus either hands the
55
  model a gold answer or forbids one.
56
 
 
 
 
 
 
 
 
 
 
 
 
57
  Return a single JSON object. No prose, no markdown fence.
 
54
  prompt. Keep it that way — an example lifted from the corpus either hands the
55
  model a gold answer or forbids one.
56
 
57
+ FIGURES AND TABLES (asset references):
58
+ - Evidence may contain `![](asset://<id>)` markers and an "ASSETS REFERENCED BY THIS
59
+ CHUNK" section. The marker is where the document placed a figure.
60
+ - A caption marked VERBATIM is printed in the document. You MAY quote it, and you may
61
+ use it as a provenance span.
62
+ - A description marked MODEL-GENERATED is another model's claim about a picture. You
63
+ may read it for orientation, but you must NOT quote it and must NOT use it as a
64
+ provenance span — it does not appear in the document, so any span taken from it is
65
+ rejected and the field is discarded.
66
+ - Never treat the `asset://` marker itself as part of a definition or a span.
67
+
68
  Return a single JSON object. No prose, no markdown fence.
src/knowledge_extraction/filters/span_filter.py CHANGED
@@ -28,6 +28,10 @@ from functools import lru_cache
28
  from ...middlewares.logging import get_logger
29
  from ..models import Chunk, Mention
30
  from ..settings import (
 
 
 
 
31
  SPAN_TOKEN_CAP,
32
  WINDOW_OVERLAP,
33
  WINDOW_WORDS,
@@ -74,10 +78,19 @@ def extract_mentions(
74
 
75
  mentions: list[Mention] = []
76
  capped = 0
 
77
  started = time.time()
 
78
 
79
  for chunk in chunks:
80
- for window_text, offset in _windows(chunk.text):
 
 
 
 
 
 
 
81
  try:
82
  found = model.predict_entities(
83
  window_text, labels, threshold=score_floor
@@ -113,11 +126,100 @@ def extract_mentions(
113
  mentions=len(deduped),
114
  dropped_overlapping=len(mentions) - len(deduped),
115
  hit_span_cap=capped,
 
 
 
 
 
116
  seconds=round(time.time() - started, 1),
117
  )
118
  return deduped
119
 
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  def _windows(text: str) -> list[tuple[str, int]]:
122
  """Overlapping word windows plus each window's character offset.
123
 
 
28
  from ...middlewares.logging import get_logger
29
  from ..models import Chunk, Mention
30
  from ..settings import (
31
+ SPAN_FALLBACK_CHARS_PER_TOKEN,
32
+ SPAN_MAX_SPLIT_DEPTH,
33
+ SPAN_SUBWORD_CAP,
34
+ SPAN_SUBWORD_MARGIN,
35
  SPAN_TOKEN_CAP,
36
  WINDOW_OVERLAP,
37
  WINDOW_WORDS,
 
78
 
79
  mentions: list[Mention] = []
80
  capped = 0
81
+ split = 0
82
  started = time.time()
83
+ fits = _token_fitter(model)
84
 
85
  for chunk in chunks:
86
+ windows = _windows(chunk.text)
87
+ budgeted: list[tuple[str, int]] = []
88
+ for text, offset in windows:
89
+ pieces = _fit_to_budget(text, offset, fits)
90
+ split += len(pieces) - 1
91
+ budgeted.extend(pieces)
92
+
93
+ for window_text, offset in budgeted:
94
  try:
95
  found = model.predict_entities(
96
  window_text, labels, threshold=score_floor
 
126
  mentions=len(deduped),
127
  dropped_overlapping=len(mentions) - len(deduped),
128
  hit_span_cap=capped,
129
+ # Windows that exceeded the model's subword budget and were split rather
130
+ # than silently truncated. Expected to be small and non-zero: 1 of 368
131
+ # on the three reference documents. A sudden rise means the corpus got
132
+ # denser, not that something broke.
133
+ split_over_budget=split,
134
  seconds=round(time.time() - started, 1),
135
  )
136
  return deduped
137
 
138
 
139
+ def _token_fitter(model):
140
+ """A `fits(text) -> bool` using the model's OWN tokeniser where possible.
141
+
142
+ The word window is a proxy for the thing that actually matters — the
143
+ subword count the transformer truncates at — and the two diverge by text
144
+ density. Asking the tokeniser removes the proxy.
145
+
146
+ Falls back to a character budget if the attribute has moved (a GLiNER
147
+ version change), erring toward over-splitting: a split window costs a little
148
+ context at one boundary, while a truncated one loses its tail entirely and
149
+ says nothing about it.
150
+ """
151
+ tokenizer = None
152
+ for path in ("data_processor.transformer_tokenizer", "data_processor.tokenizer"):
153
+ probe = model
154
+ try:
155
+ for part in path.split("."):
156
+ probe = getattr(probe, part)
157
+ except AttributeError:
158
+ continue
159
+ if hasattr(probe, "tokenize"):
160
+ tokenizer = probe
161
+ break
162
+
163
+ budget_chars = int(SPAN_SUBWORD_CAP * SPAN_FALLBACK_CHARS_PER_TOKEN)
164
+ if tokenizer is None:
165
+ logger.warning(
166
+ "span tokeniser not found; falling back to a character budget",
167
+ budget_chars=budget_chars,
168
+ )
169
+ return lambda text: len(text) <= budget_chars
170
+
171
+ limit = int(SPAN_SUBWORD_CAP * SPAN_SUBWORD_MARGIN)
172
+
173
+ def fits(text: str) -> bool:
174
+ try:
175
+ return len(tokenizer.tokenize(text)) <= limit
176
+ except Exception as exc: # noqa: BLE001 - never break the widest net
177
+ logger.warning("span tokenise failed", error=repr(exc))
178
+ return len(text) <= budget_chars
179
+
180
+ return fits
181
+
182
+
183
+ def _fit_to_budget(
184
+ text: str, offset: int, fits, depth: int = 0
185
+ ) -> list[tuple[str, int]]:
186
+ """Halve a window until every piece fits the model's subword budget.
187
+
188
+ Slices the ORIGINAL text rather than re-joining words, so character offsets
189
+ stay exact — the same reason `_windows` walks positions. An off-by-one here
190
+ would fail every span check downstream, and it would look like a bad model.
191
+
192
+ The halves overlap by `SPAN_TOKEN_CAP` words — the longest span the filter
193
+ can emit — which is exactly enough that no term can straddle the cut and be
194
+ lost, and no more. (`WINDOW_OVERLAP`, the top-level overlap, is 30 words and
195
+ would make the right half LARGER than the left on a 130-word window, so the
196
+ split would not converge.) The overlap re-finds terms, and `_dedupe` removes
197
+ them exactly, keyed on position.
198
+ """
199
+ if fits(text) or depth >= SPAN_MAX_SPLIT_DEPTH:
200
+ return [(text, offset)]
201
+
202
+ cut = text.rfind(" ", 0, len(text) // 2 + 1)
203
+ if cut <= 0:
204
+ # One enormous unbroken token. Nothing to split on; the model will
205
+ # truncate it, and there is no representation that avoids that.
206
+ return [(text, offset)]
207
+
208
+ left = text[:cut]
209
+ # Start the right half SPAN_TOKEN_CAP words back, so the boundary is covered.
210
+ back = cut
211
+ for _ in range(SPAN_TOKEN_CAP):
212
+ previous = text.rfind(" ", 0, back)
213
+ if previous <= 0:
214
+ break
215
+ back = previous
216
+ right_start = back + 1 if back > 0 else cut + 1
217
+
218
+ return _fit_to_budget(left, offset, fits, depth + 1) + _fit_to_budget(
219
+ text[right_start:], offset + right_start, fits, depth + 1
220
+ )
221
+
222
+
223
  def _windows(text: str) -> list[tuple[str, int]]:
224
  """Overlapping word windows plus each window's character offset.
225
 
src/knowledge_extraction/genre.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 5 — what KIND of document is this, decided before anything is spent.
2
+
3
+ The pipeline was calibrated on one genre (an Indonesian mining standard) and is
4
+ now handed three. The failure is not subtle: on the Komatsu shop manual the
5
+ glossary branch burns ~104 calls to abstain 93 times, because a troubleshooting
6
+ manual states symptoms and remedies, not definitions. No amount of prompt work
7
+ reaches that — it is the wrong question asked of the wrong document.
8
+
9
+ Four signals separate the three genres cleanly, and all four are already
10
+ computed for free before the paid stage:
11
+
12
+ heading coverage tabular ratio abbrev pairs rule density
13
+ BUMA standard 41% 25% 5 0.50
14
+ Komatsu shop manual 1% 69% 0 0.01
15
+ Open Pit textbook 63% 5% 0 1.10
16
+
17
+ They do not merely differ, they differ in DIFFERENT DIRECTIONS, which is what
18
+ makes a rule-based classifier honest here rather than a model call: a shop
19
+ manual is mostly tables with no headings, a textbook is mostly headed prose, a
20
+ standard sits between them and is the only one that defines its abbreviations.
21
+
22
+ **What this module does NOT do yet, and why.** Classification is on; acting on
23
+ it is not. Both levers below default to off because neither can be justified by
24
+ a measurement today, and shipping an unmeasured recall change is how the
25
+ clustering defect survived for months:
26
+
27
+ * `ROUTE_LABELS` — switching the GLiNER label set per genre. The `diagnostic`
28
+ and `conceptual` sets produce far more mentions on their genres (2.5x on
29
+ Komatsu), but only BUMA has a gold set, so "more mentions" cannot be shown to
30
+ be "better recall". Unlocked by: a gold set for a second document.
31
+ * `ROUTE_BRANCHES` — skipping branches that do not fit. Measured on exactly one
32
+ document (Komatsu: 0 definitions across two paid runs, as predicted), which
33
+ is a prediction confirmed once, not a rule. Unlocked by: a second diagnostic
34
+ document behaving the same way, or Rifqi accepting one document as enough.
35
+
36
+ Until then this is observability: the verdict and its warnings are recorded, an
37
+ operator can see why a document is expensive and unproductive, and turning
38
+ either lever on is a one-line change with the reasoning already written down.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from dataclasses import dataclass, field
44
+
45
+ # Both default OFF. See the module docstring for what unlocks each.
46
+ ROUTE_LABELS = False
47
+ ROUTE_BRANCHES = False
48
+
49
+ # Thresholds sit between the measured clusters rather than on top of any one of
50
+ # them, so a fourth document of a known genre is likely to land correctly.
51
+ # Widely separated by construction — see the table above.
52
+ HEADING_COVERAGE_LOW = 0.10
53
+ HEADING_COVERAGE_HIGH = 0.50
54
+ TABULAR_RATIO_HIGH = 0.50
55
+ TABULAR_RATIO_LOW = 0.20
56
+ RULE_DENSITY_LOW = 0.10
57
+
58
+ # Below this, the document's own structure is missing and every heading-derived
59
+ # signal downstream degrades: `source_wording` (read off headings), the
60
+ # `term_in_heading` ranking weight, and `DocumentContext.outline`. Komatsu has
61
+ # exactly ONE headed chunk of 85, and nothing warned about it.
62
+ HEADING_COVERAGE_WARN = 0.10
63
+
64
+ GENRE_LABELS = {
65
+ "standard": "broad",
66
+ "diagnostic": "diagnostic",
67
+ "conceptual": "conceptual",
68
+ "unknown": "broad",
69
+ }
70
+
71
+ # Which branches earn their spend on each genre. `unknown` runs everything —
72
+ # not knowing the genre is not a reason to extract less.
73
+ GENRE_BRANCHES: dict[str, tuple[str, ...]] = {
74
+ "standard": ("glossary", "rule", "formula", "summary"),
75
+ # A shop manual defines nothing. What it holds is a symptom x cause matrix
76
+ # across 59 tables, and the branch that reads one does not exist yet
77
+ # (Step 8) — so today the honest answer is "almost nothing here fits".
78
+ "diagnostic": ("summary",),
79
+ "conceptual": ("glossary", "rule", "summary"),
80
+ "unknown": ("glossary", "rule", "formula", "summary"),
81
+ }
82
+
83
+
84
+ @dataclass
85
+ class GenreSignals:
86
+ n_chunks: int
87
+ heading_coverage: float
88
+ tabular_ratio: float
89
+ abbrev_pairs: int
90
+ rule_density: float
91
+
92
+ def as_dict(self) -> dict:
93
+ return {
94
+ "n_chunks": self.n_chunks,
95
+ "heading_coverage": round(self.heading_coverage, 3),
96
+ "tabular_ratio": round(self.tabular_ratio, 3),
97
+ "abbrev_pairs": self.abbrev_pairs,
98
+ "rule_density": round(self.rule_density, 3),
99
+ }
100
+
101
+
102
+ @dataclass
103
+ class GenreVerdict:
104
+ genre: str
105
+ signals: GenreSignals
106
+ reasons: list[str] = field(default_factory=list)
107
+ warnings: list[str] = field(default_factory=list)
108
+
109
+ @property
110
+ def labels_variant(self) -> str:
111
+ """The label set this genre wants — advisory unless `ROUTE_LABELS`."""
112
+ return GENRE_LABELS.get(self.genre, "broad")
113
+
114
+ @property
115
+ def branches(self) -> tuple[str, ...]:
116
+ """The branches that fit — advisory unless `ROUTE_BRANCHES`."""
117
+ return GENRE_BRANCHES.get(self.genre, GENRE_BRANCHES["unknown"])
118
+
119
+ @property
120
+ def well_served(self) -> bool:
121
+ """Is this a document the pipeline can actually do something with?
122
+
123
+ False is not a failure — it is the honest answer for a shop manual
124
+ today, and it is worth saying BEFORE spending ~104 calls to find out.
125
+ """
126
+ return self.genre != "diagnostic"
127
+
128
+ def as_dict(self) -> dict:
129
+ return {
130
+ "genre": self.genre,
131
+ "labels_variant": self.labels_variant,
132
+ "branches": list(self.branches),
133
+ "well_served": self.well_served,
134
+ "routing_applied": {"labels": ROUTE_LABELS, "branches": ROUTE_BRANCHES},
135
+ "signals": self.signals.as_dict(),
136
+ "reasons": self.reasons,
137
+ "warnings": self.warnings,
138
+ }
139
+
140
+
141
+ def measure(doc, filtered) -> GenreSignals:
142
+ """Four free signals off the parsed artifact and the free filter stage."""
143
+ chunks = doc.chunks or []
144
+ n = len(chunks) or 1
145
+ return GenreSignals(
146
+ n_chunks=len(chunks),
147
+ heading_coverage=sum(1 for c in chunks if (c.heading or "").strip()) / n,
148
+ tabular_ratio=sum(1 for c in chunks if c.is_tabular) / n,
149
+ abbrev_pairs=len(filtered.abbrev_pairs),
150
+ rule_density=len(filtered.rule_candidates) / n,
151
+ )
152
+
153
+
154
+ def classify(doc, filtered) -> GenreVerdict:
155
+ """Name the genre, or say `unknown` rather than guess.
156
+
157
+ Deliberately rule-based and deliberately conservative. A misclassification
158
+ that routes a document to the wrong label set costs recall silently, which
159
+ is the one failure mode this pipeline has repeatedly proven bad at noticing
160
+ — so an unclear document gets `unknown`, which routes to exactly today's
161
+ behaviour and spends nothing extra.
162
+ """
163
+ signals = measure(doc, filtered)
164
+ reasons: list[str] = []
165
+
166
+ if (
167
+ signals.tabular_ratio >= TABULAR_RATIO_HIGH
168
+ and signals.heading_coverage <= HEADING_COVERAGE_LOW
169
+ and signals.rule_density <= RULE_DENSITY_LOW
170
+ ):
171
+ genre = "diagnostic"
172
+ reasons.append(
173
+ f"{signals.tabular_ratio:.0%} tabular with {signals.heading_coverage:.0%} "
174
+ "headed and almost no rule language — a symptom/remedy matrix, not prose"
175
+ )
176
+ elif (
177
+ signals.heading_coverage >= HEADING_COVERAGE_HIGH
178
+ and signals.tabular_ratio <= TABULAR_RATIO_LOW
179
+ ):
180
+ genre = "conceptual"
181
+ reasons.append(
182
+ f"{signals.heading_coverage:.0%} of chunks are headed and only "
183
+ f"{signals.tabular_ratio:.0%} tabular — structured expository prose"
184
+ )
185
+ elif signals.abbrev_pairs >= 3 and signals.heading_coverage > HEADING_COVERAGE_LOW:
186
+ genre = "standard"
187
+ reasons.append(
188
+ f"{signals.abbrev_pairs} abbreviation definitions in headed prose — "
189
+ "a document that names its own terms"
190
+ )
191
+ else:
192
+ genre = "unknown"
193
+ reasons.append(
194
+ "no genre signature matched; routing is unchanged and nothing is skipped"
195
+ )
196
+
197
+ warnings: list[str] = []
198
+ if signals.heading_coverage < HEADING_COVERAGE_WARN:
199
+ warnings.append(
200
+ f"heading coverage is {signals.heading_coverage:.0%} "
201
+ f"({round(signals.heading_coverage * signals.n_chunks)} of "
202
+ f"{signals.n_chunks} chunks): `source_wording`, the term_in_heading "
203
+ "ranking weight and the document outline all degrade together, and "
204
+ "nothing downstream can tell that from a document with no terms"
205
+ )
206
+ if genre == "diagnostic":
207
+ warnings.append(
208
+ "diagnostic documents define almost nothing — the glossary branch is "
209
+ "expected to abstain on nearly every cluster. The content is in the "
210
+ "tables, and the branch that reads them is not built (Step 8)"
211
+ )
212
+
213
+ return GenreVerdict(genre=genre, signals=signals, reasons=reasons, warnings=warnings)
src/knowledge_extraction/keys.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Readable concept keys — `TYPE_SUBJECT`, the label a person or an agent searches by.
2
+
3
+ Asked for in the 2026-09-08 discussion (m1). The shape is the one agreed there:
4
+ the type first, then what the entry is *about*, in capitals — `GLOSSARY_PA`,
5
+ never the entry's full text. Two properties it exists to provide:
6
+
7
+ - **One concept, one key.** `PA` drawn from five documents still reads
8
+ `GLOSSARY_PA`, so an agent that needs a term's definition writes the key and
9
+ looks it up directly — no scan, no embedding, no model call.
10
+ - **Readable at a glance.** Capitals were a deliberate call (lowercase reads
11
+ worse in a list), and lookups are capitals too.
12
+
13
+ **A key is a label, never an identity — this is m2.** `entity_id` in `ids.py`
14
+ stays the thing an expert's approval hangs on, because it is a hash of content
15
+ and survives a re-run untouched. A key is built from a *surface*, so it moves
16
+ the moment clustering canonicalises that surface differently. Both travel
17
+ together and neither substitutes for the other: the hash identifies, the key
18
+ reads. Collapsing the two is exactly how approvals get orphaned, which is the
19
+ failure `ids.py` was written to prevent.
20
+
21
+ Prefixes use the `kind` column's own vocabulary (English, singular) so the
22
+ system holds one spelling of each branch rather than two to keep in sync
23
+ (decided 2026-09-14).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import re
30
+ import unicodedata
31
+ from collections.abc import Iterable, Mapping
32
+ from typing import Any
33
+
34
+ # Keyed by the `kind` column's values, so a new kind that forgets to register
35
+ # here returns None rather than silently inventing a prefix.
36
+ _PREFIX: dict[str, str] = {
37
+ "glossary": "GLOSSARY",
38
+ "rule": "RULE",
39
+ "formula": "FORMULA",
40
+ "document": "DOCUMENT",
41
+ "domain": "DOMAIN",
42
+ }
43
+
44
+ # Past this a subject stops being a label and becomes the content it was meant
45
+ # to stand in for. A rule's `statement` can be a whole paragraph.
46
+ MAX_SUBJECT = 40
47
+
48
+ # Short, and deliberately NOT the full `entity_id`: this digest separates two
49
+ # rules that govern the same term. It does not identify the row — `entity_id`
50
+ # does, and shortening a hash for readability is only safe because of that.
51
+ DIGEST_LEN = 6
52
+
53
+
54
+ def slugify(surface: str) -> str:
55
+ """`Physical of Availability (PA)` -> `PHYSICAL_OF_AVAILABILITY_PA`.
56
+
57
+ Accents are folded rather than dropped so an Indonesian or a borrowed term
58
+ keeps its letters. Everything that is not a letter or a digit becomes a
59
+ single underscore, because a key that carries brackets or slashes cannot be
60
+ typed from memory — which is the whole point of having one.
61
+ """
62
+ folded = unicodedata.normalize("NFKD", surface or "")
63
+ folded = "".join(c for c in folded if not unicodedata.combining(c))
64
+ slug = re.sub(r"[^A-Za-z0-9]+", "_", folded).upper()
65
+ slug = re.sub(r"_+", "_", slug).strip("_")
66
+ if len(slug) <= MAX_SUBJECT:
67
+ return slug
68
+ # Cut on a word boundary so the tail is not half a word — but a single long
69
+ # token has no boundary to cut on, and an empty subject is worse than a
70
+ # blunt one.
71
+ cut = slug[:MAX_SUBJECT]
72
+ head, _, _tail = cut.rpartition("_")
73
+ return (head or cut).strip("_")
74
+
75
+
76
+ def _digest(value: str) -> str:
77
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()[:DIGEST_LEN].upper()
78
+
79
+
80
+ def term_surfaces(glossary_payloads: Iterable[Mapping[str, Any]] | None) -> dict[str, str]:
81
+ """`term_id` -> the term's surface, for keying the rules that govern it.
82
+
83
+ Built by the caller from the same run's glossary payloads, because a rule
84
+ payload carries `term_ids` and no surfaces — the words live on the glossary
85
+ entry, not on the rule.
86
+ """
87
+ out: dict[str, str] = {}
88
+ for payload in glossary_payloads or []:
89
+ term_id = payload.get("term_id")
90
+ term = payload.get("term")
91
+ if term_id and isinstance(term, str) and term.strip():
92
+ out[str(term_id)] = term.strip()
93
+ return out
94
+
95
+
96
+ def _rule_subject(
97
+ payload: Mapping[str, Any], entity_id: str, surfaces: Mapping[str, str]
98
+ ) -> str:
99
+ """A rule has no canonical surface of its own — it is a sentence.
100
+
101
+ So it borrows the term it governs and adds a short digest, because one term
102
+ usually governs several rules: `RULE_PA_C07BE4`. Asking the model to name
103
+ the rule was the obvious alternative and is the one `ids.py` already rules
104
+ out — a model cannot produce a stable identifier, and a model-written
105
+ `rule_id` was a real defect here, returning a different slug for the same
106
+ rule on consecutive runs at temperature 0. A rule that governs no term
107
+ keeps the digest alone; that it is not searchable by word was accepted when
108
+ this was agreed.
109
+ """
110
+ for term_id in payload.get("term_ids") or []:
111
+ surface = slugify(surfaces.get(str(term_id), ""))
112
+ if surface:
113
+ return f"{surface}_{_digest(entity_id)}"
114
+ return _digest(entity_id)
115
+
116
+
117
+ def _subject(
118
+ kind: str,
119
+ payload: Mapping[str, Any],
120
+ entity_id: str,
121
+ doc_id: str,
122
+ surfaces: Mapping[str, str],
123
+ ) -> str:
124
+ if kind == "glossary":
125
+ return slugify(payload.get("term") or "")
126
+ if kind == "formula":
127
+ # A formula may legitimately have no name; `entity_id` already falls
128
+ # back name -> latex -> chunk_id, so the digest inherits that work.
129
+ return slugify(payload.get("name") or "") or _digest(entity_id)
130
+ if kind == "document":
131
+ # The document's own id reads better than its title. A title is a
132
+ # verbatim transcription and routinely carries the PDF's broken
133
+ # spacing — correct to store, useless to type.
134
+ return slugify(doc_id) or _digest(entity_id)
135
+ if kind == "domain":
136
+ return slugify(payload.get("domain_name") or "")
137
+ if kind == "rule":
138
+ return _rule_subject(payload, entity_id, surfaces)
139
+ return ""
140
+
141
+
142
+ def concept_key(
143
+ kind: str,
144
+ payload: Mapping[str, Any],
145
+ *,
146
+ entity_id: str,
147
+ doc_id: str = "",
148
+ surfaces: Mapping[str, str] | None = None,
149
+ ) -> str | None:
150
+ """The `TYPE_SUBJECT` key for one entry; None when the kind is unregistered.
151
+
152
+ Never raises: the caller writes this into a row alongside content it has
153
+ already validated, and a key that cannot be built is a degraded label, not
154
+ a reason to lose the entry.
155
+ """
156
+ prefix = _PREFIX.get(kind)
157
+ if prefix is None:
158
+ return None
159
+ subject = _subject(kind, payload, entity_id, doc_id, surfaces or {})
160
+ return f"{prefix}_{subject}" if subject else prefix
src/knowledge_extraction/models.py CHANGED
@@ -67,6 +67,30 @@ class SubdomainEnum(str, Enum):
67
  # ── Stage 1: the chunk (internal view of the seam artifact) ─────────────
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  class Chunk(BaseModel):
71
  """One unit of a parsed document, as the extraction stages need it.
72
 
@@ -98,6 +122,16 @@ class Chunk(BaseModel):
98
  # it on the floor at the adapter — the same way it once dropped `latex`.
99
  heading_path: list[str] = Field(default_factory=list)
100
 
 
 
 
 
 
 
 
 
 
 
101
  # Cheap downstream filters / ranking signals
102
  has_formula: bool = False
103
  is_tabular: bool = False
@@ -115,6 +149,14 @@ class Chunk(BaseModel):
115
  # "fails verification" — see `validate/span_check.py`.
116
  latex: list[str] = Field(default_factory=list)
117
 
 
 
 
 
 
 
 
 
118
 
119
  class ParsedDoc(BaseModel):
120
  """A document's chunks plus the identity needed to version and cache them."""
 
67
  # ── Stage 1: the chunk (internal view of the seam artifact) ─────────────
68
 
69
 
70
+ class ChunkAsset(BaseModel):
71
+ """A figure or table referenced by a chunk. Crosses the seam at 0.4.0.
72
+
73
+ The subset of the parsing half's `Asset` that extraction actually uses. The
74
+ split between the two content fields is the whole point and must be preserved
75
+ into the prompt:
76
+
77
+ - `caption` is **verbatim document text**, so it is quotable as evidence and
78
+ the span check can locate it.
79
+ - `description` is **model-written** (a vision model's claim about the
80
+ picture). It is never span-checkable, never evidence, and must never be
81
+ copied into `Chunk.text` — `text` is the haystack the span check searches,
82
+ so generated prose in there would let a fabrication pass the control built
83
+ to catch it.
84
+ """
85
+
86
+ asset_id: str
87
+ kind: str
88
+ caption: str | None = None
89
+ description: str | None = None
90
+ page_no: int | None = None
91
+ storage_key: str | None = None
92
+
93
+
94
  class Chunk(BaseModel):
95
  """One unit of a parsed document, as the extraction stages need it.
96
 
 
122
  # it on the floor at the adapter — the same way it once dropped `latex`.
123
  heading_path: list[str] = Field(default_factory=list)
124
 
125
+ # Figures and tables this chunk carries or points at. New at schema 0.4.0.
126
+ # Before it, a figure was its own chunk with EMPTY text, which made it
127
+ # unreachable: no text -> no mentions -> no cluster -> never evidence. 13
128
+ # figures across three documents, each described by a vision model we paid
129
+ # for, and none ever reached a prompt.
130
+ assets: list[ChunkAsset] = Field(default_factory=list)
131
+ # chunk_ids pointing AT this chunk (set on table chunks, which stay
132
+ # standalone because their linearised text earns its evidence slot).
133
+ referenced_by: list[str] = Field(default_factory=list)
134
+
135
  # Cheap downstream filters / ranking signals
136
  has_formula: bool = False
137
  is_tabular: bool = False
 
149
  # "fails verification" — see `validate/span_check.py`.
150
  latex: list[str] = Field(default_factory=list)
151
 
152
+ # Source table markup, kept beside the pipe-linearised `text`. Crosses the
153
+ # seam at 0.4.0; before that the parsing half emitted it and the adapter
154
+ # dropped it, so a symptom x cause matrix reached the model as a wall of
155
+ # pipe-delimited text with the row/column correspondence gone. `colspan` and
156
+ # `rowspan` live here, which is what a table branch needs to recover
157
+ # (row header, column header, cell) triples.
158
+ table_html: str | None = None
159
+
160
 
161
  class ParsedDoc(BaseModel):
162
  """A document's chunks plus the identity needed to version and cache them."""
src/knowledge_extraction/mutation.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Has an entry changed in a way that invalidates an expert's ruling? (i3)
2
+
3
+ From the 2026-09-08 discussion: *"kalau dia sudah termutate harusnya dia perlu
4
+ review lagi… kalau tidak berubah, tetap approved."* The whole difficulty is in
5
+ the second half. Flipping everything a new document touches is easy and useless
6
+ — a document that merely mentions `PA` again moves `mention_count`, and if that
7
+ counts as a mutation the expert comes back to forty rows where nothing has
8
+ actually changed, finds that out one row at a time, and stops trusting the queue.
9
+
10
+ So the comparison is deliberately narrow: **only the fields an expert ruled on**
11
+ (decided 2026-09-14). Everything the pipeline computes about an entry — how
12
+ often it was mentioned, how it was classified, where it was found — can move
13
+ freely without disturbing an approval, because none of it is what the expert was
14
+ looking at when they approved.
15
+
16
+ Whitespace is collapsed before comparing, so re-extraction that reflows a
17
+ definition does not re-open it. Any other difference counts: this stays
18
+ conservative, because missing a real change means serving knowledge an expert
19
+ never agreed to, and that is the failure mode this exists to prevent.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Mapping
25
+ from typing import Any
26
+
27
+ # What a reviewer is actually judging, per kind — the fields the review queue
28
+ # puts in front of them. Everything else on the entry is machinery.
29
+ RULED_FIELDS: dict[str, tuple[str, ...]] = {
30
+ "glossary": ("term", "full_name", "definition", "source_wording"),
31
+ "rule": ("statement", "condition", "consequence"),
32
+ "formula": ("name", "formula_latex", "unit"),
33
+ "document": ("title", "purpose_verbatim"),
34
+ "domain": ("domain_name", "purpose_verbatim"),
35
+ }
36
+
37
+ # Pipeline bookkeeping. Named explicitly so the fallback below cannot mistake a
38
+ # recomputed count for a changed claim.
39
+ VOLATILE_FIELDS: frozenset[str] = frozenset(
40
+ {
41
+ "mention_count",
42
+ "diff_status",
43
+ "extraction_status",
44
+ "definition_conflict",
45
+ "conflict_variants",
46
+ "provenance",
47
+ "doc_id",
48
+ "doc_ids",
49
+ }
50
+ )
51
+
52
+
53
+ def _norm(value: Any) -> Any:
54
+ """Collapse whitespace in strings; recurse into lists and dicts.
55
+
56
+ Reflowed prose is not a changed claim. Nothing else is normalised — each
57
+ further normalisation is a difference this would stop noticing.
58
+ """
59
+ if isinstance(value, str):
60
+ return " ".join(value.split())
61
+ if isinstance(value, list | tuple):
62
+ return [_norm(v) for v in value]
63
+ if isinstance(value, Mapping):
64
+ return {k: _norm(v) for k, v in sorted(value.items())}
65
+ return value
66
+
67
+
68
+ def ruled_content(kind: str, payload: Mapping[str, Any] | None) -> dict[str, Any]:
69
+ """The subset of an entry that an approval actually covers.
70
+
71
+ An unregistered kind falls back to the whole payload minus the volatile
72
+ fields — conservative on purpose: a new kind should over-report changes
73
+ until someone decides what its reviewer is looking at, never under-report.
74
+ """
75
+ payload = payload or {}
76
+ fields = RULED_FIELDS.get(kind)
77
+ if fields is not None:
78
+ return {field: _norm(payload.get(field)) for field in fields}
79
+ return {
80
+ key: _norm(value)
81
+ for key, value in sorted(payload.items())
82
+ if key not in VOLATILE_FIELDS
83
+ }
84
+
85
+
86
+ def changed_fields(
87
+ kind: str,
88
+ reviewed: Mapping[str, Any] | None,
89
+ current: Mapping[str, Any] | None,
90
+ ) -> list[str]:
91
+ """Which ruled-on fields moved — the before/after a reviewer is shown (r4).
92
+
93
+ Same comparison as `has_mutated`, reported field by field so the queue can
94
+ say *what* changed instead of making the expert re-read the whole entry to
95
+ find out. That was the ask: show before / after, do not send them back to
96
+ the start.
97
+ """
98
+ before = ruled_content(kind, reviewed)
99
+ after = ruled_content(kind, current)
100
+ return [field for field in before if before[field] != after.get(field)]
101
+
102
+
103
+ def has_mutated(
104
+ kind: str,
105
+ reviewed: Mapping[str, Any] | None,
106
+ current: Mapping[str, Any] | None,
107
+ ) -> bool:
108
+ """True when `current` differs from what was approved, in ways that matter.
109
+
110
+ `reviewed` is what the expert had in front of them — for an `edited`
111
+ decision that is their corrected payload, not the model's original claim.
112
+ """
113
+ return ruled_content(kind, reviewed) != ruled_content(kind, current)
src/knowledge_extraction/queue/review_queue.py CHANGED
@@ -22,6 +22,22 @@ on the entry for a consumer that wants them.
22
 
23
  from __future__ import annotations
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  def build_queue(entries: list[dict], domain: dict | None = None) -> list[dict]:
27
  """Rows an expert works through, most-decision-worthy first.
@@ -36,12 +52,12 @@ def build_queue(entries: list[dict], domain: dict | None = None) -> list[dict]:
36
  entry.get("diff_status") == "conflicting"
37
  or entry.get("definition_conflict") is True
38
  )
39
- return (0 if conflicting else 2, -int(entry.get("mention_count", 0) or 0))
40
 
41
- queue = []
42
  for entry in sorted(entries, key=sort_key):
43
  provenance = entry.get("provenance") or {}
44
- queue.append(
45
  {
46
  "row_kind": "term",
47
  # What an approve/edit/reject decision attaches to. Without it a
@@ -57,21 +73,51 @@ def build_queue(entries: list[dict], domain: dict | None = None) -> list[dict]:
57
  "section_no": provenance.get("section_no"),
58
  "span": provenance.get("span"),
59
  "review_reason": _reason(entry),
60
- "_sort": sort_key(entry),
61
  }
62
  )
63
 
64
- queue.extend(_key_parameter_rows(domain))
65
-
66
- # Ranked once, over both kinds. Unresolved parameters sit between conflicts
67
- # and routine confirmations: a contradiction is still the more urgent call,
68
- # but "the model named a parameter nothing in the document corroborates" is
69
- # a judgement only the expert can make, and it should not sink beneath 60
70
- # routine rows where nobody reaches it.
71
- queue.sort(key=lambda row: row["_sort"])
72
  for rank, row in enumerate(queue, start=1):
73
  row["rank"] = rank
74
- del row["_sort"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  return queue
76
 
77
 
@@ -88,6 +134,7 @@ def _key_parameter_rows(domain: dict | None) -> list[dict]:
88
  for parameter in (domain or {}).get("key_parameters") or []:
89
  if parameter.get("term_id"):
90
  continue
 
91
  rows.append(
92
  {
93
  "row_kind": "key_parameter",
@@ -95,7 +142,12 @@ def _key_parameter_rows(domain: dict | None) -> list[dict]:
95
  # there is no term_id, which is the entire point of the row.
96
  "term_id": None,
97
  "brief_id": (domain or {}).get("brief_id"),
98
- "term": parameter.get("surface"),
 
 
 
 
 
99
  "definition": None,
100
  "source_wording": None,
101
  "mention_count": 0,
@@ -107,12 +159,38 @@ def _key_parameter_rows(domain: dict | None) -> list[dict]:
107
  "named as a key parameter but no extracted term corroborates it "
108
  "— confirm it is real"
109
  ),
110
- "_sort": (1, 0),
111
  }
112
  )
113
  return rows
114
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  def _reason(entry: dict) -> str:
117
  if entry.get("definition_conflict") or entry.get("diff_status") == "conflicting":
118
  return "conflicting definitions — expert decision required"
 
22
 
23
  from __future__ import annotations
24
 
25
+ import re
26
+ import textwrap
27
+
28
+ # At most one unresolved key parameter per this many term rows. See
29
+ # `_interleave` for why a cap replaced the promotion tier it used to have.
30
+ KEY_PARAMETER_INTERLEAVE = 5
31
+
32
+ # A key parameter NAME longer than this is not a name. The branch that produces
33
+ # them has no span check and no length guard, and on a textbook it returns the
34
+ # whole defining paragraph as the surface — see `_parameter_name`.
35
+ KEY_PARAMETER_NAME_MAX = 60
36
+
37
+ # The first sentence/label boundary in a returned surface. Applied only past
38
+ # `KEY_PARAMETER_NAME_MAX`, which is what keeps "No. of units" intact.
39
+ _HEAD_SPLIT = re.compile(r"[.:\n]")
40
+
41
 
42
  def build_queue(entries: list[dict], domain: dict | None = None) -> list[dict]:
43
  """Rows an expert works through, most-decision-worthy first.
 
52
  entry.get("diff_status") == "conflicting"
53
  or entry.get("definition_conflict") is True
54
  )
55
+ return (0 if conflicting else 1, -int(entry.get("mention_count", 0) or 0))
56
 
57
+ term_rows = []
58
  for entry in sorted(entries, key=sort_key):
59
  provenance = entry.get("provenance") or {}
60
+ term_rows.append(
61
  {
62
  "row_kind": "term",
63
  # What an approve/edit/reject decision attaches to. Without it a
 
73
  "section_no": provenance.get("section_no"),
74
  "span": provenance.get("span"),
75
  "review_reason": _reason(entry),
76
+ "_conflict": sort_key(entry)[0] == 0,
77
  }
78
  )
79
 
80
+ queue = _interleave(term_rows, _key_parameter_rows(domain))
 
 
 
 
 
 
 
81
  for rank, row in enumerate(queue, start=1):
82
  row["rank"] = rank
83
+ row.pop("_conflict", None)
84
+ return queue
85
+
86
+
87
+ def _interleave(term_rows: list[dict], parameter_rows: list[dict]) -> list[dict]:
88
+ """Conflicts, then terms by frequency with key parameters spliced in.
89
+
90
+ Unresolved key parameters used to occupy their own tier directly beneath
91
+ conflicts, which meant EVERY one of them outranked EVERY routine term no
92
+ matter how many there were. On the Komatsu shop manual that is 24 junk rows
93
+ standing in front of `Engine` (108 mentions) — the branch produces them in
94
+ bulk on exactly the documents where it understands least, so the promotion
95
+ fired hardest where it was least deserved and the expert met a wall of
96
+ noise before reaching anything real.
97
+
98
+ The original reason for promoting them is still sound and is preserved: the
99
+ key-parameter branch has no span check, so an unresolvable surface is the
100
+ one hallucination signal it can raise, and it must not sink beneath sixty
101
+ routine rows where nobody reaches it. A CAP expresses that without letting
102
+ the tail take the queue over — one parameter per five terms, so they are
103
+ always visible early and never crowd out frequency.
104
+
105
+ Conflicts stay strictly on top: a contradiction is a decision only the
106
+ expert can make, and nothing interleaves above it.
107
+ """
108
+ conflicts = [row for row in term_rows if row.get("_conflict")]
109
+ routine = [row for row in term_rows if not row.get("_conflict")]
110
+
111
+ queue = list(conflicts)
112
+ pending = list(parameter_rows)
113
+ for index, row in enumerate(routine, start=1):
114
+ queue.append(row)
115
+ if pending and index % KEY_PARAMETER_INTERLEAVE == 0:
116
+ queue.append(pending.pop(0))
117
+
118
+ # Whatever the interleave could not place — a document with more unresolved
119
+ # parameters than terms, which is itself a signal — rather than dropped.
120
+ queue.extend(pending)
121
  return queue
122
 
123
 
 
134
  for parameter in (domain or {}).get("key_parameters") or []:
135
  if parameter.get("term_id"):
136
  continue
137
+ name, verbatim = _parameter_name(parameter.get("surface"))
138
  rows.append(
139
  {
140
  "row_kind": "key_parameter",
 
142
  # there is no term_id, which is the entire point of the row.
143
  "term_id": None,
144
  "brief_id": (domain or {}).get("brief_id"),
145
+ "term": name,
146
+ # Null unless the surface had to be shortened. The full text is
147
+ # never discarded: it is what the expert judges "is this real?"
148
+ # against, and the branch picks the right CONCEPTS even when it
149
+ # returns them at paragraph length.
150
+ "surface_full": verbatim,
151
  "definition": None,
152
  "source_wording": None,
153
  "mention_count": 0,
 
159
  "named as a key parameter but no extracted term corroborates it "
160
  "— confirm it is real"
161
  ),
 
162
  }
163
  )
164
  return rows
165
 
166
 
167
+ def _parameter_name(surface: str | None) -> tuple[str, str | None]:
168
+ """A key parameter's displayable name, and its full text when shortened.
169
+
170
+ The branch returns a `surface` that is supposed to be a parameter name, and
171
+ on the Open Pit textbook it returns the right concept wrapped in its whole
172
+ defining sentence. Nothing downstream caps it, so the queue renders a
173
+ paragraph where a name belongs and the row is unreadable at a glance.
174
+
175
+ Shortening is applied ONLY past `KEY_PARAMETER_NAME_MAX`, which is what
176
+ keeps a legitimate short name containing a period ("No. of units") intact —
177
+ splitting unconditionally on the first `.` is the obvious implementation and
178
+ it silently truncates that class of name.
179
+ """
180
+ text = (surface or "").strip()
181
+ if not text:
182
+ return "", None
183
+ if len(text) <= KEY_PARAMETER_NAME_MAX and "\n" not in text:
184
+ return text, None
185
+
186
+ head = _HEAD_SPLIT.split(text, 1)[0].strip()
187
+ if not head or len(head) > KEY_PARAMETER_NAME_MAX:
188
+ # No usable boundary — a run-on clause. Cut on a word boundary rather
189
+ # than mid-token so the row still reads as language.
190
+ head = textwrap.shorten(text, width=KEY_PARAMETER_NAME_MAX, placeholder="…")
191
+ return head, text
192
+
193
+
194
  def _reason(entry: dict) -> str:
195
  if entry.get("definition_conflict") or entry.get("diff_status") == "conflicting":
196
  return "conflicting definitions — expert decision required"
src/knowledge_extraction/rank/evidence.py CHANGED
@@ -10,6 +10,13 @@ survive any future change here:
10
  3. **The full ranked list is retained**, so escalation can take the next K and
11
  self-correct.
12
 
 
 
 
 
 
 
 
13
  The rejected relevance gate had none of the three. If ranking is ever revisited,
14
  that is the test to apply.
15
  """
@@ -22,7 +29,12 @@ from ..cluster.normalize import normalize
22
  from ..filters.cue_filter import definitional_hits
23
  from ..filters.legend_filter import find_legend_lines
24
  from ..models import Chunk, TermCluster
25
- from ..settings import CUE_PROXIMITY_CHARS, EVIDENCE_K, EVIDENCE_WEIGHTS
 
 
 
 
 
26
 
27
 
28
  def rank_evidence(
@@ -59,11 +71,62 @@ def rank_evidence(
59
 
60
 
61
  def top_k(
62
- cluster: TermCluster, k: int = EVIDENCE_K, round_index: int = 0
 
 
 
63
  ) -> list[str]:
64
- """Evidence for extraction round `round_index` (0 = best K, 1 = next K…)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  start = round_index * k
66
- return cluster.evidence_chunk_ids[start : start + k]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
 
69
  def rounds_available(cluster: TermCluster, k: int, max_rounds: int) -> int:
@@ -126,6 +189,21 @@ def _score_chunk(
126
  score += w["in_legend_block"]
127
  break
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  if chunk.has_formula:
130
  score += w["formula_present"]
131
 
 
10
  3. **The full ranked list is retained**, so escalation can take the next K and
11
  self-correct.
12
 
13
+ One signal ranks on ASSET metadata (`asset_caption_names_term`, added 2026-09-11),
14
+ and it is deliberately limited to the **caption**. A caption is printed in the
15
+ document and is span-checkable; the `description` beside it is model-written and
16
+ cannot be, so ranking on a description would let a hallucinated sentence pull a
17
+ figure into evidence — the retrieval-side version of the failure the span check
18
+ exists to prevent.
19
+
20
  The rejected relevance gate had none of the three. If ranking is ever revisited,
21
  that is the test to apply.
22
  """
 
29
  from ..filters.cue_filter import definitional_hits
30
  from ..filters.legend_filter import find_legend_lines
31
  from ..models import Chunk, TermCluster
32
+ from ..settings import (
33
+ CUE_PROXIMITY_CHARS,
34
+ EVIDENCE_K,
35
+ EVIDENCE_LINK_PARTNERS,
36
+ EVIDENCE_WEIGHTS,
37
+ )
38
 
39
 
40
  def rank_evidence(
 
71
 
72
 
73
  def top_k(
74
+ cluster: TermCluster,
75
+ k: int = EVIDENCE_K,
76
+ round_index: int = 0,
77
+ chunks: list[Chunk] | None = None,
78
  ) -> list[str]:
79
+ """Evidence for extraction round `round_index` (0 = best K, 1 = next K…).
80
+
81
+ When `chunks` is supplied, up to `EVIDENCE_LINK_PARTNERS` **linked partner**
82
+ chunks are appended to the window: 0.4.0 records a table and the prose that
83
+ introduces it as a pair (`referenced_by`, both directions), and the ranker
84
+ measured as never co-selecting them — 0 of 603 clusters across the three test
85
+ documents had both sides in their top-5. The partner is therefore unseen
86
+ content, and for a bare table it is usually the only thing that names it.
87
+
88
+ Both consumers of this function pass `chunks` — `_evidence_for` (the prompt)
89
+ and `service` (the span-check source) — so the model reads exactly what the
90
+ span check searches. The full ranked list is NOT modified, so anything reading
91
+ `evidence_chunk_ids` (`rounds_available` included) is unaffected. See
92
+ `EVIDENCE_LINK_PARTNERS` for the measurement and why the expansion lives here
93
+ rather than in `rank_evidence`.
94
+
95
+ Omitting `chunks` keeps the pre-0.4.0 behaviour exactly.
96
+ """
97
  start = round_index * k
98
+ window = cluster.evidence_chunk_ids[start : start + k]
99
+ if not chunks or EVIDENCE_LINK_PARTNERS <= 0 or not window:
100
+ return window
101
+ return window + _linked_partners(window, chunks, EVIDENCE_LINK_PARTNERS)
102
+
103
+
104
+ def _linked_partners(
105
+ window: list[str], chunks: list[Chunk], cap: int
106
+ ) -> list[str]:
107
+ """Partners of chunks in `window`, best-ranked side first, capped.
108
+
109
+ Walks the window in rank order so the partner chosen belongs to the
110
+ highest-ranked linked chunk, and sorts each chunk's own partners by id so a
111
+ chunk with several links resolves identically on every run.
112
+ """
113
+ links: dict[str, set[str]] = {}
114
+ for chunk in chunks:
115
+ for other in chunk.referenced_by:
116
+ links.setdefault(chunk.chunk_id, set()).add(other)
117
+ links.setdefault(other, set()).add(chunk.chunk_id)
118
+
119
+ seen = set(window)
120
+ picked: list[str] = []
121
+ for chunk_id in window:
122
+ for partner in sorted(links.get(chunk_id, ())):
123
+ if partner in seen:
124
+ continue
125
+ picked.append(partner)
126
+ seen.add(partner)
127
+ if len(picked) >= cap:
128
+ return picked
129
+ return picked
130
 
131
 
132
  def rounds_available(cluster: TermCluster, k: int, max_rounds: int) -> int:
 
189
  score += w["in_legend_block"]
190
  break
191
 
192
+ # A figure in this chunk whose CAPTION names the term (0.4.0 assets).
193
+ #
194
+ # Word-boundary matched against the caption only — never the model-written
195
+ # `description`. A description is a claim about a picture that cannot be
196
+ # span-checked, so letting it influence retrieval would let a hallucinated
197
+ # sentence pull a figure into evidence. The caption is printed in the
198
+ # document, so it is safe to rank on.
199
+ if any(
200
+ asset.caption
201
+ and any(_word_match(normalize(v), normalize(asset.caption))
202
+ for v in cluster.variants)
203
+ for asset in getattr(chunk, "assets", []) or []
204
+ ):
205
+ score += w.get("asset_caption_names_term", 0.0)
206
+
207
  if chunk.has_formula:
208
  score += w["formula_present"]
209
 
src/knowledge_extraction/service.py CHANGED
@@ -15,6 +15,7 @@ import time
15
 
16
  from ..middlewares.logging import get_logger
17
  from .cluster import cluster_mentions
 
18
  from .diff import diff_glossary
19
  from .extract import (
20
  build_glossary_prompt,
@@ -23,8 +24,10 @@ from .extract import (
23
  extract_glossary,
24
  extract_rule,
25
  extract_summary,
 
26
  )
27
  from .filters import abbrev_pairs, extract_mentions, rule_candidates
 
28
  from .link import link_all
29
  from .models import (
30
  CallUsage,
@@ -56,6 +59,10 @@ class ExtractionResult:
56
  self.brief: dict | None = None
57
  self.review_queue: list[dict] = []
58
  self.rejected: list[RejectedField] = []
 
 
 
 
59
  self.usages: list[CallUsage] = []
60
  # Dangle counts from the linking stage. Reported rather than swallowed:
61
  # a link stage that resolves nothing looks identical to one that works.
@@ -91,19 +98,67 @@ def build_clusters(doc: ParsedDoc, filtered: FilterResult) -> ClusterResult:
91
  def estimate_cost(
92
  doc: ParsedDoc, clustered: ClusterResult, filtered: FilterResult, limit: int | None = None
93
  ) -> dict:
94
- """Dry run: exact prompts are built, nothing is sent."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  clusters = clustered.clusters[:limit] if limit else clustered.clusters
96
- prompt_tokens = 0
97
  for cluster in clusters:
98
  system, user = build_glossary_prompt(cluster, doc.chunks)
99
- prompt_tokens += est_tokens(system) + est_tokens(user)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  return {
 
101
  "glossary_calls": len(clusters),
102
- "rule_calls": len(filtered.rule_candidates),
103
- "formula_calls": sum(1 for c in doc.chunks if c.has_formula),
104
  "summary_calls": 1,
105
- "estimated_prompt_tokens": prompt_tokens,
106
- "note": "estimate only — real counts come from the API usage object",
 
 
 
 
 
 
 
107
  }
108
 
109
 
@@ -121,7 +176,9 @@ def extract_all(
121
  started = time.time()
122
 
123
  if "glossary" in branches:
124
- _run_glossary(doc, clustered, extractor, out, limit)
 
 
125
  if "rule" in branches:
126
  _run_rules(doc, filtered, extractor, out, limit)
127
  if "formula" in branches:
@@ -162,9 +219,59 @@ def extract_all(
162
  return out
163
 
164
 
165
- def _run_glossary(doc, clustered, extractor, out, limit) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  clusters = clustered.clusters[:limit] if limit else clustered.clusters
 
167
  for cluster in clusters:
 
 
 
 
 
 
 
 
 
 
168
  entry = None
169
  max_round = rounds_available(cluster, EVIDENCE_K)
170
 
@@ -177,7 +284,11 @@ def _run_glossary(doc, clustered, extractor, out, limit) -> None:
177
  continue
178
 
179
  source = evidence_text(
180
- top_k(cluster, EVIDENCE_K, round_index), doc.chunks
 
 
 
 
181
  )
182
  entry, rejections = validate_entry(entry, "glossary", source, cluster.canonical)
183
  out.rejected.extend(rejections)
 
15
 
16
  from ..middlewares.logging import get_logger
17
  from .cluster import cluster_mentions
18
+ from .cluster.normalize import normalize
19
  from .diff import diff_glossary
20
  from .extract import (
21
  build_glossary_prompt,
 
24
  extract_glossary,
25
  extract_rule,
26
  extract_summary,
27
+ prefix_tokens,
28
  )
29
  from .filters import abbrev_pairs, extract_mentions, rule_candidates
30
+ from .ids import term_id
31
  from .link import link_all
32
  from .models import (
33
  CallUsage,
 
59
  self.brief: dict | None = None
60
  self.review_queue: list[dict] = []
61
  self.rejected: list[RejectedField] = []
62
+ # i2: clusters answered from an approved entry instead of a paid
63
+ # call. Reported rather than inferred - a branch that skips work
64
+ # silently looks identical to one that found nothing.
65
+ self.reused: int = 0
66
  self.usages: list[CallUsage] = []
67
  # Dangle counts from the linking stage. Reported rather than swallowed:
68
  # a link stage that resolves nothing looks identical to one that works.
 
98
  def estimate_cost(
99
  doc: ParsedDoc, clustered: ClusterResult, filtered: FilterResult, limit: int | None = None
100
  ) -> dict:
101
+ """Dry run: exact prompts are built, nothing is sent.
102
+
103
+ **`estimated_prompt_tokens` covers all four branches as of 2026-09-11.** It
104
+ used to sum the glossary branch alone while carrying a name that reads as a
105
+ total, so every consumer — including the spend ceiling that gates on it —
106
+ was reading a partial number as a whole one. The glossary figure stays exact
107
+ (its prompts are really built); the other three are approximated from the
108
+ cached prefix plus the chunk text they are handed, which undercounts the
109
+ evidence block's own framing by a few percent. Both are labelled, because a
110
+ ceiling that does not know which half of its input is a guess is not a
111
+ ceiling.
112
+
113
+ `clusters` is reported alongside because **cluster count, not page count, is
114
+ what drives spend** — one call per cluster — and it is known here for free.
115
+ """
116
  clusters = clustered.clusters[:limit] if limit else clustered.clusters
117
+ glossary_tokens = 0
118
  for cluster in clusters:
119
  system, user = build_glossary_prompt(cluster, doc.chunks)
120
+ glossary_tokens += est_tokens(system) + est_tokens(user)
121
+
122
+ by_id = {c.chunk_id: c for c in doc.chunks}
123
+ rule_chunks = [
124
+ by_id[c.chunk_id]
125
+ for c in (filtered.rule_candidates[:limit] if limit else filtered.rule_candidates)
126
+ if c.chunk_id in by_id
127
+ ]
128
+ formula_chunks = [c for c in doc.chunks if c.has_formula]
129
+ formula_chunks = formula_chunks[:limit] if limit else formula_chunks
130
+
131
+ rule_tokens = sum(prefix_tokens("rule") + est_tokens(c.text or "") for c in rule_chunks)
132
+ formula_tokens = sum(
133
+ prefix_tokens("formula") + est_tokens(c.text or "") for c in formula_chunks
134
+ )
135
+ # The summary branch is handed the WHOLE document — few calls, but roughly a
136
+ # quarter of all input tokens, and it cannot be filtered (X7).
137
+ summary_tokens = prefix_tokens("summary") + sum(
138
+ est_tokens(c.text or "") for c in doc.chunks
139
+ )
140
+
141
+ by_branch = {
142
+ "glossary": glossary_tokens,
143
+ "rule": rule_tokens,
144
+ "formula": formula_tokens,
145
+ "summary": summary_tokens,
146
+ }
147
  return {
148
+ "clusters": len(clusters),
149
  "glossary_calls": len(clusters),
150
+ "rule_calls": len(rule_chunks),
151
+ "formula_calls": len(formula_chunks),
152
  "summary_calls": 1,
153
+ "total_calls": len(clusters) + len(rule_chunks) + len(formula_chunks) + 1,
154
+ "estimated_prompt_tokens": sum(by_branch.values()),
155
+ "estimated_prompt_tokens_by_branch": by_branch,
156
+ "glossary_tokens_are_exact": True,
157
+ "note": (
158
+ "estimate only — real counts come from the API usage object. Glossary "
159
+ "prompts are built for real; rule/formula/summary are approximated from "
160
+ "prefix + chunk text and run slightly low."
161
+ ),
162
  }
163
 
164
 
 
176
  started = time.time()
177
 
178
  if "glossary" in branches:
179
+ _run_glossary(
180
+ doc, clustered, extractor, out, limit, _approved_index(active_glossary)
181
+ )
182
  if "rule" in branches:
183
  _run_rules(doc, filtered, extractor, out, limit)
184
  if "formula" in branches:
 
219
  return out
220
 
221
 
222
+ def _approved_index(active_glossary: list[dict] | None) -> dict[str, dict]:
223
+ """Canonical key -> the approved payload, for recognising a known term (i2).
224
+
225
+ Keyed with the SAME normaliser that buckets mentions inside a document, so
226
+ one rule decides sameness at both scales rather than two that can drift.
227
+ Both `term` and `full_name` are indexed because a later document may write
228
+ the expansion where the first wrote the abbreviation.
229
+ """
230
+ index: dict[str, dict] = {}
231
+ for payload in active_glossary or []:
232
+ for surface in (payload.get("term"), payload.get("full_name")):
233
+ key = normalize(surface or "")
234
+ if key:
235
+ index.setdefault(key, payload)
236
+ return index
237
+
238
+
239
+ def _reuse_approved(approved: dict, doc: ParsedDoc, cluster) -> dict:
240
+ """Carry an approved definition onto this document without paying for it.
241
+
242
+ The entry is this document's — `term_id` is re-minted from *its* `doc_id`,
243
+ because ids stay document-scoped (decision (a), 2026-09-14) and sharing one
244
+ id across documents would move every existing `entity_id` and orphan the
245
+ approvals this whole scheme protects.
246
+
247
+ **Provenance is deliberately left pointing at the document the definition
248
+ actually came from.** It would be easy to rewrite it to this document and
249
+ wrong: nothing was extracted here, so there is no span in this document to
250
+ cite, and a fabricated one is exactly the unfalsifiable claim the span check
251
+ exists to refuse. Which documents re-mention the term is what `doc_ids`
252
+ (m3) will record once it lands.
253
+ """
254
+ entry = dict(approved)
255
+ entry["term_id"] = term_id(doc.doc_id, cluster.canonical)
256
+ entry["mention_count"] = getattr(cluster, "mention_count", 0)
257
+ entry["diff_status"] = "duplicate"
258
+ return entry
259
+
260
+
261
+ def _run_glossary(doc, clustered, extractor, out, limit, approved=None) -> None:
262
  clusters = clustered.clusters[:limit] if limit else clustered.clusters
263
+ approved = approved or {}
264
  for cluster in clusters:
265
+ # i2: a term this company has already approved is not re-extracted. One
266
+ # LLM call per cluster is the whole cost of this branch, so every term
267
+ # recognised here is a call not made — and it is also what stops the
268
+ # same concept arriving as `term 1.1` and `term 1.2`.
269
+ reused = approved.get(normalize(cluster.canonical))
270
+ if reused is not None:
271
+ out.glossary.append(_reuse_approved(reused, doc, cluster))
272
+ out.reused += 1
273
+ continue
274
+
275
  entry = None
276
  max_round = rounds_available(cluster, EVIDENCE_K)
277
 
 
284
  continue
285
 
286
  source = evidence_text(
287
+ # `doc.chunks` passed so a linked partner joins the window here
288
+ # exactly as it does in the prompt (`_evidence_for`). If these two
289
+ # ever diverge, a quote from a partner chunk is nulled as
290
+ # unlocatable and the call is wasted.
291
+ top_k(cluster, EVIDENCE_K, round_index, doc.chunks), doc.chunks
292
  )
293
  entry, rejections = validate_entry(entry, "glossary", source, cluster.canonical)
294
  out.rejected.extend(rejections)
src/knowledge_extraction/settings.py CHANGED
@@ -33,28 +33,215 @@ SPAN_SCORE_THRESHOLD = 0.25
33
  # The span NER model truncates past ~384 of its own tokens and *warns rather
34
  # than failing*, so a long chunk silently loses its tail. Indonesian technical
35
  # prose subword-tokenises at roughly 2.5x, so 220-word windows still tripped the
36
- # cap; 130 does not. Chunks are fed as overlapping windows with offsets remapped.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  WINDOW_WORDS = 130
38
  WINDOW_OVERLAP = 30
39
  SPAN_TOKEN_CAP = 12
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  # ── Clustering ──────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  FUZZY_THRESHOLD = 92
43
-
44
- # Below this length only exact matching is allowed: "PA" and "UA" score highly
45
- # against each other on token_set_ratio. Over-merging is far worse than
46
- # under-merging — an under-merge costs one extra call and one extra review row,
47
- # a wrong merge destroys a distinct term and the expert never sees it.
48
  FUZZY_MIN_LEN = 5
49
 
50
  # ── Evidence ranking ────────────────────────────────────────────────────
51
- EVIDENCE_K = 3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  CUE_PROXIMITY_CHARS = 100
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  EVIDENCE_WEIGHTS: dict[str, float] = {
55
  "definitional_cue_near": 5.0,
56
  "term_in_heading": 4.0,
57
  "in_legend_block": 3.5,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  "formula_present": 2.0,
59
  "bold_or_italic": 1.5,
60
  "first_occurrence": 1.0,
@@ -75,7 +262,37 @@ TEMPERATURE = 0.0
75
  CACHE_MIN_TOKENS = 1024
76
 
77
  # ── Validation ──────────────────────────────────────────────────────────
78
- MAX_ESCALATION_ROUNDS = 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  CONFLICT_OVERLAP_THRESHOLD = 0.4
80
  DUPLICATE_OVERLAP_THRESHOLD = 0.8
81
 
 
33
  # The span NER model truncates past ~384 of its own tokens and *warns rather
34
  # than failing*, so a long chunk silently loses its tail. Indonesian technical
35
  # prose subword-tokenises at roughly 2.5x, so 220-word windows still tripped the
36
+ # cap; 130 did not. Chunks are fed as overlapping windows with offsets remapped.
37
+ #
38
+ # ⚠️ STAYS AT 130. A 2026-09-10 sweep appeared to show 256 was free — recall flat
39
+ # across 130/180/256/320 (E1 0.878 at every size) while clusters fell 6%/23%/34%
40
+ # across the three test documents. It was raised, then REVERTED the same day after
41
+ # counting the truncation directly with GLiNER's own tokeniser:
42
+ #
43
+ # windows exceeding the 384-token cap, by WINDOW_WORDS
44
+ # 130 180 200 256 320
45
+ # BUMA (ID) 1 1 1 1 1 <- pre-existing, see below
46
+ # Komatsu (EN) 0 0 0 1 10
47
+ # Open Pit (EN) 0 4 11 46 49 <- 46 of 99 windows!
48
+ #
49
+ # At 256 nearly HALF of the Open Pit textbook's windows lose their tail. The
50
+ # "flat recall" that justified the raise was measured on BUMA, the one document
51
+ # whose truncation count does not change with window size — so the metric was
52
+ # structurally blind to the cost, in exactly the way E1 was blind to the
53
+ # clustering over-merge. The cluster reduction was not better grouping; it was
54
+ # content disappearing.
55
+ #
56
+ # This vindicates the original reasoning rather than overturning it: the ~2.5x
57
+ # Indonesian subword ratio was the motivating case, but dense ENGLISH technical
58
+ # prose also trips the cap by 180. 130 is the only size measured safe on both
59
+ # English documents.
60
+ #
61
+ # ⚠️ Separate, PRE-EXISTING finding from the same measurement: BUMA has one window
62
+ # that exceeds the cap at EVERY size including 130 (409 tokens from 130 words — a
63
+ # dense Indonesian chunk). One chunk in that document has always been losing its
64
+ # tail. Not caused by this work and not fixed by it; a smaller window or a
65
+ # per-chunk token budget would be the fix, and neither is measured yet.
66
  WINDOW_WORDS = 130
67
  WINDOW_OVERLAP = 30
68
  SPAN_TOKEN_CAP = 12
69
 
70
+ # ── The per-window SUBWORD budget (fixes the pre-existing over-cap window) ──
71
+ #
72
+ # `WINDOW_WORDS` counts WORDS; the model truncates on SUBWORDS, and the ratio
73
+ # between them is a property of the text, not of the setting. Measured with
74
+ # GLiNER's own tokeniser across all three documents, 2026-09-11:
75
+ #
76
+ # windows over the 384-token cap chars/subword (min / mean)
77
+ # BUMA 31 1 1.57 / 3.43
78
+ # Komatsu 144 0 2.53 / 3.58
79
+ # Open Pit 193 0 2.79 / 4.10
80
+ #
81
+ # One window in 368 is over, and it is the one the 2026-09-10 sweep found and
82
+ # left unfixed: `STD_2026_006_MNO::0024`, 130 words and 738 characters, which
83
+ # tokenise to 409 subwords. That chunk has ALWAYS been losing its tail, at every
84
+ # window size including 130, and the model warns rather than failing so nothing
85
+ # ever surfaced it.
86
+ #
87
+ # The fix measures instead of guessing: each window is checked against the
88
+ # model's own tokeniser and split only if it does not fit. A character budget
89
+ # would have to assume the worst ratio seen anywhere (1.57) and would therefore
90
+ # split at ~600 characters — chopping up the other 367 windows, which fit
91
+ # comfortably, to fix one.
92
+ SPAN_SUBWORD_CAP = 384
93
+
94
+ # Split at 90% of the cap. The tokeniser count excludes the special tokens and
95
+ # the label prompt GLiNER prepends, so measuring exactly to 384 still truncates.
96
+ SPAN_SUBWORD_MARGIN = 0.9
97
+
98
+ # Used only when the tokeniser cannot be located on the model (a GLiNER version
99
+ # change). 1.57 is the tightest ratio measured anywhere in the corpus, so this
100
+ # over-splits rather than silently truncating — the safe direction.
101
+ SPAN_FALLBACK_CHARS_PER_TOKEN = 1.57
102
+
103
+ # A split halves and recurses. 4 takes a 130-word window down to ~8 words, which
104
+ # is past any plausible cap; the bound exists so a pathological input cannot
105
+ # recurse indefinitely.
106
+ SPAN_MAX_SPLIT_DEPTH = 4
107
+
108
+ # Flat (non-overlapping) span decoding. MEASURED AND KEPT 2026-09-10: nested
109
+ # decoding (`flat_ner=False`) was tested on all three documents on the hypothesis
110
+ # that flat mode was discarding the parent/child spans compound terms need. It
111
+ # was not. Nested added 7-12% more mentions, delivered EXACTLY ZERO recall gain
112
+ # (E1 0.878 either way, E1c identical too), and increased token-subset
113
+ # over-merges by 40-80% (BUMA 15->24, Komatsu 18->32, Open Pit 97->134).
114
+ # `span_filter` leaves this at the library default rather than passing it; this
115
+ # constant documents the decision. Do not re-open without a new measurement.
116
+ SPAN_FLAT_NER = True
117
+
118
  # ── Clustering ──────────────────────────────────────────────────────────
119
+ # ⚠️ BOTH CONSTANTS BELOW ARE RETIRED as of 2026-09-10. The fuzzy matching pass
120
+ # they configured is commented out at its call site in `cluster/cluster.py`; they
121
+ # are still imported there because the `_fuzzy_match` function itself is parked
122
+ # (comment-out-don't-delete), and it reads them. Changing them now affects
123
+ # NOTHING — the pass does not run.
124
+ #
125
+ # Why it was retired, measured across three documents: `token_set_ratio` returns
126
+ # exactly 100 when one token set is a subset of the other, so `FUZZY_THRESHOLD`
127
+ # was inert and head nouns always absorbed their compounds. 131 of 140 merges
128
+ # were subset matches (all wrong); of the 9 remaining, 6 were plain plurals —
129
+ # now handled deterministically by `normalize(lemma=True)` — and 3 were distinct
130
+ # metrics wrongly merged. Retiring the pass took subset-merges to 0 on all three
131
+ # documents and lifted post-cluster gold recall 0.8293 -> 0.8537.
132
+ #
133
+ # `FUZZY_MIN_LEN` did its own job correctly to the end: it stopped "PA" and "UA"
134
+ # colliding. It simply never gated multi-word English compounds, which is the
135
+ # case that did the damage.
136
  FUZZY_THRESHOLD = 92
 
 
 
 
 
137
  FUZZY_MIN_LEN = 5
138
 
139
  # ── Evidence ranking ────────────────────────────────────────────────────
140
+ # 3 -> 5 on 2026-09-10 (Rifqi's call). How many top-ranked chunks each extraction
141
+ # call sees. Three things this changes, recorded because none is obvious:
142
+ #
143
+ # 1. **Cost.** Evidence is the variable part of the prompt, so this is the single
144
+ # biggest lever on spend per call after cluster count. Measured effect on the
145
+ # three test documents: see CHANGELOG_v2.md. It also lowers the CACHE HIT RATE,
146
+ # because the fixed cached prefix becomes a smaller share of each prompt.
147
+ #
148
+ # 2. **Escalation gets rarer but deeper.** `rounds_available` needs more than K
149
+ # evidence chunks to fire at all, so at 5 a cluster needs 6+ (not 4+) to
150
+ # escalate; when it does, a round covers ranks 6-10 instead of 4-6. On the three
151
+ # test documents most clusters have 1-2 evidence chunks, so escalation fires
152
+ # less often than before.
153
+ #
154
+ # 3. **It is NOT supported by the ranker measurement, and that is fine.** The
155
+ # 2026-09-10 ceiling check found 16 of 16 gold definitions already at rank <=3
156
+ # (14 at rank 1), so on the BUMA standard ranks 4-5 add cost and no recall. The
157
+ # case for 5 is the two ENGLISH documents, where definitions are dispersed
158
+ # across pages and there is no gold set to measure with — a judgement that more
159
+ # context beats a cheaper call on that document class. Revisit with a number if
160
+ # a second gold set ever exists.
161
+ EVIDENCE_K = 5
162
  CUE_PROXIMITY_CHARS = 100
163
 
164
+ # How many LINKED partner chunks may be pulled into an evidence window on top of
165
+ # the ranked K (0.4.0 two-way table pointers; added 2026-09-11). 1, not more.
166
+ #
167
+ # A table keeps its own chunk, and the prose that introduces it is a separate
168
+ # chunk; `referenced_by` records the pair in both directions. Measured on the
169
+ # three v2 artifacts, ranking only (free — no GLiNER, no spend):
170
+ #
171
+ # clusters whose top-5 touches a linked pair, by side
172
+ # clusters touches a pair BOTH sides only ONE side
173
+ # BUMA 83 31 0 31
174
+ # Komatsu 93 55 0 55
175
+ # Open Pit 427 70 0 70
176
+ #
177
+ # **Zero clusters see both sides.** The ranker never co-selects a table and the
178
+ # prose that names it, so the partner is genuinely unseen content rather than a
179
+ # duplicate — and that prose is frequently the table's only title ("Troubleshooting
180
+ # of engine (S-mode)" above a bare `S-1 | 6` grid).
181
+ #
182
+ # Cost AT THE CAP OF 1, measured on the same artifacts: evidence the model reads
183
+ # +14.5% / +24.5% / +9.9%, which is +3.4k / +11.2k / +59.5k tokens, i.e.
184
+ # **+1.6% / +4.1% / +4.2%** of each document's total v2 prompt spend (the fixed
185
+ # cached prefix dominates). Open Pit moves from 71% to ~74% of the 2M ingest
186
+ # ceiling. Clusters that actually gain a partner: 31/83, 55/93, 70/427.
187
+ #
188
+ # **Expansion happens in `top_k`, NOT in `rank_evidence`.** Two invariants depend
189
+ # on that placement:
190
+ #
191
+ # 1. The full ranked list stays untouched, so nothing that reads
192
+ # `evidence_chunk_ids` — `rounds_available` included — sees a different number.
193
+ # Appending partners to the RANKED list would inflate the evidence count and
194
+ # change escalation's arithmetic as a side effect. That is currently moot
195
+ # (`MAX_ESCALATION_ROUNDS = 0` since 2026-09-11; escalation produced 0
196
+ # definitions in 48 calls) but the separation is what keeps this change
197
+ # orthogonal to that one, and to whatever replaces it.
198
+ # 2. `evidence_text` (the span check) and `evidence_block` (the prompt) both take
199
+ # their ids from `top_k`, so expanding there keeps "what the model reads is
200
+ # exactly what the span check searches" true for free. Expanding in the prompt
201
+ # renderer instead would break it, and every quote from a partner chunk would
202
+ # be nulled as unlocatable. Verified: 0 parity failures across all 603 clusters.
203
+ #
204
+ # Set to 0 to disable. Two honest scope notes:
205
+ #
206
+ # - The document this helps most (Komatsu, 59% of clusters gain a partner) is the
207
+ # one that returns 0 definitions for GENRE reasons. This is groundwork for the
208
+ # table branch more than a glossary win.
209
+ # - The counts above were measured on the v2 artifacts and the RECORDED v2 filter
210
+ # payloads, so they predate the subword window-splitting change. Mention and
211
+ # cluster counts move with that change; the pairing structure does not.
212
+ EVIDENCE_LINK_PARTNERS = 1
213
+
214
  EVIDENCE_WEIGHTS: dict[str, float] = {
215
  "definitional_cue_near": 5.0,
216
  "term_in_heading": 4.0,
217
  "in_legend_block": 3.5,
218
+ # A figure whose CAPTION names this term sits in this chunk. Built and
219
+ # measured 2026-09-11, then set to **0.0 — OFF, because it measured as a
220
+ # no-op.** The code stays because the A/B harness and the reasoning are worth
221
+ # more than the three lines, and a diagram-heavy document could revive it.
222
+ #
223
+ # It was built to explain `definitions_citing_a_caption = 0` on all three
224
+ # documents in the first paid v2 run. Two hypotheses, both now falsified:
225
+ #
226
+ # 1. "Captions don't reach the term filter." FALSE — a caption is part of
227
+ # `Chunk.text`, so GLiNER already scans it and already produces mentions
228
+ # from it (2 of 2 captions on BUMA, 3 of 8 on Open Pit).
229
+ # 2. "Figure-bearing chunks don't get selected." FALSE — a captioned chunk is
230
+ # already inside the top-5 for 22 of 83 BUMA clusters and 64 of 427 Open
231
+ # Pit clusters. Turning this weight up to 2.5 changed the top-5 of **1
232
+ # cluster out of 603** across all three documents, and moved zero clusters
233
+ # from "no figure" to "has a figure".
234
+ #
235
+ # The actual reason is simpler, and visible by reading them: **not one caption
236
+ # in the corpus is a definition.** They are figure titles — "Figure 1.3.
237
+ # Relative ability to influence costs (Lee, 1984)", "Gambar 2.1 Analisis
238
+ # Gain/Loss Berdasarkan Setiap Parameter Produksi". A model declining to quote
239
+ # one as a definition is CORRECT, not a retrieval failure.
240
+ #
241
+ # If revived, weight it BELOW `term_in_heading`: a heading naming a term means
242
+ # "this section is about X"; a caption naming it means only "a figure about X
243
+ # is here".
244
+ "asset_caption_names_term": 0.0,
245
  "formula_present": 2.0,
246
  "bold_or_italic": 1.5,
247
  "first_occurrence": 1.0,
 
262
  CACHE_MIN_TOKENS = 1024
263
 
264
  # ── Validation ──────────────────────────────────────────────────────────
265
+ # ⚠️ RETIRED 2026-09-11 by setting it to 0 — the loop and its call site are
266
+ # untouched (comment-out-don't-delete), and restoring it is this one character.
267
+ #
268
+ # Finding 6 asked for a number either way. Here it is, counted off the six
269
+ # persisted paid runs (v1 K=3 and v2 K=5, three documents):
270
+ #
271
+ # glossary calls clusters extra calls definitions FROM escalation
272
+ # BUMA v1 69 66 3 0
273
+ # BUMA v2 83 83 0 0
274
+ # Komatsu v1 94 75 19 0
275
+ # Komatsu v2 102 93 9 0
276
+ # Open Pit v1 361 349 12 0
277
+ # Open Pit v2 430 425 5 0
278
+ #
279
+ # 48 escalation calls across three documents and two configurations, and NOT ONE
280
+ # of them produced a definition the first round had missed. `extraction_status`
281
+ # is set to "escalated" only when a later round succeeds where round 0 failed,
282
+ # and it appears zero times in every one of those six files.
283
+ #
284
+ # Why it fails is worth keeping, because it argues against reviving it naively:
285
+ # escalation hands the model the NEXT k evidence chunks, ranked below the ones it
286
+ # already declined. The 2026-09-10 ranker ceiling check found 16 of 18 gold
287
+ # definitions at rank <=3 — so if the definition is anywhere, it was already in
288
+ # round 0, and ranks 6-10 are where it is least likely to be. Raising EVIDENCE_K
289
+ # to 5 made this worse in both directions at once: escalation needs 6+ evidence
290
+ # chunks to fire at all, and the rounds it does reach are even further down.
291
+ #
292
+ # What would justify reviving it: a retry that changes the QUESTION (a different
293
+ # prompt, a larger tier) rather than one that changes the evidence to lower-ranked
294
+ # evidence. That is a different feature, not this constant.
295
+ MAX_ESCALATION_ROUNDS = 0
296
  CONFLICT_OVERLAP_THRESHOLD = 0.4
297
  DUPLICATE_OVERLAP_THRESHOLD = 0.8
298
 
src/knowledge_ingest/knowledge_store.py CHANGED
@@ -34,7 +34,7 @@ import uuid
34
  from datetime import UTC, datetime
35
  from typing import Any
36
 
37
- from sqlalchemy import select
38
 
39
  from src.db.postgres.connection import AsyncSessionLocal
40
  from src.db.postgres.models import (
@@ -43,6 +43,7 @@ from src.db.postgres.models import (
43
  KnowledgeExtractionRunRow,
44
  KnowledgeReviewRow,
45
  )
 
46
  from src.middlewares.logging import get_logger
47
 
48
  logger = get_logger("knowledge_store")
@@ -62,6 +63,84 @@ _ENTRY_KINDS: dict[str, tuple[str, str]] = {
62
  }
63
 
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  def _label(payload: dict[str, Any], key: str) -> str | None:
66
  """The listing label, trimmed. A rule's `statement` can be a paragraph, and
67
  a column meant for scanning a queue should not carry one."""
@@ -95,6 +174,43 @@ class KnowledgeStore:
95
  )
96
  return result.scalars().first()
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  async def save_document(
99
  self,
100
  artifact: Any,
@@ -120,9 +236,8 @@ class KnowledgeStore:
120
  )
121
  doc_id = payload["doc_id"]
122
  content_hash = payload["content_hash"]
123
- version = int(payload.get("version", 1))
124
 
125
- existing = await self.find_document(doc_id, content_hash, version)
126
  if existing is not None:
127
  logger.info(
128
  "knowledge_document_reused",
@@ -130,6 +245,20 @@ class KnowledgeStore:
130
  )
131
  return existing
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  row = KnowledgeDocumentRow(
134
  id=str(uuid.uuid4()),
135
  doc_id=doc_id,
@@ -262,6 +391,10 @@ class KnowledgeStore:
262
  # so, hence the warning and the count on the run row.
263
  seen: set[tuple[str, str]] = set()
264
  collisions: list[str] = []
 
 
 
 
265
  for kind, payloads in entries_by_kind.items():
266
  if kind not in _ENTRY_KINDS:
267
  raise ValueError(f"unknown entry kind {kind!r}")
@@ -277,6 +410,7 @@ class KnowledgeStore:
277
  collisions.append(f"{kind}:{entity_id}")
278
  continue
279
  seen.add((kind, entity_id))
 
280
  rows.append(
281
  KnowledgeEntryRow(
282
  id=str(uuid.uuid4()),
@@ -287,7 +421,19 @@ class KnowledgeStore:
287
  user_id=user_id,
288
  kind=kind,
289
  entity_id=entity_id,
290
- label=_label(payload, label_field),
 
 
 
 
 
 
 
 
 
 
 
 
291
  mention_count=int(payload.get("mention_count") or 0),
292
  extraction_status=payload.get("extraction_status"),
293
  diff_status=payload.get("diff_status"),
@@ -295,6 +441,12 @@ class KnowledgeStore:
295
  payload.get("definition_conflict", False)
296
  ),
297
  payload=payload,
 
 
 
 
 
 
298
  )
299
  )
300
 
@@ -349,6 +501,188 @@ class KnowledgeStore:
349
  result = await session.execute(stmt)
350
  return list(result.scalars().all())
351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  async def latest_run_for(
353
  self, scope_id: str, doc_id: str
354
  ) -> KnowledgeExtractionRunRow | None:
@@ -435,6 +769,8 @@ class KnowledgeStore:
435
  reviewer_id: str,
436
  edited_payload: dict | None = None,
437
  note: str | None = None,
 
 
438
  ) -> KnowledgeReviewRow:
439
  """Record one expert decision. APPEND-ONLY.
440
 
@@ -468,6 +804,8 @@ class KnowledgeStore:
468
  edited_payload=edited_payload,
469
  note=note,
470
  reviewer_id=reviewer_id,
 
 
471
  )
472
  session.add(row)
473
  await session.commit()
@@ -478,11 +816,65 @@ class KnowledgeStore:
478
  extra={
479
  "entity_id": entity_id,
480
  "decision": decision,
 
481
  "content_hash": content_hash[:12],
482
  },
483
  )
484
  return row
485
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  async def latest_reviews(
487
  self, scope_id: str, entity_ids: list[str] | None = None
488
  ) -> dict[str, KnowledgeReviewRow]:
@@ -513,6 +905,33 @@ class KnowledgeStore:
513
  is worked. An `edited` decision contributes the expert's corrected
514
  payload, not the model's original claim.
515
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  reviews = await self.latest_reviews(scope_id)
517
  approved = {
518
  entity_id: review
@@ -520,31 +939,69 @@ class KnowledgeStore:
520
  if review.decision in ("approved", "edited")
521
  }
522
  if not approved:
523
- return []
524
 
 
525
  async with AsyncSessionLocal() as session:
526
  result = await session.execute(
527
  select(KnowledgeEntryRow).where(
528
  KnowledgeEntryRow.scope_id == scope_id,
529
- KnowledgeEntryRow.kind == "glossary",
530
  KnowledgeEntryRow.entity_id.in_(list(approved)),
531
  )
532
  )
533
  rows = list(result.scalars().all())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
 
535
  out: list[dict] = []
 
536
  seen: set[str] = set()
537
  for row in sorted(rows, key=lambda r: r.created_at or 0, reverse=True):
538
  if row.entity_id in seen:
539
  continue
540
  seen.add(row.entity_id)
 
 
 
 
 
 
541
  review = approved[row.entity_id]
542
- out.append(
543
- review.edited_payload
544
- if review.decision == "edited" and review.edited_payload
545
- else row.payload
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  )
547
- return out
548
 
549
  async def save_declaration(
550
  self, *, scope_id: str, user_id: str, payload: dict
@@ -588,7 +1045,11 @@ class KnowledgeStore:
588
  entity_id=entity_id,
589
  )
590
  session.add(row)
591
- row.label = _label(payload, "domain_name")
 
 
 
 
592
  row.payload = payload
593
  row.extraction_status = "ok"
594
  await session.commit()
 
34
  from datetime import UTC, datetime
35
  from typing import Any
36
 
37
+ from sqlalchemy import select, update
38
 
39
  from src.db.postgres.connection import AsyncSessionLocal
40
  from src.db.postgres.models import (
 
43
  KnowledgeExtractionRunRow,
44
  KnowledgeReviewRow,
45
  )
46
+ from src.knowledge_extraction import keys, mutation
47
  from src.middlewares.logging import get_logger
48
 
49
  logger = get_logger("knowledge_store")
 
63
  }
64
 
65
 
66
+ # m4: the body a reviewer or a consumer actually wants, per kind. First
67
+ # non-empty wins, so a kind whose primary field abstained still promotes
68
+ # something rather than silently leaving the column null.
69
+ _CONTENT_FIELDS: dict[str, tuple[str, ...]] = {
70
+ "glossary": ("definition",),
71
+ "rule": ("statement", "consequence"),
72
+ "formula": ("formula_latex", "name"),
73
+ "document": ("purpose_verbatim", "title"),
74
+ "domain": ("purpose_verbatim", "domain_name"),
75
+ }
76
+
77
+ # m5: the provenance keys worth lifting out. `page` is 0-based as the parser
78
+ # reports it and `page_no` is the 1-based number a human reads; both travel
79
+ # because they are derived from one value and cannot disagree.
80
+ _PROVENANCE_KEYS = ("doc_id", "page", "page_no", "section_no", "chunk_id")
81
+
82
+
83
+ def _content(kind: str, payload: dict[str, Any]) -> str | None:
84
+ """m4: the entry's body, promoted out of `payload`."""
85
+ for field in _CONTENT_FIELDS.get(kind, ()):
86
+ value = payload.get(field)
87
+ if isinstance(value, str) and value.strip():
88
+ return value.strip()
89
+ return None
90
+
91
+
92
+ def _provenance(payload: dict[str, Any]) -> list[dict]:
93
+ """m5: provenance as a LIST, so a fused entry can carry several sources.
94
+
95
+ The extraction contract emits a single object per entry today; it is
96
+ wrapped rather than reshaped, because the list is what an entry drawn from
97
+ more than one document needs and normalising the shape here means every
98
+ consumer reads one thing.
99
+ """
100
+ raw = payload.get("provenance")
101
+ if isinstance(raw, dict):
102
+ raw = [raw]
103
+ if not isinstance(raw, list):
104
+ return []
105
+ out: list[dict] = []
106
+ for item in raw:
107
+ if not isinstance(item, dict):
108
+ continue
109
+ kept = {k: item[k] for k in _PROVENANCE_KEYS if item.get(k) is not None}
110
+ if kept:
111
+ out.append(kept)
112
+ return out
113
+
114
+
115
+ def _doc_ids(doc_id: str, provenance: list[dict]) -> list[str]:
116
+ """m3: every document behind this entry, this one first.
117
+
118
+ Derived rather than declared, so it cannot drift from the provenance it
119
+ summarises. For an ordinary entry it is `[doc_id]`; it grows only when a
120
+ definition was carried forward from an earlier document that a later one
121
+ re-mentions (i2), which is the fusion case m3 was asked for.
122
+ """
123
+ ids = [doc_id]
124
+ for item in provenance:
125
+ source = item.get("doc_id")
126
+ if isinstance(source, str) and source and source not in ids:
127
+ ids.append(source)
128
+ return ids
129
+
130
+
131
+ def _has_live_source(row: Any, withdrawn: set[str]) -> bool:
132
+ """Does this entry still rest on a document that has not been withdrawn? (i7)
133
+
134
+ Reads `doc_ids` when it is populated and falls back to `doc_id` when it is
135
+ not, so rows written before m3 landed keep answering correctly instead of
136
+ all looking orphaned at once.
137
+ """
138
+ sources = [d for d in (getattr(row, "doc_ids", None) or []) if isinstance(d, str)]
139
+ if not sources:
140
+ sources = [row.doc_id]
141
+ return any(source not in withdrawn for source in sources)
142
+
143
+
144
  def _label(payload: dict[str, Any], key: str) -> str | None:
145
  """The listing label, trimmed. A rule's `statement` can be a paragraph, and
146
  a column meant for scanning a queue should not carry one."""
 
174
  )
175
  return result.scalars().first()
176
 
177
+ async def find_document_by_hash(
178
+ self, doc_id: str, content_hash: str
179
+ ) -> KnowledgeDocumentRow | None:
180
+ """The same content-addressing lookup, across every version (i5).
181
+
182
+ Version is assigned by us now, not read off the artifact, so the reuse
183
+ check cannot key on it: computing the next version and *then* looking
184
+ it up would miss the row that already holds these exact bytes and
185
+ re-parse a document we already have — turning the idempotency guarantee
186
+ into a paid no-op.
187
+ """
188
+ async with AsyncSessionLocal() as session:
189
+ result = await session.execute(
190
+ select(KnowledgeDocumentRow)
191
+ .where(
192
+ KnowledgeDocumentRow.doc_id == doc_id,
193
+ KnowledgeDocumentRow.content_hash == content_hash,
194
+ )
195
+ .order_by(KnowledgeDocumentRow.version.desc())
196
+ )
197
+ return result.scalars().first()
198
+
199
+ async def latest_document(self, doc_id: str) -> KnowledgeDocumentRow | None:
200
+ """The current version of a document — the highest `version` (i5).
201
+
202
+ What "current" means for a document that has been amended: the newest
203
+ version supersedes, and the older rows stay for the audit trail rather
204
+ than being rewritten.
205
+ """
206
+ async with AsyncSessionLocal() as session:
207
+ result = await session.execute(
208
+ select(KnowledgeDocumentRow)
209
+ .where(KnowledgeDocumentRow.doc_id == doc_id)
210
+ .order_by(KnowledgeDocumentRow.version.desc())
211
+ )
212
+ return result.scalars().first()
213
+
214
  async def save_document(
215
  self,
216
  artifact: Any,
 
236
  )
237
  doc_id = payload["doc_id"]
238
  content_hash = payload["content_hash"]
 
239
 
240
+ existing = await self.find_document_by_hash(doc_id, content_hash)
241
  if existing is not None:
242
  logger.info(
243
  "knowledge_document_reused",
 
245
  )
246
  return existing
247
 
248
+ # i5 (ruling 2026-09-14): identity is `doc_id`, so the same document
249
+ # arriving with different bytes is a NEW VERSION of it, never a new
250
+ # document. That is what keeps an amended file's approvals attached —
251
+ # `entity_id` derives from `doc_id`, which does not move. The artifact's
252
+ # own `version` is ignored: the parsing half stamps 1 on every parse and
253
+ # has no way to know what this tenant already holds.
254
+ previous = await self.latest_document(doc_id)
255
+ version = (previous.version + 1) if previous else 1
256
+ if previous is not None:
257
+ logger.info(
258
+ "knowledge_document_versioned",
259
+ extra={"doc_id": doc_id, "version": version, "supersedes": previous.id},
260
+ )
261
+
262
  row = KnowledgeDocumentRow(
263
  id=str(uuid.uuid4()),
264
  doc_id=doc_id,
 
391
  # so, hence the warning and the count on the run row.
392
  seen: set[tuple[str, str]] = set()
393
  collisions: list[str] = []
394
+ # m1/m6: `label` carries the readable `TYPE_SUBJECT` key. Rules key off
395
+ # the term they govern, and a rule payload holds `term_ids` rather than
396
+ # words, so the surfaces are collected from this run's glossary first.
397
+ surfaces = keys.term_surfaces(entries_by_kind.get("glossary"))
398
  for kind, payloads in entries_by_kind.items():
399
  if kind not in _ENTRY_KINDS:
400
  raise ValueError(f"unknown entry kind {kind!r}")
 
410
  collisions.append(f"{kind}:{entity_id}")
411
  continue
412
  seen.add((kind, entity_id))
413
+ prov = _provenance(payload)
414
  rows.append(
415
  KnowledgeEntryRow(
416
  id=str(uuid.uuid4()),
 
421
  user_id=user_id,
422
  kind=kind,
423
  entity_id=entity_id,
424
+ # m6 (2026-09-08): the key, not the term. The readable
425
+ # term is still in `payload`, so nothing is lost to a
426
+ # reader — but the column an agent searches now holds
427
+ # something it can type from memory. `_label` remains
428
+ # the fallback for a kind with no registered prefix.
429
+ label=keys.concept_key(
430
+ kind,
431
+ payload,
432
+ entity_id=entity_id,
433
+ doc_id=doc_id,
434
+ surfaces=surfaces,
435
+ )
436
+ or _label(payload, label_field),
437
  mention_count=int(payload.get("mention_count") or 0),
438
  extraction_status=payload.get("extraction_status"),
439
  diff_status=payload.get("diff_status"),
 
441
  payload.get("definition_conflict", False)
442
  ),
443
  payload=payload,
444
+ # m3/m4/m5: promoted from the SAME object in the SAME
445
+ # insert, so the column and the jsonb can never
446
+ # disagree about what this entry says.
447
+ content=_content(kind, payload),
448
+ provenance=prov,
449
+ doc_ids=_doc_ids(doc_id, prov),
450
  )
451
  )
452
 
 
501
  result = await session.execute(stmt)
502
  return list(result.scalars().all())
503
 
504
+ async def get_by_key(
505
+ self, scope_id: str, key: str
506
+ ) -> KnowledgeEntryRow | None:
507
+ """One entry by its readable key (m8) — `GLOSSARY_PA`, not a search.
508
+
509
+ This is the lookup the 2026-09-08 discussion asked for: an agent that
510
+ meets a term it does not know writes the key and hits the row. **No
511
+ model, no embedding, no scan of payloads** — the whole reason the key
512
+ exists is that finding a glossary term should not cost a round trip to
513
+ a language model.
514
+
515
+ Keys are uppercased on the way in so a caller that types one from
516
+ memory in lower case still lands, matching how they are minted.
517
+
518
+ Note for whoever adds the index: this filters on `label`, which has no
519
+ index today, so Postgres scans within the tenant. Correct, and fine at
520
+ the current corpus size — but it is an index away from being the O(1)
521
+ the design intends, and that index rides along with the m3/m4/m5 DDL.
522
+ """
523
+ stmt = (
524
+ select(KnowledgeEntryRow)
525
+ .where(KnowledgeEntryRow.scope_id == scope_id)
526
+ .where(KnowledgeEntryRow.label == (key or "").strip().upper())
527
+ .order_by(KnowledgeEntryRow.created_at.desc())
528
+ .limit(1)
529
+ )
530
+ async with AsyncSessionLocal() as session:
531
+ result = await session.execute(stmt)
532
+ return result.scalars().first()
533
+
534
+ async def glossary_index(
535
+ self, scope_id: str, *, limit: int = 1000
536
+ ) -> dict[str, dict]:
537
+ """The company's glossary as a dictionary keyed by concept key (m8).
538
+
539
+ `{"GLOSSARY_PA": {...payload...}, "GLOSSARY_QTY": {...}}` — the shape
540
+ Mas asked for when he said the glossary should be key-value rather than
541
+ a list. Searching a list is O(N) and searching this is O(1); it is also
542
+ the shape the JSON export writes, so the file on disk and the lookup in
543
+ memory agree with each other rather than drifting.
544
+
545
+ A row whose key could not be built is skipped rather than given a
546
+ made-up one: a glossary you cannot trust the keys of is worse than a
547
+ smaller glossary.
548
+ """
549
+ rows = await self.list_entries(
550
+ scope_id=scope_id, kind="glossary", limit=limit
551
+ )
552
+ index: dict[str, dict] = {}
553
+ for row in rows:
554
+ if row.label and isinstance(row.payload, dict):
555
+ index.setdefault(row.label, row.payload)
556
+ return index
557
+
558
+ async def review_diff(
559
+ self, scope_id: str, entity_id: str, kind: str = "glossary"
560
+ ) -> dict | None:
561
+ """Before / after for one entry, for the review screen (r4).
562
+
563
+ Returns None when there is nothing to compare — no decision on record,
564
+ or the entry is gone. `changed` is empty when the approval still
565
+ stands, which is the common case and the one worth making cheap: the
566
+ screen can say "nothing to re-read" instead of showing two identical
567
+ panes.
568
+
569
+ `before` is what the expert had in front of them; for an `edited`
570
+ decision that is their own corrected text, not the model's claim.
571
+ """
572
+ reviews = await self.latest_reviews(scope_id, [entity_id])
573
+ review = reviews.get(entity_id)
574
+ if review is None:
575
+ return None
576
+
577
+ rows = await self.list_entries(scope_id=scope_id, kind=kind, limit=1000)
578
+ current = next(
579
+ (r for r in sorted(rows, key=lambda r: r.created_at or 0, reverse=True)
580
+ if r.entity_id == entity_id),
581
+ None,
582
+ )
583
+ if current is None:
584
+ return None
585
+
586
+ if review.decision == "edited" and review.edited_payload:
587
+ before = review.edited_payload
588
+ else:
589
+ async with AsyncSessionLocal() as session:
590
+ result = await session.execute(
591
+ select(KnowledgeEntryRow).where(
592
+ KnowledgeEntryRow.id == review.entry_id
593
+ )
594
+ )
595
+ reviewed_row = result.scalars().first()
596
+ before = reviewed_row.payload if reviewed_row else None
597
+
598
+ changed = mutation.changed_fields(kind, before, current.payload)
599
+ return {
600
+ "entity_id": entity_id,
601
+ "kind": kind,
602
+ "decision": review.decision,
603
+ "decided_at": review.created_at,
604
+ "changed": changed,
605
+ "status": "waiting_approval" if changed else review.decision,
606
+ "before": mutation.ruled_content(kind, before),
607
+ "after": mutation.ruled_content(kind, current.payload),
608
+ }
609
+
610
+ async def withdraw_document(self, scope_id: str, doc_id: str) -> int:
611
+ """Mark every version of a document withdrawn (i7). Returns how many.
612
+
613
+ **Nothing is deleted**, here or downstream. The entries the document
614
+ produced stay exactly where they are, and so do the reviews attached to
615
+ them — an approval is an act someone took, and destroying the record of
616
+ what it referred to would destroy the audit trail that makes the
617
+ approval mean anything.
618
+
619
+ What withdrawal *does* change is what the knowledge base will serve: a
620
+ withdrawn document stops feeding the approved baseline, so the pipeline
621
+ cannot keep answering from a standard the client has taken back. That
622
+ was the concern behind the whole discussion — never serve knowledge the
623
+ source no longer supports.
624
+ """
625
+ async with AsyncSessionLocal() as session:
626
+ result = await session.execute(
627
+ update(KnowledgeDocumentRow)
628
+ .where(
629
+ KnowledgeDocumentRow.scope_id == scope_id,
630
+ KnowledgeDocumentRow.doc_id == doc_id,
631
+ KnowledgeDocumentRow.withdrawn_at.is_(None),
632
+ )
633
+ .values(withdrawn_at=datetime.now(UTC))
634
+ )
635
+ await session.commit()
636
+ count = result.rowcount or 0
637
+ logger.info(
638
+ "knowledge_document_withdrawn",
639
+ extra={"scope_id": scope_id, "doc_id": doc_id, "versions": count},
640
+ )
641
+ return count
642
+
643
+ async def withdrawn_doc_ids(self, scope_id: str) -> set[str]:
644
+ """Documents this company has taken back."""
645
+ async with AsyncSessionLocal() as session:
646
+ result = await session.execute(
647
+ select(KnowledgeDocumentRow.doc_id).where(
648
+ KnowledgeDocumentRow.scope_id == scope_id,
649
+ KnowledgeDocumentRow.withdrawn_at.is_not(None),
650
+ )
651
+ )
652
+ return {r for (r,) in result.all() if r}
653
+
654
+ async def orphaned_entities(self, scope_id: str, kind: str = "glossary") -> set[str]:
655
+ """Entries whose every source document has been withdrawn (i7).
656
+
657
+ Surfaced for the expert to rule on rather than deleted: whether the
658
+ knowledge leaves with the document is their judgement, not the
659
+ pipeline's. A term defined in a withdrawn standard may well still be
660
+ the company's term.
661
+ """
662
+ withdrawn = await self.withdrawn_doc_ids(scope_id)
663
+ if not withdrawn:
664
+ return set()
665
+ rows = await self.list_entries(scope_id=scope_id, kind=kind, limit=1000)
666
+ return {r.entity_id for r in rows if not _has_live_source(r, withdrawn)}
667
+
668
+ async def impacted_by_document(
669
+ self, scope_id: str, doc_id: str, limit: int = 1000
670
+ ) -> list[KnowledgeEntryRow]:
671
+ """Which entries a document contributed to — the impact map (i4).
672
+
673
+ This is what makes a re-ingest proportionate: a document is amended,
674
+ these are the entries that can possibly have moved, and **only these**
675
+ go back to the reviewer. Everything else the company has approved is
676
+ untouched and stays untouched.
677
+
678
+ No separate lineage structure is needed for it — the contribution is
679
+ already recorded on the entry. Once `doc_ids` lands (m3) this also has
680
+ to match membership in that list, for entries fused from several
681
+ documents; today every entry has exactly one source, so the single
682
+ column is the whole answer.
683
+ """
684
+ return await self.list_entries(scope_id=scope_id, doc_id=doc_id, limit=limit)
685
+
686
  async def latest_run_for(
687
  self, scope_id: str, doc_id: str
688
  ) -> KnowledgeExtractionRunRow | None:
 
769
  reviewer_id: str,
770
  edited_payload: dict | None = None,
771
  note: str | None = None,
772
+ decided_by: str = "human",
773
+ authorised_by: str | None = None,
774
  ) -> KnowledgeReviewRow:
775
  """Record one expert decision. APPEND-ONLY.
776
 
 
804
  edited_payload=edited_payload,
805
  note=note,
806
  reviewer_id=reviewer_id,
807
+ decided_by=decided_by,
808
+ authorised_by=authorised_by,
809
  )
810
  session.add(row)
811
  await session.commit()
 
816
  extra={
817
  "entity_id": entity_id,
818
  "decision": decision,
819
+ "decided_by": decided_by,
820
  "content_hash": content_hash[:12],
821
  },
822
  )
823
  return row
824
 
825
+ async def auto_approve_run(
826
+ self, *, run_id: str, scope_id: str, authorised_by: str
827
+ ) -> int:
828
+ """Approve every entry a run produced, on a named person's authority (r1).
829
+
830
+ This is what `review_mode="llm"` does, and it is worth being plain about
831
+ what it is: **no model re-examines anything.** The entries were written
832
+ by a model already; choosing this mode means accepting them without a
833
+ human looking. There is no second opinion in here.
834
+
835
+ What makes it offerable rather than reckless is the record. Every row
836
+ carries `decided_by="llm"` and `authorised_by=<the person who chose
837
+ it>`, so an entry approved this way can always answer "who allowed
838
+ this?" — which was the entire point when it was agreed: if something
839
+ goes wrong it was their choice, made knowingly, and the system can show
840
+ whose.
841
+
842
+ Written through the ordinary append-only review path, so an expert can
843
+ later disagree with any of it exactly as they would with each other.
844
+ """
845
+ rows = await self.list_entries(scope_id=scope_id, run_id=run_id, limit=1000)
846
+ approved = 0
847
+ for row in rows:
848
+ try:
849
+ await self.save_review(
850
+ entity_id=row.entity_id,
851
+ scope_id=scope_id,
852
+ decision="approved",
853
+ reviewer_id="llm",
854
+ decided_by="llm",
855
+ authorised_by=authorised_by,
856
+ note=f"auto-approved (review_mode=llm) on behalf of {authorised_by}",
857
+ )
858
+ approved += 1
859
+ except LookupError:
860
+ # The entry vanished between listing and reviewing. Not fatal:
861
+ # an un-approved entry is a reviewable one, which is the safe
862
+ # direction to fail in.
863
+ logger.warning(
864
+ "knowledge_auto_approve_skipped",
865
+ extra={"entity_id": row.entity_id, "run_id": run_id},
866
+ )
867
+ logger.info(
868
+ "knowledge_run_auto_approved",
869
+ extra={
870
+ "run_id": run_id,
871
+ "scope_id": scope_id,
872
+ "approved": approved,
873
+ "authorised_by": authorised_by,
874
+ },
875
+ )
876
+ return approved
877
+
878
  async def latest_reviews(
879
  self, scope_id: str, entity_ids: list[str] | None = None
880
  ) -> dict[str, KnowledgeReviewRow]:
 
905
  is worked. An `edited` decision contributes the expert's corrected
906
  payload, not the model's original claim.
907
  """
908
+ fresh, _stale = await self._approval_state(scope_id, "glossary")
909
+ return fresh
910
+
911
+ async def stale_approvals(
912
+ self, scope_id: str, kind: str = "glossary"
913
+ ) -> set[str]:
914
+ """Entity ids whose approval no longer stands — 'termutasi' (i3).
915
+
916
+ A later document changed the content the expert ruled on, so the entry
917
+ is back to waiting approval and must not feed the baseline in the
918
+ meantime. What counts as changed is `mutation.RULED_FIELDS`, and it is
919
+ deliberately narrow — see that module for why a re-mention must not
920
+ re-open anything.
921
+ """
922
+ _fresh, stale = await self._approval_state(scope_id, kind)
923
+ return stale
924
+
925
+ async def _approval_state(
926
+ self, scope_id: str, kind: str
927
+ ) -> tuple[list[dict], set[str]]:
928
+ """Split approved entries into those that still stand and those that do not.
929
+
930
+ One pass, because the baseline and the status display must never
931
+ disagree about whether an approval is current — the same defect as
932
+ having two notions of readiness, which is why `readiness.py` shares one
933
+ predicate between the report API and Help.
934
+ """
935
  reviews = await self.latest_reviews(scope_id)
936
  approved = {
937
  entity_id: review
 
939
  if review.decision in ("approved", "edited")
940
  }
941
  if not approved:
942
+ return [], set()
943
 
944
+ reviewed_ids = [r.entry_id for r in approved.values() if r.entry_id]
945
  async with AsyncSessionLocal() as session:
946
  result = await session.execute(
947
  select(KnowledgeEntryRow).where(
948
  KnowledgeEntryRow.scope_id == scope_id,
949
+ KnowledgeEntryRow.kind == kind,
950
  KnowledgeEntryRow.entity_id.in_(list(approved)),
951
  )
952
  )
953
  rows = list(result.scalars().all())
954
+ # The exact rows the decisions were made against. Without these the
955
+ # only available comparison is document-level `content_hash`, which
956
+ # flips every entry in an edited document including the untouched
957
+ # ones — the outcome the i3 ruling rejected.
958
+ reviewed_rows: dict[str, KnowledgeEntryRow] = {}
959
+ if reviewed_ids:
960
+ reviewed = await session.execute(
961
+ select(KnowledgeEntryRow).where(
962
+ KnowledgeEntryRow.id.in_(reviewed_ids)
963
+ )
964
+ )
965
+ reviewed_rows = {r.id: r for r in reviewed.scalars().all()}
966
+
967
+ withdrawn = await self.withdrawn_doc_ids(scope_id)
968
 
969
  out: list[dict] = []
970
+ stale: set[str] = set()
971
  seen: set[str] = set()
972
  for row in sorted(rows, key=lambda r: r.created_at or 0, reverse=True):
973
  if row.entity_id in seen:
974
  continue
975
  seen.add(row.entity_id)
976
+ # i7: a withdrawn source stops feeding the baseline, approval or
977
+ # not. The approval itself is untouched and the rows stay — this
978
+ # only stops the pipeline answering from a document the client has
979
+ # taken back.
980
+ if withdrawn and not _has_live_source(row, withdrawn):
981
+ continue
982
  review = approved[row.entity_id]
983
+
984
+ if review.decision == "edited" and review.edited_payload:
985
+ # An edit is a stronger statement than an approval: the expert
986
+ # wrote the text themselves, so a later run producing different
987
+ # model output does not unsettle it. Their payload IS the entry.
988
+ out.append(review.edited_payload)
989
+ continue
990
+
991
+ ruled = reviewed_rows.get(review.entry_id)
992
+ if ruled is not None and mutation.has_mutated(
993
+ kind, ruled.payload, row.payload
994
+ ):
995
+ stale.add(row.entity_id)
996
+ continue
997
+ out.append(row.payload)
998
+
999
+ if stale:
1000
+ logger.info(
1001
+ "knowledge_approvals_stale",
1002
+ extra={"scope_id": scope_id, "kind": kind, "count": len(stale)},
1003
  )
1004
+ return out, stale
1005
 
1006
  async def save_declaration(
1007
  self, *, scope_id: str, user_id: str, payload: dict
 
1045
  entity_id=entity_id,
1046
  )
1047
  session.add(row)
1048
+ # m6: the declaration is keyed like every other entry, so one
1049
+ # lookup path covers all five kinds.
1050
+ row.label = keys.concept_key(
1051
+ "domain", payload, entity_id=entity_id
1052
+ ) or _label(payload, "domain_name")
1053
  row.payload = payload
1054
  row.extraction_status = "ok"
1055
  await session.commit()
src/knowledge_ingest/models.py CHANGED
@@ -35,6 +35,15 @@ JobStage = Literal["parsing", "filtering", "estimating", "extracting", "persisti
35
 
36
  DocumentStatus = Literal["pending", "succeeded", "failed", "skipped"]
37
 
 
 
 
 
 
 
 
 
 
38
 
39
  def _now() -> datetime:
40
  return datetime.now(UTC)
@@ -55,6 +64,18 @@ class DocumentResult(BaseModel):
55
  n_entries: int = 0
56
  error: str | None = None
57
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  class SkippedDocument(BaseModel):
60
  """A document the batch declined to take, and why.
@@ -77,6 +98,7 @@ class IngestJob(BaseModel):
77
  user_id: str
78
  status: JobStatus = "queued"
79
  stage: JobStage | None = None
 
80
  document_ids: list[str] = Field(default_factory=list)
81
  n_requested: int = 0
82
  n_succeeded: int = 0
@@ -102,10 +124,17 @@ class IngestJob(BaseModel):
102
  """
103
  self.n_succeeded = sum(1 for r in self.results if r.status == "succeeded")
104
  self.n_failed = sum(1 for r in self.results if r.status == "failed")
 
105
  if self.n_succeeded and self.n_failed:
106
  self.status = "partial"
107
  elif self.n_succeeded:
108
  self.status = "succeeded"
 
 
 
 
 
 
109
  else:
110
  self.status = "failed"
111
  self.stage = None
 
35
 
36
  DocumentStatus = Literal["pending", "succeeded", "failed", "skipped"]
37
 
38
+ # r1 (2026-09-08 discussion). Chosen by the caller at ingest time.
39
+ # "expert" - entries wait for a human to rule on them.
40
+ # "llm" - entries go straight to approved, with NO reviewer step at all.
41
+ # The second is not a second model call: the entries were produced by a model
42
+ # already, so "review by LLM" means accepting them without human review. What
43
+ # makes it safe to offer is that the decision is RECORDED against the person
44
+ # who chose it (r2/r3), not that the pipeline checks itself.
45
+ ReviewMode = Literal["expert", "llm"]
46
+
47
 
48
  def _now() -> datetime:
49
  return datetime.now(UTC)
 
64
  n_entries: int = 0
65
  error: str | None = None
66
 
67
+ # The pre-flight estimate, recorded whether or not the document was
68
+ # refused. An operator cannot plan a batch from a number they only ever see
69
+ # when it is already too late, and "why was this one refused when that one
70
+ # ran" is not answerable without the shape of a run that succeeded.
71
+ estimate: dict | None = None
72
+
73
+ # Which ceiling refused this document (`clusters` / `prompt_tokens`), or
74
+ # None if it was not refused. Distinguishes "declined before spending" from
75
+ # every other failure, which is what lets a wholly-refused batch settle
76
+ # `blocked` rather than `failed`.
77
+ blocked_by: str | None = None
78
+
79
 
80
  class SkippedDocument(BaseModel):
81
  """A document the batch declined to take, and why.
 
98
  user_id: str
99
  status: JobStatus = "queued"
100
  stage: JobStage | None = None
101
+ review_mode: ReviewMode = "expert"
102
  document_ids: list[str] = Field(default_factory=list)
103
  n_requested: int = 0
104
  n_succeeded: int = 0
 
124
  """
125
  self.n_succeeded = sum(1 for r in self.results if r.status == "succeeded")
126
  self.n_failed = sum(1 for r in self.results if r.status == "failed")
127
+ failures = [r for r in self.results if r.status == "failed"]
128
  if self.n_succeeded and self.n_failed:
129
  self.status = "partial"
130
  elif self.n_succeeded:
131
  self.status = "succeeded"
132
+ elif failures and all(r.blocked_by for r in failures):
133
+ # `blocked` was documented from the start and was unreachable until
134
+ # 2026-09-11: a spend refusal settled as `failed`, which is the one
135
+ # outcome an operator can act on (raise the ceiling, or split the
136
+ # document) hidden inside the one they cannot.
137
+ self.status = "blocked"
138
  else:
139
  self.status = "failed"
140
  self.stage = None
src/knowledge_ingest/pipeline.py CHANGED
@@ -36,6 +36,7 @@ from functools import partial
36
  from pathlib import Path
37
 
38
  from src.db.postgres.models import Document
 
39
  from src.knowledge_extraction.service import (
40
  build_clusters,
41
  estimate_cost,
@@ -54,6 +55,45 @@ logger = get_logger("knowledge_pipeline")
54
  # accident without getting in the way of anything real.
55
  MAX_PROMPT_TOKENS = 2_000_000
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  # Extraction is synchronous and blocks for ~170 s on nine pages, so it cannot run
58
  # on the event loop. Its own pool, following the #49 `dbexec` precedent: sharing
59
  # the default `to_thread` pool means a long parse competes with every other
@@ -75,16 +115,42 @@ BRANCHES = ("glossary", "rule", "formula", "summary")
75
 
76
 
77
  class SpendCeilingExceededError(RuntimeError):
78
- """The pre-flight estimate exceeded the ceiling; nothing was spent."""
79
 
80
- def __init__(self, estimate: dict) -> None:
 
 
 
 
 
 
81
  self.estimate = estimate
 
 
 
 
 
 
 
 
 
 
82
  super().__init__(
83
- f"estimated {estimate.get('estimated_prompt_tokens'):,} prompt tokens "
84
- f"exceeds the ceiling of {MAX_PROMPT_TOKENS:,}"
 
85
  )
86
 
87
 
 
 
 
 
 
 
 
 
 
88
  def _extractor():
89
  """The Azure client the extraction branches call.
90
 
@@ -121,6 +187,12 @@ class KnowledgePipeline:
121
 
122
  def __init__(self, store: KnowledgeStore | None = None) -> None:
123
  self._store = store or KnowledgeStore()
 
 
 
 
 
 
124
 
125
  async def run_document(
126
  self, document: Document, scope_id: str, user_id: str
@@ -151,10 +223,18 @@ class KnowledgePipeline:
151
  except SpendCeilingExceededError as e:
152
  logger.warning(
153
  "knowledge_spend_ceiling",
154
- extra={"document_id": document.id, "estimate": e.estimate},
 
 
 
 
 
 
155
  )
156
  result.status = "failed"
157
  result.error = str(e)
 
 
158
  return result
159
  except Exception as e:
160
  logger.error(
@@ -167,6 +247,7 @@ class KnowledgePipeline:
167
 
168
  result.run_id = run_id
169
  result.n_entries = n_entries
 
170
  result.status = "succeeded"
171
  return result
172
 
@@ -229,8 +310,56 @@ class KnowledgePipeline:
229
  clustered = await loop.run_in_executor(_EXECUTOR, build_clusters, doc, filtered)
230
 
231
  estimate = estimate_cost(doc, clustered, filtered)
232
- if (estimate.get("estimated_prompt_tokens") or 0) > MAX_PROMPT_TOKENS:
233
- raise SpendCeilingExceededError(estimate)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
  # Built before the run row so a missing Azure deployment fails without
236
  # leaving an orphan `running` row behind.
 
36
  from pathlib import Path
37
 
38
  from src.db.postgres.models import Document
39
+ from src.knowledge_extraction.genre import classify as classify_genre
40
  from src.knowledge_extraction.service import (
41
  build_clusters,
42
  estimate_cost,
 
55
  # accident without getting in the way of anything real.
56
  MAX_PROMPT_TOKENS = 2_000_000
57
 
58
+ # The SECOND ceiling, and the one that tracks what actually scales.
59
+ #
60
+ # Spend is driven by CLUSTER COUNT, not pages: the pipeline makes one call per
61
+ # term cluster, so a dense chapter costs more than a long simple one. Two things
62
+ # the token ceiling alone cannot see:
63
+ #
64
+ # 1. **Per-call cost is not linear in prompt tokens.** Each call carries its
65
+ # own completion tokens and its own latency, and the fixed cached prefix
66
+ # becomes a smaller share of each prompt as evidence grows - so the CACHE
67
+ # HIT RATE degrades as a run gets bigger, and the real bill rises faster
68
+ # than the token estimate does.
69
+ # 2. **The estimate is partly an approximation** (see `estimate_cost`), while
70
+ # cluster count is exact and known for free before anything is spent.
71
+ #
72
+ # This became urgent rather than theoretical on 2026-09-10: retiring the fuzzy
73
+ # clustering pass was correct, and it produces MORE, SMALLER clusters - the Open
74
+ # Pit textbook's single 46-page chapter went from 56% to 71% of the token
75
+ # ceiling on that change alone. A ceiling that only counts tokens would have let
76
+ # the next such improvement through unnoticed.
77
+ #
78
+ # Measured free on the three v2 artifacts, 2026-09-11:
79
+ #
80
+ # clusters calls est. prompt tokens % of the token ceiling
81
+ # BUMA 83 106 201,751 10.1%
82
+ # Komatsu 93 95 227,329 11.4%
83
+ # Open Pit 427 494 1,560,548 78.0%
84
+ #
85
+ # Read those honestly: at this corpus's density (~3,200 prompt tokens per
86
+ # cluster) the TOKEN ceiling binds first, at roughly 630 clusters. So this
87
+ # ceiling is not the tighter of the two for documents that look like these - it
88
+ # is the guard for the other shape, a document with many cheap clusters, where
89
+ # the cost is call COUNT rather than prompt size: 494 calls already runs about a
90
+ # quarter of an hour, and a batch is twenty documents.
91
+ #
92
+ # 750 is ~1.75x the largest real document measured. Generous enough not to
93
+ # obstruct anything observed, tight enough that one document cannot occupy the
94
+ # single-flight worker for an hour. Raise it with a measurement, not a hunch.
95
+ MAX_CLUSTERS = 750
96
+
97
  # Extraction is synchronous and blocks for ~170 s on nine pages, so it cannot run
98
  # on the event loop. Its own pool, following the #49 `dbexec` precedent: sharing
99
  # the default `to_thread` pool means a long parse competes with every other
 
115
 
116
 
117
  class SpendCeilingExceededError(RuntimeError):
118
+ """The pre-flight estimate exceeded a ceiling; nothing was spent.
119
 
120
+ Names WHICH ceiling bound and by how much. An operator's next move differs
121
+ completely between the two - a cluster refusal means the document is dense
122
+ and wants splitting, a token refusal means it is simply long - and "over
123
+ budget" tells them neither.
124
+ """
125
+
126
+ def __init__(self, estimate: dict, ceiling: str) -> None:
127
  self.estimate = estimate
128
+ self.ceiling = ceiling
129
+ if ceiling == "clusters":
130
+ measured, limit, unit = estimate.get("clusters", 0), MAX_CLUSTERS, "term clusters"
131
+ else:
132
+ measured, limit, unit = (
133
+ estimate.get("estimated_prompt_tokens", 0),
134
+ MAX_PROMPT_TOKENS,
135
+ "prompt tokens",
136
+ )
137
+ self.measured, self.limit = measured, limit
138
  super().__init__(
139
+ f"estimated {measured:,} {unit} exceeds the ceiling of {limit:,} "
140
+ f"({measured / limit:.0%} of it) — nothing was spent; "
141
+ f"{estimate.get('total_calls', 0):,} LLM calls were declined"
142
  )
143
 
144
 
145
+ def check_ceilings(estimate: dict) -> None:
146
+ """Raise if either ceiling binds. Clusters are checked FIRST because they
147
+ are exact where the token figure is partly approximate."""
148
+ if (estimate.get("clusters") or 0) > MAX_CLUSTERS:
149
+ raise SpendCeilingExceededError(estimate, "clusters")
150
+ if (estimate.get("estimated_prompt_tokens") or 0) > MAX_PROMPT_TOKENS:
151
+ raise SpendCeilingExceededError(estimate, "prompt_tokens")
152
+
153
+
154
  def _extractor():
155
  """The Azure client the extraction branches call.
156
 
 
187
 
188
  def __init__(self, store: KnowledgeStore | None = None) -> None:
189
  self._store = store or KnowledgeStore()
190
+ # The most recent pre-flight estimate, so `run_document` can report it
191
+ # on the SUCCESS path too (a refusal carries its own on the exception).
192
+ # Safe as instance state only because documents are run one at a time —
193
+ # `IngestService` holds a single-flight lock and `_EXECUTOR` has one
194
+ # worker. If either ever changes, pass it back instead of stashing it.
195
+ self.last_estimate: dict | None = None
196
 
197
  async def run_document(
198
  self, document: Document, scope_id: str, user_id: str
 
223
  except SpendCeilingExceededError as e:
224
  logger.warning(
225
  "knowledge_spend_ceiling",
226
+ extra={
227
+ "document_id": document.id,
228
+ "ceiling": e.ceiling,
229
+ "measured": e.measured,
230
+ "limit": e.limit,
231
+ "estimate": e.estimate,
232
+ },
233
  )
234
  result.status = "failed"
235
  result.error = str(e)
236
+ result.estimate = e.estimate
237
+ result.blocked_by = e.ceiling
238
  return result
239
  except Exception as e:
240
  logger.error(
 
247
 
248
  result.run_id = run_id
249
  result.n_entries = n_entries
250
+ result.estimate = self.last_estimate
251
  result.status = "succeeded"
252
  return result
253
 
 
310
  clustered = await loop.run_in_executor(_EXECUTOR, build_clusters, doc, filtered)
311
 
312
  estimate = estimate_cost(doc, clustered, filtered)
313
+
314
+ # What KIND of document this is, decided before anything is spent.
315
+ # Advisory today — see `genre.ROUTE_LABELS` / `ROUTE_BRANCHES`, both
316
+ # off — but recorded, because "104 calls, 93 abstentions" is a fact an
317
+ # operator should be able to see the reason for.
318
+ #
319
+ # A never-throw seam, like every other observability path here: this
320
+ # describes the run, it does not decide it, and a classifier defect must
321
+ # not take down an extraction that was about to work. `repr(e)` because
322
+ # an empty `str(e)` is how the Fernet mismatch hid for a day.
323
+ try:
324
+ verdict = classify_genre(doc, filtered)
325
+ estimate["genre"] = verdict.as_dict()
326
+ for warning in verdict.warnings:
327
+ logger.warning(
328
+ "knowledge_genre_warning",
329
+ extra={
330
+ "doc_id": doc.doc_id,
331
+ "genre": verdict.genre,
332
+ "warning": warning,
333
+ },
334
+ )
335
+ except Exception as e: # noqa: BLE001 - described above
336
+ logger.warning(
337
+ "knowledge_genre_failed",
338
+ extra={"doc_id": doc.doc_id, "error": repr(e)},
339
+ )
340
+
341
+ self.last_estimate = estimate
342
+ # Logged on EVERY run, not only on a refusal: an operator who can only
343
+ # see the number when it is already too late cannot plan a batch, and
344
+ # "why was this refused" is answerable only against what a run that
345
+ # succeeded looked like.
346
+ logger.info(
347
+ "knowledge_pre_flight_estimate",
348
+ extra={
349
+ "doc_id": doc.doc_id,
350
+ "clusters": estimate.get("clusters"),
351
+ "total_calls": estimate.get("total_calls"),
352
+ "estimated_prompt_tokens": estimate.get("estimated_prompt_tokens"),
353
+ "cluster_ceiling_pct": round(
354
+ 100 * (estimate.get("clusters") or 0) / MAX_CLUSTERS, 1
355
+ ),
356
+ "token_ceiling_pct": round(
357
+ 100 * (estimate.get("estimated_prompt_tokens") or 0) / MAX_PROMPT_TOKENS,
358
+ 1,
359
+ ),
360
+ },
361
+ )
362
+ check_ceilings(estimate)
363
 
364
  # Built before the run row so a missing Azure deployment fails without
365
  # leaving an orphan `running` row behind.
src/knowledge_ingest/service.py CHANGED
@@ -38,6 +38,39 @@ from src.middlewares.logging import get_logger
38
  logger = get_logger("knowledge_ingest")
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  @runtime_checkable
42
  class PipelinePort(Protocol):
43
  """Run the whole pipeline over ONE document and report its outcome.
@@ -102,7 +135,10 @@ class IngestService:
102
  return await self._store.list_for_user(user_id, limit)
103
 
104
  async def start(
105
- self, user_id: str, document_ids: list[str] | None = None
 
 
 
106
  ) -> tuple[IngestJob, bool]:
107
  """Admit a batch. Returns (job, reused).
108
 
@@ -127,6 +163,7 @@ class IngestService:
127
  job = IngestJob(
128
  scope_id=scope_id,
129
  user_id=user_id,
 
130
  document_ids=[d.id for d in selected],
131
  n_requested=len(selected),
132
  results=[
@@ -195,8 +232,26 @@ class IngestService:
195
  status="failed",
196
  error=repr(e),
197
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  by_id[result.document_id] = result
199
  job.results = list(by_id.values())
 
200
  await self._store.save(job)
201
 
202
  job.settle()
 
38
  logger = get_logger("knowledge_ingest")
39
 
40
 
41
+ def _roll_up_estimates(results: list[DocumentResult]) -> dict | None:
42
+ """The batch's pre-flight shape, per document and in total.
43
+
44
+ `IngestJob.estimated_cost` has existed since the job model was written and
45
+ was never populated, so the field a caller would poll to answer "what is
46
+ this going to cost, and why was that document refused" always came back
47
+ null. Spend is driven by CLUSTER COUNT — one LLM call per term cluster — so
48
+ the per-document breakdown is what an operator needs to see; a total alone
49
+ cannot tell them which document to split.
50
+ """
51
+ estimates = [(r.document_id, r.estimate) for r in results if r.estimate]
52
+ if not estimates:
53
+ return None
54
+ return {
55
+ "clusters": sum(e.get("clusters") or 0 for _, e in estimates),
56
+ "total_calls": sum(e.get("total_calls") or 0 for _, e in estimates),
57
+ "estimated_prompt_tokens": sum(
58
+ e.get("estimated_prompt_tokens") or 0 for _, e in estimates
59
+ ),
60
+ "per_document": {
61
+ doc_id: {
62
+ "clusters": e.get("clusters"),
63
+ "total_calls": e.get("total_calls"),
64
+ "estimated_prompt_tokens": e.get("estimated_prompt_tokens"),
65
+ }
66
+ for doc_id, e in estimates
67
+ },
68
+ "blocked": {
69
+ r.document_id: r.blocked_by for r in results if r.blocked_by
70
+ },
71
+ }
72
+
73
+
74
  @runtime_checkable
75
  class PipelinePort(Protocol):
76
  """Run the whole pipeline over ONE document and report its outcome.
 
135
  return await self._store.list_for_user(user_id, limit)
136
 
137
  async def start(
138
+ self,
139
+ user_id: str,
140
+ document_ids: list[str] | None = None,
141
+ review_mode: str = "expert",
142
  ) -> tuple[IngestJob, bool]:
143
  """Admit a batch. Returns (job, reused).
144
 
 
163
  job = IngestJob(
164
  scope_id=scope_id,
165
  user_id=user_id,
166
+ review_mode=review_mode,
167
  document_ids=[d.id for d in selected],
168
  n_requested=len(selected),
169
  results=[
 
232
  status="failed",
233
  error=repr(e),
234
  )
235
+ # r1: in `llm` mode there is no reviewer step - the run's
236
+ # entries are approved as soon as they land, recorded against
237
+ # the person who chose the mode (r2/r3). Never-throw: an
238
+ # auto-approval that fails leaves the entries REVIEWABLE, which
239
+ # is the safe direction, and must not fail the document.
240
+ if job.review_mode == "llm" and result.status == "succeeded" and result.run_id:
241
+ try:
242
+ await self._knowledge.auto_approve_run(
243
+ run_id=result.run_id,
244
+ scope_id=job.scope_id,
245
+ authorised_by=job.user_id,
246
+ )
247
+ except Exception as e: # noqa: BLE001
248
+ logger.error(
249
+ "ingest_auto_approve_failed",
250
+ extra={"job_id": job.id, "run_id": result.run_id, "error": repr(e)},
251
+ )
252
  by_id[result.document_id] = result
253
  job.results = list(by_id.values())
254
+ job.estimated_cost = _roll_up_estimates(job.results)
255
  await self._store.save(job)
256
 
257
  job.settle()
src/knowledge_ingest/store.py CHANGED
@@ -123,6 +123,7 @@ class PostgresJobStore:
123
  user_id=row.user_id,
124
  status=row.status,
125
  stage=row.stage,
 
126
  document_ids=list(row.document_ids or []),
127
  n_requested=row.n_requested or 0,
128
  n_succeeded=row.n_succeeded or 0,
@@ -161,6 +162,7 @@ class PostgresJobStore:
161
  user_id=job.user_id,
162
  status=job.status,
163
  stage=job.stage,
 
164
  document_ids=list(job.document_ids),
165
  n_requested=job.n_requested,
166
  n_succeeded=job.n_succeeded,
 
123
  user_id=row.user_id,
124
  status=row.status,
125
  stage=row.stage,
126
+ review_mode=row.review_mode or "expert",
127
  document_ids=list(row.document_ids or []),
128
  n_requested=row.n_requested or 0,
129
  n_succeeded=row.n_succeeded or 0,
 
162
  user_id=job.user_id,
163
  status=job.status,
164
  stage=job.stage,
165
+ review_mode=job.review_mode,
166
  document_ids=list(job.document_ids),
167
  n_requested=job.n_requested,
168
  n_succeeded=job.n_succeeded,
src/knowledge_parsing/assets.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Asset identity and persistence for figures pulled out of a parse. Added 0.4.0.
2
+
3
+ Two jobs, deliberately small:
4
+
5
+ 1. **Identity.** `asset_id()` content-addresses the bytes. The parser's own id is
6
+ not usable: Mistral names images per PAGE (`img-0.jpeg`), so the same name
7
+ recurs across pages — the Open Pit textbook produces **7 distinct names for 8
8
+ images**, and a flat store keyed on the parser's name loses one. Content
9
+ addressing also makes a re-parse idempotent and de-duplicates a figure that
10
+ appears in two documents.
11
+
12
+ 2. **Persistence.** An `AssetSink` protocol plus a local-filesystem default.
13
+
14
+ **Why a protocol and not a blob client.** `src/knowledge_parsing/` imports nothing
15
+ from the rest of `src` and that is load-bearing — the package is handed to the
16
+ team as a standalone unit, so reaching into `src.storage` (which pulls app
17
+ settings, which needs `.env`) would break the export and make the parser
18
+ un-runnable outside this repo. The app injects a blob-backed sink instead; see
19
+ `knowledge_ingest.asset_sink`.
20
+
21
+ Persistence is a **never-throw seam**. A figure whose bytes cannot be stored
22
+ yields `storage_key=None` and a logged warning, never a failed parse: the parse is
23
+ the expensive, cached, hard-to-repeat half, and losing a whole document because
24
+ one PNG could not be written is the wrong trade.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import base64
30
+ import binascii
31
+ import hashlib
32
+ import logging
33
+ from pathlib import Path
34
+ from typing import Protocol, runtime_checkable
35
+
36
+ logger = logging.getLogger("knowledge_parsing.assets")
37
+
38
+ ASSET_ID_CHARS = 16
39
+
40
+ # Mistral reports ids like "img-0.jpeg"; the suffix is the only extension signal
41
+ # available. Anything unrecognised falls back to .bin rather than guessing, so a
42
+ # stored object is never mislabelled.
43
+ _KNOWN_EXT = {"jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff"}
44
+
45
+
46
+ def asset_id(data: bytes) -> str:
47
+ """Content address for asset bytes: sha256, first 16 hex chars.
48
+
49
+ 16 chars is ~64 bits. At corpus scale (thousands of figures) the collision
50
+ probability is negligible, and it keeps the id short enough to read in a
51
+ `![](asset://…)` reference inside a prose chunk.
52
+ """
53
+ return hashlib.sha256(data).hexdigest()[:ASSET_ID_CHARS]
54
+
55
+
56
+ def extension_for(parser_id: str | None) -> str:
57
+ """File extension from the parser's own id, defaulting to `bin`."""
58
+ if not parser_id or "." not in parser_id:
59
+ return "bin"
60
+ ext = parser_id.rsplit(".", 1)[-1].strip().lower()
61
+ return ext if ext in _KNOWN_EXT else "bin"
62
+
63
+
64
+ def decode_base64(payload: str | None) -> bytes | None:
65
+ """Decode a base64 image payload, tolerating a `data:` URI prefix.
66
+
67
+ Returns None rather than raising on anything malformed — the caller treats a
68
+ missing payload as "no bytes to store", which is the same degraded path as a
69
+ failed write.
70
+ """
71
+ if not payload:
72
+ return None
73
+ text = payload.strip()
74
+ if text.startswith("data:"):
75
+ _, _, text = text.partition(",")
76
+ try:
77
+ return base64.b64decode(text, validate=False)
78
+ except (binascii.Error, ValueError) as exc:
79
+ logger.warning("asset base64 decode failed: %r", exc)
80
+ return None
81
+
82
+
83
+ @runtime_checkable
84
+ class AssetSink(Protocol):
85
+ """Somewhere to put asset bytes. Returns a storage key, or None on failure."""
86
+
87
+ def put(self, identifier: str, extension: str, data: bytes) -> str | None:
88
+ ...
89
+
90
+
91
+ class LocalAssetSink:
92
+ """Write assets to a local directory, named by content address.
93
+
94
+ The default, and what the CLI uses. Idempotent by construction: an asset whose
95
+ id already exists on disk is not rewritten, because the id IS the content.
96
+ """
97
+
98
+ def __init__(self, root: Path, prefix: str = "assets") -> None:
99
+ self.root = Path(root)
100
+ self.prefix = prefix
101
+
102
+ def put(self, identifier: str, extension: str, data: bytes) -> str | None:
103
+ try:
104
+ target_dir = self.root / self.prefix
105
+ target_dir.mkdir(parents=True, exist_ok=True)
106
+ target = target_dir / f"{identifier}.{extension}"
107
+ if not target.exists():
108
+ target.write_bytes(data)
109
+ return f"{self.prefix}/{identifier}.{extension}"
110
+ except OSError as exc:
111
+ logger.warning("asset write failed for %s: %r", identifier, exc)
112
+ return None
113
+
114
+
115
+ class NullAssetSink:
116
+ """Discard asset bytes, keeping the asset metadata. For `--no-assets`.
117
+
118
+ Not the same as having no assets: the figure is still folded into its prose
119
+ chunk with its caption and description, and still carries an `asset_id`. Only
120
+ the bytes are not retained, so `storage_key` is None.
121
+ """
122
+
123
+ def put(self, identifier: str, extension: str, data: bytes) -> str | None:
124
+ return None
125
+
126
+
127
+ def store_asset(
128
+ sink: AssetSink | None,
129
+ parser_id: str | None,
130
+ base64_payload: str | None,
131
+ ) -> tuple[str | None, str | None]:
132
+ """Content-address and persist one asset. Returns `(asset_id, storage_key)`.
133
+
134
+ `asset_id` is None only when there are no bytes to address — a table, or a
135
+ figure whose payload the parser did not return. `storage_key` is None whenever
136
+ the bytes were not retained, which is a normal outcome (`NullAssetSink`) as
137
+ well as a failure one.
138
+ """
139
+ data = decode_base64(base64_payload)
140
+ if data is None:
141
+ return None, None
142
+ identifier = asset_id(data)
143
+ if sink is None:
144
+ return identifier, None
145
+ return identifier, sink.put(identifier, extension_for(parser_id), data)
146
+
147
+
148
+ def synthetic_asset_id(*parts: str) -> str:
149
+ """Id for an asset with no bytes of its own — a table.
150
+
151
+ Derived from the document and chunk coordinates rather than content, because
152
+ there is no content to hash. Deterministic for the same document, which is
153
+ what matters: a re-parse produces the same id, so a pointer to a table
154
+ survives re-extraction the way a figure's does.
155
+ """
156
+ return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:ASSET_ID_CHARS]
src/knowledge_parsing/config.py CHANGED
@@ -88,6 +88,11 @@ class PipelineConfig:
88
 
89
  # --- behaviour ---
90
  resume: bool = True # skip documents already finished
 
 
 
 
 
91
  write_debug_pdf: bool = True # _layout.pdf & _span.pdf: fastest quality check there is
92
 
93
  # --- quality-check thresholds (see checks.py) ---
 
88
 
89
  # --- behaviour ---
90
  resume: bool = True # skip documents already finished
91
+ # Keep asset METADATA but discard the bytes. The figure is still folded into
92
+ # its prose chunk with its caption, description and content-addressed id —
93
+ # only `storage_key` comes back null. For a run where the images are not
94
+ # wanted on disk, or where storage is unavailable.
95
+ no_assets: bool = False
96
  write_debug_pdf: bool = True # _layout.pdf & _span.pdf: fastest quality check there is
97
 
98
  # --- quality-check thresholds (see checks.py) ---
src/knowledge_parsing/contracts.py CHANGED
@@ -42,13 +42,85 @@ from typing import Literal
42
  from pydantic import BaseModel, Field, computed_field
43
 
44
  ChunkKind = Literal["text", "table", "chart", "equation", "image"]
45
-
 
 
 
 
 
 
 
 
 
 
 
 
46
  # 0.3.3 — adds the computed 1-based `page_no` (S6b). Derived from `page_idx`.
47
  # 0.3.2 — adds the optional `source_title`.
48
  # 0.3.1 — adds the optional `normaliser_version`.
49
- # All patch bumps: purely additive, so every 0.3.0 consumer reads them unchanged
50
- # and the new fields are null on anything written earlier.
51
- SCHEMA_VERSION = "0.3.3"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
 
54
  class Chunk(BaseModel):
@@ -124,7 +196,24 @@ class Chunk(BaseModel):
124
  # uses item sequence, never coordinates.
125
  bbox: list[int] | None = None
126
 
127
- # Non-text attachments (formula images, table/chart crops)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  images: list[str] = Field(default_factory=list)
129
 
130
  # Source markup, kept verbatim beside the rendered prose in `text`.
 
42
  from pydantic import BaseModel, Field, computed_field
43
 
44
  ChunkKind = Literal["text", "table", "chart", "equation", "image"]
45
+ AssetKind = Literal["figure", "chart", "diagram", "table"]
46
+
47
+ # 0.4.0 — ASSET POINTERS. A figure is no longer its own chunk: its markdown
48
+ # reference is folded into the prose chunk at its reading-order position
49
+ # and the figure travels as an `Asset` on that chunk. Tables stay their
50
+ # own chunk (their linearised text earns its place as evidence) but gain
51
+ # an `Asset` and a two-way pointer. Adds `Chunk.assets` and
52
+ # `Chunk.referenced_by`; `Chunk.images` is kept as a DEPRECATED alias.
53
+ #
54
+ # A MINOR bump, not a patch: chunk boundaries change, so a 0.3.x consumer
55
+ # reading a 0.4.0 artifact would silently see a different document shape.
56
+ # `adapter.check_schema_version` rejects a newer minor, which is the
57
+ # designed behaviour — parsing and extraction ship together here.
58
  # 0.3.3 — adds the computed 1-based `page_no` (S6b). Derived from `page_idx`.
59
  # 0.3.2 — adds the optional `source_title`.
60
  # 0.3.1 — adds the optional `normaliser_version`.
61
+ # 0.3.x were all patch bumps: purely additive, so every 0.3.0 consumer reads them
62
+ # unchanged and the new fields are null on anything written earlier.
63
+ SCHEMA_VERSION = "0.4.0"
64
+
65
+
66
+ class Asset(BaseModel):
67
+ """A figure, chart, diagram or table referenced by a chunk. Added 0.4.0.
68
+
69
+ **Why this exists.** Before 0.4.0 an image became its own chunk with an EMPTY
70
+ `text`, which made it unreachable by construction: no text means no term
71
+ mentions, no mentions means the chunk joins no cluster, and a chunk in no
72
+ cluster is never selected as evidence. Measured across three documents: 13
73
+ figures, every one described by a vision model we paid for, and **zero of
74
+ them appearing in any cluster's evidence.** Meanwhile the prose that said
75
+ "Figure 1.1" had its `![](…)` reference stripped, so nothing connected the
76
+ sentence to the picture.
77
+
78
+ The asset carries both halves of the provenance split, and the distinction is
79
+ load-bearing:
80
+
81
+ - `caption` is **verbatim document text** — printed on the page, therefore
82
+ quotable as evidence and span-checkable like any other transcription.
83
+ - `description` is **model-written** — a claim *about* the picture. It is
84
+ never span-checkable and must never be quoted as evidence, which is why it
85
+ lives here and not in `Chunk.text`.
86
+ """
87
+
88
+ # Content-addressed: sha256 of the asset bytes, first 16 hex chars.
89
+ #
90
+ # NOT the parser's own id. Mistral names images per PAGE (`img-0.jpeg`), which
91
+ # already collides in practice — the Open Pit textbook yields 7 distinct names
92
+ # for 8 images, so a flat store keyed on the parser's name silently loses one.
93
+ # Content addressing also makes a re-parse idempotent and de-duplicates a
94
+ # figure that appears in two documents.
95
+ asset_id: str
96
+ kind: AssetKind
97
+
98
+ # Where the bytes live. `null` for a table (it has no bytes of its own) and
99
+ # for a figure whose persistence failed — writing assets is a never-throw
100
+ # seam, so a storage outage degrades to "no key" rather than failing a parse.
101
+ storage_key: str | None = None
102
+
103
+ page_idx: int = 0
104
+
105
+ @computed_field # type: ignore[prop-decorator]
106
+ @property
107
+ def page_no(self) -> int:
108
+ """1-based page number, for a human and for a review UI. Derived from
109
+ `page_idx` so the two cannot drift."""
110
+ return self.page_idx + 1
111
+
112
+ # `[x0, y0, x1, y1]` of the asset itself, so a UI can highlight the figure.
113
+ # Distinct from the host chunk's own `bbox`, which stays where the TEXT is.
114
+ bbox: list[int] | None = None
115
+
116
+ # Verbatim, from the document's own caption block. Quotable as evidence.
117
+ caption: str | None = None
118
+
119
+ # ⚠️ MODEL-WRITTEN. See the class docstring. Never evidence.
120
+ description: str | None = None
121
+ # Which model/feature produced `description`, so a future swap is traceable
122
+ # rather than silently changing what the field means.
123
+ description_source: str | None = None
124
 
125
 
126
  class Chunk(BaseModel):
 
196
  # uses item sequence, never coordinates.
197
  bbox: list[int] | None = None
198
 
199
+ # Figures, charts, diagrams and tables this chunk contains or points at.
200
+ # Added 0.4.0 — see the `Asset` docstring for why images stopped being chunks.
201
+ #
202
+ # A prose chunk that holds a figure carries it here AND carries the figure's
203
+ # `![](asset://<asset_id>)` reference inline in `text` at its reading-order
204
+ # position, so the model reads the reference exactly where the document put it.
205
+ assets: list[Asset] = Field(default_factory=list)
206
+
207
+ # chunk_ids of OTHER chunks that point at this one. Populated on table chunks,
208
+ # which stay standalone (their linearised text earns its place as evidence —
209
+ # 74.6% of the Komatsu manual's top-3 evidence is tabular) while still being
210
+ # reachable from the prose that introduces them.
211
+ referenced_by: list[str] = Field(default_factory=list)
212
+
213
+ # ⚠️ DEPRECATED 0.4.0 — superseded by `assets`. Still populated for one
214
+ # version so a 0.3.x reader degrades rather than breaking; it holds the bare
215
+ # parser-local filenames, which are NOT unique across a document (see
216
+ # `Asset.asset_id`). Read `assets` instead.
217
  images: list[str] = Field(default_factory=list)
218
 
219
  # Source markup, kept verbatim beside the rendered prose in `text`.
src/knowledge_parsing/normalize_mistral.py CHANGED
@@ -30,13 +30,15 @@ whitespace normalisation. See contracts.py for why.
30
  from __future__ import annotations
31
 
32
  import hashlib
 
33
  import re
34
  from collections.abc import Iterator
35
  from functools import lru_cache
36
  from pathlib import Path
37
  from typing import Any
38
 
39
- from .contracts import Chunk
 
40
  from .normalize import MAX_CHUNK_CHARS, _heading # identical section-boundary logic
41
  from .render import render_latex, render_table
42
 
@@ -62,8 +64,15 @@ def normaliser_version() -> str:
62
  # the source anyway.
63
  _DISCARDED = {"page_number", "footer"}
64
 
 
 
 
 
65
  _LEADING_HASHES = re.compile(r"^\s*(#{1,6})\s*")
66
  _MD_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
 
 
 
67
  _MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
68
  _MD_BOLD = re.compile(r"\*\*(.+?)\*\*", re.S)
69
  _MD_ITALIC = re.compile(r"\*(.+?)\*", re.S)
@@ -127,7 +136,75 @@ def _image_descriptions(response: dict[str, Any]) -> dict[str, str]:
127
  ann = im.get("image_annotation")
128
  iid = im.get("id")
129
  if iid and ann:
130
- out[iid] = ann if isinstance(ann, str) else str(ann)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  return out
132
 
133
 
@@ -221,7 +298,10 @@ def _chunk_by_page(
221
 
222
 
223
  def normalise_mistral(
224
- response: dict[str, Any], doc_id: str, granularity: str = "section"
 
 
 
225
  ) -> list[Chunk]:
226
  """Mistral OCR response (include_blocks=True) → chunks.
227
 
@@ -268,13 +348,38 @@ def normalise_mistral(
268
 
269
  # --- page mode: recall-only; a page chunk has no single heading ---
270
  if granularity == "page":
271
- return _chunk_by_page(blocks, descriptions, chapter_per_page, doc_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
  # --- section mode (default): a numbered heading opens a new chunk (mirrors normalize.py) ---
274
  chunks: list[Chunk] = []
275
  running: Chunk | None = None
276
  pieces: list[str] = []
277
  stack: list[tuple[int, str]] = []
 
 
 
 
 
 
 
 
 
278
 
279
  def push_heading(level: int, text: str) -> None:
280
  while stack and stack[-1][0] >= level:
@@ -285,7 +390,7 @@ def normalise_mistral(
285
  nonlocal running, pieces
286
  if running is not None:
287
  running.text = "\n\n".join(pieces).strip()
288
- if running.text or running.images:
289
  running.page_idxs = sorted(set(running.page_idxs))
290
  chunks.append(running)
291
  running, pieces = None, []
@@ -308,6 +413,12 @@ def normalise_mistral(
308
  continue
309
 
310
  if kind == "table":
 
 
 
 
 
 
311
  close()
312
  c = open_chunk("table", page)
313
  c.text = render_table(block.get("content") or "")
@@ -315,22 +426,67 @@ def normalise_mistral(
315
  c.table_html = block.get("content") or None
316
  c.source_items = [i]
317
  c.bbox = _bbox(block)
318
- if c.text or c.images:
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  chunks.append(c)
320
  continue
321
 
322
  if kind == "image":
323
- close()
324
- c = open_chunk("image", page)
325
- iid = block.get("image_id")
326
- c.generated_description = descriptions.get(iid) if iid else None
327
- c.source_items = [i]
328
- c.bbox = _bbox(block)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  if iid:
330
- c.images = [iid]
331
- # text stays "" — a printed caption arrives as its own `caption`
332
- # block; the model description is kept OUT of text on purpose.
333
- chunks.append(c)
 
334
  continue
335
 
336
  if kind == "equation":
@@ -362,6 +518,14 @@ def normalise_mistral(
362
  running.source_items.append(i)
363
  running.page_idxs.append(page)
364
  pieces.append(text)
 
 
 
 
 
 
 
 
365
  continue
366
 
367
  # `text` / `title` / `signature`: a NUMBERED line may open a section.
@@ -413,4 +577,19 @@ def normalise_mistral(
413
  heading=continued_from.heading)
414
 
415
  close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  return chunks
 
30
  from __future__ import annotations
31
 
32
  import hashlib
33
+ import json
34
  import re
35
  from collections.abc import Iterator
36
  from functools import lru_cache
37
  from pathlib import Path
38
  from typing import Any
39
 
40
+ from .assets import AssetSink, store_asset, synthetic_asset_id
41
+ from .contracts import Asset, Chunk
42
  from .normalize import MAX_CHUNK_CHARS, _heading # identical section-boundary logic
43
  from .render import render_latex, render_table
44
 
 
64
  # the source anyway.
65
  _DISCARDED = {"page_number", "footer"}
66
 
67
+ # Recorded on every Asset so a later swap of the description model is traceable
68
+ # rather than silently changing what the field means.
69
+ _DESCRIPTION_SOURCE = "mistral-ocr/bbox_annotation"
70
+
71
  _LEADING_HASHES = re.compile(r"^\s*(#{1,6})\s*")
72
  _MD_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
73
+ # Same shape, but capturing the target so a block's own markdown can be read
74
+ # for the image id it refers to (see _block_image_id).
75
+ _MD_IMAGE_TARGET = re.compile(r"!\[[^\]]*\]\(([^)]*)\)")
76
  _MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
77
  _MD_BOLD = re.compile(r"\*\*(.+?)\*\*", re.S)
78
  _MD_ITALIC = re.compile(r"\*(.+?)\*", re.S)
 
136
  ann = im.get("image_annotation")
137
  iid = im.get("id")
138
  if iid and ann:
139
+ out[iid] = _unwrap_description(ann)
140
+ return out
141
+
142
+
143
+ def _unwrap_description(annotation: Any) -> str:
144
+ """Pull the prose out of an annotation payload.
145
+
146
+ Mistral returns bbox annotations JSON-WRAPPED — literally
147
+ `{"description": "The diagram illustrates…"}` — and 0.3.x stored that string
148
+ raw, braces and all, so every consumer got JSON where it expected a sentence.
149
+ Unwrapped here, at the one place that reads the field.
150
+
151
+ Never raises: anything unparseable falls back to the string form, because a
152
+ slightly ugly description is worth more than a failed parse.
153
+ """
154
+ if isinstance(annotation, dict):
155
+ value = annotation.get("description") or annotation.get("text")
156
+ return str(value) if value else json.dumps(annotation, ensure_ascii=False)
157
+ if isinstance(annotation, str):
158
+ text = annotation.strip()
159
+ if text.startswith("{"):
160
+ try:
161
+ parsed = json.loads(text)
162
+ except (ValueError, TypeError):
163
+ return text
164
+ if isinstance(parsed, dict):
165
+ value = parsed.get("description") or parsed.get("text")
166
+ if value:
167
+ return str(value)
168
+ return text
169
+ return text
170
+ return str(annotation)
171
+
172
+
173
+ def _block_image_id(block: dict[str, Any]) -> str | None:
174
+ """The image this block refers to, preferring the id in its own markdown.
175
+
176
+ ⚠️ **`block["image_id"]` is not trustworthy.** Measured on the Open Pit
177
+ textbook: page 6 carries two image blocks, and the FIRST one's `content` reads
178
+ `![img-1.jpeg](img-1.jpeg)` while its `image_id` field says **`img-2.jpeg`** —
179
+ so both blocks claim the same image. Keying on `image_id` therefore resolved
180
+ both to img-2's bytes, gave two different figures one content hash, and lost
181
+ img-1 entirely (7 files written for 8 figures).
182
+
183
+ The `content` markdown is the document's own reference and was correct in
184
+ every case, so it wins; `image_id` is the fallback for a block whose content
185
+ carries no reference. This also corrects an earlier diagnosis: the ids are
186
+ globally unique across the response (`img-0` … `img-7`), so the duplication
187
+ was never page-local naming — it was this mislabelled field.
188
+ """
189
+ match = _MD_IMAGE_TARGET.search(block.get("content") or "")
190
+ if match and match.group(1).strip():
191
+ return match.group(1).strip()
192
+ return block.get("image_id")
193
+
194
+
195
+ def _image_payloads(response: dict[str, Any]) -> dict[str, str]:
196
+ """image_id -> base64 payload. Empty when `include_image_base64` was off.
197
+
198
+ 0.3.x never read these: the bytes were returned by the API, recorded nowhere,
199
+ and `Chunk.images` pointed at filenames that did not exist on disk. 0.4.0
200
+ content-addresses and persists them.
201
+ """
202
+ out: dict[str, str] = {}
203
+ for page in response.get("pages", []):
204
+ for im in page.get("images") or []:
205
+ iid, payload = im.get("id"), im.get("image_base64")
206
+ if iid and payload:
207
+ out[iid] = payload
208
  return out
209
 
210
 
 
298
 
299
 
300
  def normalise_mistral(
301
+ response: dict[str, Any],
302
+ doc_id: str,
303
+ granularity: str = "section",
304
+ asset_sink: AssetSink | None = None,
305
  ) -> list[Chunk]:
306
  """Mistral OCR response (include_blocks=True) → chunks.
307
 
 
348
 
349
  # --- page mode: recall-only; a page chunk has no single heading ---
350
  if granularity == "page":
351
+ # ⚠️ FENCED OFF AT 0.4.0, deliberately, rather than given the asset
352
+ # treatment. Page mode emits 0.3.x-shaped chunks — images standalone with
353
+ # empty text, no `assets` — so letting it run would quietly produce an
354
+ # artifact that CLAIMS schema_version 0.4.0 while having the old shape.
355
+ # Two normalisers silently diverging is exactly how the `type: "list"`
356
+ # content drop survived unnoticed and cost five gold terms.
357
+ #
358
+ # Page mode exists only for a recall-only measurement (it nulls
359
+ # `heading_path`, which the outline and `source_wording` both need), so it
360
+ # is not worth duplicating the asset logic into. Restore by porting the
361
+ # image/table branches from the section path below.
362
+ raise NotImplementedError(
363
+ "granularity='page' is not supported at schema 0.4.0: it would emit "
364
+ "0.3.x-shaped chunks (standalone image chunks, no assets) under a "
365
+ "0.4.0 version stamp. Use granularity='section', or port the asset "
366
+ "handling into _chunk_by_page first."
367
+ )
368
 
369
  # --- section mode (default): a numbered heading opens a new chunk (mirrors normalize.py) ---
370
  chunks: list[Chunk] = []
371
  running: Chunk | None = None
372
  pieces: list[str] = []
373
  stack: list[tuple[int, str]] = []
374
+ payloads = _image_payloads(response)
375
+ # A table's asset has to be added to the PROSE chunk that precedes it, but
376
+ # that chunk is already closed and appended by then — so the back-reference is
377
+ # queued and resolved once, after the loop.
378
+ _pending_back_refs: list[tuple[str, Asset]] = []
379
+ # A printed caption arrives as its own `caption` block AFTER its image block.
380
+ # The figure waiting for one is held here; the caption is attached to the asset
381
+ # AND left in the prose text, because it is verbatim document content.
382
+ _pending_caption_for: list[Asset] = []
383
 
384
  def push_heading(level: int, text: str) -> None:
385
  while stack and stack[-1][0] >= level:
 
390
  nonlocal running, pieces
391
  if running is not None:
392
  running.text = "\n\n".join(pieces).strip()
393
+ if running.text or running.assets or running.images:
394
  running.page_idxs = sorted(set(running.page_idxs))
395
  chunks.append(running)
396
  running, pieces = None, []
 
413
  continue
414
 
415
  if kind == "table":
416
+ # Tables stay their OWN chunk (0.4.0 kept this deliberately): unlike a
417
+ # figure, a table's linearised text carries real terms the filter
418
+ # finds — 74.6% of the Komatsu manual's top-3 evidence is tabular. What
419
+ # 0.4.0 adds is reachability: an `Asset` plus a two-way pointer, so the
420
+ # prose that introduces a table can be followed to it and back.
421
+ prose_before = running.chunk_id if running is not None else None
422
  close()
423
  c = open_chunk("table", page)
424
  c.text = render_table(block.get("content") or "")
 
426
  c.table_html = block.get("content") or None
427
  c.source_items = [i]
428
  c.bbox = _bbox(block)
429
+ table_asset = Asset(
430
+ # No bytes to hash, so the id is derived from stable document
431
+ # coordinates instead — deterministic across re-parses, which is
432
+ # what a durable pointer needs.
433
+ asset_id=synthetic_asset_id(doc_id, c.chunk_id, str(i)),
434
+ kind="table",
435
+ page_idx=page,
436
+ bbox=_bbox(block),
437
+ )
438
+ c.assets = [table_asset]
439
+ if prose_before is not None:
440
+ c.referenced_by = [prose_before]
441
+ _pending_back_refs.append((prose_before, table_asset))
442
+ if c.text or c.assets:
443
  chunks.append(c)
444
  continue
445
 
446
  if kind == "image":
447
+ # ── 0.4.0: a figure is NO LONGER ITS OWN CHUNK. ────────────────────
448
+ # It used to be, with an EMPTY `text`, which made it unreachable by
449
+ # construction: no text -> no term mentions -> joins no cluster ->
450
+ # never selected as evidence. Measured across three documents: 13
451
+ # figures, every one described by a vision model we pay for, and ZERO
452
+ # appearing in any cluster's evidence. Meanwhile the prose saying
453
+ # "Figure 1.1" had its `![](…)` reference stripped, so nothing
454
+ # connected the sentence to the picture.
455
+ #
456
+ # Now the reference is folded into the RUNNING prose chunk at its
457
+ # reading-order position — exactly where the document put it — and the
458
+ # figure travels as an `Asset` on that chunk.
459
+ iid = _block_image_id(block)
460
+ identifier, storage_key = store_asset(
461
+ asset_sink, iid, payloads.get(iid) if iid else None
462
+ )
463
+ # Fall back to document coordinates when the parser returned no bytes,
464
+ # so a figure is still referenced rather than silently vanishing.
465
+ if identifier is None:
466
+ identifier = synthetic_asset_id(doc_id, str(page), str(i))
467
+ asset = Asset(
468
+ asset_id=identifier,
469
+ kind="figure",
470
+ storage_key=storage_key,
471
+ page_idx=page,
472
+ bbox=_bbox(block),
473
+ description=descriptions.get(iid) if iid else None,
474
+ description_source=_DESCRIPTION_SOURCE if iid else None,
475
+ )
476
+ # P5 edge case: a figure can precede any prose on its page (Open Pit
477
+ # p.3 is close to this shape). Open a chunk rather than drop the asset.
478
+ if running is None:
479
+ running = open_chunk("text", page)
480
+ running.source_items.append(i)
481
+ running.page_idxs.append(page)
482
+ running.assets.append(asset)
483
+ # Deprecated alias, one version only.
484
  if iid:
485
+ running.images.append(iid)
486
+ # The reference itself, inline in the prose. `asset://` rather than the
487
+ # parser's filename because the filename is page-local and collides.
488
+ pieces.append(f"![](asset://{identifier})")
489
+ _pending_caption_for.append(asset)
490
  continue
491
 
492
  if kind == "equation":
 
518
  running.source_items.append(i)
519
  running.page_idxs.append(page)
520
  pieces.append(text)
521
+ # A caption directly after a figure names that figure. Attach it to
522
+ # the asset so a consumer can render "Figure 1.1 …" beside the
523
+ # image — and LEAVE it in `pieces` too, because it is printed in
524
+ # the document: verbatim, quotable as evidence, span-checkable.
525
+ # Copying it is the point; moving it would silently delete
526
+ # evidence that survived 17/17 times on the Open Pit textbook.
527
+ if kind == "caption" and _pending_caption_for:
528
+ _pending_caption_for.pop(0).caption = text.strip()
529
  continue
530
 
531
  # `text` / `title` / `signature`: a NUMBERED line may open a section.
 
577
  heading=continued_from.heading)
578
 
579
  close()
580
+
581
+ # Two-way table pointers, resolved once the loop is done. The table chunk
582
+ # already names the prose chunk in `referenced_by`; this adds the table's asset
583
+ # to that prose chunk so the link is navigable from the prose side too — the
584
+ # direction a reader, and a model reading evidence, actually travels. Deferred
585
+ # rather than done inline because the prose chunk is already closed and
586
+ # appended by the time its table is reached.
587
+ by_id = {c.chunk_id: c for c in chunks}
588
+ for prose_id, table_asset in _pending_back_refs:
589
+ host = by_id.get(prose_id)
590
+ if host is not None and not any(
591
+ a.asset_id == table_asset.asset_id for a in host.assets
592
+ ):
593
+ host.assets.append(table_asset)
594
+
595
  return chunks
src/knowledge_parsing/run.py CHANGED
@@ -21,6 +21,7 @@ from datetime import datetime
21
  from pathlib import Path
22
  from typing import Any
23
 
 
24
  from .checks import check_items, vocabulary_per_page
25
  from .config import PipelineConfig
26
  from .contracts import ParsedDocument
@@ -114,7 +115,12 @@ class _ParseOutcome:
114
  normalise_seconds: float
115
 
116
 
117
- def _parse_one(source: Path, cfg: PipelineConfig, doc_id: str) -> _ParseOutcome:
 
 
 
 
 
118
  """The per-document core: parse, normalise, assemble the `ParsedDocument`.
119
 
120
  The single place a `ParsedDocument` is built. Both `parse_one` and `run()` go
@@ -130,7 +136,7 @@ def _parse_one(source: Path, cfg: PipelineConfig, doc_id: str) -> _ParseOutcome:
130
  # character-spaced formulas, so neither applies.
131
  checks: dict[str, Any] = {"backend": "mistral-ocr"}
132
  t0 = time.perf_counter()
133
- chunks = normalise_mistral(response, doc_id)
134
  normalise_seconds = time.perf_counter() - t0
135
  else:
136
  items = json.loads(result.content_list.read_text(encoding="utf-8"))
@@ -190,6 +196,7 @@ def parse_one(
190
  source: Path,
191
  cfg: PipelineConfig,
192
  doc_id: str | None = None,
 
193
  ) -> ParsedDocument:
194
  """Parse one document and return the artifact in memory. Raises on failure.
195
 
@@ -206,7 +213,7 @@ def parse_one(
206
  """
207
  if doc_id is None:
208
  doc_id = source.stem
209
- return _parse_one(source, cfg, doc_id).artifact
210
 
211
 
212
  def run(cfg: PipelineConfig) -> Manifest:
@@ -245,7 +252,13 @@ def run(cfg: PipelineConfig) -> Manifest:
245
  # Same core the ingestion service calls — the artifact is built once,
246
  # here and there. run() adds only the folder-batch concerns around it:
247
  # the manifest record and writing the result to disk.
248
- outcome = _parse_one(source, cfg, doc_id)
 
 
 
 
 
 
249
  artifact = outcome.artifact
250
  result = outcome.parse_result
251
 
@@ -323,6 +336,8 @@ def main() -> None:
323
  # on `vlm`.
324
  p.add_argument("--effort", choices=["medium", "high"], default=None)
325
  p.add_argument("--no-resume", action="store_true", help="reprocess everything")
 
 
326
  p.add_argument("--start-page", type=int, default=None)
327
  p.add_argument("--end-page", type=int, default=None)
328
  a = p.parse_args()
@@ -338,6 +353,8 @@ def main() -> None:
338
  cfg.effort = a.effort
339
  if a.no_resume:
340
  cfg.resume = False
 
 
341
  if a.start_page is not None:
342
  cfg.start_page = a.start_page
343
  if a.end_page is not None:
 
21
  from pathlib import Path
22
  from typing import Any
23
 
24
+ from .assets import AssetSink, LocalAssetSink, NullAssetSink
25
  from .checks import check_items, vocabulary_per_page
26
  from .config import PipelineConfig
27
  from .contracts import ParsedDocument
 
115
  normalise_seconds: float
116
 
117
 
118
+ def _parse_one(
119
+ source: Path,
120
+ cfg: PipelineConfig,
121
+ doc_id: str,
122
+ asset_sink: AssetSink | None = None,
123
+ ) -> _ParseOutcome:
124
  """The per-document core: parse, normalise, assemble the `ParsedDocument`.
125
 
126
  The single place a `ParsedDocument` is built. Both `parse_one` and `run()` go
 
136
  # character-spaced formulas, so neither applies.
137
  checks: dict[str, Any] = {"backend": "mistral-ocr"}
138
  t0 = time.perf_counter()
139
+ chunks = normalise_mistral(response, doc_id, asset_sink=asset_sink)
140
  normalise_seconds = time.perf_counter() - t0
141
  else:
142
  items = json.loads(result.content_list.read_text(encoding="utf-8"))
 
196
  source: Path,
197
  cfg: PipelineConfig,
198
  doc_id: str | None = None,
199
+ asset_sink: AssetSink | None = None,
200
  ) -> ParsedDocument:
201
  """Parse one document and return the artifact in memory. Raises on failure.
202
 
 
213
  """
214
  if doc_id is None:
215
  doc_id = source.stem
216
+ return _parse_one(source, cfg, doc_id, asset_sink).artifact
217
 
218
 
219
  def run(cfg: PipelineConfig) -> Manifest:
 
252
  # Same core the ingestion service calls — the artifact is built once,
253
  # here and there. run() adds only the folder-batch concerns around it:
254
  # the manifest record and writing the result to disk.
255
+ # Assets land beside the artifact they belong to, so a run folder is
256
+ # self-describing and movable. `--no-assets` keeps the metadata and
257
+ # drops only the bytes (see assets.NullAssetSink).
258
+ sink: AssetSink = (
259
+ NullAssetSink() if cfg.no_assets else LocalAssetSink(target)
260
+ )
261
+ outcome = _parse_one(source, cfg, doc_id, asset_sink=sink)
262
  artifact = outcome.artifact
263
  result = outcome.parse_result
264
 
 
336
  # on `vlm`.
337
  p.add_argument("--effort", choices=["medium", "high"], default=None)
338
  p.add_argument("--no-resume", action="store_true", help="reprocess everything")
339
+ p.add_argument("--no-assets", action="store_true",
340
+ help="keep asset metadata but do not write image bytes to disk")
341
  p.add_argument("--start-page", type=int, default=None)
342
  p.add_argument("--end-page", type=int, default=None)
343
  a = p.parse_args()
 
353
  cfg.effort = a.effort
354
  if a.no_resume:
355
  cfg.resume = False
356
+ if a.no_assets:
357
+ cfg.no_assets = True
358
  if a.start_page is not None:
359
  cfg.start_page = a.start_page
360
  if a.end_page is not None: