gwshop: fix primary-branch tie-break and refresh the committed catalog

- src/gwshop.rs: pick the FIRST longest facet path on ties, matching the
  original Python max(paths, key=len). Rust's max_by_key returned the last,
  which silently reclassified ~2298 entries for products faceted under
  several branches (e.g. a generic kit listed per chapter). Adds a regression
  test.
- AGENTS.md: document the first-longest tie-break and the facet-partition
  coverage shortfall (non-system merchandise left out; no model kits).
- data/gwshop_catalog.json: regenerate with the fixed binary (3491 -> 3459
  entries; 34 removed, 2 added, 188 structural + 785 collections changes).
This commit is contained in:
2026-10-11 13:10:24 +02:00
parent bc165093e2
commit 93b86d34af
3 changed files with 51501 additions and 51096 deletions
+8
View File
@@ -242,6 +242,14 @@ sorting pipeline — **change it first, then the code**. Summary:
public **search-only** key live in the gitignored `algolia.json` (or public **search-only** key live in the gitignored `algolia.json` (or
`ALGOLIA_APP_ID`/`ALGOLIA_API_KEY`); never commit any other Algolia key. `ALGOLIA_APP_ID`/`ALGOLIA_API_KEY`); never commit any other Algolia key.
`data/gwshop_catalog.json` is the committed snapshot and the sorter default. `data/gwshop_catalog.json` is the committed snapshot and the sorter default.
- The dump partitions the index on the `GameSystemsRoot` hierarchical facet
(the search-only key cannot `/browse`; plain pagination caps at 1000 hits).
When a product is faceted under several branches of equal depth (e.g. a
generic kit listed per chapter), the **first longest path wins** — Python's
`max(paths, key=len)` semantics, reproduced explicitly because Rust's
`max_by_key` returns the *last* maximum. Products carrying no
`GameSystemsRoot` path (paints, Black Library books, White Dwarf, bases,
gifts — no miniature kits) are left out; the coverage shortfall is logged.
- Bookkeeping (`sorter_applied.jsonl`, `sorter_low_confidence.txt`) is written - Bookkeeping (`sorter_applied.jsonl`, `sorter_low_confidence.txt`) is written
to `<library>/../_logs/` by default — never inside the library, which may be to `<library>/../_logs/` by default — never inside the library, which may be
a seeded torrent whose file set is frozen. a seeded torrent whose file set is frozen.
+48377 -48010
View File
File diff suppressed because it is too large Load Diff
+36 -6
View File
@@ -454,12 +454,18 @@ pub fn build_catalog(hits: &[Value]) -> Value {
continue; continue;
} }
// primary branch = longest path (most specific), e.g. // primary branch = longest path (most specific), e.g.
// [40k, Space Marines, Raven Guard, Walker] // [40k, Space Marines, Raven Guard, Walker].
let primary = paths // python `max(paths, key=len)` keeps the FIRST longest path on ties;
.iter() // Rust's `max_by_key` would keep the LAST, so select explicitly to
.max_by_key(|p| p.len()) // stay faithful to the original (products faceted under several
.cloned() // branches, e.g. a generic kit listed per chapter, depend on this).
.unwrap_or_default(); let primary = paths.iter().fold(Vec::new(), |best, p| {
if p.len() > best.len() {
p.clone()
} else {
best
}
});
let at = |i: usize| -> Value { let at = |i: usize| -> Value {
primary primary
.get(i) .get(i)
@@ -596,6 +602,30 @@ mod tests {
assert_eq!(e["source"], "gwshop"); assert_eq!(e["source"], "gwshop");
} }
#[test]
fn build_catalog_primary_branch_prefers_first_longest_tie() {
// python max(paths, key=len) keeps the first longest path; the class
// layer must come from that branch, not the last tied one.
let hit = json!({
"objectID": "obj3",
"name": "Stomper Tribe",
"sku": "S3",
"GameSystemsRoot": {
"lvl0": ["Age of Sigmar"],
"lvl1": ["Age of Sigmar > Grand Alliance Destruction"],
"lvl2": ["Age of Sigmar > Grand Alliance Destruction > Sons of Behemat"],
"lvl3": [
"Age of Sigmar > Grand Alliance Destruction > Sons of Behemat > Gargant",
"Age of Sigmar > Grand Alliance Destruction > Sons of Behemat > Monster",
],
}
});
let cat = build_catalog(&[hit]);
let e = &cat["entries"][0];
assert_eq!(e["faction"], "Sons of Behemat");
assert_eq!(e["class"], "Gargant");
}
#[test] #[test]
fn build_catalog_dedups_and_handles_pathless() { fn build_catalog_dedups_and_handles_pathless() {
let hit = json!({"objectID": "obj2", "name": "Paint"}); let hit = json!({"objectID": "obj2", "name": "Paint"});