Merge pull request #228 from scub-france/feature/204-auto-stale-chunk-hash
feat(#204): deterministic chunkset hash for auto-stale detection
This commit is contained in:
commit
1064c9899c
2 changed files with 169 additions and 0 deletions
67
document-parser/domain/hashing.py
Normal file
67
document-parser/domain/hashing.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Deterministic hashing for chunksets — substrate for auto-stale detection (#204).
|
||||||
|
|
||||||
|
A `chunkset_hash` summarises the content of a list of chunks for a
|
||||||
|
document. The hash is recorded on each `DocumentStoreLink` at push time
|
||||||
|
(#203 ships the column slot). When chunks change, recomputing the hash
|
||||||
|
and comparing against the stored value tells us whether the link has
|
||||||
|
gone stale.
|
||||||
|
|
||||||
|
Why a hash and not, say, an updated_at?
|
||||||
|
- Idempotent re-pipelines on identical input bump `updated_at` without
|
||||||
|
semantic change. A content hash is the only signal that survives
|
||||||
|
that.
|
||||||
|
- It is also content-addressed: two different docs that happen to have
|
||||||
|
the same chunkset get the same hash. Useful for de-duplication
|
||||||
|
further down the road.
|
||||||
|
|
||||||
|
Inputs and exclusions are pinned. Any change to the canonical inputs
|
||||||
|
re-flips every existing link to Stale once — that is a deliberate
|
||||||
|
release-note event, not a silent migration.
|
||||||
|
|
||||||
|
This module is pure: in / out. No I/O. No randomness. No dates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from domain.value_objects import ChunkResult
|
||||||
|
|
||||||
|
|
||||||
|
# Byte separator inserted between chunks so concatenating two chunks does
|
||||||
|
# not yield the same hash as a single chunk with the joined text. \x1f is
|
||||||
|
# the Unicode "Information Separator One" — semantically appropriate and
|
||||||
|
# safe inside arbitrary text.
|
||||||
|
_CHUNK_SEPARATOR = b"\x1f"
|
||||||
|
|
||||||
|
|
||||||
|
def chunkset_hash(chunks: Iterable[ChunkResult]) -> str:
|
||||||
|
"""Return a deterministic SHA-256 hex digest over a chunkset.
|
||||||
|
|
||||||
|
Hashed inputs (per chunk, in order):
|
||||||
|
- text (str)
|
||||||
|
- source_page (int | None)
|
||||||
|
- headings (list[str], order preserved)
|
||||||
|
|
||||||
|
Excluded:
|
||||||
|
- bboxes / doc_items (rendering artefacts; do not affect retrieval)
|
||||||
|
- token_count (derived; unstable across tokenizers)
|
||||||
|
|
||||||
|
The exclusion list is intentional. Bumping it changes every link's
|
||||||
|
hash and triggers a one-time corpus-wide flip to `Stale`.
|
||||||
|
"""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
for chunk in chunks:
|
||||||
|
payload = {
|
||||||
|
"t": chunk.text,
|
||||||
|
"p": chunk.source_page,
|
||||||
|
"h": list(chunk.headings or []),
|
||||||
|
}
|
||||||
|
h.update(_CHUNK_SEPARATOR)
|
||||||
|
h.update(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode())
|
||||||
|
return h.hexdigest()
|
||||||
102
document-parser/tests/test_hashing.py
Normal file
102
document-parser/tests/test_hashing.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Tests for the chunkset hash function (#204)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from domain.hashing import chunkset_hash
|
||||||
|
from domain.value_objects import ChunkBbox, ChunkDocItem, ChunkResult
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk(text: str, *, page: int | None = 1, headings=()) -> ChunkResult:
|
||||||
|
return ChunkResult(text=text, headings=list(headings), source_page=page)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_chunkset_returns_empty_sha256() -> None:
|
||||||
|
"""Empty input has a stable, well-known hash."""
|
||||||
|
h = chunkset_hash([])
|
||||||
|
# SHA-256 of nothing is the well-known constant.
|
||||||
|
assert h == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
|
||||||
|
|
||||||
|
def test_determinism_across_invocations() -> None:
|
||||||
|
chunks = [_chunk("a"), _chunk("b"), _chunk("c")]
|
||||||
|
assert chunkset_hash(chunks) == chunkset_hash(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_change_changes_hash() -> None:
|
||||||
|
base = [_chunk("alpha")]
|
||||||
|
edited = [_chunk("alpha!")]
|
||||||
|
assert chunkset_hash(base) != chunkset_hash(edited)
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_change_changes_hash() -> None:
|
||||||
|
a = [_chunk("alpha", page=1)]
|
||||||
|
b = [_chunk("alpha", page=2)]
|
||||||
|
assert chunkset_hash(a) != chunkset_hash(b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_headings_change_changes_hash() -> None:
|
||||||
|
a = [_chunk("alpha", headings=("Section A",))]
|
||||||
|
b = [_chunk("alpha", headings=("Section B",))]
|
||||||
|
assert chunkset_hash(a) != chunkset_hash(b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_excluded_fields_do_not_change_hash() -> None:
|
||||||
|
"""token_count, bboxes, doc_items are deliberately excluded."""
|
||||||
|
a = ChunkResult(text="alpha", headings=[], source_page=1, token_count=100, bboxes=[])
|
||||||
|
b = ChunkResult(
|
||||||
|
text="alpha",
|
||||||
|
headings=[],
|
||||||
|
source_page=1,
|
||||||
|
token_count=999, # different
|
||||||
|
bboxes=[ChunkBbox(page=1, bbox=[0, 0, 10, 10])], # different
|
||||||
|
doc_items=[ChunkDocItem(self_ref="#/x", label="text")], # different
|
||||||
|
)
|
||||||
|
assert chunkset_hash([a]) == chunkset_hash([b])
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_attack_resistance() -> None:
|
||||||
|
"""Splitting one chunk into two with the same combined content must
|
||||||
|
produce a different hash from the original single-chunk version."""
|
||||||
|
one = [_chunk("HelloWorld")]
|
||||||
|
two = [_chunk("Hello"), _chunk("World")]
|
||||||
|
assert chunkset_hash(one) != chunkset_hash(two)
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_matters() -> None:
|
||||||
|
a = [_chunk("alpha"), _chunk("bravo")]
|
||||||
|
b = [_chunk("bravo"), _chunk("alpha")]
|
||||||
|
assert chunkset_hash(a) != chunkset_hash(b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_locked_fixture_three_chunks() -> None:
|
||||||
|
"""Locked fixture: a hand-built 3-chunk input has a fixed expected
|
||||||
|
hash. CI fails loud if anyone changes the canonical input list
|
||||||
|
without updating the fixture deliberately.
|
||||||
|
|
||||||
|
The expected hash below was computed once with the function as
|
||||||
|
pinned in this commit. To regenerate after a deliberate canonical
|
||||||
|
change, run:
|
||||||
|
|
||||||
|
python -c 'from domain.hashing import chunkset_hash; \\
|
||||||
|
from domain.value_objects import ChunkResult; \\
|
||||||
|
print(chunkset_hash([
|
||||||
|
ChunkResult(text="Intro paragraph.", source_page=1,
|
||||||
|
headings=["Intro"]),
|
||||||
|
ChunkResult(text="Body of section A.", source_page=2,
|
||||||
|
headings=["A"]),
|
||||||
|
ChunkResult(text="Conclusion.", source_page=3,
|
||||||
|
headings=["Conclusion"]),
|
||||||
|
]))'
|
||||||
|
"""
|
||||||
|
chunks = [
|
||||||
|
ChunkResult(text="Intro paragraph.", source_page=1, headings=["Intro"]),
|
||||||
|
ChunkResult(text="Body of section A.", source_page=2, headings=["A"]),
|
||||||
|
ChunkResult(text="Conclusion.", source_page=3, headings=["Conclusion"]),
|
||||||
|
]
|
||||||
|
expected = "6ac365ae403e53675e57884b69b0629684f2209c39730093231caa11a40e5225"
|
||||||
|
actual = chunkset_hash(chunks)
|
||||||
|
assert actual == expected, (
|
||||||
|
f"Hash drift detected. Expected {expected}, got {actual}. "
|
||||||
|
"If you intentionally changed the canonical inputs, update this "
|
||||||
|
"fixture and document the breaking change in the release notes."
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue