38 KiB
PR2 — OCR/caption derivation cache Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Cache image-OCR, image-caption, and PDF-page-OCR results in the derivation cache, keyed on SOURCE BYTES + an OCR/caption version key, so a re-ingest (or a chunker/embedding version bump) reuses the cached text instead of re-running the expensive vision engine — while keeping ingest output byte-identical.
Architecture: Two new derivation-cache namespaces "ocr" and "caption" join the existing "embedding". A new derivation_cache_key_bytes(kind, &[u8], version_key) (in kebab-core) keys on raw image/page bytes (no NFC). OcrText/ModelCaption are cached as full serde-JSON structs (block-level byte-identical). All cache wrapping is at the kebab-app call sites (image OCR, image caption) or threaded via PdfOcrOpts into apply_ocr_to_pdf_pages (which already lives in kebab-app) — never inside a kebab-parse-* crate. On a cache hit the vision engine is skipped and NO provenance event is replayed (provenance is in no wire schema / not gate-compared — design §3.6).
Tech Stack: Rust 2024, kebab-core (derivation key), kebab-app (payload + wiring + tests), blake3, serde_json. Deterministic correctness is proven with a mock OcrEngine (no models); paddle-onnx (bundled, deterministic) anchors an optional real-engine #[ignore] lane.
Global Constraints (from spec §3 + CLAUDE.md)
- Byte-identical ingest output (HARD GATE). Fresh-dir ingest = every key misses = vision engine runs exactly as today. Re-ingest = cache hit reconstructs the SAME
block.ocr/block.captionstruct (full serde round-trip ⇒ all fields, not just.joined/.text). The chunk text fed to the embedder is therefore identical. Markdown parity gate (gate-ingest.sh) is untouched and stays IDENTICAL. - Provenance NOT replayed on a cache hit (design §3.6). Skipping
apply_ocr/apply_captionmeans noProvenanceEventis pushed — correct, because provenance is in no wire schema (search_hit/answer/chunk_inspection) and the gate strips/ignores it. - Cache key on SOURCE BYTES, not text:
derivation_cache_key_bytes(no NFC — binary). One canonical helper; no per-site inline hashing. - Version keys fold engine identity + output-shaping params (OCR) / provider + prompt (caption) so a cascade bump is a miss (§3.3).
kebab-parse-*MUST NOT gain akebab-store-*dep. All caching is inkebab-app(ingest.rs,pdf_ocr_apply.rs,derivation_payload.rs).- Build/clippy:
CARGO_TARGET_DIR=/home/user/large_data/out/kebab/target,cargo clippy --workspace --all-targets -- -D warnings= 0. - GPU swap for any ollama-vision dogfood (CLAUDE.md): lemonade→
ollama-r9700on.82, OCR/caption endpointhttp://192.168.0.244:11434, restore lemonade unconditionally after.
File Structure
- Modify
crates/kebab-core/src/derivation.rs— addderivation_cache_key_bytes+ tests. - Modify
crates/kebab-app/src/derivation_payload.rs— addencode/decode_ocr_text,encode/decode_model_caption+ tests. - Modify
crates/kebab-app/src/ingest.rs— wrap the image OCR call (~1604) and image caption call (~1634) with cache lookup; build the OCR/caption version keys; accumulate touch keys. - Modify
crates/kebab-app/src/pdf_ocr_apply.rs— extendPdfOcrOptswith an optional cache handle + version key; wrap the per-pageengine.recognize(~175). - Create
crates/kebab-app/tests/ocr_caption_cache.rs— deterministic mock-engine integration tests (hit/miss, byte-identical block reconstruction, version-key invalidation), no models.
Task 1: derivation_cache_key_bytes in kebab-core
Files:
- Modify:
crates/kebab-core/src/derivation.rs(add fn afterderivation_cache_key, ~line 41; add tests in themod testsblock)
Interfaces:
-
Consumes:
blake3(already a dep). -
Produces:
pub fn derivation_cache_key_bytes(kind: &str, bytes: &[u8], version_key: &str) -> String— 32-hex, same framing asderivation_cache_keybut hashes raw bytes (NO NFC). -
Step 1: Write the failing tests (append inside
mod testsincrates/kebab-core/src/derivation.rs)
#[test]
fn bytes_key_is_32_hex_chars() {
let k = derivation_cache_key_bytes("ocr", &[0u8, 1, 2, 3], "engine|v1");
assert_eq!(k.len(), 32);
assert!(k.bytes().all(|b| b.is_ascii_hexdigit()));
}
#[test]
fn bytes_same_inputs_same_key() {
let a = derivation_cache_key_bytes("ocr", b"\xff\x00image", "paddle|abc");
let b = derivation_cache_key_bytes("ocr", b"\xff\x00image", "paddle|abc");
assert_eq!(a, b);
}
#[test]
fn bytes_different_bytes_different_key() {
let a = derivation_cache_key_bytes("ocr", b"image-a", "v1");
let b = derivation_cache_key_bytes("ocr", b"image-b", "v1");
assert_ne!(a, b);
}
#[test]
fn bytes_different_kind_different_key() {
let o = derivation_cache_key_bytes("ocr", b"same", "v1");
let c = derivation_cache_key_bytes("caption", b"same", "v1");
assert_ne!(o, c);
}
#[test]
fn bytes_version_bump_is_miss() {
// §3.6 safety: a version_key change MUST change the key so a stale OCR
// result is never reused after an engine/param bump.
let v1 = derivation_cache_key_bytes("ocr", b"page-bytes", "paddle-abc|st:0.3");
let v2 = derivation_cache_key_bytes("ocr", b"page-bytes", "paddle-abc|st:0.5");
assert_ne!(v1, v2);
}
#[test]
fn bytes_no_nfc_raw_hash() {
// Unlike the text variant, the bytes variant must NOT NFC-normalize —
// it hashes raw bytes. Two byte strings that would NFC-collapse as text
// stay distinct as bytes. (Sanity: empty vs non-empty differ.)
assert_ne!(
derivation_cache_key_bytes("ocr", b"", "v1"),
derivation_cache_key_bytes("ocr", b"\x00", "v1")
);
}
- Step 2: Run to verify they fail
Run: CARGO_TARGET_DIR=/home/user/large_data/out/kebab/target cargo test -p kebab-core derivation -- bytes
Expected: FAIL — cannot find function derivation_cache_key_bytes.
- Step 3: Implement (in
crates/kebab-core/src/derivation.rs, right afterderivation_cache_key, ~line 41)
/// Byte-addressed derivation-cache key (§3.1) for **binary** inputs (image /
/// PDF-page bytes feeding OCR / caption). Identical framing to
/// [`derivation_cache_key`] — `kind || 0x00 || content_blake3 || 0x00 ||
/// version_key`, first 32 hex chars — but hashes the raw bytes directly with
/// NO NFC normalization (NFC is meaningless on arbitrary bytes).
pub fn derivation_cache_key_bytes(kind: &str, bytes: &[u8], version_key: &str) -> String {
let content_blake3 = blake3::hash(bytes).to_hex().to_string();
let mut hasher = blake3::Hasher::new();
hasher.update(kind.as_bytes());
hasher.update(&[0x00]);
hasher.update(content_blake3.as_bytes());
hasher.update(&[0x00]);
hasher.update(version_key.as_bytes());
hasher.finalize().to_hex().to_string()[..32].to_string()
}
- Step 4: Run to verify they pass
Run: CARGO_TARGET_DIR=/home/user/large_data/out/kebab/target cargo test -p kebab-core derivation
Expected: PASS (all bytes_* + existing tests).
- Step 5: Commit
git add crates/kebab-core/src/derivation.rs
git commit -m "feat(core): derivation_cache_key_bytes — 바이트 키 변형 (OCR/caption 입력용, NFC 없음)"
Task 2: OCR/caption payload encode/decode in kebab-app
Files:
- Modify:
crates/kebab-app/src/derivation_payload.rs(add 4 fns + tests)
Interfaces:
- Consumes:
kebab_core::{OcrText, ModelCaption}(both deriveSerialize/Deserialize);serde_json(verified already a dep —crates/kebab-app/Cargo.toml:51— no Cargo.toml change needed). - Produces:
pub fn encode_ocr_text(o: &OcrText) -> Vec<u8>/pub fn decode_ocr_text(p: &[u8]) -> Option<OcrText>pub fn encode_model_caption(c: &ModelCaption) -> Vec<u8>/pub fn decode_model_caption(p: &[u8]) -> Option<ModelCaption>
Full-struct serde (not just .joined/.text) so a cache hit reconstructs block.ocr/block.caption byte-identically at the BLOCK level (the struct is persisted with the block, not only rendered into chunk text). A decode error → None → caller treats as miss → recompute (same accuracy-first contract as decode_embedding).
- Step 1: Write the failing tests (append a
mod ocr_captiontest block, or extendmod tests, incrates/kebab-app/src/derivation_payload.rs)
#[cfg(test)]
mod ocr_caption_tests {
use super::*;
use kebab_core::{ModelCaption, OcrRegion, OcrText};
fn sample_ocr() -> OcrText {
OcrText {
joined: "안녕 OCR\nsecond line".to_string(),
regions: vec![OcrRegion {
bbox: (1, 2, 3, 4),
text: "안녕".to_string(),
confidence: 0.97,
}],
engine: "paddle-onnx".to_string(),
engine_version: "ppocrv5-mobile-kor-abc123".to_string(),
}
}
#[test]
fn ocr_text_roundtrips_full_struct() {
let o = sample_ocr();
let bytes = encode_ocr_text(&o);
assert_eq!(decode_ocr_text(&bytes), Some(o));
}
#[test]
fn ocr_decode_garbage_is_none() {
assert_eq!(decode_ocr_text(b"\xff\xff not json"), None);
}
#[test]
fn model_caption_roundtrips_full_struct() {
let c = ModelCaption {
text: "a red square".to_string(),
model: "gemma4:e4b".to_string(),
model_version: "ollama/caption-v1".to_string(),
};
let bytes = encode_model_caption(&c);
assert_eq!(decode_model_caption(&bytes), Some(c));
}
#[test]
fn caption_decode_garbage_is_none() {
assert_eq!(decode_model_caption(b"\x00\x01"), None);
}
}
- Step 2: Run to verify they fail
Run: CARGO_TARGET_DIR=/home/user/large_data/out/kebab/target cargo test -p kebab-app --lib derivation_payload
Expected: FAIL — cannot find function encode_ocr_text (etc.).
- Step 3: Implement (append to
crates/kebab-app/src/derivation_payload.rs)
use kebab_core::{ModelCaption, OcrText};
/// Encode an `OcrText` as serde-JSON bytes for the `"ocr"` derivation-cache
/// namespace (§3.4). Full struct — a cache hit reconstructs `block.ocr`
/// byte-identically (all fields, not just `.joined`), so the stored block
/// matches a fresh deterministic OCR run.
pub fn encode_ocr_text(o: &OcrText) -> Vec<u8> {
serde_json::to_vec(o).expect("OcrText serialize (infallible for owned struct)")
}
/// Decode an `OcrText` from the `"ocr"` namespace. `None` on any decode error
/// → caller treats as a cache miss and recomputes (never serves a wrong value).
pub fn decode_ocr_text(payload: &[u8]) -> Option<OcrText> {
serde_json::from_slice(payload).ok()
}
/// Encode a `ModelCaption` as serde-JSON bytes for the `"caption"` namespace.
pub fn encode_model_caption(c: &ModelCaption) -> Vec<u8> {
serde_json::to_vec(c).expect("ModelCaption serialize (infallible for owned struct)")
}
/// Decode a `ModelCaption` from the `"caption"` namespace. `None` on error → miss.
pub fn decode_model_caption(payload: &[u8]) -> Option<ModelCaption> {
serde_json::from_slice(payload).ok()
}
- Step 4: Run to verify they pass
Run: CARGO_TARGET_DIR=/home/user/large_data/out/kebab/target cargo test -p kebab-app --lib derivation_payload
Expected: PASS.
- Step 5: Commit
git add crates/kebab-app/src/derivation_payload.rs
git commit -m "feat(app): ocr/caption derivation payload — full-struct serde encode/decode"
Task 3 (b1): cache image OCR at the call site
Files:
- Modify:
crates/kebab-app/src/ingest.rs(image OCR call ~1604-1611, insideingest_one_image_asset)
Interfaces:
- Consumes:
apply_ocr(engine: &dyn OcrEngine, &[u8], &mut ImageRefBlock, Option<&Lang>, &mut Vec<ProvenanceEvent>) -> Result<()>(setsblock.ocr = Some(OcrText)+ pushes provenance on success);OcrEngine::engine_name() -> &'static str,engine_version() -> String;derivation_cache_key_bytes,derivation_payload::{encode_ocr_text, decode_ocr_text};app.sqlite.derivation_cache_get/put/touch. - Produces: nothing new.
The current code (ingest.rs ~1604):
let t_ocr = std::time::Instant::now();
let res = apply_ocr(
engine,
&bytes,
block,
lang_hint.as_ref(),
&mut canonical.provenance.events,
);
ocr_ms = u64::try_from(t_ocr.elapsed().as_millis()).unwrap_or(u64::MAX);
if let Err(e) = res {
record_image_analysis_failure( /* … "OcrFailed" … */ );
}
Wrap it: build the version key, look up by image bytes, on hit set block.ocr and skip; on miss run apply_ocr then cache whatever it produced.
- Step 1: Build the OCR version key — add a small helper near the other ingest helpers (or inline). It folds engine identity + paddle output-shaping params (§3.3). Place this
fniningest.rs(module scope):
/// Version key for the `"ocr"` derivation-cache namespace (§3.3). Folds the
/// engine identity (name + version — for paddle, version is a blake3 of the
/// model assets; for ollama-vision it is engine/model) plus the paddle
/// output-shaping params, so an engine/asset/param change is a cache miss.
/// `score_thresh`/`unclip_ratio`/`max_boxes` are paddle-only but folding them
/// always is harmless (constant for non-paddle) and future-proofs the key the
/// moment they become user-configurable. Takes the params as primitives so the
/// same fn serves both image (`OcrCfg`) and pdf (`PdfOcrCfg`) — both expose
/// `score_thresh: f32` / `unclip_ratio: f32` / `max_boxes: usize`.
fn ocr_cache_version_key(
engine: &dyn OcrEngine,
score_thresh: f32,
unclip_ratio: f32,
max_boxes: usize,
) -> String {
format!(
"{}/{}|st:{}|uc:{}|mb:{}",
engine.engine_name(),
engine.engine_version(),
score_thresh,
unclip_ratio,
max_boxes,
)
}
Verified:
app.config.image_ocr() -> &OcrCfg(lib.rs:1574) with concretescore_thresh: f32/unclip_ratio: f32/max_boxes: usize(lib.rs:573/577/581);app.config.pdf_ocr() -> &PdfOcrCfg(lib.rs:1581) with the same field names+types (lib.rs:814/817/820).OcrEngineiskebab_parse_image::OcrEngine, already imported iningest.rs(theengineparam is one).
- Step 2: Replace the OCR call with the cached wrap (ingest.rs ~1604):
let t_ocr = std::time::Instant::now();
let img_ocr_cfg = app.config.image_ocr();
let ocr_vkey = ocr_cache_version_key(
engine,
img_ocr_cfg.score_thresh,
img_ocr_cfg.unclip_ratio,
img_ocr_cfg.max_boxes,
);
let ocr_key = kebab_core::derivation_cache_key_bytes("ocr", &bytes, &ocr_vkey);
if let Some(cached) = app
.sqlite
.derivation_cache_get(&ocr_key)?
.and_then(|p| crate::derivation_payload::decode_ocr_text(&p))
{
// Cache hit: reconstruct block.ocr, skip the engine + provenance
// (design §3.6 — provenance is not output/gate-affecting).
block.ocr = Some(cached);
ocr_cache_touch.push(ocr_key);
} else {
let res = apply_ocr(
engine,
&bytes,
block,
lang_hint.as_ref(),
&mut canonical.provenance.events,
);
if let Err(e) = res {
record_image_analysis_failure(
asset,
&mut canonical.provenance.events,
&mut warning_notes,
"OcrFailed",
e,
now,
);
} else if let Some(produced) = &block.ocr {
app.sqlite.derivation_cache_put(
&ocr_key,
"ocr",
&crate::derivation_payload::encode_ocr_text(produced),
)?;
}
}
ocr_ms = u64::try_from(t_ocr.elapsed().as_millis()).unwrap_or(u64::MAX);
Note: only cache on SUCCESS with a produced
block.ocr(a failed OCR leavesblock.ocr = Noneand is NOT cached — the next run retries).ocr_mson a hit is ~0, correctly reflecting the skipped engine.
- Step 3: Declare the touch-key accumulator — near the top of
ingest_one_image_asset(alongsideocr_ms/caption_ms), add:
let mut ocr_cache_touch: Vec<String> = Vec::new();
let mut caption_cache_touch: Vec<String> = Vec::new();
(The caption one is for Task 4; declare both now to avoid a second edit to the same region.)
- Step 4: Touch hit keys after store — find where the image handler calls
store_document_records(...)/derivation_cache_touchfor embeddings (the embedding touch added in PR1, ~afterVectorStore::upsert (image)). Add the OCR/caption touch alongside it (touching is idempotent and order-independent; doing it after store mirrors the embedding pattern):
app.sqlite.derivation_cache_touch(&ocr_cache_touch)?;
app.sqlite.derivation_cache_touch(&caption_cache_touch)?;
If those touch calls sit inside the
if let (Some(emb), Some(vec_store)) = …embedding block, hoist the two OCR/caption touches OUT to run unconditionally (OCR/caption happen even when embedding is disabled). Place them right afterstore_document_records(...).
- Step 5: Build + clippy
Run: CARGO_TARGET_DIR=/home/user/large_data/out/kebab/target cargo clippy -p kebab-app --all-targets -- -D warnings
Expected: Finished, 0 warnings.
- Step 6: Commit
git add crates/kebab-app/src/ingest.rs
git commit -m "feat(app): 이미지 OCR 결과 캐시 — 바이트 키 ocr 네임스페이스 (b1)"
Task 4 (b2): cache image caption at the call site
Files:
- Modify:
crates/kebab-app/src/ingest.rs(image caption call ~1634-1645, same handler)
Interfaces:
- Consumes:
apply_caption(llm: &dyn LanguageModel, &[u8], &mut ImageRefBlock, Option<&Lang>, &Config, &mut Vec<ProvenanceEvent>) -> Result<()>(gate-checkscfg.ingest.image.caption.enabled; on success setsblock.caption = Some(ModelCaption)+ provenance; disabled = no-op);LanguageModel::model_ref() -> ModelRef(.provider,.id);derivation_payload::{encode_model_caption, decode_model_caption}. - Produces: nothing new.
The current code (ingest.rs ~1634):
let t_caption = std::time::Instant::now();
let res = apply_caption(
llm,
&bytes,
block,
lang_hint.as_ref(),
&app.config,
&mut canonical.provenance.events,
);
caption_ms = u64::try_from(t_caption.elapsed().as_millis()).unwrap_or(u64::MAX);
if let Err(e) = res {
record_image_analysis_failure( /* … "CaptionFailed" … */ );
}
- Step 1: Build the caption version key + wrap — caption version = provider + prompt_template_version (§3.3; matches
ModelCaption.model_version = "{provider}/{prompt}"). Replace the caption call with:
let t_caption = std::time::Instant::now();
// Only cache when captioning is enabled (apply_caption no-ops when
// disabled; mirror that gate so a hit can't bypass the toggle).
if app.config.ingest.image.caption.enabled {
let cap_vkey = format!(
"caption|{}|{}",
llm.model_ref().provider,
app.config.ingest.image.caption.prompt_template_version,
);
let cap_key =
kebab_core::derivation_cache_key_bytes("caption", &bytes, &cap_vkey);
if let Some(cached) = app
.sqlite
.derivation_cache_get(&cap_key)?
.and_then(|p| crate::derivation_payload::decode_model_caption(&p))
{
block.caption = Some(cached);
caption_cache_touch.push(cap_key);
} else {
let res = apply_caption(
llm,
&bytes,
block,
lang_hint.as_ref(),
&app.config,
&mut canonical.provenance.events,
);
if let Err(e) = res {
record_image_analysis_failure(
asset,
&mut canonical.provenance.events,
&mut warning_notes,
"CaptionFailed",
e,
now,
);
} else if let Some(produced) = &block.caption {
app.sqlite.derivation_cache_put(
&cap_key,
"caption",
&crate::derivation_payload::encode_model_caption(produced),
)?;
}
}
}
caption_ms = u64::try_from(t_caption.elapsed().as_millis()).unwrap_or(u64::MAX);
The
caption_cache_touchaccumulator was already declared in Task 3 Step 3; the touch call was added in Task 3 Step 4.
- Step 2: Build + clippy
Run: CARGO_TARGET_DIR=/home/user/large_data/out/kebab/target cargo clippy -p kebab-app --all-targets -- -D warnings
Expected: 0 warnings.
- Step 3: Commit
git add crates/kebab-app/src/ingest.rs
git commit -m "feat(app): 이미지 caption 결과 캐시 — 바이트 키 caption 네임스페이스 (b2)"
Task 5 (b3): cache PDF per-page OCR via PdfOcrOpts
Files:
- Modify:
crates/kebab-app/src/pdf_ocr_apply.rs(PdfOcrOpts~41,apply_ocr_to_pdf_pagesper-pageengine.recognize~175) - Modify:
crates/kebab-app/src/ingest.rs(PDF OCR call site ~2214 — populate the newPdfOcrOptsfields)
Interfaces:
- Consumes:
extract_dctdecode_page_image(&pdf_doc, page_num) -> Result<Option<Vec<u8>>>(the per-page bytes, ~148);engine.recognize(&page_image_bytes, lang_hint) -> Result<OcrText>(~175);Arc<SqliteStore>(the call site already buildsstore_for_ocr = Arc::clone(&app.sqlite));OcrEngine::{engine_name, engine_version}. - Produces:
PdfOcrOptsgainsocr_cache: Option<std::sync::Arc<kebab_store_sqlite::SqliteStore>>andocr_version_key: String.apply_ocr_to_pdf_pagesreturns the samePdfOcrSummary(unchanged signature otherwise) — but itsPdfOcrSummaryshould expose how many pages were cache hits if cheap; OPTIONAL, skip if it complicates.
pdf_ocr_apply.rs is in kebab-app, so depending on kebab_store_sqlite here does NOT violate the parse-crate boundary (it already imports kebab_parse_pdf + app types).
- Step 1: Extend
PdfOcrOpts(pdf_ocr_apply.rs~41). Add two fields:
/// Optional derivation-cache handle for per-page OCR results (`"ocr"`
/// namespace). `None` ⇒ no caching (unit tests / store-less callers behave
/// exactly as before).
pub ocr_cache: Option<std::sync::Arc<kebab_store_sqlite::SqliteStore>>,
/// Version key folded into the per-page OCR cache key (§3.3). Empty when
/// `ocr_cache` is `None`.
pub ocr_version_key: String,
- Step 2: Wrap the per-page
engine.recognize(pdf_ocr_apply.rs~175). The current line is:
let ocr = match engine.recognize(&page_image_bytes, opts.lang_hint.as_ref()) {
Ok(t) => t,
Err(e) => { /* … warning event, continue … */ }
};
Replace the engine.recognize(...) acquisition with a cache-aware version. Insert BEFORE the match:
// Per-page OCR cache (§3.5 b3): key on the page image bytes + version.
let page_cache_key = opts.ocr_cache.as_ref().map(|_| {
kebab_core::derivation_cache_key_bytes("ocr", &page_image_bytes, &opts.ocr_version_key)
});
if let (Some(store), Some(key)) = (opts.ocr_cache.as_ref(), page_cache_key.as_ref())
&& let Some(hit) = store
.derivation_cache_get(key)
.ok()
.flatten()
.and_then(|p| crate::derivation_payload::decode_ocr_text(&p))
{
// Cache hit: apply this page's OCR text without running the engine
// or emitting an OcrApplied provenance event (§3.6), then continue
// to the next page.
apply_ocr_text_to_page(/* the same mutation the Ok(t) arm does */ &mut canonical, /* page idx */ , hit);
store.derivation_cache_touch(std::slice::from_ref(key)).ok();
continue;
}
let ocr = match engine.recognize(&page_image_bytes, opts.lang_hint.as_ref()) {
Ok(t) => {
if let (Some(store), Some(key)) = (opts.ocr_cache.as_ref(), page_cache_key.as_ref()) {
let _ = store.derivation_cache_put(key, "ocr", &crate::derivation_payload::encode_ocr_text(&t));
}
t
}
Err(e) => { /* … unchanged warning event, continue … */ }
};
Implementer: the existing
Ok(t) => tarm is followed by code that mutates the page block withocr.joined(thetb.text = ocr.joined.clone()/ dual-block logic around lines 214-256). The cache-hit fast path must perform the same page mutation as the normal path beforecontinue-ing — do NOT duplicate the logic by hand; instead, restructure so the hit path falls through into the SAME mutation code (e.g. setlet ocr = hit;and skip only theengine.recognize+ theOcrAppliedprovenance push, rather thancontinue). Choose whichever refactor keeps the page-mutation code single-sourced. The KEY invariants: (1) byte-identical page mutation whether hit or miss, (2) noOcrAppliedprovenance event on a hit, (3) on a miss, cache the producedOcrTextafter a successfulrecognize.
- Step 3: Populate the new
PdfOcrOptsfields at the call site (ingest.rs~2214). ThePdfOcrOpts { … }literal currently setsenabled/always_on/valid_ratio_threshold/min_char_count/lang_hint/cancel. Add:
Before the PdfOcrOpts { … } literal, resolve the pdf OCR params once, then pass them to the shared ocr_cache_version_key (Task 3 Step 1). engine here is the pdf_ocr_engine (the Some(engine) bound in the match pdf_ocr_engine):
let pdf_ocr_cfg = app.config.pdf_ocr();
let pdf_ocr_vkey = ocr_cache_version_key(
engine,
pdf_ocr_cfg.score_thresh,
pdf_ocr_cfg.unclip_ratio,
pdf_ocr_cfg.max_boxes,
);
let ocr_opts = crate::pdf_ocr_apply::PdfOcrOpts {
// … existing fields (enabled/always_on/valid_ratio_threshold/
// min_char_count/lang_hint/cancel) unchanged …
ocr_cache: Some(Arc::clone(&app.sqlite)),
ocr_version_key: pdf_ocr_vkey,
};
app.config.pdf_ocr() -> &PdfOcrCfg(verified, lib.rs:1581) exposesscore_thresh: f32/unclip_ratio: f32/max_boxes: usize(lib.rs:814/817/820), same names/types asOcrCfg, so the sharedocr_cache_version_keyworks directly — no new accessor or pdf-specific fn needed. Watch borrow timing:app.config.pdf_ocr()borrowsapp.config; bindpdf_ocr_vkey(an ownedString) before constructing thePdfOcrOptsthat alsoArc::clone(&app.sqlite), so no overlapping-borrow issue.
- Step 4: Build + clippy + existing pdf_ocr_apply tests
Run: CARGO_TARGET_DIR=/home/user/large_data/out/kebab/target cargo clippy -p kebab-app --all-targets -- -D warnings
Run: CARGO_TARGET_DIR=/home/user/large_data/out/kebab/target cargo test -p kebab-app --lib pdf_ocr_apply
Expected: 0 warnings; existing pdf_ocr_apply unit tests still pass (they pass ocr_cache: None — add that field to their PdfOcrOpts literals, behavior unchanged).
- Step 5: Commit
git add crates/kebab-app/src/pdf_ocr_apply.rs crates/kebab-app/src/ingest.rs
git commit -m "feat(app): PDF 페이지 OCR 캐시 — PdfOcrOpts 캐시 핸들 (b3, parse-crate 의존 무변경)"
Task 6: Deterministic mock-engine cache test (hit/miss + byte-identical + invalidation)
Files:
- Create:
crates/kebab-app/tests/ocr_caption_cache.rs
Interfaces:
- Consumes:
kebab_parse_image::OcrEngine(trait to implement a mock);kebab_core::{OcrText, OcrRegion};common::TestEnv;kebab_app::ingest_with_config;rusqlite(dev-dep) for cache-row counts. This test is deterministic and model-free — it is NOT#[ignore]; it runs in the default CI lane.
This is the primary correctness gate for b1/b3 (the OCR path) without any real model: a mock OcrEngine returns a fixed OcrText and COUNTS its invocations. Re-ingest must NOT re-invoke the engine (cache hit) and must reconstruct byte-identical block.ocr.
Design note for the implementer: the cleanest deterministic test injects a mock
OcrEngineinto the ingest path. If the ingest facade does not expose engine injection, drive the cache at a lower seam instead: (a) directly unit-test the b1 wrap logic by callingderivation_cache_get/put+decode_ocr_textagainst an in-memorySqliteStorewith a mock engine (proving hit skips the engine, miss invokes + caches, version bump → miss); OR (b) if image ingest CAN run with a mock/stub OCR engine via config (e.g. an engine the test can register), use the fullingest_with_configpath and assert the mock's call-count is 1 after two ingests. Pick the seam that exists; do NOT weaken the assertions. The invocation-count assertion (engine called once across two ingests) is the non-negotiable core of this test.
- Step 1: Write the mock engine + tests (
crates/kebab-app/tests/ocr_caption_cache.rs)
//! PR2: OCR/caption derivation cache. Deterministic, model-free — a mock
//! `OcrEngine` with an invocation counter proves a re-ingest is a cache HIT
//! (engine NOT re-invoked) that reconstructs byte-identical OCR text, and that
//! a version-key change forces a miss.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use kebab_core::{Lang, OcrText};
use kebab_parse_image::OcrEngine;
/// Mock OCR engine: returns a fixed `OcrText` and counts `recognize` calls.
struct CountingMockOcr {
calls: Arc<AtomicUsize>,
version: String,
}
impl OcrEngine for CountingMockOcr {
fn engine_name(&self) -> &'static str {
"mock-ocr"
}
fn engine_version(&self) -> String {
self.version.clone()
}
fn model(&self) -> &str {
"mock"
}
fn recognize(&self, _image_bytes: &[u8], _lang_hint: Option<&Lang>) -> anyhow::Result<OcrText> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(OcrText {
joined: "안녕 mock OCR".to_string(),
regions: Vec::new(),
engine: "mock-ocr".to_string(),
engine_version: self.version.clone(),
})
}
}
// The actual test body depends on the test seam chosen (see the design note).
// REQUIRED assertions, however the seam is wired:
// 1. Two ingests of the SAME image bytes + SAME engine version → engine
// `recognize` is called EXACTLY ONCE (second is a cache hit).
// 2. The reconstructed `OcrText` after the cache hit equals the first run's
// (byte-identical: joined + regions + engine + engine_version).
// 3. Changing the engine version (or score_thresh/etc. in the version key)
// → `recognize` called AGAIN (miss). Proves §3.6 invalidation safety.
- Step 2: Wire the chosen seam + run
Run: CARGO_TARGET_DIR=/home/user/large_data/out/kebab/target cargo test -p kebab-app --test ocr_caption_cache
Expected: PASS — engine invoked once across two same-input ingests; reconstructed OcrText byte-identical; version bump re-invokes.
- Step 3: Sanity — deliberately break, confirm fail
Temporarily make decode_ocr_text always return None (forces every lookup to miss). Re-run: the "engine called exactly once" assertion must FAIL (it'd be called twice). Revert.
- Step 4: Full clippy
Run: CARGO_TARGET_DIR=/home/user/large_data/out/kebab/target cargo clippy --workspace --all-targets -- -D warnings
Expected: 0 warnings.
- Step 5: Commit
git add crates/kebab-app/tests/ocr_caption_cache.rs
git commit -m "test(app): OCR 캐시 mock-engine 결정적 검증 — 히트=엔진 미호출 + byte-identical + 버전 무효화"
Task 7: Paddle deterministic real-engine gate (optional #[ignore] lane) + markdown parity
Files:
- Create/extend:
crates/kebab-app/tests/ocr_caption_cache.rs(add an#[ignore]test) OR a smoke script under the dogfood store.
Interfaces:
- Consumes: the bundled paddle-onnx assets (
crates/kebab-parse-image/assets/paddleocr-onnx/— present, no download);TestEnvwith[ingest.ocr] engine="paddle-onnx",[ingest.image.ocr] enabled=true,[models.embedding] provider="none".
The mock test (Task 6) proves the cache LOGIC deterministically. This task proves the REAL paddle engine path caches correctly (end-to-end), and that markdown output is unaffected.
- Step 1: Markdown parity gate (must stay IDENTICAL)
Run: bash /home/user/large_data/out/kebab-parity/gate-ingest.sh pr2-ocr-cache
Expected: SEARCH IDENTICAL ✓, ASK IDENTICAL ✓, CHUNKS IDENTICAL ✓. (PR2 does not touch the markdown path; this confirms no accidental regression. Requires the GPU ollama embedding box up per the gate config — if unavailable, note it and rely on the mock + unit gates for this PR, running the markdown gate before merge when the box is up.)
- Step 2: Paddle real-engine cache test (
#[ignore], AVX-gated). Generate a small PNG viacargo run --example gen_smoke_png -p kebab-parse-image -- /tmp/ocr.png(solid-color is fine — paddle runs deterministically; the test proves cache hit = identicalblock.ocrwhether or not OCR finds text). Ingest it twice under a paddle config; assert the second ingest adds NO newkind='ocr'cache rows and the stored OCR text is identical. Use the sameembedding_cache_rows-style helper scoped tokind='ocr'.
If a text-bearing fixture is desired for stronger evidence, commit a couple of small screenshots with known text into the machine-local dogfood store (
/home/user/large_data/out/kebab-dogfood/corpus/images/) and point the test at them — but this is NOT required for the cache-correctness gate (R2 in the spec; the mock test already pins correctness).
- Step 3: GPU-box dogfood (ollama-vision quality, can't byte-gate)
Per CLAUDE.md §Dogfood: swap lemonade→ollama-r9700 on .82, point OCR/caption at http://192.168.0.244:11434, ingest the dogfood image/PDF corpus, confirm a re-ingest is a cache hit (logs show OCR/caption skipped) and search/ask quality is unchanged. Restore lemonade unconditionally afterward. Record evidence in tasks/HOTFIXES.md + docs/release-notes/.
- Step 4: Commit any test/fixtures added
git add crates/kebab-app/tests/ocr_caption_cache.rs
git commit -m "test(app): paddle 실엔진 OCR 캐시 결정적 게이트 (AVX-gated) + 마크다운 패리티 확인"
Verification summary (PR-level HARD GATE)
- Byte-identical: full-struct serde ⇒ cache hit reconstructs
block.ocr/block.captionidentically (paddle deterministic ⇒ hit==fresh; ollama-vision ⇒ hit reproduces the pinned first result). Chunk text fed to the embedder is identical. Markdown gate IDENTICAL (path untouched). - Deterministic correctness (no models): Task 6 mock-engine test — engine invoked once across two same-input ingests, byte-identical reconstruction, version bump → miss. This is the hard per-PR gate that runs in CI.
- Provenance: not replayed on hit (§3.6) — verified no
OcrApplied/CaptionAppliedevent on a cache hit; provenance is in no wire schema / not gate-compared. - Boundary: all caching in
kebab-app;kebab-parse-*gains nokebab-store-*dep. - clippy
--workspace --all-targets= 0.
Notes for the PR
- Conventional-commit, trailer-free. PR via gitea-ops; ask single-shot vs review-loop before creating.
- Version bump: PR2 adds two cache namespaces but no CLI flag / no wire change / no migration / identical user-visible output → patch (defer to the release/정리 step). If a
[ingest.ocr]tuning knob is promoted to a user-facing key in the same work → minor. - Risks carried from spec §6: R2 (no text-bearing OCR corpus) is resolved by the mock-engine test being the correctness gate (text corpus is optional dogfood evidence, not a merge gate); R3 (paddle params in version key) handled by
ocr_cache_version_keyfoldingscore_thresh/unclip_ratio/max_boxes; R4 (parse boundary) handled by keeping the PDF cache inkebab-appviaPdfOcrOpts.