feat(slice3): pure facet field-set + filter/count helper (§22.4a)
This commit is contained in:
@@ -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
|
||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user