fix(review): queue an anchored file with its saver's pair as the choice (#390)

Maintainer ruling: a file never falls through both the link and the
queue. When only the saver anchor resolves a file the default profile and
the parser floor leave unlinked, the scan queues it with the anchor's pair
as its one candidate (evidence anchor: saver), never linking it. Files
already linked or resolved by the default path are unchanged. Apply
accepts the anchored pair when the bytes now resolve to it, and an
anchored item is a choice the untraced settle keeps.
This commit is contained in:
Vito Sansevero
2026-10-27 19:18:24 -07:00
parent 3660ff3ecf
commit 4eb74b8948
6 changed files with 203 additions and 189 deletions
+3 -1
View File
@@ -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:
+9 -7
View File
@@ -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).
+3
View File
@@ -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)
+2 -69
View File
@@ -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
+90 -67
View File
@@ -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):
+96 -45
View File
@@ -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