curated_feed.select =================== .. py:module:: curated_feed.select .. autoapi-nested-parse:: Turning the model's judgements into the day's selection. Every number is computed here: the arithmetic, the score floor, the count cap and the ordering. The model supplies only what needs an article read, and is never told the floor or the cap — knowing them could only tempt it to pre-filter or pad. Sub-scores stay on disk, so tuning a coefficient is an offline sweep over a saved corpus rather than an API call per iteration. This module also resolves what the response got wrong. A response can claim an article twice, name an index that does not exist, offer two discretionary picks, or quietly omit an article altogether. None of those should cost a day's feed, so each is resolved by a stated rule, recorded in `anomalies`, and reported. Attributes ---------- .. autoapisummary:: curated_feed.select.SELECTION_STAGE curated_feed.select.BELOW_FLOOR curated_feed.select.OVER_CAP curated_feed.select.PAYWALLED curated_feed.select.UNACCOUNTED curated_feed.select.RECURRENCE curated_feed.select.STALENESS curated_feed.select.PENALTY_PREFIX Classes ------- .. autoapisummary:: curated_feed.select.Resolution curated_feed.select.Scored Functions --------- .. autoapisummary:: curated_feed.select.resolve curated_feed.select.count_preference curated_feed.select.compute_adjustments curated_feed.select.score_one curated_feed.select.build_rationale curated_feed.select.select_day curated_feed.select.format_summary curated_feed.select.build_parser curated_feed.select.main Module Contents --------------- .. py:data:: SELECTION_STAGE :value: 'selections' .. py:data:: BELOW_FLOOR :value: 'below-floor' .. py:data:: OVER_CAP :value: 'over-cap' .. py:data:: PAYWALLED :value: 'paywalled' .. py:data:: UNACCOUNTED :value: 'unaccounted' .. py:data:: RECURRENCE :value: 'recurrence' .. py:data:: STALENESS :value: 'staleness' .. py:data:: PENALTY_PREFIX :value: 'penalty:' .. py:class:: Resolution The response with its conflicts settled, ready to be scored. .. py:attribute:: graded :type: list[curated_feed.models.Graded] :value: [] .. py:attribute:: dropped :type: dict[str, list[int]] .. py:attribute:: unaccounted :type: list[int] :value: [] .. py:attribute:: anomalies :type: list[str] :value: [] .. py:class:: Scored One graded article with its arithmetic worked out. .. py:attribute:: graded :type: curated_feed.models.Graded .. py:attribute:: candidate :type: curated_feed.models.Candidate .. py:attribute:: preference :type: int .. py:attribute:: adjustments :type: list[curated_feed.models.Adjustment] .. py:attribute:: score :type: float .. py:function:: resolve(response: curated_feed.models.Response, count: int) -> Resolution Settle every conflicting claim, recording each resolution. Precedence, in order: a drop beats a grade, because hard exclusions are gates that nothing may bypass; a grade beats being someone else's duplicate; and the first group to claim a duplicate keeps it. .. py:function:: count_preference(topics: collections.abc.Sequence[str], *, cap: int) -> int One point per distinct topic, capped. Names are not checked against the policy: an invented topic inflates the score, which is a policy problem rather than a parsing one, and no model text is published. Case and whitespace are folded so one topic cannot count twice. >>> count_preference(["Privacy", "privacy", "Linux"], cap=3) 2 .. py:function:: compute_adjustments(graded: curated_feed.models.Graded, candidate: curated_feed.models.Candidate, *, policy: curated_feed.config.PolicySettings, now: datetime.datetime) -> list[curated_feed.models.Adjustment] The ±1-bounded terms of scoring specification §2.3, in a fixed order. .. py:function:: score_one(graded: curated_feed.models.Graded, candidate: curated_feed.models.Candidate, *, policy: curated_feed.config.PolicySettings, now: datetime.datetime) -> Scored `score = consequence + preference + adjustments`. .. py:function:: build_rationale(graded: curated_feed.models.Graded, *, preference: int, adjustments: collections.abc.Sequence[curated_feed.models.Adjustment], policy: curated_feed.config.PolicySettings) -> str The line that is published: consequence, preference, and fixed adjustments. Built from numbers and from the fixed reason codes this module assigns itself, never from unvalidated model text: the feed is public, so the privacy property is structural rather than a rule someone has to remember. Topics and penalty rule names are both free text a model wrote *about the reader's own interests*, so neither reaches here; they stay in the selection log, which is not public. `discretionary_reason` is the one piece of model text that does reach here. `policy/prompt.md` §7 has the model write it about the article — what would otherwise have kept it out — never about the reader, so publishing it verbatim carries none of the risk that keeps topics and penalty rule names out. >>> build_rationale( ... Graded(index=1, consequence=3), preference=0, adjustments=[], ... policy=PolicySettings(), ... ) 'Consequence 3 of 3. Preference 0.' >>> build_rationale( ... Graded(index=1, consequence=2, independent_sources=3), preference=2, ... adjustments=[Adjustment(reason=RECURRENCE, value=1.0)], ... policy=PolicySettings(), ... ) 'Consequence 2 of 3. Preference +2. Reported independently by 3 outlets (+1).' >>> build_rationale( ... Graded( ... index=1, consequence=1, discretionary=True, ... discretionary_reason="an unfamiliar angle on a familiar story", ... ), ... preference=0, adjustments=[], policy=PolicySettings(), ... ) 'Consequence 1 of 3. Preference 0. Discretionary pick: an unfamiliar angle on a familiar story.' >>> build_rationale( ... Graded(index=1, consequence=3), preference=0, adjustments=[], ... policy=PolicySettings(rationale_mode="none"), ... ) '' .. py:function:: select_day(response: curated_feed.models.Response, candidates: collections.abc.Sequence[curated_feed.models.Candidate], *, policy: curated_feed.config.PolicySettings, now: datetime.datetime, run_date: datetime.date, verdict_by_id: dict[str, curated_feed.models.Verdict] | None = None) -> curated_feed.models.SelectionResult Score, gate the unreadable, apply the floor, add the pick, cap, and rank. The order is the one in the scoring specification, and it matters: the discretionary pick is added after the floor so it can bypass it, and the cap is applied last so it is a cap on what would otherwise be published. The paywall gate comes first of all, and it is a gate rather than a penalty: an article the reader cannot open is worth nothing whatever it is about, so no score and no discretionary pass gets past it. Gating here rather than after the cap is what lets the next article take the freed slot. `verdict_by_id` is a parameter rather than a config lookup so that this module keeps reading its inputs from its arguments, and stays testable without a filesystem. Absent, or absent for a given candidate, means readable — the feature is then a no-op and a saved day replays unchanged. .. py:function:: format_summary(result: curated_feed.models.SelectionResult, *, path: pathlib.Path) -> str Human-readable run report. .. py:function:: build_parser() -> argparse.ArgumentParser .. py:function:: main(argv: collections.abc.Sequence[str] | None = None) -> int