Skip to content

feat(digest): the filter assigns topics; an upvote slows their decay - #168

Merged
musingfox merged 5 commits into
mainfrom
muster/pr-filter
Nov 10, 2026
Merged

musingfox merged 5 commits into
mainfrom
muster/pr-filter

Conversation

@musingfox

Copy link
Copy Markdown
Owner

Summary

  • The filter assigns each article a topic in the same call that sorts it, replacing news clustering. A topic continues when the reply names a recent one (matched by the normalized name the prompt shows) or opens a new one; a failed reply keeps no placements, and a blank headline gets a topic of its own.
  • The filter is offered at most 300 recent topics (MAX_RECENT_TOPICS, grade A). Names are stored as one clean line capped at 120 characters.
  • A continuing topic's tags accumulate: this run's first, then its stored ones, cut to five. Stored tags read back entity tags first, so the cut drops domain tags before entity tags.
  • ADR-0026: an upvote slows how fast a topic leaves the recent list. A topic stays while the days since it last appeared are under min(L × 2^p, 3 × L), where p is its net vote decayed by the vote half-life and floored at 0. With no vote this is today's rule; a downvote only cancels an extension. The 300 cap keeps the topics that stay longest, and a shared name joins the topic that stays longest. The four help copies now say this.

Ticket: filter-assigns-topics (vault pm/cyris/tasks). Owner rulings of 2026-10-30 and 2026-11-10 are in the commit messages and ADR-0026.

Verification

  • scripts/check.sh passed outside the sandbox at both commits (821f94b: 5409 passed; 84b768f: 5419 passed).
  • Four rounds of independent review, each with mutation checks; the last passed every item.
  • Not run against a real LLM or production D1. After deploy, a real issue should show topics assigned by the filter.

🤖 Generated with Claude Code

musingfox and others added 2 commits November 10, 2026 14:19
Every article a run keeps now gets a topic the filter chose, with an id
that carries across issues, so a vote can follow a story. The stand-in
topics of the three-tier page and the clustering call are gone.

What changed:
- Every lane goes through the filter's batches, in order. A read or follow
  article is marked to keep and answered with its topic only. Each batch is
  offered the recent topics plus those earlier batches opened, by number.
