feat(segmentation): key the adjudication cache on the extractor, not the hash alone
This commit is contained in:
parent
0a11c860ce
commit
832e541fb9
2 changed files with 136 additions and 0 deletions
|
|
@ -67,6 +67,12 @@ ENTRY_FIELDS = ("segment_id", "path", "title", "okf_type", "span", "ingested_at"
|
|||
#: an authored `..` is a mistake worth naming, not a path worth normalising.
|
||||
FORBIDDEN_COMPONENTS = ("", ".", "..")
|
||||
|
||||
#: The components of the adjudication cache key, in the order
|
||||
#: :func:`plan_cache_key` returns them. Named so a mismatch message can say
|
||||
#: WHICH one moved -- that is what tells an operator whether to re-run the
|
||||
#: proposer or re-adjudicate by hand.
|
||||
CACHE_KEY_COMPONENTS = ("source_sha256", "extractor_id", "extractor_version")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SegmentEntry:
|
||||
|
|
@ -315,3 +321,48 @@ def parse_segmentation_plan(payload: Mapping[str, Any]) -> SegmentationPlan:
|
|||
adjudicated_at=_require_str(payload, "adjudicated_at", where="the segmentation plan"),
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
|
||||
def plan_cache_key(plan: SegmentationPlan) -> tuple[str, str, str]:
|
||||
"""The triple an adjudication is cached under: source AND extractor identity.
|
||||
|
||||
Not the hash alone. `source_sha256` answers "are these the same bytes?",
|
||||
which is necessary and not sufficient: the offsets in a plan index the
|
||||
canonical EXTRACTED text, and swapping the extractor or bumping its version
|
||||
can re-shape that text while the source bytes are untouched. Keyed on the
|
||||
hash alone, a stored adjudication would be replayed against text the
|
||||
adjudicator never saw, and every span would land somewhere plausible and
|
||||
wrong. This is design requirement S5b.
|
||||
"""
|
||||
return (plan.source_sha256, plan.extractor_id, plan.extractor_version)
|
||||
|
||||
|
||||
def assert_plan_applies(
|
||||
plan: SegmentationPlan,
|
||||
*,
|
||||
source_sha256: str,
|
||||
extractor_id: str,
|
||||
extractor_version: str,
|
||||
) -> None:
|
||||
"""Refuse loudly when a plan was adjudicated against a different extraction.
|
||||
|
||||
Loudly, and never by re-deriving: a silent fallback would turn "this plan
|
||||
is stale" into "this bundle is subtly wrong", which no test downstream can
|
||||
catch because every span still points at real text. The message names
|
||||
which of the three components moved, because that is what tells the
|
||||
operator whether to re-run the proposer or re-adjudicate by hand.
|
||||
"""
|
||||
observed = (source_sha256, extractor_id, extractor_version)
|
||||
differing = [
|
||||
f"{name}: plan {expected!r} != run {actual!r}"
|
||||
for name, expected, actual in zip(CACHE_KEY_COMPONENTS, plan_cache_key(plan), observed)
|
||||
if expected != actual
|
||||
]
|
||||
if differing:
|
||||
raise SegmentationError(
|
||||
"this segmentation plan was adjudicated against a different extraction "
|
||||
f"({'; '.join(differing)}) — refusing to replay its offsets, which index "
|
||||
"the canonical extracted text and would land on text no one adjudicated; "
|
||||
"re-run the proposer and re-adjudicate",
|
||||
code="segmentation_extractor_mismatch",
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue