From 84498c1df4efc40f8276f2e07b2cd028afc22683 Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:17:28 +0000 Subject: [PATCH 1/2] Add >=51% likely-resolve mix policy to the question feed Serves likely-resolve printing questions (one agreeing human vote away from resolving under the real resolver) at >=51% of the WTC feed when supply exists, falling back to the existing ranked union otherwise; logs served-mix composition per the data brief's soundness note. Co-Authored-By: Claude Fable 5 --- MPCAutofill/MPCAutofill/settings.py | 7 + .../migrations/0079_questionfeedservedlog.py | 34 ++ MPCAutofill/cardpicker/models.py | 50 +++ MPCAutofill/cardpicker/question_feed.py | 313 +++++++++++++++++- .../cardpicker/tests/test_question_feed.py | 245 ++++++++++++++ docs/features/printing-tags.md | 69 +++- 6 files changed, 696 insertions(+), 22 deletions(-) create mode 100644 MPCAutofill/cardpicker/migrations/0079_questionfeedservedlog.py diff --git a/MPCAutofill/MPCAutofill/settings.py b/MPCAutofill/MPCAutofill/settings.py index d00da745e..7b9ae6759 100755 --- a/MPCAutofill/MPCAutofill/settings.py +++ b/MPCAutofill/MPCAutofill/settings.py @@ -89,6 +89,13 @@ # ratified 2026-07-22 vote-weight scenario matrix, decision D5/S3). See # cardpicker.vote_consensus.resolve_weighted_consensus's own docstring for the full mechanism. PRINTING_TAG_IMPLICIT_CAP = env.float("PRINTING_TAG_IMPLICIT_CAP", default=1.0) +# Floor share of served `2/questionFeed/` questions that must come from the "likely-resolve" +# pool (a question one more agreeing human vote would actually resolve, per the real +# `resolve_weighted_consensus` - see cardpicker.question_feed.is_likely_resolve_printing) when +# that pool has supply, per the 2026-07-24 data brief's owner-ratified prioritization ruling. +# Selection-layer only - this setting is never read by vote_consensus.py and changes no vote's +# weight/threshold/gate. See docs/features/printing-tags.md's "Unified question feed" section. +QUESTION_FEED_LIKELY_RESOLVE_MIX_RATIO = env.float("QUESTION_FEED_LIKELY_RESOLVE_MIX_RATIO", default=0.51) # django-ratelimit rate string (see cardpicker.views.post_submit_printing_tag), keyed by the # client-generated anonymous ID (IP as a fallback if that header is somehow missing). Shared # across printing-tag/artist-vote/tag-vote submission (_printing_tag_rate_limit_key/_rate are diff --git a/MPCAutofill/cardpicker/migrations/0079_questionfeedservedlog.py b/MPCAutofill/cardpicker/migrations/0079_questionfeedservedlog.py new file mode 100644 index 000000000..277b8ed7b --- /dev/null +++ b/MPCAutofill/cardpicker/migrations/0079_questionfeedservedlog.py @@ -0,0 +1,34 @@ +# Hand-written (not `manage.py makemigrations`-generated - see the PR this migration ships +# with for why) to exactly match `cardpicker.models.QuestionFeedServedLog`/ +# `QuestionFeedServedPool` as of this migration. + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cardpicker", "0078_pilotrunledger_counters"), + ] + + operations = [ + migrations.CreateModel( + name="QuestionFeedServedLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("anonymous_id", models.CharField(db_index=True, max_length=40)), + ( + "pool", + models.CharField( + choices=[("likely_resolve", "Likely resolve"), ("remainder", "Remainder")], max_length=16 + ), + ), + ("question_type", models.CharField(max_length=32)), + ("origin_reason", models.CharField(blank=True, default="", max_length=64)), + ("served_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "indexes": [models.Index(fields=["anonymous_id", "served_at"], name="qf_served_log_anon_served_idx")], + }, + ), + ] diff --git a/MPCAutofill/cardpicker/models.py b/MPCAutofill/cardpicker/models.py index a9687a378..76c546eba 100755 --- a/MPCAutofill/cardpicker/models.py +++ b/MPCAutofill/cardpicker/models.py @@ -1741,6 +1741,54 @@ def __str__(self) -> str: return f"ImageEvidence card={self.card_id} content_hash={self.content_hash} extractors={sorted(self.extractor_versions)}" +class QuestionFeedServedPool(models.TextChoices): + """Which side of `question_feed.py`'s >=51% mix-composition split a served question came + from - see `QuestionFeedServedLog`'s own docstring.""" + + LIKELY_RESOLVE = "likely_resolve", gettext_lazy("Likely resolve") + REMAINDER = "remainder", gettext_lazy("Remainder") + + +class QuestionFeedServedLog(models.Model): + """ + One row per `GET 2/questionFeed/` response that actually served a question - the mix- + composition record `cardpicker.question_feed`'s >=51%-likely-resolve serving policy + requires (2026-07-24 data brief, SOUNDNESS NOTE: "Recommend ... log served-mix composition + (ratio + family/reason per served question) per session, so a future audit can correlate + click latency/agreement-rate against a session's easy-question exposure" - see + docs/features/printing-tags.md's "Unified question feed" section for the full citation). + This is a selection-layer bias-conditioning record ONLY - it is never read by + `vote_consensus.resolve_weighted_consensus` or any consensus computation, and writing a + row here changes no vote's weight, threshold, or gate. Append-only, same convention as + `CardScanLog` (a durable audit trail, not a mutated cache) - the serving path's own read of + this table (`question_feed._served_mix_ratio`) is a cheap two-count aggregate over + `anonymous_id`, not a full-row scan. + + `pool` records which side of the mix split this item came from;`question_type` mirrors + `QuestionFeedItem.type` (e.g. "confirm_suggestion"/"identify_printing"/"artist"/"tag"); + `origin_reason` is a short, human-readable tag for which specific ranked-order rule matched + (e.g. "printing_one_vote_from_resolving", "tier_2_contested", "tier_4_quick_negative_to_ + review", "tier_4_fresh") - free text rather than a closed enum, since the ranked order + itself is expected to keep evolving (see this module's own module-level TextChoices for + values that ARE meant to be a closed set; this one deliberately isn't). + """ + + anonymous_id = models.CharField(max_length=40, db_index=True) + pool = models.CharField(max_length=16, choices=QuestionFeedServedPool.choices) + question_type = models.CharField(max_length=32) + origin_reason = models.CharField(max_length=64, blank=True, default="") + served_at = models.DateTimeField(auto_now_add=True) + + class Meta: + # explicit name (rather than Django's default hash-derived one) so the migration below + # can be hand-written and verified against this file without needing a live `makemigrations` + # run to discover what hash Django would have picked. + indexes = [models.Index(fields=["anonymous_id", "served_at"], name="qf_served_log_anon_served_idx")] + + def __str__(self) -> str: + return f"anonymous_id={self.anonymous_id} pool={self.pool} question_type={self.question_type}" + + __all__ = [ "Faces", "CardTypes", @@ -1764,4 +1812,6 @@ def __str__(self) -> str: "UserCryptoProfile", "LandsAmbiguousResidue", "ImageEvidence", + "QuestionFeedServedPool", + "QuestionFeedServedLog", ] diff --git a/MPCAutofill/cardpicker/question_feed.py b/MPCAutofill/cardpicker/question_feed.py index 4b159e930..4a39254f0 100644 --- a/MPCAutofill/cardpicker/question_feed.py +++ b/MPCAutofill/cardpicker/question_feed.py @@ -3,7 +3,10 @@ printing/artist/tag tabs (see docs/features/printing-tags.md's questionFeed section and journal/2026-07-14-queue-question-feed-design.md for the full design writeup this implements). Deliberately a "dumb ranked union" per spec: three fixed-order tiers, first -non-empty match wins, no cross-tier scoring/ML. +non-empty match wins, no cross-tier scoring/ML - EXCEPT for the one deliberate ordering policy +this module now adds on top of that union (2026-07-24, see "Mix composition policy" below), +which is a served-question SELECTION change only, never a change to how any of tiers 1/2/4 +individually rank their own candidates. Tier 1 (confirm_suggestion) is large relative to the others at current volume (28,112 cards - the full AI deductive-vote backfill, confirmed via a live query during design) - a voter @@ -17,27 +20,94 @@ moved out to a dedicated Moderation tab (`POST 2/moderationQueue/` in views.py, unaffected by this module) so ordinary tagging and report review are separate, switchable views instead of one hijacking the other. See docs/features/moderation.md. -""" -from typing import Optional +Mix composition policy (2026-07-24, owner-ratified per the WTC vote-queue data brief - fenced +report tail, item "OWNER ADDENDUM"; full citation in docs/features/printing-tags.md's "Unified +question feed" section): serve >=`settings.QUESTION_FEED_LIKELY_RESOLVE_MIX_RATIO` (default +0.51) of a session's questions from the LIKELY-RESOLVE pool - a printing question one more +agreeing human vote would actually resolve under the real resolver, per +`is_likely_resolve_printing` below - whenever that pool still has supply for this voter, +falling back to the pre-existing three-tier ranked union otherwise (with one refinement inside +tier 4 - see `_tier_4_fresh`'s own docstring - that prioritizes cards whose latest Stage D +scan-log origin is a "quick-negative" reason over the harder/open-ended remainder, per the same +data brief's queue-composition ranking). This is a SELECTION-LAYER policy only: it makes zero +change to `vote_consensus.resolve_weighted_consensus`'s weights, `PRINTING_TAG_MIN_VOTES`/ +`MIN_SHARE` thresholds, or the D1/D4 human-backed-priority mechanisms - `is_likely_resolve_ +printing` calls that same real resolver to classify a question, it never reimplements its +arithmetic. Every served item (from either the likely-resolve pool or the remainder) is +recorded in `QuestionFeedServedLog` - the bias-conditioning record the data brief's SOUNDNESS +NOTE calls for, so a future audit can correlate click behavior against a session's +easy-question exposure. See `_served_mix_ratio`/`_log_served` below. +""" -from django.db.models import Count, Q +from collections import defaultdict +from typing import Hashable, Optional + +from django.conf import settings +from django.db.models import ( + Case, + Count, + IntegerField, + OuterRef, + Q, + Subquery, + Value, + When, +) from cardpicker.artist_consensus import get_contested_artist_card_ids from cardpicker.attribute_tags import ATTRIBUTE_CHIP_TAG_NAMES +from cardpicker.local_calculate_verdicts import ( + JOIN_KEY_ANONYMOUS_ID, + JOIN_KEY_UNKNOWN_SET_CODE_SKIP_REASON, + STAGE_D_FALLBACK_ANONYMOUS_ID, +) from cardpicker.models import ( ArtistVoteStatus, Card, + CardScanLog, CardTagVote, PrintingTagStatus, + QuestionFeedServedLog, + QuestionFeedServedPool, Tag, TagVoteStatus, VoteSource, ) from cardpicker.printing_candidates import get_ranked_printing_candidates -from cardpicker.printing_consensus import get_contested_card_ids +from cardpicker.printing_consensus import NO_MATCH, get_contested_card_ids from cardpicker.schema_types import QuestionFeedCounts, QuestionFeedItem, TypeEnum from cardpicker.tag_consensus import get_tag_net_polarity, get_tag_review_queue_pairs +from cardpicker.vote_consensus import ( + VoteTuple, + is_human_backed_source, + resolve_vote_weight, + resolve_weighted_consensus, +) + +# Origin reasons `local_calculate_verdicts.py`'s Stage D join-key/fallback calculators write to +# `CardScanLog.skip_reason` that the 2026-07-24 data brief's queue-composition item classifies +# as "answerable-as-quick-negative" - a quick, low-ambiguity classification click (custom-art/ +# no-match/visual-contradiction), not an open-ended one. Two of these have named constants in +# `local_calculate_verdicts.py` already (imported above); "eliminated"/"border-mismatch"/ +# "frame-mismatch" are that module's own inline skip-reason vocabulary (no named constant exists +# for these three there today) taken verbatim - see that module's own skip_reason call sites. +# Deliberately EXCLUDES "ambiguous" despite the brief calling it "YES - answerable" in principle: +# the same brief's prioritization item ranks it as BLOCKED on a build dependency +# (`CardScanLog.survivor_pks` is unpopulated for every to-review card - see that field's own +# docstring), not free supply today, so it falls into this module's default/hard-open-ended +# bucket alongside "no-sub-check-evidence"/"no-text" rather than the quick-negative one. +QUICK_NEGATIVE_SKIP_REASONS = frozenset( + {JOIN_KEY_UNKNOWN_SET_CODE_SKIP_REASON, "eliminated", "border-mismatch", "frame-mismatch"} +) + +# anonymous_id placeholder for the hypothetical vote `is_likely_resolve_printing` adds - never +# persisted, never compared against a real anonymous_id; passed through `resolve_vote_weight` +# (rather than reading `vote_consensus._SOURCE_WEIGHTS[VoteSource.USER]` directly) purely so this +# stays routed through the one sanctioned weight-resolution entry point, matching every other +# caller's convention, even though `resolve_vote_weight`'s only override (the deductive-backfill +# zero-weight cohort) can never match `source=VoteSource.USER` regardless of anonymous_id. +_HYPOTHETICAL_VOTE_ANONYMOUS_ID = "question-feed-hypothetical-vote" def _tag_confidence(card: Card) -> dict[str, float]: @@ -92,6 +162,112 @@ def _tag_item(card: Card, tag_name: str) -> QuestionFeedItem: return QuestionFeedItem(type=TypeEnum.tag, card=card.serialise(), tagName=tag_name) +def _printing_vote_tuples(card: Card) -> list[VoteTuple]: + """ + Builds `VoteTuple`s for `card`'s current `CardPrintingTag` rows - the exact same per-vote + weight/human-backed resolution `printing_consensus.resolve_printing` uses + (`resolve_vote_weight`/`is_human_backed_source`, both imported from `vote_consensus` rather + than reimplemented), just without that function's private printing-lookup bookkeeping this + caller doesn't need (only the outcome KEY, an int pk or the `NO_MATCH` sentinel, matters + for the likely-resolve check below). + """ + return [ + VoteTuple( + outcome_key=NO_MATCH if vote.is_no_match else vote.printing_id, + weight=resolve_vote_weight(vote.source, vote.anonymous_id), + is_human_backed=is_human_backed_source(vote.source), + ) + for vote in card.printing_tags.all() + ] + + +def is_likely_resolve_printing(card: Card) -> bool: + """ + True when ONE hypothetical additional agreeing human vote (`VoteSource.USER` weight) added + to `card`'s current highest-weighted printing outcome group would resolve it under the REAL + resolver (`vote_consensus.resolve_weighted_consensus` - the same function + `printing_consensus.resolve_printing` calls; this never reimplements its weight/threshold + arithmetic). This is the serve-time LIKELY-RESOLVE classification the 2026-07-24 data + brief's exact-code simulation approach specifies (the same method that produced its + 46,310-card LIKELY-RESOLVE SUPPLY figure): find the currently-leading outcome group by + summed weight, add one hypothetical `VoteSource.USER` vote to THAT group, re-run the real + resolver, and check whether it wins with that group's own key. + + False for a card with no printing-tag votes at all (there is no "leading" group to add to - + this is exactly the cold-start population the brief's item 1 table calls out as having + "ZERO non-zero-weight signal", never likely-resolve by this definition) and false for an + already-RESOLVED card (a caller should never ask, since `_likely_resolve_printing_card` + only scans `PrintingTagStatus.UNRESOLVED` cards, but this stays a plain `False` rather than + raising either way - the resolver would simply report the same key already won, which this + function would then also (correctly, if uninterestingly) report as "likely resolve"). + """ + vote_tuples = _printing_vote_tuples(card) + if not vote_tuples: + return False + + current_weight_by_key: dict[Hashable, float] = defaultdict(float) + for vote in vote_tuples: + current_weight_by_key[vote.outcome_key] += vote.weight + leading_key = max(current_weight_by_key.items(), key=lambda pair: pair[1])[0] + + hypothetical_vote = VoteTuple( + outcome_key=leading_key, + weight=resolve_vote_weight(VoteSource.USER, _HYPOTHETICAL_VOTE_ANONYMOUS_ID), + is_human_backed=True, + ) + winning_key = resolve_weighted_consensus( + vote_tuples + [hypothetical_vote], + min_weight=settings.PRINTING_TAG_MIN_VOTES, + min_share=settings.PRINTING_TAG_MIN_SHARE, + ) + return winning_key == leading_key + + +def _likely_resolve_printing_card(anonymous_id: str) -> Optional[Card]: + """ + First UNRESOLVED printing card (in `date_created` order, same scan convention tier 1 uses) + that both carries at least one existing `CardPrintingTag` row and passes + `is_likely_resolve_printing` - the >=51% mix-composition policy's own supply pool (see this + module's docstring for the ratio policy this feeds, and `get_next_question_feed_item` for + where it's consulted). + + Cost/approach (compute-per-serve, no caching layer - stated per this change's own spec): + pre-filters to `printing_tags__isnull=False` (97,212 of 218,345 cards at the 2026-07-24 data + brief's snapshot - cards carrying ANY printing-tag signal, not the full unresolved + population, though this still includes the ~8k zero-weight-only deductive-backfill rows + that `is_likely_resolve_printing` will correctly reject) before doing a per-card Python-side + `is_likely_resolve_printing` check via `.iterator()` - the same "scan in priority order, + stop at the first match" shape `_tier_1_confirm_suggestion` already uses, not a new + performance-risk pattern this change introduces. Worst case (this voter has already + excluded most of the pool, or the pool is nearly exhausted) is a bounded scan of the + pre-filtered ~97k rows, not the full 218k-card catalog and not unbounded - accepted as a v1 + cost matching this module's own "known v1 property, not a bug" convention (see the module + docstring), not solved with a materialized/cached index here. + """ + candidates = ( + Card.objects.filter(printing_tag_status=PrintingTagStatus.UNRESOLVED, printing_tags__isnull=False) + .exclude(printing_tags__anonymous_id=anonymous_id) + .distinct() + .order_by("date_created") + ) + for card in candidates.iterator(): + if is_likely_resolve_printing(card): + return card + return None + + +def _likely_resolve_item(card: Card) -> QuestionFeedItem: + """Serves `card` as a `confirm_suggestion` (it has a live AI-sourced suggestion to confirm - + the common shape within this pool, the data brief's 45,154-of-46,310 single-candidate split) + or a bare `identify_printing` question (the multi-candidate remainder) - the same two item + shapes tiers 1/2 already produce; the likely-resolve pool changes WHICH card gets served + first, never what an individual served item looks like.""" + item = _confirm_suggestion_item(card) + if item is not None: + return item + return _identify_printing_item(card) + + def _tier_1_confirm_suggestion(anonymous_id: str) -> Optional[QuestionFeedItem]: cards = ( Card.objects.filter( @@ -110,7 +286,7 @@ def _tier_1_confirm_suggestion(anonymous_id: str) -> Optional[QuestionFeedItem]: return None -def _tier_2_contested(anonymous_id: str) -> Optional[QuestionFeedItem]: +def _tier_2_contested(anonymous_id: str) -> Optional[tuple[QuestionFeedItem, str]]: printing_card = ( Card.objects.filter(printing_tag_status=PrintingTagStatus.UNRESOLVED, pk__in=get_contested_card_ids()) .exclude(printing_tags__anonymous_id=anonymous_id) @@ -118,7 +294,7 @@ def _tier_2_contested(anonymous_id: str) -> Optional[QuestionFeedItem]: .first() ) if printing_card is not None: - return _identify_printing_item(printing_card) + return _identify_printing_item(printing_card), "tier_2_contested_printing" artist_card = ( Card.objects.filter(artist_vote_status=ArtistVoteStatus.CONTESTED, pk__in=get_contested_artist_card_ids()) @@ -127,7 +303,7 @@ def _tier_2_contested(anonymous_id: str) -> Optional[QuestionFeedItem]: .first() ) if artist_card is not None: - return _artist_item(artist_card) + return _artist_item(artist_card), "tier_2_contested_artist" for card_id, tag_name in get_tag_review_queue_pairs(): # scoped to (card, tag, anonymous_id), not just (card, anonymous_id) - a voter who @@ -140,11 +316,25 @@ def _tier_2_contested(anonymous_id: str) -> Optional[QuestionFeedItem]: card = Card.objects.get(pk=card_id) status = card.tag_vote_statuses.get(tag_name) if status == TagVoteStatus.CONTESTED: - return _tag_item(card, tag_name) + return _tag_item(card, tag_name), "tier_2_contested_tag" return None -def _tier_4_fresh(anonymous_id: str) -> Optional[QuestionFeedItem]: +def _latest_stage_d_origin_reason_subquery() -> Subquery: + """Correlated subquery: `card`'s most recent Stage D join-key/fallback `CardScanLog. + skip_reason` (the ORIGIN reason - the specific sub-check outcome that first routed this card + toward review), or `None` if no such row exists. Feeds `_tier_4_fresh`'s quick-negative + reordering below - see that function's own docstring for why.""" + return Subquery( + CardScanLog.objects.filter( + card_id=OuterRef("pk"), anonymous_id__in=[JOIN_KEY_ANONYMOUS_ID, STAGE_D_FALLBACK_ANONYMOUS_ID] + ) + .order_by("-scanned_at") + .values("skip_reason")[:1] + ) + + +def _tier_4_fresh(anonymous_id: str) -> Optional[tuple[QuestionFeedItem, str]]: # named "tier 4" (not renumbered to 3) even though moderation's former tier 3 was removed # (see module docstring) - keeps this name stable against every docstring/test/comment # that already refers to "tier 4" rather than triggering a pure-renumbering diff. @@ -157,16 +347,38 @@ def _tier_4_fresh(anonymous_id: str) -> Optional[QuestionFeedItem]: # these "one vote from resolving" cards first within this tier, a small, concrete answer # to "prioritize whichever question is closest to actually resolving" without building a # full scoring system (out of scope - see this module's docstring). + # + # 2026-07-24 addition: `is_quick_negative` is a SECONDARY tiebreak (after `-vote_count`, + # never ahead of it - a real "closer to resolving" card still wins first, exactly as + # before) that prioritizes a card whose latest Stage D scan-log origin is a quick-negative + # reason (`QUICK_NEGATIVE_SKIP_REASONS`) over one that's hard/open-ended or has no scan-log + # row at all - the data brief's queue-composition ranking's second-from-last remainder + # slice, ahead of the smallest "hard/open-ended" slice. Most tier-4 candidates share + # `vote_count=0` (the "totally fresh" case), so in practice this origin-reason tiebreak is + # what actually decides ordering among them, not a rarely-reached fallback. printing_card = ( Card.objects.filter(printing_tag_status=PrintingTagStatus.UNRESOLVED) .exclude(pk__in=get_contested_card_ids()) .exclude(printing_tags__anonymous_id=anonymous_id) .annotate(vote_count=Count("printing_tags", distinct=True)) - .order_by("-vote_count", "-date_created") + .annotate(origin_reason=_latest_stage_d_origin_reason_subquery()) + .annotate( + is_quick_negative=Case( + When(origin_reason__in=QUICK_NEGATIVE_SKIP_REASONS, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ) + ) + .order_by("-vote_count", "is_quick_negative", "-date_created") .first() ) if printing_card is not None: - return _identify_printing_item(printing_card) + origin_reason = ( + "tier_4_quick_negative_to_review" + if printing_card.origin_reason in QUICK_NEGATIVE_SKIP_REASONS + else "tier_4_fresh_printing" + ) + return _identify_printing_item(printing_card), origin_reason artist_card = ( Card.objects.filter(artist_vote_status=ArtistVoteStatus.UNRESOLVED) @@ -175,7 +387,7 @@ def _tier_4_fresh(anonymous_id: str) -> Optional[QuestionFeedItem]: .first() ) if artist_card is not None: - return _artist_item(artist_card) + return _artist_item(artist_card), "tier_4_fresh_artist" for card_id, tag_name in get_tag_review_queue_pairs(): # see tier 2's identical comment above - scoped to (card, tag, anonymous_id) @@ -184,13 +396,75 @@ def _tier_4_fresh(anonymous_id: str) -> Optional[QuestionFeedItem]: card = Card.objects.get(pk=card_id) status = card.tag_vote_statuses.get(tag_name) if status == TagVoteStatus.UNRESOLVED: - return _tag_item(card, tag_name) + return _tag_item(card, tag_name), "tier_4_fresh_tag" return None +def _served_mix_ratio(anonymous_id: str) -> float: + """ + `likely_resolve` share of this `anonymous_id`'s own served-question history so far + (`QuestionFeedServedLog`) - consulted by `get_next_question_feed_item` to decide whether the + NEXT served item should try the likely-resolve pool first. Two cheap `COUNT` queries, + indexed on `(anonymous_id, served_at)` - no per-row scan, no caching needed at this cost. + + Returns 0.0 (below every plausible target ratio) for a session with no served-log rows yet, + so a fresh session's very first question still tries the likely-resolve pool, rather than + treating "no data" as "ratio already satisfied." + """ + total = QuestionFeedServedLog.objects.filter(anonymous_id=anonymous_id).count() + if total == 0: + return 0.0 + likely_resolve_count = QuestionFeedServedLog.objects.filter( + anonymous_id=anonymous_id, pool=QuestionFeedServedPool.LIKELY_RESOLVE + ).count() + return likely_resolve_count / total + + +def _log_served(anonymous_id: str, item: QuestionFeedItem, pool: str, origin_reason: str) -> QuestionFeedItem: + """Records one served-question row (see `QuestionFeedServedLog`'s own docstring for why - + the data brief's SOUNDNESS NOTE bias-conditioning record) and returns `item` unchanged, so + every `get_next_question_feed_item` return path can stay a simple one-liner.""" + QuestionFeedServedLog.objects.create( + anonymous_id=anonymous_id, pool=pool, question_type=item.type.value, origin_reason=origin_reason + ) + return item + + def get_next_question_feed_item(anonymous_id: str) -> Optional[QuestionFeedItem]: - """The dumb ranked union itself - first non-None tier wins, in priority order.""" - return _tier_1_confirm_suggestion(anonymous_id) or _tier_2_contested(anonymous_id) or _tier_4_fresh(anonymous_id) + """ + The ranked union itself. When this session's served-mix ratio (`_served_mix_ratio`) is + below `settings.QUESTION_FEED_LIKELY_RESOLVE_MIX_RATIO` AND the likely-resolve pool still + has supply for this voter, that pool is served first - otherwise (ratio already at/above + target, or the pool has no supply for this voter right now) this falls through to the + pre-existing three-tier ranked union unchanged (tier 1 -> tier 2 -> tier 4, first non-empty + tier wins), with tier 4's own quick-negative reordering (see its docstring). This never + infinite-loops or blocks on a starved pool - each branch is a single bounded query/scan, and + an exhausted likely-resolve pool simply falls through to the remainder every time, letting + the session's ratio drop honestly rather than stalling to protect it. + """ + if _served_mix_ratio(anonymous_id) < settings.QUESTION_FEED_LIKELY_RESOLVE_MIX_RATIO: + likely_resolve_card = _likely_resolve_printing_card(anonymous_id) + if likely_resolve_card is not None: + item = _likely_resolve_item(likely_resolve_card) + return _log_served( + anonymous_id, item, QuestionFeedServedPool.LIKELY_RESOLVE, "printing_one_vote_from_resolving" + ) + + tier_1_item = _tier_1_confirm_suggestion(anonymous_id) + if tier_1_item is not None: + return _log_served(anonymous_id, tier_1_item, QuestionFeedServedPool.REMAINDER, "tier_1_confirm_suggestion") + + tier_2_result = _tier_2_contested(anonymous_id) + if tier_2_result is not None: + tier_2_item, tier_2_reason = tier_2_result + return _log_served(anonymous_id, tier_2_item, QuestionFeedServedPool.REMAINDER, tier_2_reason) + + tier_4_result = _tier_4_fresh(anonymous_id) + if tier_4_result is not None: + tier_4_item, tier_4_reason = tier_4_result + return _log_served(anonymous_id, tier_4_item, QuestionFeedServedPool.REMAINDER, tier_4_reason) + + return None def _tag_review_card_ids_by_status() -> tuple[set[int], set[int]]: @@ -290,4 +564,9 @@ def get_remaining_estimate() -> QuestionFeedCounts: return QuestionFeedCounts(total=total, confirmable=confirmable, contested=contested, fresh=fresh) -__all__ = ["get_next_question_feed_item", "get_remaining_estimate"] +__all__ = [ + "get_next_question_feed_item", + "get_remaining_estimate", + "is_likely_resolve_printing", + "QUICK_NEGATIVE_SKIP_REASONS", +] diff --git a/MPCAutofill/cardpicker/tests/test_question_feed.py b/MPCAutofill/cardpicker/tests/test_question_feed.py index 6489d1446..d3ead310a 100644 --- a/MPCAutofill/cardpicker/tests/test_question_feed.py +++ b/MPCAutofill/cardpicker/tests/test_question_feed.py @@ -2,18 +2,27 @@ from cardpicker import views from cardpicker.artist_consensus import resolve_and_persist_artist +from cardpicker.local_calculate_verdicts import ( + JOIN_KEY_ANONYMOUS_ID, + JOIN_KEY_UNKNOWN_SET_CODE_SKIP_REASON, +) from cardpicker.models import ( ArtistVoteStatus, + CardScanLog, PrintingTagStatus, + QuestionFeedServedLog, + QuestionFeedServedPool, TagModerationClass, TagVoteStatus, VotePolarity, VoteSource, ) +from cardpicker.printing_consensus import resolve_and_persist_printing from cardpicker.question_feed import ( _tier_1_confirm_suggestion, get_next_question_feed_item, get_remaining_estimate, + is_likely_resolve_printing, ) from cardpicker.tag_consensus import resolve_and_persist_tag_votes from cardpicker.tests.factories import ( @@ -331,3 +340,239 @@ def test_pending_approval_pairs_never_surface_here_even_for_a_moderator_session( client.force_login(moderator_user) response = client.get(reverse(views.get_question_feed), {"anonymousId": "anon-1"}) assert response.json()["item"] is None + + +def make_one_vote_from_resolving_card() -> tuple: + """ + Two machine (OCR) votes for the same printing - summed weight 1.0 - is the 2026-07-24 data + brief's "ONE more human vote resolves it" shape (45,154 of the 46,310-card LIKELY-RESOLVE + SUPPLY): a hypothetical human vote (weight 1.0) totals 2.0, clearing + `PRINTING_TAG_MIN_VOTES=2` outright. `artist_vote_status=RESOLVED` isolates this fixture to + the printing axis only - otherwise a fresh card's default UNRESOLVED artist status would + make it independently servable as a *different* (artist) question type via tier 4, which + would falsely look like this same printing question resurfacing to tests that assert + exclusion/non-recurrence (same isolation `make_pending_pair` above already relies on). + """ + card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED, artist_vote_status=ArtistVoteStatus.RESOLVED) + printing = CanonicalCardFactory() + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.OCR, anonymous_id="bot-1") + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.OCR, anonymous_id="bot-2") + return card, printing + + +def make_two_votes_from_resolving_card() -> tuple: + """ + A single machine (OCR) vote - weight 0.5 - is the data brief's "TWO more human votes + resolve it" shape (39,968 of the near-threshold population): a hypothetical human vote + (weight 1.0) only totals 1.5, still short of `PRINTING_TAG_MIN_VOTES=2`. See + `make_one_vote_from_resolving_card` above for why `artist_vote_status=RESOLVED`. + """ + card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED, artist_vote_status=ArtistVoteStatus.RESOLVED) + printing = CanonicalCardFactory() + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.OCR, anonymous_id="bot-1") + return card, printing + + +def seed_served_log(anonymous_id: str, likely_resolve_count: int, remainder_count: int) -> None: + for _ in range(likely_resolve_count): + QuestionFeedServedLog.objects.create( + anonymous_id=anonymous_id, + pool=QuestionFeedServedPool.LIKELY_RESOLVE, + question_type="confirm_suggestion", + origin_reason="printing_one_vote_from_resolving", + ) + for _ in range(remainder_count): + QuestionFeedServedLog.objects.create( + anonymous_id=anonymous_id, + pool=QuestionFeedServedPool.REMAINDER, + question_type="identify_printing", + origin_reason="tier_4_fresh_printing", + ) + + +class TestIsLikelyResolvePrinting: + """Serve-time LIKELY-RESOLVE classification (question_feed.is_likely_resolve_printing) - + matches the real resolver on constructed 1-away/2-away fixtures, per the data brief's + bimodal arithmetic (a printing pair is always exactly 1-away or 2-away, never further - + PRINTING_TAG_MACHINE_WEIGHT is a constant 0.5/vote).""" + + def test_no_votes_at_all_is_not_likely_resolve(self, db): + card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + assert is_likely_resolve_printing(card) is False + + def test_one_machine_vote_two_away_is_not_likely_resolve(self, db): + card, printing = make_two_votes_from_resolving_card() + + assert is_likely_resolve_printing(card) is False + + # round-trip against the real resolver: adding the actual hypothetical vote does NOT + # resolve this card, confirming the classification agrees with resolve_printing itself + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.USER, anonymous_id="anon-1") + resolve_and_persist_printing(card) + card.refresh_from_db() + assert card.printing_tag_status == PrintingTagStatus.UNRESOLVED + + def test_two_machine_votes_one_away_is_likely_resolve(self, db): + card, printing = make_one_vote_from_resolving_card() + + assert is_likely_resolve_printing(card) is True + + # round-trip: adding the actual hypothetical vote DOES resolve this card + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.USER, anonymous_id="anon-1") + resolve_and_persist_printing(card) + card.refresh_from_db() + assert card.printing_tag_status == PrintingTagStatus.RESOLVED + + def test_multi_candidate_leading_group_one_away_is_likely_resolve(self, db): + # near-threshold multi-candidate shape (1,156 of the 46,310-card supply): two machine + # votes for the leading printing (weight 1.0) plus one machine vote for a losing + # candidate (weight 0.5) - the leading group is still exactly one human vote from + # clearing quorum and share + card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + leading_printing = CanonicalCardFactory() + losing_printing = CanonicalCardFactory() + CardPrintingTagFactory(card=card, printing=leading_printing, source=VoteSource.OCR, anonymous_id="bot-1") + CardPrintingTagFactory(card=card, printing=leading_printing, source=VoteSource.OCR, anonymous_id="bot-2") + CardPrintingTagFactory(card=card, printing=losing_printing, source=VoteSource.OCR, anonymous_id="bot-3") + + assert is_likely_resolve_printing(card) is True + + +class TestMixComposition: + """Serve-mix policy (>=QUESTION_FEED_LIKELY_RESOLVE_MIX_RATIO from the likely-resolve pool + when it has supply, per the 2026-07-24 data brief) - ratio gating, graceful degradation, + per-voter exclusion, and the served-mix log this policy's soundness note requires.""" + + def test_fresh_session_tries_likely_resolve_first_when_supply_exists(self, db): + card, _ = make_one_vote_from_resolving_card() + + item = get_next_question_feed_item("anon-1") + + assert item is not None + assert item.card.identifier == card.identifier + log = QuestionFeedServedLog.objects.get(anonymous_id="anon-1") + assert log.pool == QuestionFeedServedPool.LIKELY_RESOLVE + assert log.origin_reason == "printing_one_vote_from_resolving" + + def test_ratio_below_target_prefers_likely_resolve_even_when_remainder_supply_exists(self, db): + seed_served_log("anon-1", likely_resolve_count=20, remainder_count=80) # ratio = 0.2 + likely_resolve_card, _ = make_one_vote_from_resolving_card() + CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) # remainder-only distractor + + item = get_next_question_feed_item("anon-1") + + assert item is not None + assert item.card.identifier == likely_resolve_card.identifier + newest_log = QuestionFeedServedLog.objects.filter(anonymous_id="anon-1").latest("served_at") + assert newest_log.pool == QuestionFeedServedPool.LIKELY_RESOLVE + + def test_ratio_at_target_serves_remainder_even_when_likely_resolve_supply_exists(self, db): + # already at 60% likely-resolve, above the 51% floor - the greedy per-serve policy must + # not keep piling more likely-resolve on top of an already-satisfied ratio, i.e. this + # item must be reached via the remainder chain (tiers 1/2/4), never via the dedicated + # likely-resolve branch - even though tier 4's own pre-existing "-vote_count" heuristic + # can legitimately land on the SAME underlying card the likely-resolve pool would also + # have picked (that card really is closest to resolving by both measures at once) - only + # `pool` on the logged row, not card identity, is the thing this policy actually decides + make_one_vote_from_resolving_card() # likely-resolve supply exists... + CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) # ...so does plain remainder + seed_served_log("anon-1", likely_resolve_count=60, remainder_count=40) + + item = get_next_question_feed_item("anon-1") + + assert item is not None + newest_log = QuestionFeedServedLog.objects.filter(anonymous_id="anon-1").latest("served_at") + assert newest_log.pool == QuestionFeedServedPool.REMAINDER + + def test_degrades_gracefully_to_remainder_with_no_supply_and_no_hang(self, db): + # ratio under target, but nothing in the catalog qualifies as likely-resolve - must + # fall straight through to the remainder tiers, not raise or loop + fresh_card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + + item = get_next_question_feed_item("anon-1") + + assert item is not None + assert item.card.identifier == fresh_card.identifier + log = QuestionFeedServedLog.objects.get(anonymous_id="anon-1") + assert log.pool == QuestionFeedServedPool.REMAINDER + + def test_ratio_drops_honestly_once_the_likely_resolve_pool_is_exhausted(self, db): + # this voter has already voted on the only likely-resolve card (excluded from the pool + # for them specifically) - the mix ratio is free to fall below target rather than the + # feed stalling/erroring to try to protect it + seed_served_log("anon-1", likely_resolve_count=10, remainder_count=0) # ratio = 1.0 so far + exhausted_card, printing = make_one_vote_from_resolving_card() + CardPrintingTagFactory(card=exhausted_card, printing=printing, source=VoteSource.USER, anonymous_id="anon-1") + fresh_card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + + item = get_next_question_feed_item("anon-1") + + assert item is not None + assert item.card.identifier == fresh_card.identifier + newest_log = QuestionFeedServedLog.objects.filter(anonymous_id="anon-1").latest("served_at") + assert newest_log.pool == QuestionFeedServedPool.REMAINDER + + def test_returns_none_with_no_log_row_when_nothing_is_servable_at_all(self, db): + assert get_next_question_feed_item("anon-1") is None + assert not QuestionFeedServedLog.objects.filter(anonymous_id="anon-1").exists() + + def test_likely_resolve_pool_excludes_cards_this_voter_already_voted_on(self, db): + card, printing = make_one_vote_from_resolving_card() + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.USER, anonymous_id="anon-1") + + item = get_next_question_feed_item("anon-1") + + assert item is None or item.card.identifier != card.identifier + + def test_a_second_voters_own_exclusion_does_not_affect_a_first_voter(self, db): + card, printing = make_one_vote_from_resolving_card() + CardPrintingTagFactory(card=card, printing=printing, source=VoteSource.USER, anonymous_id="anon-1") + + item_for_second_voter = get_next_question_feed_item("anon-2") + + assert item_for_second_voter is not None + assert item_for_second_voter.card.identifier == card.identifier + log = QuestionFeedServedLog.objects.get(anonymous_id="anon-2") + assert log.pool == QuestionFeedServedPool.LIKELY_RESOLVE + + def test_logs_a_row_for_a_remainder_served_item_too(self, db): + card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + + item = get_next_question_feed_item("anon-1") + + assert item is not None + assert item.card.identifier == card.identifier + log = QuestionFeedServedLog.objects.get(anonymous_id="anon-1") + assert log.pool == QuestionFeedServedPool.REMAINDER + assert log.question_type == item.type.value + + def test_tier_4_prioritizes_quick_negative_to_review_origin_over_no_scan_log_at_all(self, db): + no_origin_card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + quick_negative_card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + CardScanLog.objects.create( + card=quick_negative_card, + anonymous_id=JOIN_KEY_ANONYMOUS_ID, + skip_reason=JOIN_KEY_UNKNOWN_SET_CODE_SKIP_REASON, + ) + + item = get_next_question_feed_item("anon-1") + + assert item is not None + assert item.card.identifier == quick_negative_card.identifier + log = QuestionFeedServedLog.objects.get(anonymous_id="anon-1") + assert log.origin_reason == "tier_4_quick_negative_to_review" + assert no_origin_card.identifier != quick_negative_card.identifier + + def test_tier_4_does_not_treat_ambiguous_origin_as_quick_negative(self, db): + # "ambiguous" is deliberately excluded from QUICK_NEGATIVE_SKIP_REASONS (blocked on the + # survivor_pks gap per the data brief - see question_feed.py's own module docstring) - + # whichever card is served, it must never be logged as the quick-negative reason + ambiguous_card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + CardScanLog.objects.create(card=ambiguous_card, anonymous_id=JOIN_KEY_ANONYMOUS_ID, skip_reason="ambiguous") + CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + + item = get_next_question_feed_item("anon-1") + + assert item is not None + log = QuestionFeedServedLog.objects.get(anonymous_id="anon-1") + assert log.origin_reason != "tier_4_quick_negative_to_review" diff --git a/docs/features/printing-tags.md b/docs/features/printing-tags.md index 13fd9aea5..d122f974f 100644 --- a/docs/features/printing-tags.md +++ b/docs/features/printing-tags.md @@ -300,13 +300,72 @@ printings, artists, tags, and moderation from one screen. - **Unified question feed**: `GET 2/questionFeed/` replaces the old printing/artist/tag/moderation tab switcher with one typed, prioritized stream (`confirm_suggestion` → contested pairs → `moderation` → fresh - unresolved; "dumb ranked union," no cross-tier scoring). Full rationale - in `journal/2026-07-14-queue-question-feed-design.md` (gitignored, - local-only). **Known v1 property, not a bug**: at current volume a - voter only sees tier-1 (`confirm_suggestion`) questions until all - ~28k are exhausted — an interleaved/weighted union is the likely v2 + unresolved; "dumb ranked union," no cross-tier scoring, with one + deliberate exception — the mix-composition policy immediately below). + Full rationale in `journal/2026-07-14-queue-question-feed-design.md` + (gitignored, local-only). **Known v1 property, not a bug**: at current + volume a voter only sees tier-1 (`confirm_suggestion`) questions until + all ~28k are exhausted — an interleaved/weighted union is the likely v2 fix, out of scope for v1. Every tier excludes `(card, tag)` pairs the requesting `anonymous_id` already voted on. +- **Mix-composition policy** (2026-07-24, `cardpicker/question_feed.py`, + owner-ratified per the WTC vote-queue data brief's OWNER ADDENDUM — + that brief was a read-only diagnostic session with no committed doc of + its own; cited here by its session transcript path, + `/tmp/claude-1001/-home-ubuntu-ProxyPrints-github-io/e893dbef-a798-47a3-9479-8c95170d3c47/tasks/a42091a50f00d5417.output`'s + fenced report tail — `docs/theory.md` has no streaming/selection note + yet to cite instead; update this citation once one lands): the feed + serves ≥`settings.QUESTION_FEED_LIKELY_RESOLVE_MIX_RATIO` (default + `0.51`) of a session's questions from the **LIKELY-RESOLVE pool** — + printing questions one more agreeing human vote would actually resolve + under the real resolver — whenever that pool has supply for the + requesting voter, falling back to the three-tier ranked union otherwise. + **Classification** (`question_feed.is_likely_resolve_printing`): finds + a card's current highest-weighted printing outcome group, adds one + hypothetical `VoteSource.USER`-weight vote to that group, and re-runs + the real `vote_consensus.resolve_weighted_consensus` (never a + reimplementation of its weight/threshold arithmetic) to check whether + that group now wins — the same exact-code simulation approach the data + brief used to derive its 46,310-card LIKELY-RESOLVE SUPPLY figure + (45,154 single-candidate + 1,156 multi-candidate near-threshold cards). + Compute-per-serve, no caching layer: `_likely_resolve_printing_card` + pre-filters to cards carrying ≥1 `CardPrintingTag` row (~97k of 218k at + the brief's snapshot, not the full unresolved population) then scans in + `date_created` order via `.iterator()` until the first match — the same + bounded "scan in priority order, stop at first hit" shape tier 1 already + uses, accepted as a v1 cost like tier 1's own starvation-risk property + above, not solved with a materialized index. **Remainder ordering**: + within tier 4, cards whose latest Stage D join-key/fallback + `CardScanLog.skip_reason` is a "quick-negative" reason + (`question_feed.QUICK_NEGATIVE_SKIP_REASONS` — + `unknown-set-code`/`eliminated`/`border-mismatch`/`frame-mismatch`) are + now prioritized (as a secondary tiebreak, after the pre-existing + `-vote_count` "closest to resolving" ordering, never ahead of it) over + the harder/open-ended remainder — `"ambiguous"` is deliberately + excluded from that set despite being nominally answerable, since the + brief ranks it as blocked on the `CardScanLog.survivor_pks` gap (see + that field's own docstring), not free supply today. **Soundness**: this + is a selection-layer policy only — it makes zero change to + `vote_consensus.resolve_weighted_consensus`'s weights, + `PRINTING_TAG_MIN_VOTES`/`MIN_SHARE` thresholds, or the D1/D4 + human-backed-priority mechanisms (see that function's own docstring); + `is_likely_resolve_printing` only ever calls it, never reimplements it. + The brief's own SOUNDNESS NOTE flags a presentation-bias risk this + policy doesn't eliminate on its own: serving a mix skewed hard toward + machine-agreeing "easy" questions could habituate reflexive + confirmation, eroding the vote-weight model's independence assumption + even though no vote's weight ever changes — the same failure _category_ + (a UI's own suggestion signal contaminating what looks like independent + confirmation) the implicit-vote weight's exclusion from suggestedness + and IMPLICIT's human-backed exclusion already guard against elsewhere. + **Mix logging**: every served item (either pool) is recorded in + `QuestionFeedServedLog` (`anonymous_id`, `pool`, `question_type`, + `origin_reason`, `served_at`) — the bias-conditioning record the + SOUNDNESS NOTE calls for, so a future audit can correlate click + latency/agreement-rate against a session's easy-question exposure; + `question_feed._served_mix_ratio` reads it back as two cheap indexed + `COUNT`s, never a per-row scan. Append-only, same convention as + `CardScanLog` — never read by any consensus computation. - **Remaining-work count**: `get_remaining_estimate()` returns `QuestionFeedCounts` (`schemas/schemas/QuestionFeedCounts.json`), not a single number. `total` is a `.distinct().count()` union across From 463f91da946bc4f4f132b417dbaf3adc03533bff Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:39:04 +0000 Subject: [PATCH 2/2] Rebase migration onto master, cite theory.md's streaming/selection note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renumbers the served-log migration to 0080 with a dependency on 0079_envelopetrip (landed on master after this branch's base, PR #440) to resolve the migration-graph conflict; retargets the mix-composition policy's soundness citation from an ephemeral session-transcript path to docs/theory.md's now-landed §10, and folds the mix-log's existence back into that section per its own invitation. Co-Authored-By: Claude Fable 5 --- ...dservedlog.py => 0080_questionfeedservedlog.py} | 2 +- MPCAutofill/cardpicker/question_feed.py | 9 ++++++--- docs/features/printing-tags.md | 10 ++++++---- docs/theory.md | 14 ++++++++++++++ 4 files changed, 27 insertions(+), 8 deletions(-) rename MPCAutofill/cardpicker/migrations/{0079_questionfeedservedlog.py => 0080_questionfeedservedlog.py} (95%) diff --git a/MPCAutofill/cardpicker/migrations/0079_questionfeedservedlog.py b/MPCAutofill/cardpicker/migrations/0080_questionfeedservedlog.py similarity index 95% rename from MPCAutofill/cardpicker/migrations/0079_questionfeedservedlog.py rename to MPCAutofill/cardpicker/migrations/0080_questionfeedservedlog.py index 277b8ed7b..fd1466ddb 100644 --- a/MPCAutofill/cardpicker/migrations/0079_questionfeedservedlog.py +++ b/MPCAutofill/cardpicker/migrations/0080_questionfeedservedlog.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ - ("cardpicker", "0078_pilotrunledger_counters"), + ("cardpicker", "0079_envelopetrip"), ] operations = [ diff --git a/MPCAutofill/cardpicker/question_feed.py b/MPCAutofill/cardpicker/question_feed.py index 4a39254f0..2d1737f95 100644 --- a/MPCAutofill/cardpicker/question_feed.py +++ b/MPCAutofill/cardpicker/question_feed.py @@ -21,9 +21,12 @@ this module) so ordinary tagging and report review are separate, switchable views instead of one hijacking the other. See docs/features/moderation.md. -Mix composition policy (2026-07-24, owner-ratified per the WTC vote-queue data brief - fenced -report tail, item "OWNER ADDENDUM"; full citation in docs/features/printing-tags.md's "Unified -question feed" section): serve >=`settings.QUESTION_FEED_LIKELY_RESOLVE_MIX_RATIO` (default +Mix composition policy (2026-07-24, owner-ratified per the WTC vote-queue data brief's OWNER +ADDENDUM; soundness citation now `docs/theory.md` §10 "Streaming and continuous operation" - +that section names this exact served-mix/human-vote-quality surface and its own "the place to +fold it in" invitation once a mix-logging mechanism landed, which this change is; full citation +in docs/features/printing-tags.md's "Unified question feed" section): serve +>=`settings.QUESTION_FEED_LIKELY_RESOLVE_MIX_RATIO` (default 0.51) of a session's questions from the LIKELY-RESOLVE pool - a printing question one more agreeing human vote would actually resolve under the real resolver, per `is_likely_resolve_printing` below - whenever that pool still has supply for this voter, diff --git a/docs/features/printing-tags.md b/docs/features/printing-tags.md index d122f974f..07d392259 100644 --- a/docs/features/printing-tags.md +++ b/docs/features/printing-tags.md @@ -311,10 +311,12 @@ printings, artists, tags, and moderation from one screen. - **Mix-composition policy** (2026-07-24, `cardpicker/question_feed.py`, owner-ratified per the WTC vote-queue data brief's OWNER ADDENDUM — that brief was a read-only diagnostic session with no committed doc of - its own; cited here by its session transcript path, - `/tmp/claude-1001/-home-ubuntu-ProxyPrints-github-io/e893dbef-a798-47a3-9479-8c95170d3c47/tasks/a42091a50f00d5417.output`'s - fenced report tail — `docs/theory.md` has no streaming/selection note - yet to cite instead; update this citation once one lands): the feed + its own, so its raw finding isn't independently citable; the durable + soundness citation is [`theory.md`](../theory.md) §10 "Streaming and + continuous operation," which names this exact served-mix/human-vote- + quality surface and explicitly invites folding in a mix-logging + mechanism once one lands — this policy, and its `QuestionFeedServedLog` + below, is that mechanism, noted in place in §10's own text): the feed serves ≥`settings.QUESTION_FEED_LIKELY_RESOLVE_MIX_RATIO` (default `0.51`) of a session's questions from the **LIKELY-RESOLVE pool** — printing questions one more agreeing human vote would actually resolve diff --git a/docs/theory.md b/docs/theory.md index db9741d2f..e69b260e7 100644 --- a/docs/theory.md +++ b/docs/theory.md @@ -959,6 +959,20 @@ measurement" discipline (§9). If a labeled study of this effect (or a built mix-logging mechanism) lands later, this section is the place to fold it in — flagged here rather than left unlinked. +**Mix-logging mechanism landed, 2026-07-24** (same day, a separate +change): `cardpicker.question_feed`'s ≥51%-likely-resolve serving +policy (docs/features/printing-tags.md's "Mix-composition policy" — +itself owner-ratified from a read-only WTC vote-queue data brief, not +from this section) writes one `QuestionFeedServedLog` row per served +question (`anonymous_id`/`pool`/`question_type`/`origin_reason`/ +`served_at`) — the mix-logging mechanism this paragraph names as the +prerequisite for a future labeled study. This still is NOT the labeled +study itself (no agreement-rate/latency data has been analyzed against +it yet), and it changes nothing about the argument above — the +resolver remains unaffected either way. Noted here per this section's +own "the place to fold it in" invitation, not asserting more than the +log now existing. + --- ## Status