Wire the guarded conversational RAG answer layer end-to-end
This commit is contained in:
@@ -1,5 +1,504 @@
|
||||
# Progress Log
|
||||
|
||||
## 2026-08-04 (evening) — Section routing: contraindication retrieval goes from 0.05 to 1.00, at zero cloud cost
|
||||
|
||||
The retrieval defect measured earlier today is fixed by routing rather than by
|
||||
embedding. **No cloud call was made and nothing was re-embedded** — Bedrock
|
||||
access is still revoked.
|
||||
|
||||
**The change.** A question that names its own attribute does not need
|
||||
similarity to guess which section answers it. `rag/sections.py` maps the
|
||||
question to a `section_key`; `QdrantRetriever.find_by_section` then filters on
|
||||
`(drug_id, section_key)` and returns **every** part of that section as a
|
||||
`scroll`, not a top-k. `RetrievalService` takes that route when it resolves and
|
||||
falls back to similarity otherwise.
|
||||
|
||||
Two rules carry the safety. **Longest phrase wins**: "chống chỉ định" and "chỉ
|
||||
định" differ by one prefix word and mean opposite things, so every phrase is
|
||||
sorted by length and the longer is tested first — the same rule keeps "quá
|
||||
liều" from being read as "liều" and "hướng dẫn xử trí ADR" from being read as
|
||||
"tác dụng phụ". **No match is not a guess**: an unrecognised question returns
|
||||
`None` and falls back rather than picking a section it is unsure of.
|
||||
|
||||
**Measured against the real `duocthu_v1` collection, no embedding involved:**
|
||||
|
||||
| | similarity (measured this afternoon) | section routing |
|
||||
|---|---|---|
|
||||
| hit@1, 160 generated cases | 0.544 | **1.000** |
|
||||
| `chong_chi_dinh` | **0.05** | **1.00** |
|
||||
| misroutes / empty / leaked sections | — | 0 / 0 / 0 |
|
||||
|
||||
**The generated 160 flattered it, and testing on human-written questions said
|
||||
so.** Those questions use the phrasings the table was built from, so 160/160 is
|
||||
partly circular. Run against the 16 single-drug questions humans actually wrote
|
||||
in `Golden Dataset/golden_e2e_v1.csv`, the first version scored **10/16**. The
|
||||
six failures were two gaps: four questions say just "Liều Metformin cho người
|
||||
lớn?" — bare "liều", which the table lacked — and one says "Bà bầu", a
|
||||
colloquial phrasing for pregnancy. Adding those phrases (no code change, which
|
||||
is what the open/closed table is for) took it to **16/16** while the confusable
|
||||
pairs still resolve correctly; bare "liều" is safe only because "quá liều" is
|
||||
longer and tested first, and there is a regression test pinning exactly that.
|
||||
|
||||
**A circular import was found and fixed properly rather than worked around.**
|
||||
`service -> sections -> routing -> service`, because `normalize_name` lived in
|
||||
`routing.py`. It is a text utility with no knowledge of drugs or sections, so
|
||||
it moved to `rag/text.py`; `routing.py` re-exports it so existing imports keep
|
||||
working.
|
||||
|
||||
**Also wired, and still unproven:** `BedrockCohereQueryEmbedder` replaces the
|
||||
SHA-256 hash embedder for the similarity fallback path. It has been
|
||||
import-checked only — **never run against Bedrock** — so the fallback path
|
||||
remains unverified end to end. The section route does not depend on it.
|
||||
|
||||
Verification actually run: ai-service **37 passed, 3 skipped** (22 before, +15);
|
||||
ingestion **296 passed** (unchanged, checked for regression); `ruff --select
|
||||
F,E9,B,ARG` over `rag/`, `adapters/`, `bootstrap.py`, `config.py` and `tests/`
|
||||
— **all checks passed**; section-route evaluation against the live collection
|
||||
160/160; human-written golden questions 16/16.
|
||||
|
||||
Not established: multi-attribute questions ("liều dùng và chống chỉ định") pick
|
||||
the longest phrase, which is deterministic but arbitrary; phrase coverage
|
||||
beyond these 16 human questions is unmeasured; and none of this speaks to
|
||||
whether the retrieved text is clinically correct.
|
||||
|
||||
## 2026-08-04 (afternoon) — First real embeddings exist; retrieval measured at 54% and the cause is not what the small sample said
|
||||
|
||||
The corpus is embedded for the first time. Bedrock IAM was opened on the
|
||||
owner's explicit instruction, all 15,100 chunks were embedded with
|
||||
`cohere.embed-v4:0`, loaded into Qdrant, and **cloud access was then revoked
|
||||
and proven revoked** before the owner's 17:00 deadline. Measured spend
|
||||
**~$0.49** of a personal $138 budget.
|
||||
|
||||
**Gate results.** 15,100/15,100 embedded; 15,100 points in `duocthu_v1` over 59
|
||||
batches; collection point count 15,100 — count gate **PASS**. Manifest records
|
||||
`cohere.embed-v4:0`, 1024 dimensions, Cosine, corpus SHA
|
||||
`04a27166eaa255b516829f8364227e65ad700e51446b569609d18b5efd11189c`. Corpus SHA
|
||||
was re-verified against the morning audit before spending: identical, and
|
||||
identical to the post-lint copy, so the 12:05 `chunker.py` edit did not change
|
||||
output.
|
||||
|
||||
**Both providers were probed live before choosing.** Titan v2 and Cohere v4
|
||||
each returned 1024 dimensions with a **measured L2 norm of 1.000000**. That
|
||||
settles a question left open since 2026-08-03: Cohere's `normalized` field was
|
||||
`None` because AWS's docs never state it. It is now measured. Cohere was chosen
|
||||
on two measured grounds — the corpus is Vietnamese and Cohere is explicitly
|
||||
multilingual, and `bedrock_cohere.py` batches 96 texts per request while
|
||||
`bedrock_titan.py` sends one, which at a measured 2.3s per call is ~9.6 hours
|
||||
versus minutes. The $0.41 price difference did not drive it.
|
||||
|
||||
**The retrieval number, and a correction to a claim made earlier the same
|
||||
day.** A 160-case evaluation (20 per section, 8 sections, questions generated
|
||||
from the corpus so labels are structural) measured **hit@1 0.544, hit@3 0.663,
|
||||
hit@5 0.738**. Per section:
|
||||
|
||||
| section | hit@1 |
|
||||
|---|---|
|
||||
| `chong_chi_dinh` | **0.05** (1/20) |
|
||||
| `chi_dinh` | 0.30 |
|
||||
| `tac_dung_khong_mong_muon` | 0.40 |
|
||||
| `lieu_luong_va_cach_dung` | 0.60 |
|
||||
| `qua_lieu_va_xu_tri` | 0.65 |
|
||||
| `than_trong` | 0.65 |
|
||||
| `tuong_tac_thuoc` | 0.80 |
|
||||
| `thoi_ky_mang_thai` | 0.90 |
|
||||
|
||||
An earlier 15-case run gave a similar headline (0.533) but led to the **wrong
|
||||
diagnosis**: four of its seven failures were contraindication questions
|
||||
answered with indications, so the cause was reported as embedding weakness at
|
||||
negation. At 160 cases that pair accounts for only **3** confusions. The
|
||||
dominant mechanism is different and larger: **`duoc_ly_va_co_che_tac_dung`
|
||||
absorbs questions from every other section** — 10 from adverse effects, 8 from
|
||||
contraindications, 7 from dosage, 5 from indications. It is the largest section
|
||||
(1,896 chunks) and describes the drug in general terms, so it sits close to
|
||||
almost any question about that drug. This is the small-sample failure mode
|
||||
CLAUDE.md warns about, reproduced on this project.
|
||||
|
||||
**Re-embedding cannot fix this, and the capability to fix it already exists.**
|
||||
Verified by reading the code, not assumed: `apps/ai-service/adapters/qdrant.py`
|
||||
`search()` filters on `drug_id` only and lets vector similarity choose the
|
||||
chunk; `rag/routing.py` resolves drug and intent but **not section**; and
|
||||
`find_by_payload` — the "return the whole section" method in `ingestion/load/`
|
||||
— is **never called anywhere in `apps/ai-service`**. Attribute questions
|
||||
therefore depend on similarity picking the right section, which is what
|
||||
measures 54%. The fix is to resolve the attribute to a `section_key` and
|
||||
retrieve that section whole; `ATTRIBUTE_TO_SECTION` already exists in
|
||||
`embed/benchmark_local.py`.
|
||||
|
||||
**A silent-failure hazard found and closed.** `apps/ai-service` embedded
|
||||
queries with `LocalHashQueryEmbedder` — SHA-256 of tokens, explicitly plumbing
|
||||
only — while the collection now holds Cohere vectors. Querying across those two
|
||||
spaces returns hits and raises nothing; the results are simply meaningless.
|
||||
`BedrockCohereQueryEmbedder` was added and wired behind
|
||||
`EMBEDDING_PROVIDER=cohere-v4`. **It has only been import-checked — never run
|
||||
against Bedrock**, because cloud access was revoked first, as instructed.
|
||||
|
||||
**Two operational lessons, both paid for.** `bedrock_runtime.py` set no boto3
|
||||
timeout, so a single throttled response held a socket open for over five
|
||||
minutes and stalled the whole run; `connect_timeout=10, read_timeout=60` plus
|
||||
standard retries fixed it. Then the first full run still died at ~14,600/15,100
|
||||
because the retry backoff (2s, 4s) was far shorter than a per-minute token
|
||||
quota needs. The disk cache made that survivable: the resumed run recorded
|
||||
**14,977 cache hits and 123 misses**, so only 123 vectors were paid for twice —
|
||||
zero, in fact, since the first run's work was already saved.
|
||||
|
||||
**Cloud shutdown, verified rather than asserted.** Both policies detached and
|
||||
deleted; `InvokeModel` and `ListFoundationModels` both now return
|
||||
`AccessDeniedException`. No EC2 instance, no EBS volume, and — because the
|
||||
policy never granted `CreateProvisionedModelThroughput` — no way for this
|
||||
identity to create the one Bedrock resource that bills hourly.
|
||||
|
||||
Not established: retrieval quality is not acceptable for clinical use, no
|
||||
clinician-authored release gate exists, the generated evaluation questions use
|
||||
template phrasing rather than real clinical language, and no LLM answer layer
|
||||
has ever run against real evidence.
|
||||
|
||||
## 2026-08-04 — Chunk schema v4 passes the embedding-readiness gate
|
||||
|
||||
Reviewed the live Claude coordination and its last changes before editing. The
|
||||
delivery plan was objectively stale: it still described schema v2/15,076 chunks,
|
||||
empty embed/load/API modules, embedding before content-safety gates, and allowed
|
||||
unverified inferred table headers as retrieval text. The plan and ADR 0004/0006
|
||||
now put content safety, exact provenance, fail-closed schema validation and local
|
||||
pseudo-vector smoke tests before any provider call. Bedrock remains benchmark-
|
||||
only and requires separate owner approval for any paid/full-corpus run.
|
||||
|
||||
Implemented schema v4 and regenerated the canonical chunk artifact. Retrieval
|
||||
`text` may repeat route/population labels so continuation chunks remain safe in
|
||||
isolation; contiguous `source_text` remains byte-reassemblable and drives exact
|
||||
physical/printed page provenance. `context_labels` records retrieval-only
|
||||
prefixes. All 151 unverified table/formula descriptors embargo `header_row` and
|
||||
cell-like column text. Attachments now carry physical page, printed page,
|
||||
`block_id`, `bbox` and optional crop, and those region references survive the
|
||||
Qdrant adapter and RAG citation response. The loader accepts exactly schema v4,
|
||||
rejects booleans/non-integers/out-of-range pages, and keeps the normalized
|
||||
LF/CRLF-stable corpus identity.
|
||||
|
||||
Canonical artifact measured after regeneration:
|
||||
|
||||
- 15,100 chunks: 14,949 prose + 151 block descriptors;
|
||||
- 4,105,382 `cl100k_base` tokens; 0 chunks above the 800-token ceiling;
|
||||
- all `chunk-ready` gates pass: exact provenance, source uniqueness,
|
||||
reassembly, attachment coverage, descriptor embargo and schema checks all
|
||||
have 0 failures; 151 descriptors match 151 quarantined blocks;
|
||||
- raw file SHA-256:
|
||||
`8dfae08ae6d9222089c5cdb4207a064fe67989f10f7552b555af0aef6331d9a1`;
|
||||
- normalized corpus SHA-256 used by the Qdrant manifest:
|
||||
`04a27166eaa255b516829f8364227e65ad700e51446b569609d18b5efd11189c`.
|
||||
|
||||
Verification actually run:
|
||||
|
||||
- ingestion: **292 passed**; focused post-lint patch: **26 passed**;
|
||||
- AI service with `RUN_INTEGRATION=1`: **25 passed**, including real local
|
||||
Qdrant, PostgreSQL and FastAPI round-trips;
|
||||
- full canonical local smoke with deterministic 4D pseudo-vectors: first and
|
||||
second loads both upserted 15,100 records and both held exactly 15,100 points;
|
||||
manifest hash matched; data and sidecar test collections were removed and
|
||||
Qdrant returned to 0 collections;
|
||||
- Ruff `F,E9,B,ARG` on the files changed for this gate: clean; `git diff
|
||||
--check`: clean (Git only reported Windows LF/CRLF conversion warnings).
|
||||
|
||||
Conclusion: the canonical corpus is **technically READY TO EMBED**, meaning its
|
||||
input/schema/provenance/load plumbing meets the measured gates. This does not
|
||||
authorize a provider call, does not establish retrieval quality for any model,
|
||||
and does not prove whole-book medical accuracy. Human-reviewed clinical eval,
|
||||
table reconstruction, and recall for borderless tables/bar-less formulas remain
|
||||
outside what these gates prove.
|
||||
|
||||
## 2026-08-04 — Real local datastore plumbing, guarded RAG API, and printed-page citations
|
||||
|
||||
Read the live Claude Code process and coordination before editing. Claude owned
|
||||
`ingestion/load/` and `embed/cache.py`; it completed the disk cache, Qdrant
|
||||
port/adapter, idempotent UUID5 upsert, payload indexes and corpus-SHA manifest.
|
||||
Its real local Qdrant scale check loaded all 15,066 chunk records twice with
|
||||
1,024-dimensional deterministic pseudo-vectors and held the point count at
|
||||
15,066. Those vectors are not embeddings and establish no retrieval-quality
|
||||
claim. No Bedrock call, IAM change, or cloud spend occurred.
|
||||
|
||||
Built the first runnable `apps/ai-service` boundary: FastAPI `/health` and
|
||||
`POST /v1/rag/query`, a Qdrant retriever filtered by resolved `drug_id`, a
|
||||
PostgreSQL trace repository plus migration, structured human/non-human scope
|
||||
and fact/recommendation intent gates, parent hydration, quarantine handling,
|
||||
and an extractive answer layer. The answer layer refuses evidence that has only
|
||||
a physical page; citations expose only the printed folio, chunk id and optional
|
||||
source crop. Quarantined tables/formulas return a PDF-verification warning and
|
||||
never auto-extract numeric content.
|
||||
|
||||
Fixed the missing provenance at its source. Chunk schema is now v3 and
|
||||
`cli chunk` reads the real folio map from the 1,668-page PDF. It refuses a
|
||||
monograph whose physical range cannot be mapped, and `chunk-ready` has a new
|
||||
`chunk_without_printed_page_range` gate. Regenerated scope: 684 monographs,
|
||||
15,066 chunks (14,915 prose + 151 descriptors), zero oversized, and
|
||||
15,066/15,066 records with a two-value printed-page range. New artifact SHA:
|
||||
`e474c83790b450d3262f532e81abf6526a485e3a98e376413247da23f4619c38`.
|
||||
|
||||
Verification actually run:
|
||||
|
||||
- `python -m pytest -q` and Ruff over `ingestion/`: **258 passed**, lint clean;
|
||||
- `python -m ingestion.cli chunk-ready`: every gate passed, including printed
|
||||
page range 0/0 failures;
|
||||
- ai-service with `RUN_INTEGRATION=1`: **22 passed**, including a real chunk
|
||||
round-trip through local Qdrant, PostgreSQL migration/insert/read-back, and a
|
||||
full FastAPI → Qdrant → guarded citation → PostgreSQL trace round-trip;
|
||||
- local Docker services: PostgreSQL 16 and Qdrant 1.18.3 reachable; integration
|
||||
collections were UUID-scoped and removed after tests;
|
||||
- ArgoCD local: namespace, CRD and seven controller pods are running; the
|
||||
existing unrelated `guestbook` lab app is Synced/Healthy with four history
|
||||
entries. This repo's three Application YAML files parse and point to
|
||||
`master`/the Helm chart, but they are not installed and the chart still has
|
||||
no workload templates, so project sync/rollback was not performed.
|
||||
|
||||
Still open: no real embedding exists, no full canonical Qdrant collection can
|
||||
serve semantic search, `population_tags` are absent, no clinician-authored
|
||||
release-gate cases exist, and the API currently has no production answer/query
|
||||
embedding provider. The local hashing provider is explicitly plumbing-only.
|
||||
|
||||
## 2026-08-04 — Load stage built and proven against a real Qdrant; bbox rounding found
|
||||
|
||||
`ingestion/load/` was a 0-byte `__init__.py`. It now holds the vector-store
|
||||
boundary: a `VectorStore` port, an `InMemoryVectorStore` that is the reference
|
||||
implementation of its contract, and `QdrantVectorStore` as the only module that
|
||||
names `qdrant_client` — imported lazily, the same arrangement that confines
|
||||
boto3 to `bedrock_runtime`. `embed/cache.py` was added alongside it.
|
||||
|
||||
Three design decisions are worth carrying forward.
|
||||
|
||||
The cache key is `(model_id, input_kind, text_sha256)`, not `chunk_id` as
|
||||
§4.A of the delivery plan proposed. Measured reason: `chunks.jsonl` holds
|
||||
15,066 records but only **14,869 distinct texts**, so 197 records (1.31%) are
|
||||
repeats that a chunk-keyed cache would pay for twice. The content key also
|
||||
cannot serve a stale vector after an edit — a changed text is a changed digest,
|
||||
so it is a miss.
|
||||
|
||||
Point ids are `uuid5(chunk_id)`. A random id would make a re-run append a
|
||||
second copy of a dose and nothing would report an error.
|
||||
|
||||
The corpus manifest lives in a `<name>__manifest` sidecar collection rather
|
||||
than a reserved point inside the data collection, because
|
||||
`qdrant_point_count != chunk_count` is a v1 gate and a gate needing an
|
||||
"except the manifest" footnote will eventually be read wrong.
|
||||
|
||||
**Whole-corpus check against a real server.** A local Qdrant **1.18.3** was
|
||||
started from `infra/docker/docker-compose.yml` (local container, no cloud) and
|
||||
all 15,066 real chunk records were loaded with deterministic pseudo-vectors at
|
||||
1,024 dimensions — a check of the loading mechanism, **not embeddings, which
|
||||
still do not exist**. Corpus sha256 `30d5154273e0959a…`. First load: 15,066
|
||||
points in 59 batches, 14.0s, point-count gate PASS. Second load: still 15,066,
|
||||
so idempotency holds at real scale, not only against the fake store.
|
||||
|
||||
**That sha is already stale, which is the point.** `chunks.jsonl` was
|
||||
regenerated at 09:53 the same day — `chunker.py` changed two minutes earlier
|
||||
and every chunk gained `printed_page_range`, 18,229,918 → 18,753,003 bytes,
|
||||
sha now `e474c83790b450d3…`. Re-measured on the new artifact: still **15,066
|
||||
chunks, 0 over the 800-token ceiling** (largest exactly 800), all 15,066
|
||||
carrying `printed_page_range`, 14,915 prose + 151 block descriptors, 197
|
||||
duplicate texts (1.31%) unchanged because only a field was added. Suite
|
||||
**258 passed**. Had the old corpus been embedded and loaded, then the new one
|
||||
loaded into the same collection, two generations would have mixed with no error
|
||||
at query time — A6 is what refuses that, and it now has a real instance rather
|
||||
than a hypothetical one.
|
||||
|
||||
**A sampled check passed and was wrong.** Comparing 5 payloads gave 5/5
|
||||
identical. Scrolling the entire collection instead found **86 of 15,066 chunks**
|
||||
whose payload did not equal its source record. Classifying every differing leaf:
|
||||
**96 differences, all floats, all inside `attachments[].bbox`, maximum absolute
|
||||
delta 5.684e-14**, and **zero** non-float differences — every text, id, page
|
||||
number, page range, token count and boolean round-tripped exactly. A PDF point
|
||||
is 1/72 inch, so that delta cannot move a rendered crop. It is pinned by a
|
||||
regression test that fails if the loss reaches another field or grows past 1e-9.
|
||||
|
||||
The layer responsible was isolated rather than assumed: the source
|
||||
`chunks.jsonl` returns the value exactly, our own `json.dumps`/`loads` returns
|
||||
it exactly, and **Qdrant reached over raw HTTP with no SDK involved** returns it
|
||||
one ULP low. Nothing needs re-chunking — a regenerated corpus would carry the
|
||||
identical value and be rounded identically. Qdrant also stores dense vectors as
|
||||
float32, so precision beyond f32 is discarded at load regardless.
|
||||
|
||||
Cache format was decided on measurements, not preference: 300 real chunk texts
|
||||
at 1,024 dimensions cost **21,098 bytes/record — ~318 MB per model** for the
|
||||
corpus, with a **7.8s** offset-index rebuild per open. float32 `.npy` (62 MB)
|
||||
and base64 float32 in JSONL (~87 MB) were measured and set aside; append-only
|
||||
JSONL survives an interrupted run and stays readable, which outweighs disk at
|
||||
one or two models. Revisit at three (~950 MB). It lands in
|
||||
`ingestion/data/processed/`, already excluded by `.gitignore:34`.
|
||||
|
||||
**A gap in this work, found and closed the same day.** Payload indexes were
|
||||
created on `drug_id`, `section_key`, `atc_codes` and `chunk_kind` and reported
|
||||
as done — but `VectorStore` had no query method, so all that was really proven
|
||||
is that `create_payload_index` returns without raising. Filtered retrieval is
|
||||
the whole of mode A. `find_by_payload` now exists on the port and both stores,
|
||||
as a `scroll` rather than a `search`: it returns **every** match, never a
|
||||
top-k, because "return the whole section" is the plan's non-negotiable — two of
|
||||
five contraindications reads as a complete list. Verified on a real server: all
|
||||
five parts returned with no leak from the PANTOPRAZOL/OMEPRAZOL pair that
|
||||
measures cosine 1.000 on contraindications; a deliberately 300-part section
|
||||
(above the 256 scroll page) comes back whole so paging cannot truncate; and a
|
||||
real multi-part section from `chunks.jsonl` round-trips to exactly its own
|
||||
chunk ids.
|
||||
|
||||
Tests: **255 passed** with Qdrant running (206 before this work, +49);
|
||||
**247 passed, 8 skipped** with it stopped, so an offline machine and CI see
|
||||
skips rather than failures. After the mode A work and the other worktree's
|
||||
`cli.py` fix the suite stands at **268 passed** and
|
||||
`ruff --select F,E9,B,ARG` reports **no findings at all** across `ingestion/`.
|
||||
|
||||
Still missing, and deliberately so: `printed_page_range` and `population_tags`
|
||||
are not in the payload (open questions to Codex in
|
||||
`coordination/CLAUDE_TASK_2026-08-04.md`); `cli embed` / `cli load` are not
|
||||
wired because `cli.py` is Codex's; and **no real embedding vector has ever been
|
||||
produced** — every vector the load path has carried was synthetic. The Bedrock
|
||||
request shapes remain documentation-derived and unproven.
|
||||
|
||||
Measured cost: **$0**. No Bedrock call, no IAM change, no cloud resource.
|
||||
|
||||
## 2026-08-03 — Bedrock embedding boundary built; IAM diagnosed, not yet opened
|
||||
|
||||
`ingestion/embed/` was an empty `__init__.py`. It now holds the provider
|
||||
boundary the model benchmark needs: an `EmbeddingProvider` ABC that owns input
|
||||
validation, request-size batching and timing, and three adapters behind it —
|
||||
`amazon.titan-embed-text-v2:0`, `cohere.embed-v4:0`, and `BAAI/bge-m3` as the
|
||||
zero-cost local control. boto3 is named in exactly one module and imported
|
||||
lazily, so the package imports and the whole suite runs with no AWS account.
|
||||
|
||||
Two design points are worth carrying forward. `input_kind` is a required
|
||||
argument, not a keyword: Cohere embeds corpus records and queries into
|
||||
different subspaces, and sending `search_document` for a query raises no error
|
||||
— recall just drops. And `normalized` is three-valued. Titan is asked to
|
||||
normalize and says so; the Bedrock docs never state whether Cohere's float
|
||||
vectors are unit-length, so that field stays `None` instead of guessing, and
|
||||
`embed.probe` prints a *measured* L2 norm to settle it on the first live call.
|
||||
|
||||
The AWS side is diagnosed and stuck. `ai-lab-user` has no inline and no
|
||||
attached user policy; its one group (`AI-Lab-Group`) grants EC2, IAM, ELB and
|
||||
VPC full access and nothing else. There is no `bedrock:*` grant anywhere on
|
||||
the identity — confirmed by running both `list-foundation-models` and
|
||||
`invoke-model` and reading the two `AccessDeniedException` messages. Two
|
||||
least-privilege policies are drafted in `infra/aws/iam/` but **deliberately
|
||||
not applied**: that identity carries `IAMFullAccess` and could attach them
|
||||
itself, which is exactly why it was left to a human.
|
||||
|
||||
Consequence: every request-body shape in the two Bedrock adapters is derived
|
||||
from the AWS user guide (read today) and **has never been accepted by the
|
||||
service**. That is unproven, not verified. Tests: 22 new, all with a stub
|
||||
invoker and zero network; **203 passed** overall, up from 181. Lint clean on
|
||||
every file added (`--select F,E9,B,ARG`); the one remaining finding is a
|
||||
pre-existing `cli.py` import owned by the other worktree.
|
||||
|
||||
Measured cost so far: **$0**. Nothing was embedded, nothing reached Qdrant.
|
||||
|
||||
## 2026-08-03 — Exact hard-10 gate and all-block table chunking experiment
|
||||
|
||||
Extended the isolated table/formula sandbox beyond the 100-page sample. An
|
||||
exact ten-block risk gate covered four cross-page pairs, a merged header, a
|
||||
fragmented fraction bar, and the bar-less ADENOSIN formula; all ten source crops
|
||||
were visually checked. The full run then processed all 151 canonical blocks:
|
||||
141 physical tables, ten formulas, 133 logical table parents, 669 row children,
|
||||
and seven cross-page logical tables.
|
||||
|
||||
Full-scope visual inspection exposed a continuation bug: FAMCICLOVIR p647 and
|
||||
INSULIN p811 repeat their column headers, while other continuation pages start
|
||||
directly with data. The linker now distinguishes these cases; repeated headers
|
||||
are not emitted as data, and INSULIN's changed `Phối hợp` first-column meaning
|
||||
is preserved. Both branches have regressions.
|
||||
|
||||
The expanded, source-derived retrieval suite contains 2,436 cases. With drug
|
||||
and table/formula lane resolved before ranking, deterministic hybrid character
|
||||
TF-IDF measured 94.42% Recall@1, 99.79% Recall@5, and 96.90% MRR. Row questions
|
||||
were 94.82% / 100%; formula questions 100% / 100%. Five ambiguous whole-table
|
||||
questions fell below top five because the same drug owns several near-identical
|
||||
tables; production must clarify or route using an additional table anchor.
|
||||
Neural MiniLM is now opt-in and excluded from the default parsing gate.
|
||||
|
||||
Measured chunk design: table-parent tokens min/median/p90/p95/max =
|
||||
66/188/441/678/1,893; only four of 133 parents exceed 800. Row children are
|
||||
75-token median, 172 p95, 471 max. Keep every logical parent intact, index both
|
||||
parent and header-aware rows, never split a row, and hydrate row hits to the
|
||||
complete parent/source pages. Final checks: **181 tests passed**, readiness
|
||||
20/20, lint clean.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-03 — 100-page table/formula reconstruction and RAG sandbox
|
||||
|
||||
Built an isolated experiment under `ingestion/scratch/rag-table-pilot` without
|
||||
writing sandbox representations into the canonical corpus. The risk-stratified
|
||||
100-page run reconstructed 120 tables and 10 formula regions, rendered and
|
||||
manually inspected all 130 crops, and linked four tables continued across page
|
||||
pairs 132-133, 646-647, 825-826, and 1373-1374.
|
||||
|
||||
The retrieval router fixes the drug and data lane before vector ranking. On 461
|
||||
source-derived queries, hybrid row+whole character TF-IDF reached 92.62%
|
||||
Recall@1, 98.70% Recall@5, and 95.04% MRR. Cached English-oriented MiniLM was
|
||||
worse (88.29% / 97.18% / 91.92%). Eighteen row-hit answer previews all hydrated
|
||||
to the complete parent Markdown table; eight included both pages of a continued
|
||||
table. A narrow deterministic interval probe passed 172/172 generated cases;
|
||||
this is a mechanics check, not clinical ground truth.
|
||||
|
||||
Visual review exposed one canonical defect: ADENOSIN p147's bar-less printed
|
||||
formula region ended after its numerator and omitted `Nồng độ adenosin
|
||||
(3 mg/ml).` The bar-less band now extends 31pt below its synthetic anchor,
|
||||
capturing the denominator but stopping before `Ví dụ:`; a regression pins that
|
||||
boundary. Canonical artifacts were regenerated after the fix: 684 monographs,
|
||||
11,974 sections, 15,066 chunks, 151 descriptors, 0 unassigned spans, all 20
|
||||
readiness gates passing, **180 tests passed**, and lint clean.
|
||||
|
||||
Decision: JSON grid + Markdown answer view, row and whole-table retrieval, and
|
||||
mandatory parent hydration are viable for the next stage. This remains a
|
||||
retrieval experiment, not production clinical approval; merged-cell semantics,
|
||||
unit/multi-axis reasoning, Vietnamese embedding comparison, borderless/bar-less
|
||||
recall, clinician-authored evals, and final expert review remain open.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-03 — Whole-corpus parser repair after manual baseline audit
|
||||
|
||||
Implemented and re-ran the parser over all 1,668 pages after manually reading
|
||||
the high-risk baseline outliers. The fixes are structural, with regressions:
|
||||
|
||||
- restored the missing `THUỐC TƯƠNG TỰ HORMON GIẢI PHÓNG GONADOTROPIN`
|
||||
boundary (`Tên chung quốc tế và mã ATC` is its real first anchor), separating
|
||||
pages 1371–1373 from `THUỐC PHIỆN - OPIAT - OPIOID`;
|
||||
- require both physical and inferred printed page bounds, so back-index page
|
||||
1655 can no longer extend ZOLPIDEM's real `[1492, 1494]` range;
|
||||
- keep plain label-shaped text as body when it is an adjacent wrapped
|
||||
continuation in the same PDF block (including NADROPARIN's “không phải là
|
||||
chống chỉ định”);
|
||||
- classify known table cells before headings, putting WARFARIN and IOBITRIDOL
|
||||
dosing tables back under `lieu_luong_va_cach_dung`;
|
||||
- added confirmed heading variants for CLORPHENIRAMIN dosage forms and tetanus
|
||||
toxoid dosing, and real provenance for combined inline fields;
|
||||
- made verified formula bands column-aware: NETILMICIN opposite-column prose
|
||||
is retained while AMPICILIN's gutter-adjacent formula stays quarantined;
|
||||
- visually inspected all **151/151 unique table/formula regions** against the
|
||||
rendered PDF; every region is genuinely 2D and remains quarantined;
|
||||
- emit every physical table/formula region atomically at its first stream
|
||||
occurrence, fixing split/contradictory ownership on CAPECITABIN, IMATINIB,
|
||||
CARBOPLATIN, NETILMICIN, and TRASTUZUMAB;
|
||||
- route explicit `Bảng N. Điều chỉnh liều ...` appendices back to dosage even
|
||||
when the book prints them after `Tên thương mại` (CAPECITABIN p309);
|
||||
- added readiness gates for every individual section part's source-span IDs
|
||||
and duplicate physical-region IDs.
|
||||
|
||||
Final regenerated artifacts and evidence:
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| tests | **180 passed**; lint clean |
|
||||
| segmentation | **684 monographs**, 11,974 sections, 8,213,036 prose chars |
|
||||
| back-index validation | **96.2% recall (678/705), 99.1% precision** |
|
||||
| quarantined regions | **151 blocks / 151 unique IDs**, all visually checked |
|
||||
| chunks | **15,066** (14,915 prose + 151 block descriptors), 0 over 800 tokens |
|
||||
| chunk readiness | **20/20 PASS** (including duplicate-region prevention) |
|
||||
| coverage | 252,799 spans, **0 unassigned** across all 1,668 pages |
|
||||
| residual ink | 3,931 classified regions, **0 unclassified** across all pages |
|
||||
|
||||
Canonical `ingestion/data/processed/{monographs,chunks,coverage_ledger}` were
|
||||
regenerated. Remaining limits: no whole-document human-reviewed clinical
|
||||
ground truth, no row/column reconstruction for quarantined tables, and unknown
|
||||
recall for borderless tables/bar-less formulas. This is ready for retrieval
|
||||
experiments, not a claim of production clinical approval.
|
||||
|
||||
---
|
||||
|
||||
Chronological record of work done on this project, newest entry on top. The
|
||||
goal is continuity across sessions: if a work session ends unexpectedly
|
||||
(context/token limit, interruption), whoever picks this up next — human or
|
||||
@@ -13,6 +512,300 @@ end if that risk is showing.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-03 — Independent re-verification, a redundant rule in my own uncommitted fix, a provenance defect, and the measurements a retrieval design has to be built on
|
||||
|
||||
No code was changed in this session: the four files from the previous round
|
||||
are still uncommitted and under external review (Codex). Everything below is
|
||||
measurement, and the numbers live nowhere else — the investigation scripts
|
||||
were deleted per the repo rule, so this entry is the record.
|
||||
|
||||
### 1. Re-verified the whole tree from scratch
|
||||
|
||||
| check | command | result |
|
||||
|---|---|---|
|
||||
| tests | `python -m pytest -q` | **164 passed**, 39.31s |
|
||||
| lint | `ruff check --select F,E9,B,ARG .` | clean |
|
||||
| gates | `cli chunk-ready` | **18/18 PASS**; 683 monographs, 11,966 sections, 8,212,880 chars, 167 quarantined blocks |
|
||||
| recall/precision | `cli validate --pdf …` | 683 detected, 705 ground truth, **96.0% (677/705) / 99.1%** |
|
||||
| span ledger | `cli coverage --pdf …` (re-run) | 252,799 spans after merge, 9,398,772 chars, **unassigned 0** |
|
||||
| reproducibility | `cli run` → sha256 | **byte-identical** to `monographs.jsonl` (`84f41d96…`) |
|
||||
| reproducibility | `cli chunk` → sha256 | **byte-identical** to `chunks.jsonl` (`63472db4…`); 15,076 chunks (14,909 prose + 167 descriptors), 0 oversized, 4,072,725 tokens (cl100k_base) |
|
||||
|
||||
Recall rose 92.9% → 96.0% because of the uncommitted back-index rejoin, and
|
||||
the mechanism is the denominator: 725 → 705 ground-truth entries once wrapped
|
||||
fragments stop counting as entries. The detector did not improve.
|
||||
|
||||
**Not re-run: `cli residual-ink`.** `residual_ink.json` is dated 2026-08-01
|
||||
12:03, before the 17:36 assembler edits. Its stored contents (3,931 regions,
|
||||
no `unclassified` kind) are last session's numbers, not this session's.
|
||||
|
||||
**Doc drift found:** `docs/verification-strategy.md` quotes 252,733 spans /
|
||||
177,679 `normalized_text` / 12,764 `heading`; measured today 252,799 /
|
||||
177,754 / 12,752. The `unassigned = 0` conclusion still holds.
|
||||
|
||||
### 2. The x0 geometry change in the uncommitted diff is redundant
|
||||
|
||||
Assembled the whole book four times with the two new rules toggled:
|
||||
|
||||
| variant | monographs | sections | chars |
|
||||
|---|---|---|---|
|
||||
| current (x0 + italic) | 683 | 11,966 | 8,212,880 |
|
||||
| **old x1 rule + italic** | 683 | 11,966 | **8,212,880** — 0 differences of any kind |
|
||||
| x0, no italic | 683 | 11,966 | 8,212,844 (3 sections differ) |
|
||||
| x1, no italic (= `bc01782`) | 683 | 11,966 | 8,212,780 (9 sections differ, 11,014 char delta) |
|
||||
|
||||
The italic rule alone recovers all 9 sections (CEFAZOLIN dosing 1,062 →
|
||||
5,620 chars; CALCI LACTAT `than_trong` 582 → 1,504, `tuong_tac_thuoc` 2,346 →
|
||||
1,439). The x0 rule alone recovers 6 of 9 and adds **nothing** on top of the
|
||||
italic rule.
|
||||
|
||||
Worse, the justification is wrong: the real NEVIRAPIN span on physical page
|
||||
1045 is `TimesNewRomanPS-ItalicMT` (verified by reading the span's font), so
|
||||
the italic rule is what fixes that page — not the 0.01pt overlap the code
|
||||
comment and the new test's docstring credit. The test itself is valid but
|
||||
pins the geometric rule only, because the `_span()` fixture helper never
|
||||
produces an italic font. **Either keep the x0 rule as defence-in-depth with
|
||||
an honest comment, or revert it — but the current comment overstates it.**
|
||||
|
||||
### 3. `source_page_range` is wrong for 13 of 683 monographs
|
||||
|
||||
Section-level provenance (`parts`) is correct everywhere; the monograph-level
|
||||
page range is not. 12 monographs overshoot by +1 page; **ZOLPIDEM declares
|
||||
`[1492, 1655]` while every one of its sections comes from 1492-1494** — a
|
||||
164-page claim reaching into the back index.
|
||||
|
||||
Root cause for ZOLPIDEM, confirmed: physical page 1655 (printed 1656, back
|
||||
matter) carries a **bold** span reading exactly `Tương tác thuốc`, which
|
||||
`_classify` emits as a `_SectionEvent`, and the `_SectionEvent` branch at
|
||||
`segment/assembler.py:496` updates `source_page_range[1]` with **no
|
||||
`in_monograph_range` guard** — unlike the `_TextEvent` branch at line 511.
|
||||
Verified that **0 spans past physical 1495 pass `in_monograph_range`**, so no
|
||||
text was contaminated and `empty_section` is still 0. The defect is confined
|
||||
to one provenance field.
|
||||
|
||||
The +1 cause is **not isolated** — it is not lifted tables (all 12 have
|
||||
`tables: []`); the likely candidate is a next-page boilerplate span bumping
|
||||
the range before being excluded, but that was not measured.
|
||||
|
||||
### 4. Corpus profile — what a retrieval design actually has to work with
|
||||
|
||||
- **13 of 19 fields have p90 < 1,500 chars**, i.e. the whole section fits one
|
||||
chunk. Only four routinely need splitting: `duoc_ly` (p90 4,939, max
|
||||
14,099), `lieu_luong` (4,873 / 14,197), `than_trong` (2,419), `tuong_tac`
|
||||
(2,147). Confirms ADR 0004 on the cleaned corpus.
|
||||
- **ATC**: 668/683 (97.8%) carry ≥1 code, **171 (25.0%) carry more than one**,
|
||||
max 20, 1,043 distinct codes.
|
||||
- **`ten_thuong_mai` present in 492 (72%)** monographs.
|
||||
- **The back index holds 344 `X - xem Y` lines** — brand → generic aliases —
|
||||
which `parse_back_index` currently discards wholesale (correct for
|
||||
validation, but this is the highest-value retrieval asset in the book,
|
||||
because clinicians type brand names).
|
||||
- **401 `xem [thêm] mục/chuyên luận` phrases across 261 monographs**; a chunk
|
||||
containing one is useless retrieved alone.
|
||||
- **Dosing population markers**: `Trẻ em` 53%, `Người lớn` 51%, `Người cao
|
||||
tuổi` 15%, `Trẻ sơ sinh` 8%, `Suy thận` 8%, `Suy gan` 6% of 682 dosing
|
||||
sections — real sub-section boundaries, better split points than token
|
||||
windows.
|
||||
- **167 quarantined blocks, 129 (77%) inside `lieu_luong_va_cach_dung`** —
|
||||
the most dangerous field is the one the tables were lifted out of.
|
||||
|
||||
### 5. Cross-drug confusability — the number that decides the architecture
|
||||
|
||||
First hypothesis (much repeated boilerplate across drugs) was **refuted**:
|
||||
only **171 of 11,966 sections** share exact text with another drug (1.4%), and
|
||||
the six heavy clinical fields are 100% distinct.
|
||||
|
||||
Then measured, per field, each drug's TF-IDF cosine against its *nearest other
|
||||
drug*. **This is a lexical proxy, not an embedding measure** — it bounds the
|
||||
problem from one side only.
|
||||
|
||||
| field | median | p90 | p99 | max | drugs with NN > 0.7 |
|
||||
|---|---|---|---|---|---|
|
||||
| `lieu_luong_va_cach_dung` | 0.314 | 0.455 | 0.631 | 0.836 | 4 (0.6%) |
|
||||
| `tuong_tac_thuoc` | 0.284 | 0.461 | 0.870 | 0.984 | 18 (2.8%) |
|
||||
| `tac_dung_khong_mong_muon` | 0.300 | 0.437 | 0.856 | 1.000 | 13 (1.9%) |
|
||||
| `chi_dinh` | 0.408 | 0.637 | 0.885 | 0.924 | 31 (4.5%) |
|
||||
| `chong_chi_dinh` | 0.346 | 0.633 | 0.898 | **1.000** | 37 (5.4%) |
|
||||
|
||||
Named pairs: `PANTOPRAZOL ↔ OMEPRAZOL` (contraindications **1.000**,
|
||||
indications 0.913) · `BENZATHIN PENICILIN G ↔ PHENOXYMETHYLPENICILIN`
|
||||
(contraindications **1.000**) · `DIGOXIN ↔ DIGITOXIN` (0.891 / 0.911) ·
|
||||
`NATRI NITRIT ↔ NATRI THIOSULFAT` (dosing 0.631 — two different steps of the
|
||||
same cyanide-antidote protocol) · `IOBITRIDOL ↔ ACID IOXAGLIC` (0.984) ·
|
||||
`ESTRIOL ↔ ESTRON` · `GLICLAZID ↔ GLIMEPIRID` · `NAPHAZOLIN ↔ OXYMETAZOLIN`.
|
||||
|
||||
Name layer: **19 drug names are a substring of another drug name**
|
||||
(`CLOROTHIAZID` in `HYDROCLOROTHIAZID`, `EPHEDRIN` in `PSEUDOEPHEDRIN`,
|
||||
`LORATADIN` in `DESLORATADIN`, `ATROPIN` in `HOMATROPIN HYDROBROMID` — all
|
||||
genuinely different drugs), and 106 of 683 names share a 6-character prefix
|
||||
across 38 clusters.
|
||||
|
||||
**Conclusion drawn from this, for the retrieval design: vector similarity must
|
||||
never be allowed to choose the *drug* — only which passage within an
|
||||
already-resolved drug.** The dangerous confusions are concentrated in a
|
||||
small, enumerable set of same-class pairs, which is exactly the population
|
||||
this project's verification strategy says to census rather than sample.
|
||||
|
||||
### Not done yet / next up
|
||||
|
||||
Sequenced in **`docs/v1-delivery-plan.md`** (written this session): a
|
||||
two-week plan to a running v1, scoped down to two deployables (`web` +
|
||||
`ai-service`) because the four NestJS services measure 0 `.ts` files each.
|
||||
The items below are the ones that plan depends on.
|
||||
|
||||
- The confusable-pair census must become a **committed fixture produced by
|
||||
production code** (`ingestion/validation/`), not a deleted scratch script.
|
||||
Until then these numbers are only in this entry.
|
||||
- ADR 0007 (retrieval architecture) not written. Proposed content: vectors
|
||||
never pick the drug; the unit returned to the LLM is the **complete
|
||||
section** (enabled by `section_not_reassemblable_from_chunks = 0`, because a
|
||||
partial contraindication list reads as "no contraindication"); and eval
|
||||
split in two — **routing** correctness (ground truth derivable from the
|
||||
corpus itself, 683 × 19 pairs, no human needed) versus **content**
|
||||
correctness (requires a clinician; cannot be self-generated without
|
||||
fabricating evidence).
|
||||
- Entity/alias layer (683 canonical names + 344 back-index aliases + 492
|
||||
`ten_thuong_mai` + 1,043 ATC codes) — zero-regret, needed by every
|
||||
architecture, must use longest-exact-match because of the 19 substring
|
||||
traps.
|
||||
- `residual-ink` re-run; `verification-strategy.md` numbers re-synced;
|
||||
regression test for `parse_back_index` (still has none); the
|
||||
`source_page_range` guard; the x0-rule comment decision.
|
||||
- Open question for the user, not a technical one: this is the **2018
|
||||
edition**; the 3rd edition (2022) exists. For a document with legal force
|
||||
over prescribing, staying on 2018 should be a deliberate decision, and it
|
||||
makes edition-independence a real requirement for the pipeline.
|
||||
- Still untouched: `embed/`, `load/`, Qdrant, `ai-service`, and the general
|
||||
chapters (printed 37-98) and appendices (printed 1497-1528), which remain
|
||||
outside the corpus entirely.
|
||||
|
||||
## 2026-08-01 (cont'd, 7) — "still errors?" — yes: two more real content-loss bugs, both in dosing sections
|
||||
|
||||
Asked whether errors remained after the previous round, the honest answer was
|
||||
that this session has found real defects every time it looked one level
|
||||
deeper. It looked again, and found two more.
|
||||
|
||||
**1. Chunks ended on a bare population label, with the dose in the next
|
||||
chunk.** `split_sentences` treats `:` as a sentence boundary and
|
||||
`_OPENS_SENTENCE` accepts a digit, so `"Người lớn: 500 mg mỗi 8 giờ."` splits
|
||||
after the colon. When the packer flushed at that point, the chunk ended on the
|
||||
label. Measured: **38 prose chunks**, e.g. AMOXICILIN's ending on a Lyme
|
||||
indication followed by a bare `Người lớn:`. Retrieval on that chunk returns a
|
||||
population with no dose; on the next, a dose with no population. Outlier item
|
||||
17 counted population markers on 1,121 of ~1,400 monograph pages, so this is
|
||||
the common shape, not an edge case. The packer now carries trailing label
|
||||
atoms into the next part instead of flushing on them: **38 → 2**, and chunks
|
||||
ending on any colon **721 → 19**.
|
||||
|
||||
**2. A section name printed mid-line was swallowed as a heading — real text
|
||||
loss, in dosing sections.** Chasing the last 2 of those 38 showed the defect
|
||||
was not in chunking at all. CISPLATIN (physical page 402) prints
|
||||
`Suy thận: Chống chỉ định.` inside `liều lượng và cách dùng`; the second half
|
||||
is itself a section name, so it was matched as a heading. The result: the
|
||||
renal-impairment contraindication **disappeared from the dosing text** and the
|
||||
section ended on a bare `Suy thận:`. ISOPRENALIN had the same shape. Same
|
||||
family as the FLUOROURACIL bug fixed earlier today, but that rule only covered
|
||||
a label directly *under* a heading and could not see this one.
|
||||
|
||||
Fixed geometrically: a real section heading opens its line, so a non-bold
|
||||
section name with another span printed to its left is body text. "To the left"
|
||||
is checked properly — same page/block/line *and* `previous.x1 <= span.x0` —
|
||||
because the synthetic test fixtures place every span at identical coordinates,
|
||||
and a looser check passed on real data while breaking the AMITRIPTYLIN
|
||||
inline-value case.
|
||||
|
||||
Verified after the fix: CISPLATIN's dosing section contains
|
||||
`Suy thận: Chống chỉ định.` again, ISOPRENALIN's `Trẻ em:` is followed by its
|
||||
doses, and `chong_chi_dinh` is no longer polluted. Monograph and section counts
|
||||
unchanged at 683 / 11,966 — nothing was traded away for the recovery.
|
||||
|
||||
**State:** 18/18 gates pass, **163 tests** (was 161), ruff F/E9/B/ARG clean,
|
||||
15,077 chunks with 0 over the ceiling, 8,212,780 section characters.
|
||||
|
||||
**Standing conclusion, worth writing down:** every round of "is it clean now?"
|
||||
this session has ended with real defects found — five in the previous round,
|
||||
two in this one, and four of the previous five were in code written the same
|
||||
day. The gates and tests prove what those instruments can see. They do not
|
||||
prove the corpus is correct, and the largest unmeasured area is unchanged:
|
||||
content accuracy against the source, with no human-reviewed ground truth for
|
||||
8.2M characters.
|
||||
|
||||
## 2026-08-01 (cont'd, 6) — Bug hunt after declaring "clean": the token count was wrong by 2x, 14.7% of chunks were over the ceiling, and two stage boundaries measured different pipelines
|
||||
|
||||
I had just reported the tree as clean. It was not. Going looking properly
|
||||
found five real defects, four of them in code written earlier the same day.
|
||||
|
||||
**1. `estimate_tokens` was wrong by a factor of two, and the number it
|
||||
produced was reported.** ADR 0004 sized chunks with `len(text) // 4`,
|
||||
described honestly as an estimate. Measured against `cl100k_base` on the real
|
||||
corpus:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| estimate (chars/4) | 2,115,427 tokens |
|
||||
| real tokenizer | **4,093,440 tokens** |
|
||||
| real/estimate | median **1.95**, p95 2.50, max **6.0** |
|
||||
| oversized by estimate | **0** |
|
||||
| oversized in fact | **1,884 of 12,838 = 14.7%**, largest 1,645 tokens |
|
||||
|
||||
Vietnamese diacritics cost several byte-pair tokens each. "0 oversized" was
|
||||
reassuring and false. `chunk/tokens.py` now counts with the real tokenizer,
|
||||
injected so the chunking logic stays testable without it, with a fallback of
|
||||
chars/2 that errs small rather than large.
|
||||
|
||||
**2. The packer could exceed the ceiling on its own.** Two causes, both
|
||||
measured on VORICONAZOL's `tương tác thuốc`: an atom of 710 tokens was left
|
||||
whole because it was under the 800 ceiling, and the overlap builder added
|
||||
whole atoms until the running total *passed* the budget, so a 251-token atom
|
||||
produced a 273-token overlap against a 65-token setting. 273 + 710 = 983.
|
||||
Atoms are now split against the 650 target, leaving room for overlap, and the
|
||||
overlap stops *before* exceeding its budget.
|
||||
|
||||
**3. An over-long comma list was left as one atom.** VORICONAZOL's
|
||||
interaction list is one "sentence" hundreds of drug names long. Truncated by
|
||||
an embedding model it reads as "this drug is not listed" — a false negative
|
||||
in the direction that matters. Split at commas, which is lossless for a list.
|
||||
|
||||
After 1-3: **0 chunks over the ceiling**, verified by an independent tiktoken
|
||||
re-count of the written file, not by the pipeline's own number. 15,049 chunks
|
||||
(was 12,838 — the rise is real sub-chunking that should have happened all
|
||||
along).
|
||||
|
||||
**4. `cli validate` measured a different pipeline than `cli run`.** It used
|
||||
the raw span stream (no transcription repair) and passed no table regions, so
|
||||
recall/precision described a build that is not the one producing the output —
|
||||
the same class of mismatch already fixed for `coverage`. Now shares
|
||||
`_extracted_and_repaired_spans` and `_region_index`. Result after the fix is
|
||||
unchanged at 92.9% / 99.1%.
|
||||
|
||||
**5. `chunk/io.py` dropped `SectionPart` when reading monographs back**, so
|
||||
per-part provenance died at the stage boundary — against CLAUDE.md's explicit
|
||||
rule. Now carried: 12,290 parts across 11,966 sections.
|
||||
|
||||
**Two new gates, and the gate itself was wrong twice before the data was.**
|
||||
`section_not_reassemblable_from_chunks` rebuilds each section from its own
|
||||
chunks by removing the deliberate overlap and compares. First version joined
|
||||
chunk texts with a newline and reported **734** sections missing — the first
|
||||
one it named was present. Second version probed a 60-character head and
|
||||
reported **1**, NAPROXEN, where the probe straddled an overlap seam that
|
||||
legitimately repeats text. The working version compares with whitespace
|
||||
removed, because each split seam loses exactly one space to `.strip()`
|
||||
(measured on ABACAVIR: two single spaces in a 4,232-character section,
|
||||
nothing else). It proves no character of content is lost, reordered or
|
||||
duplicated beyond the intended overlap. **0.**
|
||||
|
||||
**Also fixed:** all 8 real lint findings (`ruff --select F,E9,B,ARG`) — five
|
||||
unused imports and three `zip()` calls without explicit `strict=`. The zips
|
||||
were the adjacent-pair idiom and not bugs; `strict=False` now says so. And
|
||||
the transcription splice could leave a fragment holding only a space, which
|
||||
showed up as two `whitespace_only` spans; dropped, and proven inert by the
|
||||
sha256 over every section's text being byte-identical before and after
|
||||
(`6af13301…`).
|
||||
|
||||
**State after the hunt:** 18/18 gates pass (10 corpus + 8 chunk), 161 tests
|
||||
(was 158), `ruff F/E9/B/ARG` clean, `unassigned = 0`, `cli validate` 92.9% /
|
||||
99.1%, 15,049 chunks with 0 over the ceiling.
|
||||
|
||||
## 2026-08-01 (cont'd, 5) — ADR 0006 implemented: chunks now reference their lifted blocks; `chunk/` runs for the first time; 16/16 gates green
|
||||
|
||||
**Why this was needed, in one line**: a chunk of a section whose table had
|
||||
|
||||
Reference in New Issue
Block a user