⚡ Swarm Architecture

zeroCPR Complement Guardrails — Design

# zeroCPR Complement Guardrails — Design

Date: 2026-07-03 Status: design (awaiting user review → writing-plans) Author: workstation-lc (with Pierre) · peer cross-check: gridiron (mesh DM #3908)

Goal

Stop the zeroCPR complement fallback from proposing irrelevant, brand-conflicting, or incompatible complements, by adding generation-time guardrails. The fix runs at generation time and writes a new signal_type='zerocpr_v2' alongside the existing rows (non-destructive), so old vs new can be diffed before the Marie/Lucas export is flipped to v2.

Why (the measured problem)

100% coverage was reached, but ~10.9k FR anchors ride on the zeroCPR fallback, and the fallback is pathologically concentrated. Measured on FR (signal_type='zerocpr'):

  • 105,297 pairs drawn from only 671 distinct complement SKUs. 15 SKUs attach to
>1,000 anchors each; the top 5 (generic wipes/rolls/disinfectant) attach to 6,451 anchors each and span all 16 merchandising sections.
  • 83% of pairs involve a SKU glued to >300 anchors (hub-collapse).
  • 89% of pairs jump merchandising section (e.g. shoe → body-deodorant).
  • Concentration is category-specific: section 003 EPI/workwear = 41% of all
pairs, with 78% of its anchors sharing a single SKU. Other sections 23–73%.
  • 22% of pairs suggest a Lyréco own-brand complement on a competitor-branded
anchor (supplier-relationship risk, e.g. Rapid stapler → Lyreco staples).

Marie's four reported cases map to four distinct modes:

| Reported case | Mode | Dominant fix | |---|---|---| | Wet wipes / gloves everywhere, weak match | hub-collapse | hub-IDF demotion + cap + diversify; cooc gate where data exists | | Shoe → deodorant | cross-section + material | section-coherence gate + material tagger | | Rapid stapler → Lyreco staples | supplier politics | supplier-protection gate (own-brand suppression) | | Casio HR-8RCE adapter → Lyreco calc | cross-brand compat | reuse brand_compatible + extend durable detector |

Root cause

The zeroCPR fallback (scripts/refresh_complements_fallback.py::_persist_batch) bypasses the guardrails the Type A/B path already uses. Type A/B assemble complements through core.complement_families.assemble_anchor_blocks, which already applies: per_family_cap (within-anchor anti-bloat), the subcategory co-occurrence gate cooc (specificity, lift≥1.5 — this is what keeps hubs out of A/B), and brand_gate/brand_compatible (captive-consumable brand compat). The fallback instead calls rank_family_skus directly and takes top-N-by-sales per family with a flat CAP=10 — no per-family cap, no cooc gate, no brand gate, no section gate. Result: every anchor is handed the same best-selling generic SKUs.

So the design is mostly "route zeroCPR through the existing guardrails, and add the few that don't yet exist" — not a rebuild.

Design decisions (locked)

1. Material compatibility (v1): keyword-tag high-risk families only. No structured material field exists on ecom_products (only free text: web_description, key_words). Tag material from those two fields for footwear-care and gloves families only, via a curated keyword list. Full structured material/attribute data is deferred to the Lyréco data-ask. 2. Rejected family picks: re-call qwen with constraints. When a coherence gate rejects the LLM's family itself (not just a SKU), re-prompt qwen (local, free, zero Claude quota) with the gate rules stated in-prompt AND a section-filtered vocabulary, then re-run the result through the gates (defense in depth). 3. Non-destructive rollout. Write signal_type='zerocpr_v2' with a distinct source_file, leaving zerocpr rows intact for diff/A-B until the export is flipped. 4. Common path is deterministic (no LLM). The LLM's family picks are already stored in product_complements.family_code; re-derivation reuses them and only re-calls the LLM for anchors whose families were pruned.

Architecture

` stored family picks (product_complements WHERE signal_type='zerocpr', per anchor) │ group by anchor -> {anchor: [family_code, ...]} ▼ ┌─ COHERENCE GATES (per anchor × candidate family) ─────────────┐ │ section-coherence · supplier-protection · material · brand │ └───────────────────────────────────────────────────────────────┘ │ some families pass │ all families for an anchor rejected ▼ ▼ ┌─ HUB-AWARE SKU FILL ──────┐ ┌─ qwen RE-CALL (constrained) ─┐ │ reuse assemble_anchor_ │ │ section-filtered vocab + │ │ blocks: per_family_cap, │ │ gate rules in-prompt │ │ cooc gate (where data), │ └───────────┬──────────────────┘ │ brand_gate; ADD hub-IDF │◀──────────────┘ new compliant families │ demotion + global cap + │ (re-run through gates) │ diversify │ └───────────┬───────────────┘ ▼ persist signal_type='zerocpr_v2' (source_file='zerocpr-v2-2026-07-03') `

Components

C1 — Coherence gates (core/complement_gates.py, new)

Pure predicates, no I/O. Each takes an anchor record + a candidate (family or SKU) and returns bool. Table-driven tests from Marie's real cases.

  • section_coherent(anchor_section, family_section, allow_list) — True if same
section, or the pair is in the curated adjacency allow-list. Kills shoe→deodorant.
  • supplier_protected(anchor, complement_sku, fit_critical_families) — returns
False (block) when: anchor is a durable (is_durable_anchor) and competitor-branded (lyreco_brand false, brand non-null) and the complement's family is a fit-critical captive-consumable family and the complement is Lyréco own-brand (lyreco_brand true). This is a politics gate, distinct from brand_compatible (a compat gate): it blocks own-brand even when physically compatible. Kills Rapid→Lyreco-staples.
  • material_compatible(anchor, complement_sku) — applies only when the family is
in the material-sensitive set (footwear-care, gloves). Extracts material tags from web_description+key_words of both sides via tag_materials() (C4); blocks on a hard clash (e.g. anchor tagged cuir, complement care-product tagged textile only). Absent tags never block (unknown → keep). Kills leather-deo-on-cotton.

Reused, not rebuilt: brand_compatible (cross-brand captive-consumable compat — handles Casio-adapter once the durable detector covers calculators), is_durable_anchor, is_in_perimeter, _is_active.

C2 — Hub-aware SKU fill (extend rank_family_skus + route through assemble_anchor_blocks)

The hub problem is cross-anchor (one SKU on thousands of anchors); the existing per_family_cap is within-anchor and does not address it. Two layers:

  • Where co-occurrence data exists: route zeroCPR through the existing cooc gate
(build_subcat_cooc_map, lift≥1.5) — the same specificity gate that keeps hubs out of Type A/B. Generic wipes have low lift against random anchor subcats → dropped.
  • Where it does not (the zeroCPR norm — these anchors are the fallback because they
lacked co-occurrence signal): a new hub-IDF demotion in the SKU score: score = sales_rank_term − penalty(global_anchor_attachments(sku)), plus a global attachment cap (a SKU may back at most N anchors across the whole run) as a backstop, plus diversification so an anchor is not handed the family's fixed top-N. The runner tracks a cross-anchor attachment counter as it persists.

Open knob (out to gridiron, DM #3908): exact form of penalty(...) and the cap N. Working default: penalty ∝ log1p(anchor_attachments) (IDF-style, primary, continuous) with a hard cap as backstop; N seeded conservatively and validated against the post-run attachment distribution. Failure floor to respect: diversification must not surface low-sales tail SKUs that are worse complements than a generic-but-relevant one — the fill keeps a minimum-sales floor per SKU.

C3 — qwen re-call, constrained (scripts/refresh_complements_fallback.py)

For anchors whose families were all/partly pruned by C1: rebuild the vocabulary passed to build_fallback_prompt to only same-section + adjacency-allowed families (the LLM literally cannot pick out-of-section), state the brand/material rules in the system prompt, call qwen (backend='ollama', gpu-wsl, concurrency≤5), then re-run the returned families through C1 (defense in depth) before SKU-fill. Zero Claude quota.

C4 — Material tagger (core/complement_gates.py::tag_materials)

Pure. Given web_description+key_words, returns a set of material tags from a curated lexicon (v1): `{cuir/leather, textile/coton/tissu, nitrile, latex, vinyle, cuir_synth, inox/metal, plastique}`. Applied only to the material-sensitive families. Curated list lives in the module; extend as cases surface.

C5 — Runner integration

refresh_complements_fallback.py gains a --v2 mode (or a sibling refresh_complements_guardrails.py) that: loads stored zerocpr family picks per anchor, runs C1→C2 (+C3 for pruned), persists signal_type='zerocpr_v2', source_file='zerocpr-v2-2026-07-03', non-destructive DELETE-by-(country,source_file, anchor)+INSERT (same pattern as today). Keeps the pickle-cache pattern for traffic if needed. The Marie export + review-queue flip to v2 in a separate follow-up step, only after diffing v2 vs v1.

Seeded starter lists (refine during review/impl)

Section adjacency allow-list (bidirectional; codes from product_taxonomy):

  • 003 EPI ↔ 002 HYGIÈNE ET ENTRETIEN (safety + cleaning)
  • 005 CONSOMMABLES IMPRES ↔ 006 MACHINES DE BUREAU ↔ 004 ENVIRONNEMENT INFORMATIQUE
  • 001 RESTAURATION ↔ 002 HYGIÈNE ET ENTRETIEN
  • 016 EMBALLAGE/EXPÉDITION ↔ 011 FOURNITURES DE BUREAU
  • 009 BLOCS ET CAHIERS ↔ 010 ÉCRITURE/CORRECTION ↔ 011 FOURNITURES DE BUREAU

All other cross-section pairs are rejected (→ qwen re-call). Same-section always passes.

Fit-critical captive-consumable families (supplier-protection; resolved to family_code via product_taxonomy label match at build time): agrafes/staples, encre/toner/cartouches, ruban & étiquettes / label-tape, rouleaux calculatrice/caisse, dosettes/capsules machine. Durable-detector extension: add `AGRAFEUSE|STAPLER| CALCULATRICE|CALCULATOR to _DURABLE_RX` so staplers and calculators are recognized as durables (enables both brand_compatible for the Casio case and supplier-protection for the Rapid case).

Data flow / storage

  • Read: product_complements (zerocpr picks), ecom_products (brand, lyreco_brand,
section/family, text fields), product_taxonomy (labels, adjacency codes), and where present the subcat co-occurrence inputs for build_subcat_cooc_map. All Postgres :5433.
  • Write: product_complements new rows, signal_type='zerocpr_v2'. Never touch curated
or zerocpr rows.

Error handling

  • Missing brand/section/material data → gate treats "unknown" as pass (never blocks
on absence), matching the existing brand_compatible convention. This keeps coverage and avoids false rejections; the cost is some unknown-data pairs slipping through, which the review queue still flags.
  • qwen unreachable / empty parse → the anchor keeps whatever families passed C1; if none,
it becomes an honest gap in v2 (logged), not a crash. Runner reports pruned/re-called/ gap counts.
  • Cross-DB: none required (all inputs are Postgres). No Oracle/VPN at runtime.

Testing (TDD)

  • Gate unit tests (tests/test_complement_gates.py): each predicate table-driven,
seeded with Marie's four real cases as fixtures + synthetic clashes and unknown-data-passes-through cases.
  • Hub-fill invariants: after fill, no SKU exceeds the global cap; no anchor is handed
a single-family monoculture beyond per_family_cap; min-sales floor respected.
  • Durable-detector: stapler + calculator now classified durable; Casio adapter on a
Lyreco calc rejected by brand_compatible.
  • End-to-end (small fixture): post-guardrail, cross-section rate < 15% (adjacency
allow-list permits some legit cross-section) and no SKU attaches to more than the cap; supplier-protection blocks the Rapid case.
  • Regression: Type A/B path output unchanged (guardrail additions are additive /
gated behind the zeroCPR path).

Deferred to the Lyréco data-ask (explicitly NOT in v1)

  • Exact device↔accessory/consumable compatibility maps (Casio HR-8RCE model-level, staple
gauge, calculator roll size) — model-level, not brand-level.
  • A structured material/attribute table (removes the keyword-tagging approximation).
  • A supplier-protection brand list (which brands are strategic suppliers to shield) — v1
uses the own-brand-vs-competitor heuristic via lyreco_brand.

Success criteria

  • Cross-section pair rate drops from 89% to <15% in v2.
  • No complement SKU attaches to more than the cap (hub-collapse broken); the 671-SKU pool
widens materially.
  • Zero own-brand complements on competitor-branded durables in fit-critical families.
  • Marie's four cases each verified fixed in v2 output.
  • Type A/B output unchanged; no Claude quota spent (qwen only).
`