Implement core/differ.py with the Differ class that performs hash-based change detection between document versions, enabling the pipeline to skip unchanged content during re-ingestion.
Requirements
- Implement
Differ class in core/differ.py
- Implement
diff(prev_markdown: str | None, curr_markdown: str, prev_chunk_hashes: set[str] | None, curr_chunk_hashes: set[str]) -> DiffResult method:
- If
prev_markdown is None (first ingest): return DiffResult(changed=True, added_hashes=curr_chunk_hashes, removed_hashes=set(), unchanged_hashes=set(), prev_hash=None, curr_hash=SHA256(normalized(curr_markdown)))
- Apply whitespace normalization to both markdowns
- Compute SHA-256 of both normalized markdowns
- If hashes match: return
DiffResult(changed=False, added_hashes=set(), removed_hashes=set(), unchanged_hashes=curr_chunk_hashes, prev_hash=prev_hash, curr_hash=curr_hash)
- If hashes differ: compute set operations on chunk hashes:
added = curr_chunk_hashes - prev_chunk_hashes
removed = prev_chunk_hashes - curr_chunk_hashes
unchanged = curr_chunk_hashes & prev_chunk_hashes
- Return
DiffResult(changed=True, added_hashes=added, removed_hashes=removed, unchanged_hashes=unchanged, prev_hash=prev_hash, curr_hash=curr_hash)
- Whitespace normalization must treat trailing-whitespace-only edits as unchanged
- Create
tests/core/test_differ.py covering: first ingest (all added), unchanged document (full-document hash match), modified chunk (partial change), added chunk, removed chunk, whitespace-only change (classified as unchanged)
Design Guidance
core/differ.py already exists as a stub — implement Differ there
- Import:
from context_library.storage.models import DiffResult
- Whitespace normalization function (reuse same normalization as
Chunk hash computation):
import re
def _normalize(text: str) -> str:
text = re.sub(r'[ \t]+', ' ', text)
text = '\n'.join(line.rstrip() for line in text.splitlines())
return text.strip()
- SHA-256 computation:
hashlib.sha256(normalized.encode('utf-8')).hexdigest()
- The
Differ class has no constructor parameters — it is stateless
- The differ does NOT chunk the content itself; it receives pre-computed
curr_chunk_hashes from the domain chunker. This is important: the pipeline calls the chunker first, then the differ.
DiffResult is a frozen Pydantic model — construct it with keyword arguments
- The differ's set operations work on
chunk_hash strings (SHA-256 hex digests of chunk content)
Acceptance Criteria
Dependencies
Phase 1: Data Models and Schema Foundation
Parent Issue
Part of #40
Discussion
This work is detailed in discussion 41
Implement
core/differ.pywith theDifferclass that performs hash-based change detection between document versions, enabling the pipeline to skip unchanged content during re-ingestion.Requirements
Differclass incore/differ.pydiff(prev_markdown: str | None, curr_markdown: str, prev_chunk_hashes: set[str] | None, curr_chunk_hashes: set[str]) -> DiffResultmethod:prev_markdownisNone(first ingest): returnDiffResult(changed=True, added_hashes=curr_chunk_hashes, removed_hashes=set(), unchanged_hashes=set(), prev_hash=None, curr_hash=SHA256(normalized(curr_markdown)))DiffResult(changed=False, added_hashes=set(), removed_hashes=set(), unchanged_hashes=curr_chunk_hashes, prev_hash=prev_hash, curr_hash=curr_hash)added = curr_chunk_hashes - prev_chunk_hashesremoved = prev_chunk_hashes - curr_chunk_hashesunchanged = curr_chunk_hashes & prev_chunk_hashesDiffResult(changed=True, added_hashes=added, removed_hashes=removed, unchanged_hashes=unchanged, prev_hash=prev_hash, curr_hash=curr_hash)tests/core/test_differ.pycovering: first ingest (all added), unchanged document (full-document hash match), modified chunk (partial change), added chunk, removed chunk, whitespace-only change (classified as unchanged)Design Guidance
core/differ.pyalready exists as a stub — implementDiffertherefrom context_library.storage.models import DiffResultChunkhash computation):hashlib.sha256(normalized.encode('utf-8')).hexdigest()Differclass has no constructor parameters — it is statelesscurr_chunk_hashesfrom the domain chunker. This is important: the pipeline calls the chunker first, then the differ.DiffResultis a frozen Pydantic model — construct it with keyword argumentschunk_hashstrings (SHA-256 hex digests of chunk content)Acceptance Criteria
Differ.diff()withprev_markdown=Nonereturnschanged=Truewith allcurr_chunk_hashesinadded_hashesDiffer.diff()with identical content (after normalization) returnschanged=Falseregardless of whitespace differencesDiffer.diff()with one modified chunk correctly places it inadded_hashes(new hash) andremoved_hashes(old hash)Differ.diff()with a new chunk added correctly places it inadded_hashesonlyDiffer.diff()with a chunk removed correctly places it inremoved_hashesonlychanged=Falsetests/core/test_differ.pypasses all casesDependencies
Phase 1: Data Models and Schema Foundation
Parent Issue
Part of #40
Discussion
This work is detailed in discussion 41