diff --git a/database/review_schema.py b/database/review_schema.py index 0aa8318b..94ed1064 100644 --- a/database/review_schema.py +++ b/database/review_schema.py @@ -235,7 +235,9 @@ def evidence_offers_choice(evidence: Any) -> bool: candidates = evidence.get("candidates") if not isinstance(candidates, list): return False - return sum(1 for c in candidates if choosable(c)) >= MIN_CHOOSABLE + # The saver anchor's one pair (#390) is a suggestion to confirm. + needed = 1 if evidence.get("anchor") == "saver" else MIN_CHOOSABLE + return sum(1 for c in candidates if choosable(c)) >= needed def evidence_digest(text: str) -> str: diff --git a/py/api/admin.py b/py/api/admin.py index 87553e7c..047c6b8a 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -1594,8 +1594,7 @@ class AdminRoutesMixin: whose final outcome is ambiguous and unlinked (the resolver found it ambiguous and the fallback did not link it) is recorded for Needs review (best effort, counted in ``review`` when given); one a saved - row already links is reported ``unchanged`` and never queued, and one - whose saver anchors its branch (#390) ``unresolved``. An + row already links is reported ``unchanged`` and never queued. An ambiguous file with nothing to choose is reported ``unresolved`` and its pending items without a choice leave the queue. """ @@ -1630,12 +1629,15 @@ class AdminRoutesMixin: if ambiguous and outcome == "ambiguous": if self._review_file_linked(media_file, roots): outcome = "unchanged" # already linked: not a review case - elif self._saver_anchored_review( - media_file, roots, records.get("canonical"), review - ): - outcome = "unresolved" # its saver names its branch (#390) else: - self._record_scanned_review(media_file, roots, review) + # The records name the item versions that count; the + # saver anchor (#390) is checked on the captured bytes. + self._record_scanned_review( + media_file, + roots, + review, + scanned=(records.get("canonical"), None), + ) elif untraced and outcome == "unresolved": # Untraced by the default profile: the inspection profile may # still offer a choice (queued for review, never linked). diff --git a/py/api/review.py b/py/api/review.py index dc9f72d6..0e49f6dc 100644 --- a/py/api/review.py +++ b/py/api/review.py @@ -730,6 +730,9 @@ def _candidate_still_offered(resolution: dict, choice: str) -> bool: that rewords the top-level reason or reorders candidates leaves a pending item applicable. """ + if isinstance(resolution, dict) and resolution.get("state") == "resolved": + # The bytes now name one pair (the saver anchor, #390): only it. + resolution = {"state": "ambiguous", "candidates": [resolution]} if not isinstance(resolution, dict) or resolution.get("state") != "ambiguous": return False evidence, _count, limited = review_evidence(resolution) diff --git a/py/api/review_recording.py b/py/api/review_recording.py index c78ef700..141cf14a 100644 --- a/py/api/review_recording.py +++ b/py/api/review_recording.py @@ -88,7 +88,8 @@ class ReviewRecordingMixin: def _record_scanned_review(self, media_file, roots, review=None, scanned=None): """Record one ambiguous, unlinked scanned file (logged and counted). - ``scanned`` is the scan's ``(metadata, inspection_resolution)``. + ``scanned`` is the scan's ``(metadata, inspection_resolution)``; the + resolution is None when the scan did not run the inspection profile. """ try: outcome = self._record_review_sync( @@ -210,74 +211,6 @@ class ReviewRecordingMixin: del memory[next(iter(memory))] return None - def _saver_anchored_review(self, media_file, roots, canonical, review=None): - """True when the saver that wrote a default-ambiguous file resolves - its branch (#390), so the file is not queued (blocking). - - Its current bytes' pending, undecided items then leave the queue - (counted in ``review["untraced"]``: nothing to choose). A failure is - logged and counted, and the file is queued as before. A file state - (size, ``mtime_ns``, item version) found unanchored in this process - is not inspected again, and bytes carrying a decision are not - inspected at all (a decision is never settled). - """ - located = self._review_location(media_file, roots) - if canonical is None or located is None: - return False - locator = os.path.normcase(located[3]) - try: - facts = os.lstat(located[3]) - except OSError: - return False # gone: the capture decides as before - if self.db.review_decided_at(locator, facts.st_size, facts.st_mtime_ns): - return False - version = review_capture.inspection_review_version(canonical) - if version is None: - return False - state = _digest(f"anchor:{facts.st_size}:{facts.st_mtime_ns}:{version}") - memory = self._unanchored_memory() - key = _memory_key(locator) - if memory.get(key) == state: - return False - try: - anchored = review_capture.saver_anchored(canonical, media_file.name) - except Exception: - self.logger.warning( - f"{media_file.name} could not be inspected for Needs review", - exc_info=True, - ) - if review is not None: - review["errors"] += 1 - return False - if not anchored: - memory.pop(key, None) # re-inserted last: the newest state - memory[key] = state - while len(memory) > MAX_UNTRACED_MEMORY: - del memory[next(iter(memory))] - return False - try: - # No version is current for these bytes: any pending item goes. - removed = self.db.settle_stale_review_items( - locator, facts.st_size, facts.st_mtime_ns, () - ) - except Exception: - self.logger.warning( - f"Needs review items of {media_file.name} could not be removed", - exc_info=True, - ) - if review is not None: - review["errors"] += 1 - return True - if review is not None: - review["untraced"] += removed - return True - - def _unanchored_memory(self): - memory = getattr(self, "_unanchored_inspections", None) - if memory is None: - memory = self._unanchored_inspections = {} - return memory - def _settle_stale_reviews(self, media_file, roots, canonical, review=None): """Remove the pending, undecided items of an untraced file's current bytes recorded under another version (best effort, counted in diff --git a/tests/test_review_saver_anchor.py b/tests/test_review_saver_anchor.py index 696a7451..f7e146fa 100644 --- a/tests/test_review_saver_anchor.py +++ b/tests/test_review_saver_anchor.py @@ -3,11 +3,13 @@ The workspace inspector (since #383) passes the file's name to the resolver, which anchors the file on the one saver whose ``filename_prefix`` names it (``_GraphResolver.saver_stages``). The scan and Needs review use the same -rule: a file whose branch that anchor resolves is never queued, and its -pending, undecided items leave the queue on the next scan. No match, several -matches or an unfinished walk anchor nothing, so such a file stays queued as -before. Apply re-resolves an inspection item with the file's name, so a -choice the anchor now contradicts is refused as stale evidence. +rule. Maintainer ruling: a file never falls through both the link and the +queue, so when only that anchor resolves a file the default profile and the +parser floor do not link, it is queued with the anchor's pair as its one +choice (never linked automatically). No match, several matches or an +unfinished walk anchor nothing, so such a file is queued as before. Apply +re-resolves with the file's name, so a choice the anchor contradicts is +refused as stale evidence. """ import json @@ -17,7 +19,6 @@ try: from tests.test_review import unlinkable_graph, write_png from tests.test_review_inspection import ( PROMPTS, - _counted, InspectionTestCase, qwen_edit_graph, ) @@ -26,7 +27,6 @@ except ImportError: # discovered with tests/ as the top-level directory from test_review_inspection import ( PROMPTS, InspectionTestCase, - _counted, qwen_edit_graph, ) @@ -80,20 +80,47 @@ def _without_name(real): return unnamed +def _only_candidate(test, item): + """The positive text of an anchored item's one candidate.""" + evidence = json.loads(item["evidence"]) + test.assertEqual(evidence.get("anchor"), "saver") + test.assertEqual(evidence.get("profile"), "inspection") + (candidate,) = evidence["candidates"] + return candidate["positive"]["text"] + + class TestInspectionItemsAnchor(InspectionTestCase): - async def test_file_its_saver_names_is_not_queued(self): + async def test_file_its_saver_names_is_queued_with_the_anchor_choice(self): self._roots() path = self._write("branch_1_00001_.png", graph=branch_graph(*PROMPTS)) job = await self._scan() - self.assertEqual(self._items(), []) + (item,) = self._items() + self.assertEqual(_only_candidate(self, item), "add a red hat") + self.assertEqual(item["candidate_count"], 1) + self.assertEqual( + item["resolver_version"], + review_capture.inspection_review_version( + {"prompt": branch_graph(*PROMPTS)} + ), + ) (row,) = job["outcomes"]["review"] self.assertEqual(row["path"], "branch_1_00001_.png") - self.assertNotIn("more than one prompt could match", row["detail"]) - # Never queued is not linked: ingestion stays on the default profile. + # Queued, never linked: ingestion stays on the default profile. self.assertIsNone(self._linked_text(path)) + async def test_anchored_item_stays_on_rescan(self): + self._roots() + self._write("branch_1_00001_.png", graph=branch_graph(*PROMPTS)) + await self._scan() + (item,) = self._items() + + job = await self._scan("all") + + self.assertEqual([i["id"] for i in self._items()], [item["id"]]) + self.assertEqual(job["scope"]["review_untraced"], 0) + async def test_file_no_saver_names_stays_queued(self): self._roots() self._write("elsewhere_00001_.png", graph=branch_graph(*PROMPTS)) @@ -102,6 +129,7 @@ class TestInspectionItemsAnchor(InspectionTestCase): (item,) = self._items() self.assertEqual(item["candidate_count"], len(PROMPTS)) + self.assertNotIn("anchor", json.loads(item["evidence"])) async def test_file_several_savers_name_stays_queued(self): self._roots() @@ -113,7 +141,7 @@ class TestInspectionItemsAnchor(InspectionTestCase): (item,) = self._items() self.assertEqual(item["candidate_count"], len(PROMPTS)) - async def test_pending_item_of_an_anchored_file_leaves_on_rescan(self): + async def test_old_rule_item_is_replaced_by_the_anchor_choice(self): self._roots() self._write("branch_1_00001_.png", graph=branch_graph(*PROMPTS)) real = review_capture.resolve_inspection @@ -124,14 +152,27 @@ class TestInspectionItemsAnchor(InspectionTestCase): ), ): await self._scan() - self.assertEqual(len(self._items()), 1) # queued under the old rule + (old,) = self._items() # queued under the old rule with every branch + self.assertEqual(old["candidate_count"], len(PROMPTS)) - job = await self._scan("all") + await self._scan("all") - self.assertEqual(self._items(), []) - self.assertEqual(job["scope"]["review_untraced"], 1) + (item,) = self._items() + self.assertNotEqual(item["id"], old["id"]) + self.assertEqual(_only_candidate(self, item), "add a red hat") - async def test_apply_refuses_a_choice_the_anchor_contradicts(self): + async def test_apply_links_the_anchor_choice(self): + self._require_routes() + self._roots() + path = self._write("branch_1_00001_.png", graph=branch_graph(*PROMPTS)) + await self._scan() + + _item, (status, data) = await self._apply("add a red hat") + + self.assertEqual(status, 200, data) + self.assertEqual(self._linked_text(path), "add a red hat") + + async def test_apply_refuses_an_old_choice_the_anchor_contradicts(self): self._require_routes() self._roots() path = self._write("branch_1_00001_.png", graph=branch_graph(*PROMPTS)) @@ -157,16 +198,24 @@ class TestInspectionItemsAnchor(InspectionTestCase): class TestDefaultItemsAnchor(InspectionTestCase): - async def test_default_ambiguous_file_its_saver_names_is_not_queued(self): + async def test_anchor_resolves_and_nothing_links_so_it_is_queued(self): + """Maintainer ruling: never unlinked and unqueued. The anchor resolves + the file, the default profile and the parser floor link nothing, so + it is queued with the anchor's pair as its choice.""" self._roots() path = self.output_dir / "side_0_00001_.png" - write_png(path, prompt=saved_unlinkable_graph("cat", "dog")) + graph = saved_unlinkable_graph("cat", "dog") + write_png(path, prompt=graph) + self.assertEqual( + review_capture.resolve_prompt_pair({"prompt": graph})["state"], "ambiguous" + ) job = await self._scan() - self.assertEqual(self._items(), []) + (item,) = self._items() + self.assertEqual(_only_candidate(self, item), "cat") (row,) = job["outcomes"]["review"] - self.assertNotIn("more than one prompt could match", row["detail"]) + self.assertIn("more than one prompt could match", row["detail"]) self.assertIsNone(self._linked_text(path)) async def test_default_ambiguous_file_no_saver_names_stays_queued(self): @@ -181,31 +230,29 @@ class TestDefaultItemsAnchor(InspectionTestCase): (item,) = self._items() evidence = json.loads(item["evidence"]) self.assertNotIn("profile", evidence) + self.assertNotIn("anchor", evidence) self.assertEqual(item["resolver_version"], review_capture.RESOLVER_VERSION) - async def test_pending_default_item_of_an_anchored_file_leaves_on_rescan(self): + async def test_file_the_parser_floor_links_is_not_queued(self): + """Already linked by the default path: unchanged by the anchor.""" self._roots() - write_png( - self.output_dir / "side_0_00001_.png", - prompt=saved_unlinkable_graph("cat", "dog"), - ) - with patch.object(review_capture, "saver_anchored", return_value=False): - await self._scan() - self.assertEqual(len(self._items()), 1) # queued under the old rule - self.api._unanchored_inspections = {} # a restart onto the new rule + graph = saved_unlinkable_graph("cat", "dog") + del graph["3"] # the parser floor now reads a real sampler + path = self.output_dir / "side_0_00001_.png" + write_png(path, prompt=graph) - job = await self._scan("all") + await self._scan() self.assertEqual(self._items(), []) - self.assertEqual(job["scope"]["review_untraced"], 1) + self.assertIsNotNone(self._linked_text(path)) - async def test_apply_refuses_a_default_choice_the_anchor_contradicts(self): + async def test_apply_refuses_an_old_default_choice_the_anchor_contradicts(self): self._require_routes() self._roots() path = self.output_dir / "side_0_00001_.png" write_png(path, prompt=saved_unlinkable_graph("cat", "dog")) - with patch.object(review_capture, "saver_anchored", return_value=False): - await self._scan() + with patch.object(review_capture, "_captured_inspection", return_value=None): + await self._scan() # queued from the default profile, as before #390 item, detail, observed = await self._open() # side_0 is the saver of "cat"; choose the other branch. wrong = self._candidate(detail, "dog") @@ -218,42 +265,18 @@ class TestDefaultItemsAnchor(InspectionTestCase): self.assertEqual(data["code"], "review-conflict", data) self.assertIsNone(self._linked_text(path)) - async def test_unanchored_file_is_not_inspected_again_until_it_changes(self): - self._roots() - path = self.output_dir / "unsaved_00001_.png" - write_png(path, prompt=saved_unlinkable_graph("cat", "dog")) - with _counted(review_capture, "saver_anchored") as first: - await self._scan() - self.assertEqual(len(first), 1) - - with _counted(review_capture, "saver_anchored") as again: - await self._scan("all") - self.assertEqual(again, []) - - write_png(path, prompt=saved_unlinkable_graph("cat", "dog"), color=(0, 9, 0)) - with _counted(review_capture, "saver_anchored") as changed: - await self._scan("all") - self.assertEqual(len(changed), 1) - - async def test_decided_default_item_of_an_anchored_file_is_kept(self): + async def test_apply_accepts_an_old_default_choice_the_anchor_names(self): self._require_routes() self._roots() - write_png( - self.output_dir / "side_0_00001_.png", - prompt=saved_unlinkable_graph("cat", "dog"), - ) - with patch.object(review_capture, "saver_anchored", return_value=False): + path = self.output_dir / "side_0_00001_.png" + write_png(path, prompt=saved_unlinkable_graph("cat", "dog")) + with patch.object(review_capture, "_captured_inspection", return_value=None): await self._scan() - item, detail, observed = await self._open() - status, data = await self._decide( - item["id"], self._body(detail, observed, "unresolved") - ) + + _item, (status, data) = await self._apply("cat") + self.assertEqual(status, 200, data) - before = (self._items(), self._decisions()) - - await self._scan("all") - - self.assertEqual((self._items(), self._decisions()), before) + self.assertEqual(self._linked_text(path), "cat") class TestSaverAnchored(InspectionTestCase): diff --git a/utils/review_capture.py b/utils/review_capture.py index 15d33d3d..44bfd987 100644 --- a/utils/review_capture.py +++ b/utils/review_capture.py @@ -43,13 +43,16 @@ links stay on the default profile, which never loads the table (D1). The saver anchor (#390): the inspection profile resolves a file with its name, as the workspace inspector does since #383, so the one saver whose -``filename_prefix`` names the file anchors its branch. A file whose branch -that anchor resolves is never queued, from either profile -(``saver_anchored`` for a default item); no match, several matches or an -unfinished walk anchor nothing and the file stays queued as before. Apply -re-resolves an inspection item with the file's name too. -``REVIEW_RULE_VERSION`` is folded into inspection item versions, so items -recorded before the anchor are re-evaluated by the next scan. +``filename_prefix`` names the file anchors its branch. When that anchor alone +resolves a file the default profile left ambiguous and nothing links +(``saver_anchored``), the scan queues it with the anchor's pair as its one +candidate (evidence ``anchor: "saver"``), never linking it: a file never +falls through both the link and the queue (maintainer ruling). No match, +several matches or an unfinished walk anchor nothing and the file is queued +as before. Apply re-resolves with the file's name, so only the anchored +branch can be applied. ``REVIEW_RULE_VERSION`` is folded into inspection +item versions, so inspection items recorded before the anchor are recorded +again by the next scan. """ from __future__ import annotations @@ -114,6 +117,9 @@ MAX_CANDIDATE_DEPTH = 8 # The resolver profile an item's evidence was built with (``profile``). DEFAULT = "default" INSPECTION = "inspection" +# The ``anchor`` an item recorded from the saver anchor names (#390). +SAVER_ANCHOR = "saver" +ANCHOR_REASON = "The saver that wrote this file names its branch" # Inspection item versions: above every plausible ``RESOLVER_VERSION`` and, # with 40 bits of the folded digest, below 2**53 (exact as a JSON number). INSPECTION_VERSION_BASE = 1 << 40 @@ -195,38 +201,54 @@ def resolve_inspection(metadata: dict, file_name: str | None = None) -> dict: def inspection_choice(metadata: dict, file_name: str | None = None) -> dict | None: - """The inspection profile's ambiguous result when it offers a choice. + """The inspection profile's result when the file is a review case. The scan asks only for a file the default profile found ambiguous - without a choice that nothing links. ``file_name`` anchors as in - ``resolve_inspection``: a file whose branch its saver resolves offers - no choice. + without a choice that nothing links. The result is ambiguous with a + choice, or ``resolved`` when ``file_name`` anchors the file on its saver + and only that anchor resolves it (``saver_anchored``): the item then + offers the anchor's pair. None otherwise. """ if inspection_review_version(metadata) is None: return None resolution = resolve_inspection(metadata, file_name) + if resolution.get("state") == "resolved": + return resolution if _anchor_alone(metadata, file_name) else None if resolution.get("state") != "ambiguous" or not reviewable(resolution): return None return resolution +def _anchor_alone(metadata: Any, file_name: str | None) -> bool: + """True when ``metadata`` does not resolve without ``file_name``.""" + if not file_name: + return False + return resolve_inspection(metadata).get("state") != "resolved" + + def saver_anchored(metadata: Any, file_name: str | None) -> bool: """True when the saver that wrote ``file_name`` resolves its branch (#390). The inspection profile resolves ``metadata`` with the file's name, and without it the file does not resolve: the anchor alone decides it. A file - the default profile found ambiguous with a choice is then not queued. No - name, an unusable table, no matching saver, several, or an unfinished - walk anchor nothing (False). The profile without the name runs only when - the named run resolved. + the default profile left ambiguous is then queued with the anchor's pair + as its one candidate. No name, an unusable table, no matching saver, + several, or an unfinished walk anchor nothing (False). The profile + without the name runs only when the named run resolved. """ + return _anchored(metadata, file_name) is not None + + +def _anchored(metadata: Any, file_name: str | None) -> dict | None: + """The anchored result ``saver_anchored`` describes, else None.""" if not file_name or not isinstance(metadata, dict): - return False + return None if inspection_review_version(metadata) is None: - return False - if resolve_inspection(metadata, file_name).get("state") != "resolved": - return False - return resolve_inspection(metadata).get("state") != "resolved" + return None + resolution = resolve_inspection(metadata, file_name) + if resolution.get("state") != "resolved": + return None + return resolution if _anchor_alone(metadata, file_name) else None def item_profile(evidence: Any) -> str: @@ -315,24 +337,24 @@ def record_ambiguous_file( if resolution is None or resolution.get("state") != "ambiguous": return "skipped" profile, version = DEFAULT, RESOLVER_VERSION - if not reviewable(resolution): - version = inspection_review_version(captured[0]) if inspection else None - if version is None: + named = _captured_inspection(captured[0], relative, scanned) if inspection else None + anchored = named is not None and named[0].get("state") == "resolved" + if anchored: + # Only the saver anchor resolves it (#390): offer that one pair. + profile, version = INSPECTION, named[1] + resolution = { + "state": "ambiguous", + "reason": ANCHOR_REASON, + "candidates": [named[0]], + } + elif not reviewable(resolution): + if named is None: return "untraced" - profile = INSPECTION - if ( - scanned is not None - and scanned[1] is not None - and _same_records(captured[0], scanned[0]) - ): - resolution = scanned[1] # the same records: no second run - else: - # Heavier, so only for a file ambiguous with nothing to choose. - resolution = resolve_inspection(captured[0], _file_name(relative)) + profile, (resolution, version) = INSPECTION, named if resolution.get("state") != "ambiguous" or not reviewable(resolution): return "untraced" evidence, count, limited = review_evidence( - resolution, profile, version, captured[0] + resolution, profile, version, captured[0], anchored=anchored ) try: inserted = db.record_review_item( @@ -356,6 +378,33 @@ def record_ambiguous_file( return "limited" if limited else "recorded" +def _captured_inspection(metadata: dict, relative: str, scanned) -> tuple | None: + """``(resolution, item_version)`` of the captured records under the + inspection profile, anchored on the file's name; None when the table is + unusable. + + A ``resolved`` result means the saver anchor alone resolves the file + (#390); a result that also resolves without the name is returned as + ``unresolved`` (no review case, as before). The scan's result is reused + when the capture holds the same records (``inspection_choice`` applies + the same rule). + """ + version = inspection_review_version(metadata) + if version is None: + return None + if ( + scanned is not None + and scanned[1] is not None + and _same_records(metadata, scanned[0]) + ): + return scanned[1], version # the same records: no second run + name = _file_name(relative) + resolution = resolve_inspection(metadata, name) + if resolution.get("state") == "resolved" and not _anchor_alone(metadata, name): + resolution = {**resolution, "state": "unresolved"} + return resolution, version + + def _file_name(relative: str) -> str: """The last part of a ``/``-separated relative path (the saved name).""" return relative.rsplit("/", 1)[-1] @@ -412,9 +461,9 @@ def captured_review( capture. ``resolution`` comes from ``profile`` (``DEFAULT`` or ``INSPECTION``; the latter anchored on ``file_name``, the file's own name, as the item was recorded). With ``file_name`` (Apply), a default - result its saver anchor resolves (``saver_anchored``) is that anchored - result, so no candidate is offered any more. None when the capture is - gone or carries no prompt metadata. + result its saver anchor alone resolves (``saver_anchored``) is that + anchored result, so only the anchored branch is still offered. None + when the capture is gone or carries no prompt metadata. """ if store.acquire(capture.token) is None: return None @@ -444,13 +493,11 @@ def captured_review( if profile == INSPECTION: return canonical, resolve_inspection(canonical, file_name) resolution = resolve_prompt_pair(canonical) - if ( - file_name is not None - and resolution.get("state") == "ambiguous" - and saver_anchored(canonical, file_name) - ): - # Its saver names its branch (#390): no choice is left to apply. - return canonical, resolve_inspection(canonical, file_name) + if file_name is not None and resolution.get("state") == "ambiguous": + anchored = _anchored(canonical, file_name) + if anchored is not None: + # Its saver names its branch (#390): only that one is offered. + return canonical, anchored return canonical, resolution @@ -477,12 +524,14 @@ def review_evidence( profile: str = DEFAULT, version: int | None = None, metadata: Any = None, + anchored: bool = False, ) -> tuple[dict, int, bool]: """``(evidence, candidate_count, limited)`` for one ambiguous result. Inspection evidence also names its ``profile`` and ``semantics``, the table fingerprint of ``metadata``'s graph it was resolved with; - ``version`` is the item's version. + ``version`` is the item's version. ``anchored`` evidence (the saver + anchor's one pair, #390) also names ``anchor: "saver"``. """ base = { "resolver_version": RESOLVER_VERSION if version is None else version, @@ -491,6 +540,8 @@ def review_evidence( if profile == INSPECTION: base["profile"] = INSPECTION base["semantics"] = semantics_version(metadata) + if anchored: + base["anchor"] = SAVER_ANCHOR candidates: list[dict] = [] seen: set[str] = set() total = 0