Adds technical design docs for the foundation of the doc-centric ingest pivot: - 202 — Document lifecycle state machine - 203 — Per (document, store) ingestion state - 204 — Auto-detect Stale state via chunk content hash - 205 — Audit trail for chunk edits (chunks → first-class entity) - 206 — Migration of existing documents to the new model Status: Accepted on all five. Each doc spells out the domain entities, persistence schema, services orchestration, API contract, alternatives considered, risks, and testing strategy. ADR placeholders called out where load-bearing decisions warrant a follow-up document. Refs #202 #203 #204 #205 #206
265 lines
12 KiB
Markdown
265 lines
12 KiB
Markdown
# Design: Auto-detect Stale state via chunk content hash
|
|
|
|
- **Issue:** #204
|
|
- **Title on issue:** [FEATURE] Auto-detect Stale state via chunk content hash
|
|
- **Author:** Pier-Jean Malandrino
|
|
- **Date:** 2026-04-29
|
|
- **Status:** Accepted
|
|
- **Target milestone:** 0.6.0 — Doc-centric ingest
|
|
- **Impacted layers:** backend: domain · persistence · services · frontend (read-only)
|
|
- **Audit dimensions likely touched:** Hexagonal Architecture · DDD · Performance · Tests · Security
|
|
- **ADR spawned?:** no
|
|
|
|
---
|
|
|
|
## 1. Problem
|
|
|
|
Without auto-detection, a user who edits a chunk has to remember which stores hold the doc and click "re-ingest" everywhere. They will forget. The next query against the stale store returns the old embedding, and the customer reports "I fixed it yesterday and it's still wrong". Manual flagging is a bug factory.
|
|
|
|
The detection contract is: **the system, not the user, knows when a stored chunkset diverges from the current draft chunkset.** Implementation = a deterministic hash recorded at push time and compared on read or on chunk write.
|
|
|
|
## 2. Goals
|
|
|
|
- [ ] Define a deterministic `chunkset_hash(chunks: list[ChunkResult])` function.
|
|
- [ ] Store the hash on each `DocumentStoreLink` at push time (#203 ships the column slot).
|
|
- [ ] On any chunk modification, recompute the current chunkset hash and compare against each link; if mismatch, mark the link `Stale` and re-aggregate the document state.
|
|
- [ ] On document read (single-doc API), compare on the fly as a safety net so older drift is caught.
|
|
- [ ] Unit tests prove: edit a chunk → link state becomes `Stale`; re-push → link state becomes `Ingested` with the new hash.
|
|
|
|
## 3. Non-goals
|
|
|
|
- Per-chunk change tracking — that's the audit trail (#205); this issue cares about the *aggregate* hash only.
|
|
- Background sweeper job that scans the whole corpus for drift — for 0.6.0, detection is event-driven (on chunk write) + on-read; a sweeper can come later.
|
|
- Diff-aware re-ingest at the chunk granularity — that's #223; this issue tells you *whether* a re-ingest is needed, not which chunks to re-embed.
|
|
- A user-facing toggle to "force stale" — out of scope.
|
|
|
|
## 4. Context & constraints
|
|
|
|
### Existing code surface
|
|
|
|
- `document-parser/domain/value_objects.py` — `ChunkResult` (line 93).
|
|
- `document-parser/services/analysis_service.py` — chunking pipeline.
|
|
- `document-parser/persistence/database.py` — `analysis_jobs.chunks_json` (the canonical chunkset today).
|
|
- `document-parser/services/ingestion_service.py` — push pipeline.
|
|
- `document-parser/api/ingestion.py` — push endpoint.
|
|
- `document-parser/persistence/document_store_link_repo.py` (created by #203).
|
|
|
|
### Hexagonal Architecture constraints
|
|
|
|
- `chunkset_hash` is a **pure domain function** — lives in `domain/hashing.py`. No I/O. No timestamps. Deterministic over the input.
|
|
- Detection (compare + transition) is orchestrated in services. Persistence reads the stored hash via the link repo.
|
|
- The hash is opaque to API/frontend in 0.6.0; surfaced only as a debug field on `StoreLinkResponse` (#203 added it).
|
|
|
|
### Hard constraints
|
|
|
|
- Hash function must be **stable across processes / machines / Python versions** — so SHA-256 (`hashlib`), not Python `hash()`. No salt.
|
|
- Must be cheap on a 500-chunk doc — a single linear pass, no JSON re-parse on hot path.
|
|
- Result is a hex string, length 64. Stored as `TEXT` in SQLite.
|
|
|
|
## 5. Proposed design
|
|
|
|
### 5.1 Domain
|
|
|
|
`document-parser/domain/hashing.py`:
|
|
|
|
```python
|
|
import hashlib
|
|
import json
|
|
from collections.abc import Iterable
|
|
from .value_objects import ChunkResult
|
|
|
|
def chunkset_hash(chunks: Iterable[ChunkResult]) -> str:
|
|
"""
|
|
Deterministic hash over a chunkset.
|
|
|
|
Hashed inputs (per chunk, in chunkset order):
|
|
- text (str)
|
|
- source_page (int | None)
|
|
- headings (list[str], preserved order)
|
|
|
|
Excluded:
|
|
- bboxes / doc_items (rendering artefacts; do not affect retrieval semantics)
|
|
- token_count (derived; unstable across tokenizers)
|
|
"""
|
|
h = hashlib.sha256()
|
|
for chunk in chunks:
|
|
payload = {
|
|
"t": chunk.text,
|
|
"p": chunk.source_page,
|
|
"h": list(chunk.headings or []),
|
|
}
|
|
h.update(b"\x1f")
|
|
h.update(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode())
|
|
return h.hexdigest()
|
|
```
|
|
|
|
Notes:
|
|
- The `\x1f` (Unit Separator) byte between chunks defends against the "join attack" where chunk A's tail and chunk B's head produce the same hash as chunk A+B merged.
|
|
- `separators=(",", ":")` is the canonical compact JSON form.
|
|
- The exclusion list is intentional and documented inline — changing it bumps every doc to `Stale` once and is a deliberate one-time event (covered in §10 Rollback).
|
|
|
|
`detect_stale_links(current_hash, links) -> list[DocumentStoreLink]` — pure helper returning the subset of links whose `chunkset_hash != current_hash`.
|
|
|
|
### 5.2 Persistence
|
|
|
|
No new schema. The `chunkset_hash` column was added by #203 on `document_store_links`.
|
|
|
|
A new index supports the on-read safety net:
|
|
|
|
```sql
|
|
CREATE INDEX IF NOT EXISTS idx_dsl_doc_state ON document_store_links(document_id, state);
|
|
```
|
|
|
|
### 5.3 Infra adapters
|
|
|
|
None. Hashing is in-memory.
|
|
|
|
### 5.4 Services
|
|
|
|
Two call sites:
|
|
|
|
**A. On chunk write (event-driven detection)**
|
|
|
|
`AnalysisService.persist_chunks(doc_id, chunks)`:
|
|
1. Persist chunks (existing path: `analysis_jobs.chunks_json`).
|
|
2. Compute `current_hash = chunkset_hash(chunks)`.
|
|
3. Read all `DocumentStoreLink` rows for `doc_id`.
|
|
4. For each link with `chunkset_hash != current_hash` and `state in (Ingested,)`:
|
|
- `link.mark_stale(at=now)` and persist.
|
|
5. Re-aggregate the document state (#202).
|
|
|
|
This runs in the same transaction as the chunk write. The full pass is O(N_links) which is small (a doc rarely lives in more than a handful of stores).
|
|
|
|
**B. On push completion (set the new hash)**
|
|
|
|
Already covered by #203's `IngestionService.ingest`. Add the `current_hash` argument when calling `link.mark_ingested(hash_=current_hash, at=now, run_id=...)`.
|
|
|
|
**C. On read (safety net)**
|
|
|
|
`DocumentService.find_by_id(id)`:
|
|
1. Fetch document + links (existing).
|
|
2. Compute `current_hash` (cheap; cached per request).
|
|
3. Mark any `Ingested` link with mismatched hash as `Stale` and persist (best-effort, swallowed if write fails — log).
|
|
|
|
This guards against drift caused by direct DB writes / restored backups / older deploys.
|
|
|
|
### 5.5 API
|
|
|
|
No new endpoint. `StoreLinkResponse.chunksetHash` (already added by #203) is now actually populated.
|
|
|
|
### 5.6 Frontend — feature module
|
|
|
|
No changes in this issue. #224 (stale indicator) reads the existing field.
|
|
|
|
### 5.7 Cross-cutting
|
|
|
|
- Feature flag: none.
|
|
- Logs: `INFO event=stale_detected doc_id=<id> store_id=<id> previous_hash=<8> current_hash=<8>` (truncated for privacy / log volume).
|
|
- ADR: not required. The choice of SHA-256 + canonical JSON is documented inline in `hashing.py`.
|
|
|
|
## 6. Alternatives considered
|
|
|
|
### Alternative A — Per-chunk hashes only (no chunkset hash)
|
|
|
|
- **Summary:** Skip the aggregate hash; track each chunk's hash and detect "any per-chunk hash drift".
|
|
- **Why not:** Per-chunk hashing is needed for #223 (diff-aware re-embed) but not for "is this link stale at all?". A single `chunkset_hash` is one-comparison cheap; per-chunk is N-comparisons. Both will exist after #223 lands; this issue ships the cheap top-level signal.
|
|
|
|
### Alternative B — Store updated_at instead of hash
|
|
|
|
- **Summary:** Compare `chunks.updated_at > link.last_push_at`.
|
|
- **Why not:** Brittle. Re-running a pipeline on identical input bumps `updated_at` without semantic change. Hash is content-addressed and survives idempotent rewrites.
|
|
|
|
### Alternative C — MD5 / xxHash
|
|
|
|
- **Summary:** Use a faster non-cryptographic hash.
|
|
- **Why not:** SHA-256 is fast enough on the volumes in scope (`<500 chunks`, microseconds in CPython hashlib via OpenSSL bindings). The cryptographic hash also gives us collision resistance for free. xxHash would require an extra dependency.
|
|
|
|
## 7. API & data contract
|
|
|
|
### Endpoints
|
|
|
|
No additions. Existing `StoreLinkResponse.chunksetHash` becomes populated.
|
|
|
|
### Persistence schema
|
|
|
|
```sql
|
|
CREATE INDEX IF NOT EXISTS idx_dsl_doc_state ON document_store_links(document_id, state);
|
|
```
|
|
|
|
### Env vars / config
|
|
|
|
None.
|
|
|
|
### Breaking changes
|
|
|
|
None.
|
|
|
|
## 8. Risks & mitigations
|
|
|
|
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|
|
|------|-----------------|------------|--------|---------------|------------------------|
|
|
| Hash function changes (e.g. someone adds `bboxes` to the input) silently invalidates every link | DDD | Medium | High | Every doc flips to `Stale` post-deploy | Hash function is in a single file with a docstring listing the canonical inputs; CI fixture asserts a fixed hash for a known chunkset. Bumping requires updating the fixture deliberately. |
|
|
| Read-side detection updates state under a GET (write-on-read) | Performance / Decoupling | Low | Medium | Slow / unexpected SQL in read paths | Best-effort, swallowed write; only triggered when the API actually serves the doc detail page (already a write-allowed code path). Disabled if a query param `?refresh=false` is set (future). |
|
|
| Unicode normalization issues (NFC vs NFD) produce different hashes for "the same" text | Tests | Low | Medium | Drift after a copy-paste from a Mac | Document the policy: text is stored as-is, no normalization; if drift appears, normalize at write time, not at hash time. |
|
|
| JSON ordering instability across Python versions | Tests | Low | High | Hash mismatch on different prod nodes | `separators=(",", ":")` + `ensure_ascii=False` + explicit key order in the dict literal. Reviewer checklist mentions this. |
|
|
|
|
## 9. Testing strategy
|
|
|
|
### Backend — pytest
|
|
|
|
- **Unit (domain):**
|
|
- `test_chunkset_hash_determinism.py` — same input → same output across multiple invocations.
|
|
- `test_chunkset_hash_sensitivity.py` — every input field in the canonical list changes the hash; excluded fields (`bboxes`, `token_count`) do not.
|
|
- `test_chunkset_hash_join_attack.py` — separating into different chunks must produce different hash from concatenation.
|
|
- **Locked fixture** `test_chunkset_hash_fixture.py` — a hand-built 3-chunk input whose hash is hard-coded; CI fails if anyone changes the function silently.
|
|
- **Services:**
|
|
- `test_stale_detection_on_edit.py` — edit a chunk → link state becomes `Stale`.
|
|
- `test_stale_clears_on_repush.py` — re-push → link state becomes `Ingested` with the new hash.
|
|
- `test_stale_safety_net_on_read.py` — direct DB tampering → next read flips state to `Stale`.
|
|
|
|
### Frontend — Vitest
|
|
|
|
None new in this issue.
|
|
|
|
### E2E — Karate UI
|
|
|
|
Out of scope here; lands with #224.
|
|
|
|
### Manual QA
|
|
|
|
1. Push a doc to the default store → `chunksetHash` populated, `state == "Ingested"`.
|
|
2. Edit a chunk via API → `state == "Stale"`.
|
|
3. Re-push → `state == "Ingested"`, new hash.
|
|
|
|
## 10. Rollout & observability
|
|
|
|
### Release branch
|
|
|
|
`release/0.6.0`.
|
|
|
|
### Feature flag
|
|
|
|
None. Detection is always on; cheap; correctness improvement.
|
|
|
|
### Observability
|
|
|
|
- Log lines as in §5.7.
|
|
- One-time bump scenario: if we ever change the canonical input list, every link will flip to `Stale` once. That is a deliberate decision; the operator must be informed via release notes, and a one-shot reindex job is recommended (out of scope here).
|
|
|
|
### Rollback plan
|
|
|
|
The migration is additive (a new index). Reverting the code leaves the existing `chunkset_hash` column populated but unused — harmless. The index can be dropped in a follow-up.
|
|
|
|
## 11. Open questions
|
|
|
|
- Should the safety-net read-side check be opt-in via a query param? **Decision:** always-on for 0.6.0; revisit if the cost shows up in profiles.
|
|
- Should headings include the *path* (parent → leaf) or just the leaf? **Decision:** the full ordered list as it sits on `ChunkResult.headings`. If the source mutates the list semantics, that is a separate domain concern.
|
|
|
|
## 12. References
|
|
|
|
- **Issue:** https://github.com/scub-france/Docling-Studio/issues/204
|
|
- **Related issues:** #202 (lifecycle), #203 (per-store state), #205 (audit), #206 (migration), #223 (diff-aware re-ingest), #224 (stale indicator)
|
|
- **ADRs:** none planned
|
|
- **Project docs:**
|
|
- Architecture: `docs/architecture.md`
|
|
- Coding standards: `docs/architecture/coding-standards.md`
|