# 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
- 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
- 22% of pairs suggest a Lyréco own-brand complement on a competitor-branded
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
supplier_protected(anchor, complement_sku, fit_critical_families)— returns
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
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
coocgate
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
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,
product_taxonomy (labels, adjacency codes), and where
present the subcat co-occurrence inputs for build_subcat_cooc_map. All Postgres :5433.
- Write:
product_complementsnew rows,signal_type='zerocpr_v2'. Never touch curated
zerocpr rows.
Error handling
- Missing brand/section/material data → gate treats "unknown" as pass (never blocks
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,
- 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,
- Hub-fill invariants: after fill, no SKU exceeds the global cap; no anchor is handed
per_family_cap; min-sales floor respected.
- Durable-detector: stapler + calculator now classified durable; Casio adapter on a
brand_compatible.
- End-to-end (small fixture): post-guardrail, cross-section rate < 15% (adjacency
- Regression: Type A/B path output unchanged (guardrail additions are additive /
Deferred to the Lyréco data-ask (explicitly NOT in v1)
- Exact device↔accessory/consumable compatibility maps (Casio HR-8RCE model-level, staple
- A structured material/attribute table (removes the keyword-tagging approximation).
- A supplier-protection brand list (which brands are strategic suppliers to shield) — v1
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
- 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).
`