Skip to content

Commit 8ac6dbb

Browse files
Pigbibicodex
andcommitted
fix: scope identity digest conflicts by period
Co-Authored-By: Codex <noreply@openai.com>
1 parent a3f8a5a commit 8ac6dbb

2 files changed

Lines changed: 85 additions & 6 deletions

File tree

src/quant_advisor_research/publication_identity.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,18 +221,20 @@ def parse_reports_index(payload: object) -> ReportsIndex:
221221
bindings = tuple(_validate_v2_entry(entry) for entry in reports)
222222
else:
223223
raise _error("unsupported_index_version")
224-
identity_map: dict[tuple[str, str, str | None, str | None], str | None] = {}
225-
digest_map: dict[str, tuple[str, str, str | None, str | None]] = {}
224+
identity_map: dict[tuple[str, tuple[str, str, str | None, str | None]], str | None] = {}
225+
digest_map: dict[tuple[str, str, str], tuple[str, str, str | None, str | None]] = {}
226226
for binding in bindings:
227227
identity = (binding.json_name, binding.html_name, binding.markdown_name, binding.manifest_name)
228-
if identity in identity_map and identity_map[identity] != binding.fingerprint_digest:
228+
identity_key = (binding.period_key, identity)
229+
if identity_key in identity_map and identity_map[identity_key] != binding.fingerprint_digest:
229230
raise _error("identity_content_conflict")
230-
identity_map[identity] = binding.fingerprint_digest
231+
identity_map[identity_key] = binding.fingerprint_digest
231232
if binding.fingerprint_digest is not None:
232-
previous = digest_map.get(binding.fingerprint_digest)
233+
digest_key = (binding.period_key, binding.fingerprint_version or "", binding.fingerprint_digest)
234+
previous = digest_map.get(digest_key)
233235
if previous is not None and previous != identity:
234236
raise _error("identity_digest_conflict")
235-
digest_map[binding.fingerprint_digest] = identity
237+
digest_map[digest_key] = identity
236238
return ReportsIndex(schema_version=schema_version, bindings=bindings)
237239

238240

@@ -280,6 +282,15 @@ def serialize_reports_index_v2(index: ReportsIndex) -> str:
280282
),
281283
)
282284
for binding in ordered:
285+
if (
286+
binding.schema_version not in {"5", "6"}
287+
or binding.fingerprint_version != FINGERPRINT_VERSION
288+
or binding.fingerprint_digest is None
289+
or type(binding.display_order) is not int
290+
or type(binding.canonical_identity) is not bool
291+
or type(binding.display_primary) is not bool
292+
):
293+
raise _error("v2_serialization_invalid_binding")
283294
item: dict[str, Any] = {
284295
"period_key": binding.period_key,
285296
"as_of": binding.as_of,
@@ -297,5 +308,9 @@ def serialize_reports_index_v2(index: ReportsIndex) -> str:
297308
item["md"] = binding.markdown_name
298309
if binding.manifest_name is not None:
299310
item["manifest"] = binding.manifest_name
311+
try:
312+
_validate_v2_entry(item)
313+
except IdentityMetadataError as exc:
314+
raise _error("v2_serialization_invalid_binding") from exc
300315
reports.append(item)
301316
return json.dumps({"schema_version": 2, "reports": reports}, ensure_ascii=False, indent=2) + "\n"

tests/test_publication_identity.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import hashlib
4+
import json
5+
from dataclasses import replace
46
from pathlib import Path
57

68
import pytest
@@ -192,6 +194,68 @@ def test_v2_duplicate_identity_or_digest_conflicts_fail_closed() -> None:
192194
parse_reports_index({"schema_version": 2, "reports": [first, same_digest]})
193195

194196

197+
def test_same_digest_is_allowed_across_different_periods() -> None:
198+
first_report = build_report(as_of="2026-06-20")
199+
second_report = build_report(as_of="2026-06-27")
200+
digest = digest_for(first_report)
201+
202+
index = parse_reports_index(
203+
{
204+
"schema_version": 2,
205+
"reports": [v2_entry(first_report, digest=digest), v2_entry(second_report, digest=digest)],
206+
}
207+
)
208+
209+
assert {binding.period_key for binding in index.bindings} == {
210+
"weekly:2026-06-15:2026-06-21",
211+
"weekly:2026-06-22:2026-06-28",
212+
}
213+
214+
215+
def test_v1_verified_binding_cannot_be_serialized_as_incomplete_v2() -> None:
216+
report = build_report()
217+
binding = parse_reports_index({"schema_version": 1, "reports": [v1_entry()]}).bindings[0]
218+
verified_v1 = verify_identity_binding(binding, report)
219+
220+
with pytest.raises(IdentityMetadataError, match="v2_serialization_invalid_binding"):
221+
serialize_reports_index_v2(ReportsIndex(2, (verified_v1,)))
222+
223+
224+
def test_complete_v2_binding_serializes_and_parses_round_trip() -> None:
225+
report = build_report()
226+
parsed = parse_reports_index({"schema_version": 2, "reports": [v2_entry(report)]})
227+
verified = verify_identity_binding(parsed.bindings[0], report)
228+
229+
round_tripped = parse_reports_index(
230+
json.loads(serialize_reports_index_v2(ReportsIndex(2, (verified,))))
231+
).bindings[0]
232+
233+
assert round_tripped.period_key == verified.period_key
234+
assert round_tripped.fingerprint_digest == verified.fingerprint_digest
235+
assert round_tripped.json_name == verified.json_name
236+
assert round_tripped.verification_status == "PENDING_REPORT_VALIDATION"
237+
238+
239+
@pytest.mark.parametrize(
240+
"mutation",
241+
[
242+
lambda binding: replace(binding, display_order=None),
243+
lambda binding: replace(binding, display_order=-1),
244+
lambda binding: replace(binding, display_order=True),
245+
lambda binding: replace(binding, fingerprint_version=None),
246+
lambda binding: replace(binding, fingerprint_digest=None),
247+
lambda binding: replace(binding, schema_version=None),
248+
],
249+
)
250+
def test_serializer_revalidates_complete_v2_invariants(mutation) -> None:
251+
report = build_report()
252+
parsed = parse_reports_index({"schema_version": 2, "reports": [v2_entry(report)]})
253+
verified = verify_identity_binding(parsed.bindings[0], report)
254+
255+
with pytest.raises(IdentityMetadataError, match="v2_serialization"):
256+
serialize_reports_index_v2(ReportsIndex(2, (mutation(verified),)))
257+
258+
195259
@pytest.mark.parametrize("key", ["md", "manifest"])
196260
def test_optional_identity_names_are_validated_when_declared(key: str) -> None:
197261
report = build_report()

0 commit comments

Comments
 (0)