Skip to content

A facet has three states: filter in, filter out, or do not care - #18

Merged
HarryCordewener merged 1 commit into
mainfrom
feat/filter-polarity
Jul 31, 2026
Merged

A facet has three states: filter in, filter out, or do not care#18
HarryCordewener merged 1 commit into
mainfrom
feat/filter-polarity

Conversation

@HarryCordewener

Copy link
Copy Markdown
Member

FacetChoice was a value or nothing, so the panel could ask only Evennia and could not ask anything but Evennia — a question the catalogue can answer and the interface had no way to put.

It now carries a polarity, and every choice facet gets it at once: charset, codebase, family, genre and language, plus the codebase-family filter the reference pages link in on. Null still means the facet is not being asked about, which is the state that must not be confused with either of the others.

Three decisions worth reviewing

Polarity is applied once, to the answer — never per token. A facet can hand one row several tokens (a game reached an hour ago is in the last day, the last week and the last month). Inverting per token would make an exclusion mean "some token differs", which every multi-token row satisfies — so !day would return everything. Covers() compares the value, Admits() applies the polarity, and they are separate methods so a call site cannot collapse them by accident.

An excluded value does not drop games that have no value for that facet. The tempting reading of "not Evennia" is has a codebase, and it is not Evennia, which quietly discards every game whose codebase we never identified — turning a gap in our own measurement into a property of those games. Not identifying a codebase is a measurement, and it is not a measurement of being Evennia. The unknown can itself be excluded, which is the separate question games whose codebase we did identify; there is a test for each.

The panel offers both directions as two <optgroup>s in one select. That is the only way to carry a third state in a plain GET form with no script, so the question stays entirely in the URL and a filtered listing stays linkable. In a URL an exclusion is a leading !, and a value that genuinely starts with one is doubled (!!important) so it stays reachable rather than reading as its own negation.

One bug worth recording

Invert() is a method rather than a property because a record's generated ToString prints every public property — so a property returning another FacetChoice recursed until the stack ran out. It surfaced as an unrelated test dying inside an assertion message, with nothing pointing at facets.

Verified against the real catalogue

Every polarity pair partitions it exactly:

Filter Games
(none) 16
codebase-family=PennMUSH / !PennMUSH 2 / 14
codebase=Evennia / !Evennia 1 / 15
codebase=~unknown / !~unknown 5 / 11

Five suites green, zero warnings. Ten new tests in FacetPolarityTests.

Not in this change

Presence facets — the measured protocols and TLS — are a different shape (a set of checkboxes, not a choice) and keep their two states. "Games that do not offer GMCP" is the obvious next question and a good follow-up, but it is a different control and would have made this diff two features.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NrGKmKcRCGktyhRTFbQDMk

FacetChoice was a value or nothing, so the panel could ask "only Evennia" and
could not ask "anything but Evennia" -- a question the catalogue can answer and
the interface had no way to put. It now carries a polarity, and every choice
facet gets it at once: charset, codebase, family, genre and language, plus the
codebase-family filter the reference pages link in on.

Null still means the facet is not being asked about, which is the state that
must not be confused with either of the others.

The polarity is applied ONCE, TO THE ANSWER, and never per token. A facet can
hand one row several tokens -- a game reached an hour ago is in the last day, the
last week and the last month -- so inverting the comparison per token would make
an exclusion mean "some token differs", which every multi-token row satisfies.
Covers() compares the value and Admits() applies the polarity, and they are
separate methods so the call site cannot collapse them by accident.

An excluded value does not drop the games that have no value for the facet. The
tempting reading of "not Evennia" is "has a codebase, and it is not Evennia",
which quietly discards every game whose codebase we never identified -- turning a
gap in our own measurement into a property of those games. Not identifying a
codebase is a measurement, and it is not a measurement of being Evennia. The
unknown can itself be excluded, which is the separate question of "games whose
codebase we did identify", and there is a test for each.

In a URL an exclusion is a leading bang, and a value that genuinely starts with
one is doubled so it stays reachable rather than reading as its own negation.
The panel offers both directions as two <optgroup>s in the same select -- the
only way to carry a third state in a plain GET form with no script -- so the
question is still entirely in the URL and a filtered listing is still linkable.

One bug worth recording. Invert() is a method rather than a property because a
record's generated ToString prints every public property, so a property returning
another FacetChoice recursed until the stack ran out -- and it surfaced as an
unrelated test dying inside an assertion message, with nothing pointing at
facets.

Verified against the real catalogue: every polarity pair partitions it exactly.
2 PennMUSH and 14 not, 1 Evennia and 15 not, 5 with no identified codebase and
11 with one, against 16 games.

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

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3d929c8e-ecf6-487c-a55c-3eeac570b350

📥 Commits

Reviewing files that changed from the base of the PR and between 0854234 and 99b2e39.

📒 Files selected for processing (9)
  • src/MUI.Catalog/Facets.cs
  • src/MUI.Catalog/Views.cs
  • src/MUI.Web/Api/ApiModels.cs
  • src/MUI.Web/Api/GameFilterBinding.cs
  • src/MUI.Web/Components/FacetPanel.razor
  • src/MUI.Web/Components/Pages/Games.razor
  • src/MUI.Web/Reference/ReferenceFigures.cs
  • tests/MUI.Catalog.Tests/FacetPolarityTests.cs
  • tests/MUI.Web.Tests/ReferenceFiguresTests.cs

Comment @coderabbitai help to get the list of available commands.

@HarryCordewener
HarryCordewener merged commit ee48650 into main Jul 31, 2026
3 checks passed
@HarryCordewener
HarryCordewener deleted the feat/filter-polarity branch July 31, 2026 16:02
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