Skip to content

Commit 96ac3c6

Browse files
Pigbibicodex
andcommitted
fix: preserve recovered publication identities
Co-Authored-By: Codex <noreply@openai.com>
1 parent 62f0407 commit 96ac3c6

4 files changed

Lines changed: 199 additions & 15 deletions

File tree

‎src/quant_advisor_research/archive_backfill.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def backfill_site_archive(
6868
publication_plan = build_publication_plan(
6969
report_paths,
7070
mandatory_current=current_report,
71-
recovered_history=report_paths if current_report is not None else None,
71+
recovered_history=report_paths,
7272
)
7373
preflight_publication_plan(publication_plan)
7474
report_paths = [entry.source_path for entry in publication_plan.entries]
@@ -80,7 +80,7 @@ def backfill_site_archive(
8080
site_url=site_url,
8181
feed_title=feed_title,
8282
mandatory_current=current_report,
83-
recovered_history=report_paths if current_report is not None else None,
83+
recovered_history=report_paths,
8484
publication_plan=publication_plan,
8585
)
8686
for entry in publication_plan.entries:

‎src/quant_advisor_research/publisher.py‎

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -315,8 +315,43 @@ def unique_report_paths_by_content(report_paths: list[str | Path]) -> list[Path]
315315
return list(require_publish_candidates(None, report_paths).selected_paths)
316316

317317

318-
def preflight_publish_destinations(report_paths: list[str | Path]) -> None:
319-
plan = build_publication_plan(report_paths)
318+
RECOVERED_JSON_PATTERN = re.compile(
319+
r"^advisory_report_(?P<as_of>\d{4}-\d{2}-\d{2})(?P<variant>\.variant-(?P<digest>[0-9a-f]{12}))?\.json$"
320+
)
321+
322+
323+
def _recovered_public_identity(candidate: _ReportCandidate) -> tuple[str, str, str, str, bool]:
324+
assert candidate.report is not None and candidate.fingerprint is not None
325+
match = RECOVERED_JSON_PATTERN.fullmatch(candidate.path.name)
326+
if match is None or match.group("as_of") != str(candidate.report.get("as_of", "")):
327+
raise ValueError("recovered_public_identity_invalid")
328+
digest = match.group("digest")
329+
if digest is not None and digest != _variant_digest(candidate.fingerprint):
330+
raise ValueError("recovered_variant_digest_mismatch")
331+
json_name = candidate.path.name
332+
html_name = report_filename(candidate.report)
333+
markdown_name = f"advisory_report_{match.group('as_of')}.md"
334+
manifest_name = f"{json_name}.manifest.json"
335+
if digest is not None:
336+
html_name = _variant_name(html_name, digest)
337+
markdown_name = _variant_name(markdown_name, digest)
338+
return html_name, json_name, markdown_name, manifest_name, digest is None
339+
340+
341+
def preflight_publish_destinations(
342+
report_paths: list[str | Path],
343+
*,
344+
mandatory_current: str | Path | None = None,
345+
recovered_history: list[str | Path] | None = None,
346+
reject_invalid: bool = False,
347+
publication_plan: PublicationPlan | None = None,
348+
) -> None:
349+
plan = publication_plan or build_publication_plan(
350+
report_paths,
351+
mandatory_current=mandatory_current,
352+
recovered_history=recovered_history,
353+
reject_invalid=reject_invalid,
354+
)
320355
preflight_publication_plan(plan)
321356

322357

@@ -362,25 +397,80 @@ def build_publication_plan(
362397
if reject_invalid and selection.quarantined:
363398
raise ValueError("invalid_report_candidate")
364399
selected_paths = list(selection.selected_paths)
365-
artifact_ids = _relative_artifact_ids(selected_paths)
366-
candidates = [
400+
mandatory_resolved = Path(mandatory_current).resolve() if mandatory_current is not None else None
401+
recovered_paths = {
402+
path.resolve()
403+
for path in _canonical_paths(list(recovered_history or []))
404+
if path.resolve() != mandatory_resolved
405+
}
406+
all_paths = _canonical_paths(selected_paths + list(recovered_paths))
407+
artifact_ids = _relative_artifact_ids(all_paths)
408+
all_candidates = [
367409
_load_candidate(path, artifact_ids[path], index)
368-
for index, path in enumerate(selected_paths)
410+
for index, path in enumerate(all_paths)
369411
]
370-
valid_candidates = [candidate for candidate in candidates if candidate.report is not None]
412+
candidates_by_path = {candidate.path.resolve(): candidate for candidate in all_candidates}
413+
recovered_candidates = [
414+
candidate
415+
for candidate in all_candidates
416+
if candidate.path.resolve() in recovered_paths and candidate.report is not None
417+
]
418+
recovered_identities: dict[Path, tuple[str, str, str, str, bool]] = {}
419+
identity_fingerprints: dict[str, str] = {}
420+
recovered_canonical_names_by_period: dict[str, set[str]] = {}
421+
for candidate in recovered_candidates:
422+
identity = _recovered_public_identity(candidate)
423+
recovered_identities[candidate.path.resolve()] = identity
424+
if identity[4] and candidate.period is not None:
425+
recovered_canonical_names_by_period.setdefault(candidate.period.key, set()).add(identity[1])
426+
for name in identity[:4]:
427+
previous = identity_fingerprints.get(name)
428+
if previous is not None and previous != candidate.fingerprint:
429+
raise ValueError("recovered_public_identity_conflict")
430+
identity_fingerprints[name] = candidate.fingerprint or ""
431+
if any(len(names) > 1 for names in recovered_canonical_names_by_period.values()):
432+
raise ValueError("recovered_public_identity_conflict")
433+
434+
selected_candidates: list[_ReportCandidate] = []
435+
for path in selected_paths:
436+
candidate = candidates_by_path[path.resolve()]
437+
if candidate.report is None:
438+
continue
439+
if candidate.path.resolve() != mandatory_resolved:
440+
matches = [
441+
recovered
442+
for recovered in recovered_candidates
443+
if recovered.period == candidate.period and recovered.fingerprint == candidate.fingerprint
444+
]
445+
if matches:
446+
candidate = sorted(matches, key=lambda item: item.artifact_id)[0]
447+
if candidate.path.resolve() not in {item.path.resolve() for item in selected_candidates}:
448+
selected_candidates.append(candidate)
449+
valid_candidates = selected_candidates
371450
groups: dict[str, list[_ReportCandidate]] = {}
372451
for candidate in valid_candidates:
373452
assert candidate.period is not None
374453
groups.setdefault(candidate.period.key, []).append(candidate)
375-
mandatory_resolved = Path(mandatory_current).resolve() if mandatory_current is not None else None
376454
entries_by_path: dict[Path, PublicationEntry] = {}
377455
ordered_groups: list[tuple[CanonicalPeriod, list[_ReportCandidate]]] = []
378456
for group in groups.values():
379457
ranked = _publication_rank(group)
380-
owner = next(
381-
(candidate for candidate in ranked if candidate.path.resolve() == mandatory_resolved),
382-
ranked[0],
458+
mandatory_owner = next(
459+
(candidate for candidate in ranked if candidate.path.resolve() == mandatory_resolved), None
383460
)
461+
recovered_canonical = [
462+
candidate
463+
for candidate in group
464+
if recovered_identities.get(candidate.path.resolve(), ("", "", "", "", False))[4]
465+
]
466+
if mandatory_owner is not None:
467+
owner = mandatory_owner
468+
elif len({recovered_identities[candidate.path.resolve()][0] for candidate in recovered_canonical}) > 1:
469+
raise ValueError("recovered_public_identity_conflict")
470+
elif recovered_canonical:
471+
owner = sorted(recovered_canonical, key=lambda item: item.artifact_id)[0]
472+
else:
473+
owner = ranked[0]
384474
assert owner.period is not None
385475
ordered_groups.append(
386476
(owner.period, [owner, *(candidate for candidate in ranked if candidate is not owner)])
@@ -399,22 +489,28 @@ def build_publication_plan(
399489
canonical_html = report_filename(candidate.report)
400490
canonical_json = f"advisory_report_{as_of}.json"
401491
canonical_markdown = f"advisory_report_{as_of}.md"
402-
if candidate is owner:
492+
recovered_identity = recovered_identities.get(candidate.path.resolve())
493+
if recovered_identity is not None and (not recovered_identity[4] or candidate is owner):
494+
html_name, json_name, markdown_name, manifest_name, _ = recovered_identity
495+
canonical_owner = candidate is owner and recovered_identity[4]
496+
elif candidate is owner:
403497
html_name, json_name, markdown_name = canonical_html, canonical_json, canonical_markdown
498+
manifest_name = f"{json_name}.manifest.json"
404499
canonical_owner = True
405500
else:
406501
suffix = _variant_digest(candidate.fingerprint)
407502
html_name = _variant_name(canonical_html, suffix)
408503
json_name = _variant_name(canonical_json, suffix)
409504
markdown_name = _variant_name(canonical_markdown, suffix)
505+
manifest_name = f"{json_name}.manifest.json"
410506
canonical_owner = False
411507
entries_by_path[candidate.path] = PublicationEntry(
412508
report=candidate.report,
413509
source_path=candidate.path,
414510
html_name=html_name,
415511
json_name=json_name,
416512
markdown_name=markdown_name,
417-
manifest_name=f"{json_name}.manifest.json",
513+
manifest_name=manifest_name,
418514
fingerprint=candidate.fingerprint,
419515
canonical_owner=canonical_owner,
420516
generated_at=candidate.generated_at,

‎tests/test_build_pipeline.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from quant_advisor_research import build_pipeline as build_pipeline_module
1111
from quant_advisor_research.build_pipeline import build_advisory_artifacts, default_weekly_as_of
1212
from quant_advisor_research.cross_repo_smoke import run_cross_repo_smoke
13+
import quant_advisor_research.publisher as publisher_module
1314

1415

1516
ROOT = Path(__file__).resolve().parents[1]
@@ -121,6 +122,12 @@ def test_archive_backfill_same_identity_different_fingerprint_publishes_variant(
121122
second_payload = json.loads(second.read_text(encoding="utf-8"))
122123
second_payload["recommendations"][0]["reasons"] = ["different semantic content"]
123124
second.write_text(json.dumps(second_payload), encoding="utf-8")
125+
second_variant = second.with_name(
126+
f"advisory_report_2026-05-31.variant-"
127+
f"{publisher_module._variant_digest(publisher_module.report_content_fingerprint(second_payload))}.json"
128+
)
129+
second.rename(second_variant)
130+
second = second_variant
124131
output = tmp_path / "site"
125132
backfill_site_archive(
126133
report_paths=[first, second],

‎tests/test_publisher_period_redesign.py‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
from quant_advisor_research.publisher import (
1313
build_publication_plan,
1414
classify_report_path,
15+
preflight_publish_destinations,
1516
main as publisher_main,
1617
publish_reports,
1718
report_content_fingerprint,
1819
select_publish_candidates,
1920
unique_report_paths_by_content,
21+
_variant_digest,
2022
)
2123
import quant_advisor_research.publisher as publisher_module
2224
from quant_advisor_research.time_contract import canonical_reference_time
@@ -122,7 +124,10 @@ def test_publication_plan_pins_mandatory_current_as_group_owner_and_first(
122124
recovered["generated_at"] = "2026-06-16T12:00:00Z"
123125
recovered["expires_at"] = "2026-06-23T12:00:00Z"
124126
current_path = write_report(tmp_path / "advisory_report_2026-06-21.json", current)
125-
recovered_path = write_report(tmp_path / "recovered.json", recovered)
127+
recovered_digest = _variant_digest(report_content_fingerprint(recovered))
128+
recovered_path = write_report(
129+
tmp_path / f"advisory_report_2026-06-15.variant-{recovered_digest}.json", recovered
130+
)
126131

127132
plan = build_publication_plan(
128133
[current_path], mandatory_current=current_path, recovered_history=[recovered_path]
@@ -149,6 +154,82 @@ def test_publication_plan_pins_mandatory_current_as_group_owner_and_first(
149154
assert "Tue, 16 Jun 2026 12:00:00 GMT" in feed
150155

151156

157+
def test_recovered_public_identities_are_preserved_across_ranking_changes(tmp_path: Path) -> None:
158+
canonical = build_v5("2026-06-20")
159+
variant = copy.deepcopy(canonical)
160+
variant["recommendations"][0]["reasons"] = ["variant content"]
161+
canonical_path = write_report(tmp_path / "advisory_report_2026-06-20.json", canonical)
162+
variant_digest = _variant_digest(report_content_fingerprint(variant))
163+
variant_path = write_report(
164+
tmp_path / f"advisory_report_2026-06-20.variant-{variant_digest}.json", variant
165+
)
166+
167+
plan = build_publication_plan(
168+
[canonical_path, variant_path], recovered_history=[canonical_path, variant_path]
169+
)
170+
reversed_plan = build_publication_plan(
171+
[variant_path, canonical_path], recovered_history=[variant_path, canonical_path]
172+
)
173+
174+
assert [(entry.json_name, entry.html_name) for entry in plan.entries] == [
175+
("advisory_report_2026-06-20.json", "2026-06-20-weekly-model-recommendations.html"),
176+
(variant_path.name, f"2026-06-20-weekly-model-recommendations.variant-{variant_digest}.html"),
177+
]
178+
assert [(entry.json_name, entry.html_name) for entry in plan.entries] == [
179+
(entry.json_name, entry.html_name) for entry in reversed_plan.entries
180+
]
181+
182+
183+
def test_recovered_identity_conflicts_fail_before_write(tmp_path: Path) -> None:
184+
first = build_v5("2026-06-20")
185+
second = copy.deepcopy(first)
186+
second["recommendations"][0]["reasons"] = ["different content"]
187+
first_path = write_report(tmp_path / "a" / "advisory_report_2026-06-20.json", first)
188+
second_path = write_report(tmp_path / "b" / "advisory_report_2026-06-20.json", second)
189+
190+
with pytest.raises(ValueError, match="recovered_public_identity_conflict"):
191+
build_publication_plan(
192+
[first_path, second_path], recovered_history=[first_path, second_path]
193+
)
194+
195+
196+
def test_recovered_variant_digest_mismatch_fails_closed(tmp_path: Path) -> None:
197+
path = write_report(
198+
tmp_path / "advisory_report_2026-06-20.variant-000000000000.json", build_v5()
199+
)
200+
201+
with pytest.raises(ValueError, match="recovered_variant_digest_mismatch"):
202+
build_publication_plan([path], recovered_history=[path])
203+
204+
205+
def test_multiple_recovered_canonical_identities_same_period_fail_closed(tmp_path: Path) -> None:
206+
monday = build_v5("2026-06-15")
207+
sunday = copy.deepcopy(monday)
208+
sunday["as_of"] = "2026-06-21"
209+
sunday["recommendations"][0]["reasons"] = ["different content"]
210+
monday_path = write_report(tmp_path / "advisory_report_2026-06-15.json", monday)
211+
sunday_path = write_report(tmp_path / "advisory_report_2026-06-21.json", sunday)
212+
213+
with pytest.raises(ValueError, match="recovered_public_identity_conflict"):
214+
build_publication_plan(
215+
[monday_path, sunday_path], recovered_history=[monday_path, sunday_path]
216+
)
217+
218+
219+
def test_preflight_publish_destinations_forwards_publish_context(tmp_path: Path) -> None:
220+
current = write_report(tmp_path / "advisory_report_2026-06-20.json", build_v5())
221+
variant = copy.deepcopy(build_v5())
222+
variant["recommendations"][0]["reasons"] = ["variant content"]
223+
variant_digest = _variant_digest(report_content_fingerprint(variant))
224+
variant_path = write_report(
225+
tmp_path / f"advisory_report_2026-06-20.variant-{variant_digest}.json", variant
226+
)
227+
228+
preflight_publish_destinations(
229+
[current], mandatory_current=current, recovered_history=[variant_path]
230+
)
231+
232+
152233
def test_invalid_v6_cannot_shadow_valid_v5(tmp_path: Path) -> None:
153234
v5_path = write_report(tmp_path / "v5.json", build_v5())
154235
invalid_v6 = build_v6()

0 commit comments

Comments
 (0)