curated_feed.models

Data contracts for the curation pipeline.

Three of these are boundaries rather than conveniences. Candidate is the corpus record schema, consumed by later stages and by manual evaluation sessions. Response is what comes back from the model, which is untrusted input and is validated as such. SelectionResult is the record of what was chosen and why.

Validation here covers shape and range only. Conflicts between records — the same article both dropped and listed as a duplicate, or a candidate the response never mentions — are resolved during selection, where they can be recorded and reported rather than merely rejected.

Attributes

Classes

Candidate

One normalised feed item.

Graded

The model's reading of one article, as specified in policy/prompt.md.

Response

A day's judgements: what was dropped, and what was graded.

Adjustment

One weighted term of a score, kept beside the reason it was applied.

Selection

A selected article, with the arithmetic that put it there.

SelectionResult

The state/selections/DATE.json artifact.

Verdict

One line of state/paywall.jsonl: whether one article can be read.

PublishedEntry

One line of state/published.jsonl, and one entry of the feed.

FeedMeta

What the last render produced, so the next one can tell if it changed.

DeployMeta

What the last upload sent, so an unchanged feed is not sent again.

Module Contents

curated_feed.models.MIN_CONSEQUENCE = 1
curated_feed.models.MAX_CONSEQUENCE = 3
curated_feed.models.TIMESTAMP_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
curated_feed.models.VERDICT_FREE = 'free'
curated_feed.models.VERDICT_PAYWALLED = 'paywalled'
curated_feed.models.VERDICT_UNKNOWN = 'unknown'
class curated_feed.models.Candidate(/, **data: Any)[source]

Bases: pydantic.BaseModel

One normalised feed item.

The field order below is the corpus record schema, and JSON is written in it. Changing the fields, their order or their meaning is a deliberate act.

model_config

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

id: str
url: str
title: str
summary: str
source: str
source_url: str
folder: str | None
published_at: datetime.datetime | None
published_missing: bool
fetched_at: datetime.datetime
class curated_feed.models.Graded(/, **data: Any)[source]

Bases: pydantic.BaseModel

The model’s reading of one article, as specified in policy/prompt.md.

Every field is a judgement that requires having read the article. Nothing here is arithmetic, and nothing here knows the floor, the item cap or the ordering — those belong to selection.

model_config

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

index: int = None
consequence: int = None
topics: list[str] = None
penalties: list[str] = None
durable: bool = False
duplicates: list[int] = None
independent_sources: int = None
discretionary: bool = False
discretionary_reason: str | None = None
class curated_feed.models.Response(/, **data: Any)[source]

Bases: pydantic.BaseModel

A day’s judgements: what was dropped, and what was graded.

model_config

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

dropped: dict[str, list[int]] = None
graded: list[Graded] = None
property accounted_indices: dict[int, int][source]

How often the response mentions each candidate number.

The coverage invariant wants exactly one mention per candidate. Counting rather than testing membership is what makes both failures visible: a count of zero is an article never considered, and a count above one is an article claimed twice.

class curated_feed.models.Adjustment(/, **data: Any)[source]

Bases: pydantic.BaseModel

One weighted term of a score, kept beside the reason it was applied.

model_config

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

reason: str
value: float
class curated_feed.models.Selection(/, **data: Any)[source]

Bases: pydantic.BaseModel

A selected article, with the arithmetic that put it there.

rationale is the only field published; everything else exists for tuning and stays in the private selection log.

model_config

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

candidate_id: str
rank: int = None
consequence: int
preference: int
topics: list[str] = None
adjustments: list[Adjustment] = None
score: float
discretionary: bool = False
discretionary_reason: str | None = None
rationale: str = ''
duplicates: list[str] = None
class curated_feed.models.SelectionResult(/, **data: Any)[source]

Bases: pydantic.BaseModel

The state/selections/DATE.json artifact.

Rejects are grouped by reason code rather than listed one by one, which keeps the artifact small while preserving a complete account of the day. anomalies records what the response got wrong and selection had to resolve; an empty list is the normal case, and a growing one is a policy problem.

model_config

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

run_date: datetime.date
candidate_count: int = None
selected: list[Selection] = None
rejected: dict[str, list[str]] = None
anomalies: list[str] = None
class curated_feed.models.Verdict(/, **data: Any)[source]

Bases: pydantic.BaseModel

One line of state/paywall.jsonl: whether one article can be read.

Keyed by candidate id and written once. The log is append-only and outlives a run, so detail carries why a verdict was reached — “HTTP 403”, “no markup” — which is the whole audit trail for a day whose feed looks short.

model_config

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

candidate_id: str
url: str
verdict: Literal['free', 'paywalled', 'unknown']
checked_at: datetime.datetime
detail: str = ''
class curated_feed.models.PublishedEntry(/, **data: Any)[source]

Bases: pydantic.BaseModel

One line of state/published.jsonl, and one entry of the feed.

Denormalised on purpose: rendering is then a function of this log alone, and cannot be changed by a later edit to the corpus or the selection.

summary is the source’s own description, carried here for the same reason everything else is. It defaults to empty because the log is append-only and outlives its schema: lines written before the field existed have to stay readable, and they render as they always did.

model_config

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

candidate_id: str
url: str
title: str
summary: str = ''
source: str
published_at: datetime.datetime | None
rationale: str
run_date: datetime.date
class curated_feed.models.FeedMeta(/, **data: Any)[source]

Bases: pydantic.BaseModel

What the last render produced, so the next one can tell if it changed.

The feed-level <updated> moves only when the content hash does. Stamping the run time on every render makes a reader treat every item as modified.

model_config

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

content_hash: str
updated: datetime.datetime
class curated_feed.models.DeployMeta(/, **data: Any)[source]

Bases: pydantic.BaseModel

What the last upload sent, so an unchanged feed is not sent again.

Written only after a real upload succeeds. A failed one leaves the previous hash in place, which is what makes the next run retry rather than assume.

model_config

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

content_hash: str
deployed_at: datetime.datetime