Skip to content

Make the listing a directory, and never let a facet lie about a click - #14

Merged
HarryCordewener merged 1 commit into
feat/ecosystem-dashboardfrom
feat/faceted-search
Jul 31, 2026
Merged

Make the listing a directory, and never let a facet lie about a click#14
HarryCordewener merged 1 commit into
feat/ecosystem-dashboardfrom
feat/faceted-search

Conversation

@HarryCordewener

Copy link
Copy Markdown
Member

Stacks on #11 (chore/park-the-importer), which is main plus #9 and #11. Review after those land, or the diff will include their work.

Spec §9's faceted search over /games: activity band, last seen, measured protocol support, TLS, negotiated charset, codebase, family, genre, language — plus random game, and the archived toggle that already existed. Blazor static SSR, method="get", no script: the querystring is the whole state, so a filtered listing is linkable, the back button works and the server recomputes everything.

A facet cannot lie about what a click produces

IGameQueries.SearchAsync returns the listing and the facet counts from one pass, so the two cannot be answers to two slightly different questions. Each count is measured against the set the click actually lands on:

  • a choice facet replaces its own selection, so its values are counted with that selection lifted and every other filter still applied;
  • a presence facet (protocols, TLS) intersects, so its values are counted against the current results.

A value nothing matches is never drawn — a facet that can be clicked into an empty listing is a facet lying about the catalogue, and not drawing the click is the cheapest way to make that impossible. FacetedSearchTests.EveryCountIsExactlyWhatChoosingThatValueReturns and its Postgres and API counterparts check this by taking every advertised count, running that choice, and comparing sizes.

An unknown is not a no, and the page says so

The protocol facet reads capability.*.measured and nothing else. There is deliberately no way to ask for the complement: a capability is written true when observed and otherwise not written at all, so "not listed" covers a game we never measured as well as one that declined, and only one of those is a fact about them. The panel states this in the markup rather than in a comment.

Every declared facet carries its own absence as a selectable value, spelled three ways because they are three facts — not identified (our parsers), not declared (what the game published), nothing negotiated (the handshake). FacetChoice models the absence as a member rather than an empty string so the two cannot be typed for each other.

charset reads the handshake row rather than the precedence winner: CHARSET is one of the few fields both a handshake and MSSP write, so the winner is silently the game's own assertion for every server that never negotiates — a facet labelled measured answering from the declared column.

One vocabulary

FacetKeys names each facet once; GameFilterBinding is the one parser and the page now binds through it, so /games?band=quiet and /api/games?band=quiet cannot drift. FacetSurfaceTests walks FacetKeys by reflection in both directions — every key the query names is read by the binding, and every group the query returns is a key the vocabulary names. The API publishes the facets with each group's evidence, and refuses an unreadable band; the page refuses it out loud rather than answering with the unfiltered catalogue.

Plain mode

?plain=1 prints the whole panel — every value, its count, and the parameter that selects it — because a text browser cannot operate a <select> but can edit a URL. Width is pinned at 80 columns.

Two bugs fixed on the way

  • band=archived had two answers. The demo fixture read it as asking for the archive; Postgres read it as a filter over a listing the archive had already left, so it returned nothing. Both implementations now feed rows to one FacetedSearch, and only that closed it.
  • The plain-text link was a hand-kept list of q and archived. The first facet added would have been silently dropped, offering the unfiltered catalogue as "this page, as text". It is rebuilt from the live querystring now.

Left out deliberately

  • No TLS data exists yet, and the facet says nothing rather than something. CatalogueBinder writes EndpointKind.Telnet unconditionally and nothing writes capability.tls.measured, so the only honest measured signal — an endpoint of kind tls — is never recorded. The facet queries it correctly and therefore renders nothing on real data; filling it from capability.ssl.declared would have been the central lie. FacetQueriesPostgresTests.TlsIsAnEndpointWeOpenedAndNeverAnSslLineInMssp seeds a TLS endpoint and pins the query, so it becomes a real facet the day the crawler dials TLS.
  • codebase values are raw and capped at the 12 commonest. MSSP CODEBASE is versioned (PennMUSH 1.8.8p0), so normalising it into families would be inventing a value; FAMILY is the coarse facet and is offered beside it. The tail stays reachable by search and by URL, and the panel says so.
  • No facet wizard (§9's "find-a-game" flow) and no sort control.
  • The listing is a whole-catalogue pass per request, because the counts have to be measured against the same set the listing came from. Same order as before — FieldsForAsync already read every field of every listed game — and the note in SearchAsync records what the next shape is and what it would cost.

Verifying

dotnet build MUIndex.slnx -c Release clean, all five suites green: Catalog 154, Crawl 130, Crawler 77, Discovery 182, Web 130.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NrGKmKcRCGktyhRTFbQDMk

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: bedee7f3-347b-48ee-a906-477ec6d77d52

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

@HarryCordewener
HarryCordewener changed the base branch from main to chore/park-the-importer July 31, 2026 15:18
Spec §9's faceted search, over one vocabulary and one pass. Activity band,
last seen, measured protocols, TLS, negotiated charset, codebase, family,
genre and language; a random game that carries whatever is on screen; and the
archive toggle that was already there.

Every count beside a value is what choosing that value returns, because the
counts and the listing come out of the same call. A choice facet is counted
with its own selection lifted, since choosing replaces; a tick box is counted
against the results, since ticking intersects. Both answer the same question —
what happens if I click this — and a value nothing matches is not drawn at all,
which is the cheapest way to make a dead end impossible.

An unknown is never a no, and the page says so rather than a comment saying so.
The protocol boxes read capability.*.measured and nothing else, so an unticked
box means we did not see it offered and never that a game declined it — that
distinction is why the field is keyed by source. Every declared facet carries
its own absence as a choice, spelled three different ways because they are three
different facts: a codebase is "not identified" (our parsers), a genre is "not
declared" (what the game published), an encoding is "nothing negotiated" (the
handshake). Charset reads the handshake row rather than the precedence winner,
or a facet labelled measured would answer from MSSP for every server that never
negotiates.

TLS is an endpoint we opened, never MSSP's SSL line. Nothing writes a TLS
endpoint yet — CatalogueBinder records what it dialled and the crawler dials
plaintext — so the facet renders nothing today, which is the honest rendering of
a measurement nobody has taken. FacetQueriesPostgresTests seeds one and pins the
query, so it becomes a real facet the day the crawler takes it.

Filtering moved into MUI.Catalog.FacetedSearch, which both IGameQueries
implementations now feed rows to. That closed a live divergence: band=archived
lifted the archive exclusion in the demo fixture and did not in Postgres, so one
filter had two answers and only the fixture's was tested.

The page binds through GameFilterBinding, which the read API already used — one
parser, two callers, so /games?band=quiet and /api/games?band=quiet cannot drift.
The API publishes the facets too, with each group's evidence, and refuses an
unreadable band rather than answering the unfiltered catalogue; the page refuses
it out loud for the same reason. The plain surface prints the whole panel with
the parameter that selects each value, because a text browser cannot work a
select and can edit a URL.

Also: the plain-text link is rebuilt from the live querystring rather than a
hand-kept list of two parameters, which would have dropped every facet added
after it.
@HarryCordewener
HarryCordewener changed the base branch from chore/park-the-importer to feat/ecosystem-dashboard July 31, 2026 15:23
@HarryCordewener
HarryCordewener merged commit 5589c1c into main Jul 31, 2026
1 check passed
@HarryCordewener
HarryCordewener deleted the feat/faceted-search branch July 31, 2026 15:53
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