Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions MPCAutofill/MPCAutofill/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions MPCAutofill/cardpicker/migrations/0080_questionfeedservedlog.py
Original file line number Diff line number Diff line change
@@ -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", "0079_envelopetrip"),
]

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")],
},
),
]
50 changes: 50 additions & 0 deletions MPCAutofill/cardpicker/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1811,6 +1811,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",
Expand All @@ -1834,4 +1882,6 @@ def __str__(self) -> str:
"UserCryptoProfile",
"LandsAmbiguousResidue",
"ImageEvidence",
"QuestionFeedServedPool",
"QuestionFeedServedLog",
]
Loading
Loading