- The filter gives each topic typed tags, at most five (MAX_TOPIC_TAGS,
  the design's 2-5; a topic given seven keeps exactly five). They go to
  topic_tags only, so /interest reads none of them.
- A topic's skim articles are sand at two or more in this issue and water
  at one; read and follow stay pebbles. news_clusters and filtered_headlines
  keep their names, since stored issues use them. With no LLM, only the
  skim lane takes the excerpt fallback, so a read or follow article is
  never also a water row.
- The follow-up exclusion becomes "keep all of a topic".
- NEWS_CLUSTER_SYSTEM, cluster_news and ProcessResult.url_to_tags go.
  digest.filter_snippet_length now names how much of an article the filter
  reads to pick headlines and their topics, in /settings, onboarding and
  the example config; the onboarding feed list says the news tag keeps an
  article from the scorer. The UI spec orders water as one list whose tags
  are its topic's, and says a stored issue keeps the old order.

How a topic is named and joined:
- An unplaced article's raw title becomes its topic name, and a stored
  name is printed into the next issue's filter prompt, so a title holding
  a newline could add a line that reads as an article. A name is one line,
  with no control characters, its whitespace collapsed and its length
  capped at MAX_TOPIC_NAME_LENGTH (grade A, §5), wherever it enters the
  TopicBook, and the prompt flattens each name it prints too.
- A name joins by the form it is offered in, since a reply names the
  offered form: a stored name the offer cleans or cuts (rows main stores
  as uncut titles) is still joined, and a name two recent topics share in
  that form joins the newest of them.
- An entry with an empty headline and no topic name places nothing: the
  article is a topic of its own, as an unplaced one is, named by its title,
  rather than every such article joining one topic named "".
- A topic of its own takes its id from a hash of the URL exactly as
  stored_articles keys it (SQLite's BINARY collation; nothing upstream
  folds a URL's case), so two URLs differing only in case are two topics.
- A batch's placements are made only once its whole reply is read, and a
  failed batch gives each of its articles a topic of its own, as §3.2
  says; a reply whose later entry failed validation keeps none of its
  earlier placements.

The recent-topic list:
- A TopicBook offers the MAX_RECENT_TOPICS (300) last seen, newest first;
  the topics a run opens follow them, since a later batch must see them.
  300 is measured with build_filter_prompt: 300 names of 34 characters are
  about as long as a full batch of 20 articles at the example's
  500-character snippet (11,879 against 11,530 characters). The cap, not a
  doubling, is the bound: 300 names at MAX_TOPIC_NAME_LENGTH add 37,679
  characters, about 4.3 times the 11,577-character prompt.
- A continuing topic's tags accumulate: a save keeps this run's tags
  first, then the stored ones they do not repeat, cut to MAX_TOPIC_TAGS,
  and a run with no tags keeps the stored ones. Replacing them let a run
  that gave none clear them and broke the entity-tag preference. The store
  reads a topic's tags entity tags first, then by tag (owner, 2026-11-10),
  so the cut drops a stored domain or region tag before the one kind that
  carries preference; no migration and no reliance on rowid.

Why this way:
- LLM only: embedding candidates dropped precision to 0.34-0.69, and an
  end-of-issue merge call returned nothing in 8 of 9 calls (ADR-0023). A
  guard test holds both out, and the owner's granularity examples too.
- The recent list is the plain digest.topic_lifetime_days, because the rule
  for extending a topic is not decided (owner, 2026-10-30). The
  recent_topic_list voted-up extension stays unread, and the §5 row for
  RECENT_VOTE_CUTOFF names what does read it, score_issue_topics; a guard
  holds that text and the onboarding news-tag text.

A follow article now reaches the model with digest.summarize_follow off,
since the goal gives every article a topic the filter chose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner chose Rule D+C on 2026-11-10 (ADR-0026): a topic stays on the
recent list while the days since its last article are under
min(L × 2 ** p, MAX_TOPIC_LIFETIMES × L), p its net vote decayed by the
vote half-life and floored at 0, K = MAX_TOPIC_LIFETIMES = 3. So no vote
leaves at L as before, a downvote only cancels a stretch, and no vote, an
archived page's included, keeps a topic past three lifetimes. The run now
offers the filter recent_topic_list instead of the plain lifetime.

What changed:
- The run reads K × L days of topics and every topic vote, and keeps the
  topics the rule holds; the vote sync writes no topic vote, so on a first
  run that is the only topic_votes read, which the e2e statement pin
  records after "SELECT topics" (49 statements; the D1 route count follows
  len(EXPECTED_D1)). Tests pin the K × L read (a topic last seen 17.5 days
  ago with 20 fresh upvotes is offered, which a 2 × L read misses) and the
  decayed vote (a topic last seen 8 days ago with a 28-day-old upvote is
  off the list while one with a day-old upvote stays, which a raw net vote
  misses).
- The 300 cap sorts on that expiry, latest first and the newest of a tie
  first, since sorting on last_seen dropped the extended topics first. A
  name two listed topics share joins the one that stays longest, the first
  listed (owner, 2026-11-10); with no vote that is the newest.
- kept_by_upvote goes: nothing reads it now. RECENT_VOTE_CUTOFF is read
  only by score_issue_topics' lookup, and the recent list changes no
  score: it sets which topics are offered and, past the cap, kept.
- The guard tests that kept the helper out now pin it in and pin the §5
  row for MAX_TOPIC_LIFETIMES; §3 and §5 describe the new list. The
  settings page, onboarding, the prototype and settings.example.toml say
  an upvote slows how fast a topic leaves the list new articles can join,
  fading with the vote half-life, and never past three times the lifetime
  after the topic last appeared. "A topic you voted up stays current
  longer" was false: nothing extended a voted topic, and "current" also
  named the discount's population, which no vote changes.

Rejected, as ADR-0026 records: C alone does not fade, D alone has no
bound, a downvote that shortens below L brings the story back under a
fresh id, and the helper as it stood was bounded by H and undone by the
300 cap. ADR-0023's "a topic ends after L days" is superseded in part,
linked both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

Review of #168: I found nothing worth raising inline.

I read the core source changes: domain/topics.py (TopicBook, expiry), service_layer/filtering.py (placement and sections), prompts.py, run_digest.py, topic_order.py and the store tag-order change. I did not read the test files or run the suite.

What I checked:

  • Placements are applied only after the whole reply is parsed, so a failed batch places nothing and falls back to own-topic ids.
  • Positional topic_id is bounded by the offered count, and _listed only grows, so indexes stay valid.
  • A duplicate or unknown article id is skipped with a warning.
  • The expires[t.id] lookup in TopicBook is consistent with recent_topic_list, which returns the filtered window and its expiry map together.
  • Topic-store read failures degrade to no continuing topics and are recorded in summary.

One thing to be aware of, not a bug: recent_topics now reads a window of 3 × topic_lifetime_days, up to 3 years at the max setting, and only caps afterwards at MAX_RECENT_TOPICS. The query has no LIMIT, so the read grows with topic history. That is fine at current scale.

@musingfox

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T15:48:03.655443Z a230e3a Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 84b768f2cd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/test_filter.py
Comment on lines +19 to +20
def _book() -> TopicBook:
return TopicBook([], date="2026-09-17", period="morning")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark the filter tests as integration tests

Passing a real TopicBook into filter_articles changes these tests from isolated filtering tests into cross-module tests: the filter consumes the book's names() and holds() results and calls its real placement methods. This is not merely using another module to construct fixture data. tests/AGENTS.md R2(d) explicitly classifies handing a real object between modules as integration testing, and requires the whole file to take the highest level. Leaving pytestmark = pytest.mark.unit makes level-selected runs misclassify this coverage; mark the file integration or inject a fake book instead.

AGENTS.md reference: tests/AGENTS.md:L9-L11

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e3954aa: both tests/test_filter.py and tests/test_degrade.py (which also hands a real TopicBook to filter_articles) are now integration, per tests/CLAUDE.md R2(d).

Both files hand a real TopicBook to filter_articles, and the filter's
placements flow into the book's own methods. tests/CLAUDE.md R2(d) makes
that integration, and the highest level wins for the whole file. A fake
book would keep them unit, but the point of these tests is that the
filter and the book agree on names and positions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@musingfox

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

Reviewed #168. I read domain/topics.py, service_layer/filtering.py, topic_order.py, and how run_digest and digest_pipeline wire them. I found nothing worth an inline comment.

What I checked:

  • Batch failure: a failed reply places nothing, and every article in that batch falls back to its own topic.
  • Topic numbers: a model-supplied topic number is bounded by the count offered at the start of the batch, so it can't index a topic opened mid-reply.
  • Names: blank or hostile names are normalized, and a blank name leaves the article to its own topic.
  • Recent-list expiry and cap: topic_expiry and the cap ordering are consistent with ADR-0026.
  • Store failure: a failed topic-store read costs the run its recent list, not the run, and is recorded in recent_topics_error.

I did not run the test suite, and I did not read the test files or the whole diff.

The one gap I noticed is TopicBook.ensure on a read or follow article with a blank title. It stores a topic with an empty name, and the filter won't offer or join it later. It is an edge case that is unlikely to matter.

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e3954aa6fa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/test_topic_decay.py Outdated
from cyris.domain.models import TopicSummary, TopicVote
from cyris.domain.topic_rank import topic_preferences

pytestmark = pytest.mark.unit

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark the topic-decay tests as integration tests

The _on_list helper feeds the real domain.topic_rank.topic_preferences output into domain.topics.recent_expiry, and the 9.2/9.3-day assertions verify the combined vote-decay and expiry behavior, rather than merely using another module to construct fixture data. This meets R2(d)'s cross-module criterion, so the whole file must be marked integration. Leaving it as unit misclassifies this coverage in level-selected runs; change the file-level marker or test expiry with explicitly supplied preference values.

AGENTS.md reference: tests/AGENTS.md:L9-L11

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e998e9a: tests/test_topic_decay.py is integration. tests/test_topic_order.py stays unit (one module, topic_order, over a fake store).

_on_list feeds topic_rank.topic_preferences into topics.recent_expiry and
the assertions check the two together, which tests/CLAUDE.md R2(d) makes
integration. tests/test_topic_order.py stays unit: it calls one module,
topic_order, over a fake store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@musingfox

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

Review of #168: I found nothing I'd flag as a correctness, data-loss or security problem.

I read the non-test source changes in filtering.py, digest_pipeline.py, run_digest.py, the adapters/store/topics.py tag ordering and the schema comments. I did not read the tests, docs or config files in the diff, and I did not run the suite.

What I checked:

  • Failed batches: a batch whose reply fails places nothing and falls back to excerpt headlines, with every article given its own topic.
  • Duplicate and unknown ids: a duplicated id is dropped, and an unknown id is logged and kept as a headline with no link.
  • Topic-store read failure: it degrades to "every topic is new" and records recent_topics_error in the summary. The issue still publishes.
  • No-LLM path: read and follow articles get a topic in the pipeline when the filter returns early.

I'm not certain about one thing. The change drops the tag_store.save(url_to_tags) call, so cluster tags no longer reach article_tags. The new schema comment says only scoring tags feed /interest. If anything else relied on the cluster tags, it will quietly stop getting them.

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e998e9a306

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +121 to +122
kept = sorted(recent, key=leaves, reverse=True)[:MAX_RECENT_TOPICS]
self._listed = [TopicRecord(id=t.id, name=topic_name(t.name), urls=[]) for t in kept]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep fallback singleton topics out of subsequent offers

After a failed batch, a no-LLM run, or an omitted read/follow answer, ensure() creates a topic that no other article is supposed to join. However, save_topics() persists it without distinguishing it from an assigned topic, and the next run's recent_topic_list() returns it here. This constructor then makes it joinable both by position and by name, so another article can inherit the fallback topic's identity and votes. The isolation currently lasts only for the originating run, contrary to the singleton behavior documented in docs/architecture.md. Preserve a singleton discriminator through storage and exclude those records from subsequent offers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Owner decision: a topic of its own stays closed within its issue only; a later run may continue it, votes included, so a story whose first report got no answer still continues. Keeping such topics out of later offers would need a stored flag and a migration on both stores. a230e3a makes docs/architecture.md and the domain/topics.py docstring say this.

An article with no answer (no LLM, a failed batch, a read or follow
article the model left out) gets a topic of its own. Nothing else in that
run can join it, but it is stored like any topic, so a later run offers
it and an article there may continue it, votes included. The docs said no
other article could ever join it.

The owner chose this over keeping such topics out of later offers
(2026-11-10): a story whose first report got no answer still continues,
and keeping them out would need a stored flag and a migration on both
stores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@musingfox

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: a230e3a734

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

Review of #168: I found nothing worth raising as an inline comment.

I read the changes under src/ — TopicBook, the topic-assigning filter (filtering.py), sections_by_topic, the pipeline wiring, and recent_topic_list with its expiry logic. I did not read the tests or docs diffs, and I did not run the tests.

What I checked:

  • Failure handling: a failed or malformed filter reply places nothing, and every article in that batch gets a topic of its own. The ensure calls cover the read and follow lanes.
  • Blank or odd names: place rejects blank names, and a model-supplied topic position can't point past the list the batch was offered.
  • Recent-topic expiry: recent_topic_list only passes topics that appear in expires, so TopicBook's expires[t.id] lookup can't raise a KeyError on that path.
  • Topic ids: ids carry across issues, and a new topic's id comes from its name, so a rerun reuses the same id.

One thing to be aware of, which I'd call a design choice rather than a bug: sand blocks now have an empty merged summary, and the page falls back to the first member's excerpt. The CHANGELOG says so.

🤖 Generated with Claude Code

@musingfox
musingfox merged commit ff6e6b2 into main Nov 10, 2026
3 checks passed
@musingfox
musingfox deleted the muster/pr-filter branch November 10, 2026 17:09
musingfox added a commit that referenced this pull request Nov 10, 2026
#167 deleted Deps.publish while #168 was open, and #168's new
tests/test_filter_topics.py still passed publish=None, so main's CI
and release both failed on ff6e6b2 with five TypeErrors. Each PR's
own CI was green; only their merge broke.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
musingfox added a commit that referenced this pull request Nov 10, 2026
#167 changed nothing in §1–§6, so it has no row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant