diff --git a/backend/app/facets.py b/backend/app/facets.py new file mode 100644 index 0000000..a10b7d3 --- /dev/null +++ b/backend/app/facets.py @@ -0,0 +1,110 @@ +"""§22.4a SLICE-3 — faceted catalog filtering + counts (read). + +Pure functions over already-mirrored entries; no I/O, no DB. An "entry" here is +a plain dict carrying at least: + - "state": the lifecycle state column, + - "metadata_malformed": bool, + - "meta": the per-entry metadata mapping (from cached_rfcs.meta_json). + +Facetable fields (§5.1, plan decision 1): a collection's declared `enum` and +`tags` fields, in declaration order, plus the built-in `state` facet appended +last — but only when the collection declares a schema (INV-5: a no-`fields:` +collection has no facets at all, so the frontend keeps its legacy chips). `text` +fields are not faceted in v1 (they get a detail control in SLICE-4). + +Counts use drill-down semantics (plan decision 2): the count for a value of +field F is taken over entries matching every OTHER field's selection (and the +malformed toggle), not F's own — so within-field values stay switchable (OR +within a field, AND across fields). The returned items list applies ALL +selections. +""" +from __future__ import annotations + +from typing import Any + +# enum + tags are facetable; text is rendered as a detail control (SLICE-4). +FACETABLE_TYPES = {"enum", "tags"} + + +def facet_fields(fields: dict[str, dict] | None) -> list[tuple[str, str]]: + """Ordered `[(name, type), ...]` facetable from the schema, `state` last. + + Empty when the collection declares no schema (INV-5).""" + if not fields: + return [] + out = [ + (name, spec.get("type")) + for name, spec in fields.items() + if spec.get("type") in FACETABLE_TYPES + ] + out.append(("state", "enum")) + return out + + +def allowed_filter_keys(fields: dict[str, dict] | None) -> set[str]: + """Query-param keys the collection-scoped list accepts (plan decision 6).""" + keys = {name for name, _ in facet_fields(fields)} + keys.update({"unreviewed", "malformed"}) + return keys + + +def _values_for(entry: dict[str, Any], name: str, ftype: str) -> list[str]: + """The facet value(s) an entry contributes for field `name` (str-cast).""" + if name == "state": + v = entry.get("state") + return [str(v)] if v else [] + meta = entry.get("meta") or {} + v = meta.get(name) + if v is None: + return [] + if ftype == "tags": + return [str(x) for x in v] if isinstance(v, list) else [] + return [str(v)] + + +def _matches(entry: dict[str, Any], name: str, ftype: str, selected: set[str]) -> bool: + if not selected: + return True + return bool(set(_values_for(entry, name, ftype)) & selected) # OR within field + + +def filter_and_count( + entries: list[dict[str, Any]], + fields: dict[str, dict] | None, + selections: dict[str, set[str]], + only_malformed: bool = False, +) -> tuple[list[dict[str, Any]], dict[str, dict[str, int]]]: + """Filter `entries` by `selections` and compute drill-down facet counts. + + `selections` maps a facet field name → the set of selected values (OR within + the field; AND across fields). `only_malformed` narrows items and counts to + entries flagged malformed (INV-3). Returns `(items, facets)` where + `facets = {field: {value: count}}`. With no schema → `([all passing], {})`. + """ + facetable = facet_fields(fields) + + def passes_malformed(e: dict[str, Any]) -> bool: + return (not only_malformed) or bool(e.get("metadata_malformed")) + + items = [ + e for e in entries + if passes_malformed(e) + and all(_matches(e, n, t, selections.get(n, set())) for n, t in facetable) + ] + + facets: dict[str, dict[str, int]] = {} + for name, ftype in facetable: + counts: dict[str, int] = {} + for e in entries: + if not passes_malformed(e): + continue + if not all( + _matches(e, on, ot, selections.get(on, set())) + for on, ot in facetable + if on != name + ): + continue + for val in _values_for(e, name, ftype): + counts[val] = counts.get(val, 0) + 1 + facets[name] = counts + return items, facets diff --git a/backend/tests/test_facets.py b/backend/tests/test_facets.py new file mode 100644 index 0000000..92d20a8 --- /dev/null +++ b/backend/tests/test_facets.py @@ -0,0 +1,87 @@ +"""§22.4a SLICE-3 — pure facet field-set + filter/count (PUC-3). + +Per docs/design/2026-06-06-configurable-collection-metadata.md §5.1, §6.4. +""" +from app import facets + + +PRIORITY = {"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}} +SCHEMA = { + "priority": {"type": "enum", "values": ["P0", "P1", "P2"]}, + "tags": {"type": "tags"}, + "owner": {"type": "text"}, +} + + +def _e(slug, state="active", malformed=False, **meta): + return {"slug": slug, "state": state, "metadata_malformed": malformed, "meta": meta} + + +def test_facet_fields_orders_declared_then_state_skips_text(): + # enum + tags in declaration order, text skipped, state appended last. + assert facets.facet_fields(SCHEMA) == [ + ("priority", "enum"), ("tags", "tags"), ("state", "enum")] + + +def test_no_schema_yields_no_facets(): + # INV-5: a collection with no fields has no facets (frontend keeps chips). + assert facets.facet_fields(None) == [] + assert facets.facet_fields({}) == [] + + +def test_filter_and_count_basic_counts(): + entries = [ + _e("a", priority="P0", tags=["checkout"]), + _e("b", priority="P0", tags=["cart"]), + _e("c", priority="P1", tags=["checkout", "cart"]), + ] + items, fac = facets.filter_and_count(entries, SCHEMA, {}) + assert {i["slug"] for i in items} == {"a", "b", "c"} + assert fac["priority"] == {"P0": 2, "P1": 1} + assert fac["tags"] == {"checkout": 2, "cart": 2} + assert fac["state"] == {"active": 3} + + +def test_filter_compose_or_within_and_across(): + entries = [ + _e("a", priority="P0", tags=["checkout"]), # P0 + checkout + _e("b", priority="P0", tags=["cart"]), # P0, no checkout + _e("c", priority="P1", tags=["checkout"]), # checkout, not P0 + ] + # priority=P0 AND tags=checkout → only "a". + items, _ = facets.filter_and_count( + entries, SCHEMA, {"priority": {"P0"}, "tags": {"checkout"}}) + assert {i["slug"] for i in items} == {"a"} + + +def test_drilldown_counts_exclude_own_field_selection(): + entries = [ + _e("a", priority="P0"), + _e("b", priority="P1"), + _e("c", priority="P1"), + ] + # With P0 selected, the priority facet still counts P1 over the set that + # ignores priority's own selection — so P1 stays switchable. + _, fac = facets.filter_and_count(entries, SCHEMA, {"priority": {"P0"}}) + assert fac["priority"] == {"P0": 1, "P1": 2} + + +def test_malformed_toggle_narrows_items_and_counts(): + entries = [ + _e("a", state="active", malformed=True, priority="P9"), + _e("b", state="active", malformed=False, priority="P0"), + ] + items, fac = facets.filter_and_count(entries, SCHEMA, {}, only_malformed=True) + assert {i["slug"] for i in items} == {"a"} + assert fac["priority"] == {"P9": 1} + + +def test_missing_value_contributes_no_facet_value(): + entries = [_e("a", priority="P0"), _e("b")] # b has no priority + _, fac = facets.filter_and_count(entries, SCHEMA, {}) + assert fac["priority"] == {"P0": 1} + + +def test_allowed_filter_keys(): + assert facets.allowed_filter_keys(PRIORITY) == {"priority", "state", + "unreviewed", "malformed"}