Repository navigation
feat(digest): the filter assigns topics; an upvote slows their decay - #168
Conversation
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>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
|
Review of #168: I found nothing worth raising inline. I read the core source changes: What I checked:
One thing to be aware of, not a bug: |
|
@codex review |
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| def _book() -> TopicBook: | ||
| return TopicBook([], date="2026-09-17", period="morning") |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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>
|
@codex review |
|
Reviewed #168. I read What I checked:
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 🤖 Generated with Claude Code |
There was a problem hiding this comment.
💡 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".
| from cyris.domain.models import TopicSummary, TopicVote | ||
| from cyris.domain.topic_rank import topic_preferences | ||
|
|
||
| pytestmark = pytest.mark.unit |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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>
|
@codex review |
|
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 What I checked:
I'm not certain about one thing. The change drops the 🤖 Generated with Claude Code |
There was a problem hiding this comment.
💡 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".
| 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] |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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>
|
@codex review |
|
Codex Review: Didn't find any major issues. Keep them coming! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
|
Review of #168: I found nothing worth raising as an inline comment. I read the changes under What I checked:
One thing to be aware of, which I'd call a design choice rather than a bug: sand blocks now have an empty merged 🤖 Generated with Claude Code |
Summary
MAX_RECENT_TOPICS, grade A). Names are stored as one clean line capped at 120 characters.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.shpassed outside the sandbox at both commits (821f94b: 5409 passed; 84b768f: 5419 passed).🤖 Generated with Claude Code