Commit Graph

52 Commits

Author SHA1 Message Date
Brummel 79de4e1404 feat: enforce + surface packed index binding (closes #4)
Iteration 1 shipped the packed format with the corpus_sha256 + embed_model
manifest in its header but only validated structure at load. A corpus release
or an embed_model change could still ship an index that loads "successfully"
yet belongs to a different corpus/model, and the only signal was the anonymous
degraded:true — indistinguishable from a transient IONOS outage. This closes
that gap.

load_packed_or_store now, after the structural checks pass, compares the
header's embed_model against cfg.embed_model and its corpus_sha256 against a
fresh hash of cfg.alpha_id_path. On either disagreement it returns
IndexStatus::Mismatch("embed model" | "corpus sha256") with no vector index,
so Mode Hybrid degrades to Lexical rather than serving a matrix that does not
belong to the deployed corpus/model. The check is ordered model-then-corpus so
a model mismatch never reads the corpus file.

The mismatch is now observable two ways, not just via degraded:
- Pipeline::load emits a prominent stderr warning naming the packed path and
  the reason when the index is a Mismatch.
- IndexStatus gains a Display (ok / absent / mismatch: <reason>) and the human
  diagnostics line carries an index=<status> token, e.g.
  "[3 segments, mode Lexical, 12 ms, degraded=false, index=mismatch: corpus sha256]".
The index_status also rides in the JSON diagnostics (it was already a
serialized Diagnostics field from iteration 1). index_status is orthogonal to
degraded: a transient IONOS degrade leaves index_status=Ok, a structural
binding problem sets Mismatch.

Tests (67 green, +6 net): hermetic unit tests for the model- and
corpus-hash-mismatch rejections and a matching-pair acceptance, a Display
render test, a lib test that a Mode Hybrid suggest under a mismatch is
degraded with index_status=Mismatch (degrading before any IONOS post, so no
network), an end-to-end CLI test asserting the load warning and the
index=mismatch token reach stderr, and the existing transient-degrade test
extended with the index_status=Ok foil. The iteration-1
packed_beats_store_builds_without_store test was repaired (hermetic temp corpus
+ a real header hash) since the new semantic enforcement necessarily rejects
its previous structural-only-era placeholder hash; its intent (packed beats
store) is unchanged.

No IONOS calls are made by any test. The per-file store, embedding build, and
the VectorIndex are untouched. mmap remains rejected (spec § Out of scope).

Spec: docs/specs/2026-05-31-packed-versioned-index-bundle.md
Plan: docs/plans/2026-06-01-packed-versioned-bundle-iter2.md

closes #4

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:59:25 +02:00
Brummel 9bd64840f5 plan: index binding enforcement iteration 2 (refs #4)
Bite-sized TDD plan for iteration 2 of the packed-versioned-bundle cycle:
the enforcement + observability layer on the format iteration 1 delivered.
Task 1 adds Display for IndexStatus and the semantic corpus_sha256/embed_model
comparison inside load_packed_or_store (hermetic unit tests). Task 2 adds the
load-time mismatch warning, the index= token on the human diagnostics line,
and the observable-degrade tests (a Hybrid suggest under mismatch degrades —
hermetically, before any IONOS post — carrying index_status=Mismatch, with the
existing transient-degrade test extended as the index_status=Ok foil).

refs #4

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:51:05 +02:00
Brummel 43b1b6fc25 feat: packed single-file embedding index (closes #3)
The semantic index was ~90 896 per-entry files (index/embeddings/<safe-model>/
<sha256>.f32), a deployment liability: the file count — not the 356 MB — is
hostile to container layers, object storage, and Git-LFS, and Pipeline::load
opened every one individually. This packs a complete per-file store into one
self-describing matrix file loaded in a single read.

New src/packed.rs owns the format: 4-byte magic "AIPK", u32 LE format_version,
u32 LE header length, a JSON PackedHeader {n_rows, dim, embed_model,
corpus_sha256, corpus_name}, pad to a 4-byte boundary, then row-major
little-endian f32 payload in corpus order (row i = corpus entry i). write_packed
is atomic (temp+rename); read_packed rejects a wrong magic, a newer-than-
supported version, a malformed header, or a payload length inconsistent with
n_rows×dim. corpus_sha256 hashes the raw corpus file bytes.

Pipeline::load now calls a free fn load_packed_or_store(cfg, entries) that
prefers the packed file and falls back to today's all-or-nothing per-file store,
reporting which path it took via a new IndexStatus {Ok, Absent, Mismatch}
carried into Diagnostics. `alpha-id index --pack` writes the file from a
complete store (refusing an incomplete one with exit 2), recording corpus_sha256
+ embed_model in the header as the artifact-binding data.

Iteration boundary (deliberate): this is iteration 1 of the packed-versioned-
bundle cycle. load_packed_or_store validates the packed file STRUCTURALLY only
(magic/version/payload length via read_packed, plus n_rows == entries.len());
it does NOT yet compare the header's corpus_sha256/embed_model against the live
config, and IndexStatus::Mismatch fires only on structural corruption. The
load-time mismatch warning, the index= token on the diagnostics line, and the
Hybrid-degrade-names-reason wiring are iteration 2 (issue #4), which consumes
the binding data this commit writes. The per-file store is untouched — it stays
the build/resume path; the packed file is an additive serve-path artifact.

mmap was considered and rejected: the brute-force full-scan cosine search
touches every row per query, so memory-mapping yields no runtime benefit over a
single read and would add an unsafe dependency (spec § Out of scope).

Verification: cargo test green (61 tests; 9 new — 3 format round-trip/hash/bad-
magic, 4 load-path incl. packed-beats-store and structural n_rows mismatch, 2
--pack CLI incl. exit-2 refusal). An end-to-end seam test writes the packed
file via the CLI, removes the per-file store, and asserts the pipeline builds
its index from the packed file alone as IndexStatus::Ok (#3 acceptance b).

Spec: docs/specs/2026-05-31-packed-versioned-index-bundle.md
Plan: docs/plans/2026-06-01-packed-versioned-bundle-iter1.md

closes #3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:42:08 +02:00
Brummel 2489d2029e plan: packed index format iteration 1 (refs #3)
Bite-sized TDD plan for iteration 1 of the packed-versioned-bundle cycle:
the on-disk packed format (src/packed.rs), the IndexStatus enum + packed-first
Pipeline::load path with per-file-store fallback, and the `index --pack`
subcommand. Structural validation only (magic/version/n_rows/payload length);
the semantic corpus+model mismatch enforcement, load warning, and index=
diagnostics token are deferred to iteration 2 (#4), as documented in the
plan's iteration-boundary note.

refs #3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:24:15 +02:00
Brummel 2bb1bfc534 spec: packed versioned index bundle (refs #3 #4)
Design spec for the "versioned bundle" cycle binding issues #3 (pack the
per-file embedding store into one contiguous matrix file) and #4 (bind
{corpus snapshot, embed model, index} as one versioned artifact).

Ratified decisions (user-approved across the brainstorm):
- Approach 1: single self-describing file (magic + JSON-header manifest +
  row-major f32 payload), loaded with one fs::read. No mmap (a brute-force
  full-scan cosine index touches every row per query, so mmap yields no
  runtime benefit and is rejected to avoid an unsafe dependency). VectorIndex
  and the per-file store are untouched — the store stays the build/resume
  path, the packed file is an additive serve-path artifact.
- Mismatch posture: loud but not fatal. A structurally wrong index (corpus
  sha256 or embed_model disagreement) builds no VectorIndex and surfaces as a
  distinct IndexStatus::Mismatch in diagnostics plus a load-time warning,
  instead of silently degrading Mode Hybrid to Lexical. Mode Lexical, which
  never needs the index, keeps serving.
- Two iterations, one format: iter 1 delivers the format + `index --pack` +
  packed-first load; iter 2 enforces the manifest check and the observable
  index_status surface.

grounding-check: PASS (7 load-bearing assumptions ratified by green tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:15:18 +02:00
Brummel d83b10f939 refactor: rename Mode variants C/A to Lexical/Hybrid (closes #2)
The retrieval-mode enum used opaque single letters `Mode { C, A }`;
neither the identifier nor the operator-facing `--mode a` token
conveyed what the mode does. Rename to self-describing names that map
directly onto the glossary definitions:

  Mode::C -> Mode::Lexical  (BM25 + tag filter, offline, no IONOS)
  Mode::A -> Mode::Hybrid   (lexical u semantic -> RRF -> rerank)

Decisions taken (the issue's open questions, resolved):
- CLI canonical tokens are now `lexical` / `hybrid`; the default is
  `lexical`. The legacy `c` / `a` tokens stay accepted as
  case-insensitive input aliases (zero cost; protects hand-typed
  invocations). A unit test pins the alias contract.
- The diagnostics `mode` string (Debug-derived) now emits
  "Lexical"/"Hybrid". Verified safe: no downstream consumer parses it.
  doctate is the future integrator and embeds the library (the `Mode`
  enum), not the JSON string (design spec §103).
- The reserved, out-of-scope generative tier (spec's "Mode B") keeps
  conceptual room: a future `Mode::Generative` does not collide with
  the retrieval-strategy names Lexical/Hybrid.

Surface: enum + FromStr, CLI defaults and `eval --mode both`
expansion, the private `collect_mode_c`/`collect_mode_a` methods (now
`collect_lexical`/`collect_hybrid`) and their comments, the two
pipeline mode test files (renamed), the eval CLI test, the glossary
(old letter forms demoted to Avoid lists), and the spec CLI synopsis.

closes #2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:15:08 +02:00
Brummel 9a81f9eaf7 docs: plan for renaming Mode variants A/C (refs #2)
Records the ratified naming decisions (Lexical/Hybrid, c/a kept as
input aliases, diagnostics string free to change — no downstream
consumer) and the bite-sized rename task list for the implement step.

refs #2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:06:37 +02:00
Brummel c9363c6675 fix: normalize --chapters filter input to padded chapter numbers
The --chapters filter compared the raw CLI token by exact string
equality against the zero-padded chapter tags ("01".."22") that
the corpus carries (roman_to_padded, src/claml.rs). The CLI only
split and trimmed the input, so the intuitive forms — `--chapters 4`
or `--chapters E,I` — never matched any candidate and the suggestion
list came back empty, indistinguishable from "no clinical match".

Add a pure `normalize_chapters` helper that zero-pads bare 1-2 digit
chapter numbers to two digits and partitions the token list into
known chapters (1..=22) and unrecognised tokens. The Suggest arm now
feeds the padded set into the filter and emits a stderr warning for
each unrecognised token, so misuse is visible instead of silent.

`--chapters 4` and `--chapters 04` now behave identically. ICD
code-letter mapping (E -> chapter) is deliberately left out of scope:
under this contract a letter is simply an unrecognised token and
triggers the warning.

closes #1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:03:51 +02:00
Brummel 1fded48142 test: red for un-padded --chapters filter input
Pins the property that the `--chapters` CLI token list normalizes
to the zero-padded chapter-tag form ("01".."22") that Filter::accepts
compares against, so "4" filters identically to "04", and any token
mapping to no known chapter is surfaced as unrecognised (for a stderr
warning) rather than silently collapsing the result set to empty.

RED: alpha_id::model::normalize_chapters does not exist yet — the
test fails to compile. The GREEN side follows in the next commit.

refs #1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:53:17 +02:00
Brummel 1ce863f76c chore: add project profile + bootstrap glossary
Add .claude/dev-cycle-profile.yml (skills-plugin profile) with
paths.glossary pointing at the new docs/glossary.md, so the glossary
becomes standing reading for every role. The glossary pins 19 canonical
terms bootstrapped from the design spec and source doc comments;
canonical forms are English (repo-English rule), German spec-prose forms
listed under Avoid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:17:53 +02:00
Brummel ca352aafc7 docs: retire superpowers/ layout — keep design spec under docs/specs/
The superpowers workflow methodology is no longer in use. Move the
methodology-neutral design spec to docs/specs/ as the project's design
ledger, drop the superpowers-bound implementation plan (it referenced
superpowers sub-skills), and remove the now-dead /docs/superpowers/
.gitignore entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:02:38 +02:00
Brummel 1f4607ced1 feat: progress reporting during corpus embedding
Emit a `… embedded N/total (pct%)` line every 1000 items (and at
completion) so a full --confirm run is observable rather than silent
for its full duration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:00:08 +02:00
Brummel f047cd1b5b refactor: input contract — one extracted phrase per line (spec §1a)
split_segments now treats each non-empty trimmed line as one retrieval
unit; blank lines are skipped, not paragraph delimiters. Name/signature
retained (minimal-churn decision). Full suite 45/0. End-to-end: feeding
the GP dictation as extracted phrases recovers I10.90 (rank 4) which was
entirely missing in both modes under the paragraph contract, and largely
dissolves the cross-segment diabetes crowding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:30:05 +02:00
Brummel 7cec295d99 docs: input-contract revision — extracted phrases instead of dictation paragraphs
Source-string analysis showed the Hypertonie miss is not a bug but a
structural consequence of embedding whole noisy Anamnese paragraphs
against terse Alpha-ID corpus strings. Resolution (user-decided): the
caller (doctate's LLM) extracts diagnostic phrases; this project still
contains no LLM — it just receives a cleaner input contract.

Spec: new §1a (rationale + generous extraction contract + the honest
extractor-is-recall-bottleneck trade-off); §1/§2/§3/§6/§8/§9/status
updated; §6 reworks eval (representativeness shift, paraphrase axis,
hand-labeled phrase-list set, separated extraction-ceiling vs matcher
recall). Plan: Goal/Architecture, revision banner, Task 6 rewritten
(one non-empty line = one unit; split_segments name/fields retained,
minimal-churn decision), Task 11/19 revision notes.

Code not yet changed (segment.rs still blank-line splits) — next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:26:30 +02:00
Brummel 8348e33264 fix: symmetric umlaut/ß folding in lexical path + eval digraph case
Smoke-testing invented German dictations exposed that `ae`/`oe`/`ue`
spellings collapsed lexical recall (e.g. "Hyperlipidaemie" → garbage,
"Hyperlipidämie" → correct). normalize() now folds ä→ae, ö→oe, ü→ue,
ß→ss; it is applied only in lexical.rs (both corpus index and query),
so the path is symmetric under either spelling. The embedding path
(raw text for bge-m3) is intentionally untouched.

eval.rs inject_errors case 2 changed from umlaut-deletion (ä→a) to the
realistic ASCII-digraph variant (ä→ae) so the eval harness exercises —
and guards against regression of — exactly this failure class.

TDD: failing tests first; normalize_tests 5/5, full suite 44/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:43:41 +02:00
Brummel 093862d21b docs: spec/plan revision — ClaML Modifier expansion (§3a) + eval-credibility notes
Documents the corpus↔ClaML 5th-digit gap, the decided Modifier/
ModifierClass expansion fix (Goal 1), and the deterministic /
seeded-sample eval corrections from the final review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:25:10 +02:00
Brummel 752558629f fix: deterministic fusion tie-order + seeded random eval sampling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:40:08 +02:00
Brummel 16fd91fa68 polish: drop redundant Filter import; document equal eval metrics 2026-05-18 19:29:50 +02:00
Brummel 6179d89931 feat: C-vs-A eval comparison; go/no-go metric
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:24:26 +02:00
Brummel d2cb3db07a feat: Mode A hybrid pipeline with graceful degradation to C
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:15:14 +02:00
Brummel 9eab9dc736 feat: Qwen3-VL reranker client 2026-05-18 19:09:57 +02:00
Brummel 2934bbc8f4 fix: --sample 0 no longer panics; avoid needless corpus clone 2026-05-18 19:06:56 +02:00
Brummel d08cd293a0 feat: cost-guarded resumable index build (sample smoke + full)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:00:47 +02:00
Brummel d920183f97 harden: NaN-safe sort + dimension-mismatch guard in VectorIndex 2026-05-18 18:54:06 +02:00
Brummel 34e2f70d91 feat: brute-force cosine vector index 2026-05-18 18:50:20 +02:00
Brummel f19bd6e800 fix: embed_batch length+index pairing; reject non-numeric components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:46:32 +02:00
Brummel 2686edab91 feat: resumable sha256 embedding cache + cost estimate 2026-05-18 18:41:27 +02:00
Brummel ff41d53155 feat: shared IONOS HTTP client with retry/backoff 2026-05-18 18:37:31 +02:00
Brummel 41debc4f60 feat: CLI suggest/eval/index; Mode C baseline runnable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:31:35 +02:00
Brummel 377fc84875 feat: seeded transcription-error injection + Recall@k 2026-05-18 18:26:43 +02:00
Brummel 079ecea2a3 perf: precompute valid_by_code at load; tighten code guard; clarify gap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:23:46 +02:00
Brummel 5305fb251e fix: drop 3-char-root meta fallback; rely on expanded ClaML map (Goal 1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:15:21 +02:00
Brummel b53c4bfb71 fix: honor ModifiedBy all="true" (ignore ValidModifierClass restriction) 2026-05-18 18:06:34 +02:00
Brummel 944a4bef4f fix: honor ValidModifierClass subset; inherit usage type; harden ordering
- Fix 1: capture <ValidModifierClass> codes from <ModifiedBy all="false">
  and filter each modifier level to only valid codes before cartesian
  product; eliminates ~194 phantom codes (e.g. M07.01–03) while keeping
  valid ones (M07.00/04/07/09). Split Start/Empty XML event arms to
  correctly track children of <ModifiedBy>.
- Fix 2: synthesized para295/para301 inherit parent values; "V" is
  promoted to "P" (parent "V" = needs subdivision, terminal is codeable).
  Other types ("P", "O", "Z", "") pass through unchanged.
- Fix 3: debug_assert in ModifierClass handler guards against ClaML
  document-order assumption changing silently in debug/test builds.
- Fix 4: add expands_single_modifier_chain regression test verifying
  C88.00/C88.01 synthesis from a single all="true" ModifiedBy (corpus-
  verified: Alpha-ID I30531, I31044).
2026-05-18 17:58:22 +02:00
Brummel 4ee705e57d feat: expand ClaML Modifier/ModifierClass into terminal 5th-digit codes
ICD-10-GM models the 4th/5th sub-digits of many groups (incl. all E10-E14
diabetes) via <Modifier>/<ModifierClass> referenced by ordered <ModifiedBy>
on the parent <Class>, not as nested <Class> elements. load() previously
returned nothing for codes like E11.90/E11.72/I10.91, so ~1.7k corpus
codes (incl. ~1.2k billable) fell back to the non-billable 3-digit root
and were silently dropped under --billable-only.

Collect per-Class ModifiedBy refs and all ModifierClass defs during the
existing single stream, then expand: cartesian product of the referenced
modifiers' ModifierClass codes in ModifiedBy order, concatenated verbatim
onto the parent code (E11 + .9 + 0 = E11.90), matching the Alpha-ID
corpus format exactly. Terminal leaves are billable (Para295/Para301=P);
description = parent label + each ModifierClass label joined by ' - ';
chapter/group/exotic/ifsg/content inherited from parent. Purely additive:
explicit <Class> entries always win. Generic over every modifier group.

Map size 12334 -> 17249 (+4915 synthesized).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:32:33 +02:00
Brummel 6647a66bd0 feat: Mode C pipeline end-to-end (lexical + tags + fusion)
Implement Pipeline::load/suggest for Mode C: lexical search per segment,
cross-segment dedup via fusion, tag derivation, filter application.
Add meta_lookup fallback (exact → strip trailing 0 → 3-char root) to
bridge the ICD code granularity gap between the corpus (E11.90-style
6-char codes) and ClaML (which only stores E11, E11.0-style entries).
2026-05-18 17:04:34 +02:00
Brummel 8d9ab0e299 feat: RRF and cross-segment max-score dedupe 2026-05-18 16:54:25 +02:00
Brummel a896c5bbde fix: store IndexReader once; sanitize all tantivy query specials
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:51:48 +02:00
Brummel 6dd1c17306 feat: tantivy lexical index over Alpha-ID texts 2026-05-18 16:44:51 +02:00
Brummel ce7403fee9 feat: tag derivation from ClaML metadata + validity 2026-05-18 16:40:08 +02:00
Brummel 27d5144864 feat: dictation segmentation on blank lines 2026-05-18 16:38:21 +02:00
Brummel 9f3e79c874 feat: text normalization with medical abbreviation expansion 2026-05-18 16:36:06 +02:00
Brummel c8ea9a5fb3 fix: accumulate mixed-content ClaML labels (full titles)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:34:19 +02:00
Brummel bb42f07790 feat: streaming ClaML parser for ICD metadata and titles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:25:46 +02:00
Brummel 2fe9656c84 refactor: idiomatic find in primary_code (clippy) 2026-05-18 16:22:18 +02:00
Brummel 3e8284bfc0 feat: Alpha-ID-SE corpus parser with code normalization 2026-05-18 16:20:21 +02:00
Brummel 176aa87c0b refactor: derive Default for Filter (clippy) 2026-05-18 16:18:34 +02:00
Brummel 30799ddb0b feat: core domain types and Filter predicate 2026-05-18 16:16:08 +02:00
Brummel 70be6449db chore: scaffold alpha-id crate skeleton 2026-05-18 16:12:59 +02:00
Brummel bb5123aa4a chore: gitignore .claude/worktrees
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:09:00 +02:00