# Category audit (TASK-004)

**Date:** 2026-10-06  
**Environment:** Docker unavailable on audit machine — live SQL counts deferred until `docker compose up` + `php spark migrate`.

## Static analysis (confirmed in code)

| Issue | Evidence |
|-------|----------|
| App shows 16 aisles; Ops API exposes 7 canonical slugs | `lib/models/shop_categories.dart` vs `baskit_ops/app/Libraries/CatalogCategories.php` |
| 11 app slugs map to `other` on server | `normalizeSlug()` has no entries for `frozen`, `drinks`, `snacks`, etc. |
| `mergeWithApi` masks zero counts | Adds `other` count onto `pantry` only |
| Substring classifier | `str_contains` on bucket/name in `CatalogCategories::normalizeSlug` |
| Ingestion overwrites categories | `Ingestion.php` updates `category_id` every scrape |
| Category screen loads max 50 products, no filters | `category_screen.dart` + `CatalogueService` limit cap |

## Known misclassification patterns (from keyword rules)

Products whose **names or raw paths** contain substrings like `egg`, `cream`, `roll`, `butter`, `milk`, `fruit`, `veg`, `vas`, `fresh` without word boundaries are filed into wrong aisles (see plan examples).

## Post-migration verification (run when Docker is up)

```sql
-- Distinct scrape paths by retailer
SELECT r.slug, rs.category_raw, COUNT(*) AS n
FROM retailer_skus rs
JOIN retailers r ON r.id = rs.retailer_id
WHERE rs.category_raw IS NOT NULL AND TRIM(rs.category_raw) <> ''
GROUP BY r.slug, rs.category_raw
ORDER BY n DESC
LIMIT 200;

-- Live products per category slug
SELECT c.slug, COUNT(p.id) AS n
FROM categories c
LEFT JOIN products p ON p.category_id = c.id AND p.status = 'live'
WHERE c.active = TRUE
GROUP BY c.slug
ORDER BY n DESC;
```

After `php spark catalog:recategorise --dry-run`, compare `other` share and per-aisle counts.
